diff --git a/__test__/features/Calendars/TimezoneInGrid.test.tsx b/__test__/features/Calendars/TimezoneInGrid.test.tsx
index 53391b9..a3d0ab3 100644
--- a/__test__/features/Calendars/TimezoneInGrid.test.tsx
+++ b/__test__/features/Calendars/TimezoneInGrid.test.tsx
@@ -2,11 +2,10 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import CalendarApp from "../../../src/components/Calendar/Calendar";
import { updateSlotLabelVisibility } from "../../../src/components/Calendar/utils/calendarUtils";
import EventPreviewModal from "../../../src/features/Events/EventDisplayPreview";
-import * as CalendarSlice from "../../../src/features/Calendars/CalendarSlice";
+import * as SettingsSlice from "../../../src/features/Settings/SettingsSlice";
import * as calendarUtils from "../../../src/components/Calendar/utils/calendarUtils";
import { CalendarEvent } from "../../../src/features/Events/EventsTypes";
import { renderWithProviders } from "../../utils/Renderwithproviders";
-import preview from "jest-preview";
describe("Calendar - Timezone Integration", () => {
const mockCalendarRef = { current: null };
@@ -56,7 +55,7 @@ describe("Calendar - Timezone Integration", () => {
});
it("dispatches setTimeZone action when timezone is changed", async () => {
- const setTimeZoneSpy = jest.spyOn(CalendarSlice, "setTimeZone");
+ const setTimeZoneSpy = jest.spyOn(SettingsSlice, "setTimeZone");
renderWithProviders(
,
@@ -226,8 +225,7 @@ describe("EventDisplayPreview - Timezone Display", () => {
for (const { tz, expectedOffset } of timezones) {
const state = {
...baseState,
- calendars: {
- ...baseState.calendars,
+ settings: {
timeZone: tz,
},
};
diff --git a/__test__/features/Settings/SettingUserSync.test.tsx b/__test__/features/Settings/SettingUserSync.test.tsx
new file mode 100644
index 0000000..d596209
--- /dev/null
+++ b/__test__/features/Settings/SettingUserSync.test.tsx
@@ -0,0 +1,477 @@
+import { configureStore } from "@reduxjs/toolkit";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
+import SettingsPage from "../../../src/features/Settings/SettingsPage";
+import settingsReducer, {
+ setIsBrowserDefaultTimeZone,
+ setTimeZone,
+} from "../../../src/features/Settings/SettingsSlice";
+import userReducer, {
+ getOpenPaasUserDataAsync,
+ setTimezone as setUserTimeZone,
+} from "../../../src/features/User/userSlice";
+import { api } from "../../../src/utils/apiUtils";
+import { browserDefaultTimeZone } from "../../../src/utils/timezone";
+import { renderWithProviders } from "../../utils/Renderwithproviders";
+
+describe("Timezone synchronization after getOpenPaasUserDataAsync", () => {
+ let apiGetSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorage.clear();
+ apiGetSpy = jest.spyOn(api, "get");
+ });
+
+ afterEach(() => {
+ apiGetSpy.mockRestore();
+ });
+
+ it("should sync timezone to both user and settings state after fetching user data", async () => {
+ const mockUserData = {
+ id: "667037022b752d0026472254",
+ firstname: "John",
+ lastname: "Doe",
+ preferredEmail: ["test@test.com"],
+ configurations: {
+ modules: [
+ {
+ name: "core",
+ configurations: [
+ { name: "language", value: "fr" },
+ { name: "datetime", value: { timeZone: "Europe/Paris" } },
+ ],
+ },
+ ],
+ },
+ };
+
+ // Return a fake fetch-like response
+ apiGetSpy.mockResolvedValue({
+ json: async () => mockUserData,
+ });
+
+ const store = configureStore({
+ reducer: { user: userReducer, settings: settingsReducer },
+ preloadedState: {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ family_name: "Doe",
+ name: "John",
+ sid: "mockSid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: null,
+ tokens: null,
+ coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
+ loading: false,
+ error: null,
+ },
+ settings: { language: "en", timeZone: "UTC", view: "calendar" },
+ },
+ });
+
+ const result = await store.dispatch(getOpenPaasUserDataAsync());
+
+ expect(result.type).toBe("user/getOpenPaasUserData/fulfilled");
+
+ const state = store.getState();
+ expect(state.user.coreConfig.datetime.timeZone).toBe("Europe/Paris");
+ expect(state.settings.timeZone).toBe("Europe/Paris");
+ expect(localStorage.getItem("timeZone")).toBe("Europe/Paris");
+ });
+
+ it("should keep browser timezone if API response has no timezone configuration", async () => {
+ const mockUserData = {
+ id: "667037022b752d0026472254",
+ firstname: "John",
+ lastname: "Doe",
+ preferredEmail: "[test@test.com](mailto:test@test.com)",
+ configurations: {
+ modules: [
+ { name: "core", configurations: [{ name: "language", value: "en" }] },
+ ],
+ },
+ };
+
+ apiGetSpy.mockResolvedValue({
+ json: async () => mockUserData,
+ });
+
+ const browserTimezone = browserDefaultTimeZone ?? "UTC";
+
+ const store = configureStore({
+ reducer: { user: userReducer, settings: settingsReducer },
+ preloadedState: {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ family_name: "Doe",
+ name: "John",
+ sid: "mockSid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: null,
+ tokens: null,
+ coreConfig: {
+ language: "en",
+ datetime: { timeZone: browserTimezone },
+ },
+ loading: false,
+ error: null,
+ },
+ settings: {
+ language: "en",
+ timeZone: browserTimezone,
+ view: "calendar",
+ },
+ },
+ });
+
+ const result = await store.dispatch(getOpenPaasUserDataAsync());
+
+ expect(result.type).toBe("user/getOpenPaasUserData/fulfilled");
+
+ const state = store.getState();
+ expect(state.user.coreConfig.datetime.timeZone).toBe(null);
+ expect(state.settings.timeZone).toBe(browserTimezone);
+ });
+});
+
+// Mock localStorage
+const localStorageMock = (() => {
+ let store: Record = {};
+ return {
+ getItem: (key: string) => store[key] || null,
+ setItem: (key: string, value: string) => {
+ store[key] = value;
+ },
+ removeItem: (key: string) => {
+ delete store[key];
+ },
+ clear: () => {
+ store = {};
+ },
+ };
+})();
+
+Object.defineProperty(window, "localStorage", {
+ value: localStorageMock,
+});
+
+describe("Timezone Logic - Backend to Frontend Flow", () => {
+ let store: any;
+
+ beforeEach(() => {
+ localStorageMock.clear();
+ store = configureStore({
+ reducer: {
+ settings: settingsReducer,
+ user: userReducer,
+ },
+ preloadedState: {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ family_name: "Doe",
+ name: "John",
+ sid: "mockSid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: null,
+ tokens: null,
+ coreConfig: { language: "en", datetime: { timeZone: "UTC" } },
+ loading: false,
+ error: null,
+ },
+ settings: { language: "en", timeZone: "UTC", view: "calendar" },
+ },
+ });
+ });
+
+ describe("Backend Response Handling", () => {
+ test("Backend returns NULL => Should use browser default timezone", async () => {
+ const backendResponse = {
+ firstname: "John",
+ lastname: "Doe",
+ id: "123",
+ preferredEmail: "john@example.com",
+ configurations: {
+ modules: [
+ {
+ name: "core",
+ configurations: [
+ {
+ name: "datetime",
+ value: {
+ timeZone: null,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ await store.dispatch(
+ getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
+ );
+
+ const settingsState = store.getState().settings;
+ const userState = store.getState().user;
+
+ expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
+ expect(settingsState.isBrowserDefaultTimeZone).toBe(true);
+ expect(userState.coreConfig.datetime.timeZone).toBe(null);
+ expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
+ });
+
+ test("Backend returns SPECIFIC VALUE => Should use that value", async () => {
+ const specificTimezone = "America/New_York";
+ const backendResponse = {
+ firstname: "Jane",
+ lastname: "Smith",
+ id: "456",
+ preferredEmail: "jane@example.com",
+ configurations: {
+ modules: [
+ {
+ name: "core",
+ configurations: [
+ {
+ name: "datetime",
+ value: {
+ timeZone: specificTimezone,
+ },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ await store.dispatch(
+ getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
+ );
+
+ const settingsState = store.getState().settings;
+ const userState = store.getState().user;
+
+ expect(settingsState.timeZone).toBe(specificTimezone);
+ expect(settingsState.isBrowserDefaultTimeZone).toBe(false);
+ expect(userState.coreConfig.datetime.timeZone).toBe(specificTimezone);
+ expect(localStorage.getItem("timeZone")).toBe(specificTimezone);
+ });
+
+ test("Backend returns NO datetime config => Should use browser default", async () => {
+ const backendResponse = {
+ firstname: "Bob",
+ lastname: "Johnson",
+ id: "789",
+ preferredEmail: "bob@example.com",
+ configurations: {
+ modules: [
+ {
+ name: "core",
+ configurations: [
+ // No datetime config
+ ],
+ },
+ ],
+ },
+ };
+
+ await store.dispatch(
+ getOpenPaasUserDataAsync.fulfilled(backendResponse, "", undefined)
+ );
+
+ const settingsState = store.getState().settings;
+ const userState = store.getState().user;
+
+ expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
+ expect(settingsState.isBrowserDefaultTimeZone).toBe(true);
+ expect(userState.coreConfig.datetime.timeZone).toBe(null);
+ expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
+ });
+ });
+
+ test("Settings state ALWAYS has a concrete value (never null)", () => {
+ // Test 1: Browser default scenario
+ store.dispatch(setIsBrowserDefaultTimeZone(true));
+ let settingsState = store.getState().settings;
+
+ expect(settingsState.timeZone).toBe(browserDefaultTimeZone);
+ expect(settingsState.timeZone).not.toBe(null);
+
+ // Test 2: Specific timezone scenario
+ store.dispatch(setTimeZone("Europe/Paris"));
+ settingsState = store.getState().settings;
+
+ expect(settingsState.timeZone).toBe("Europe/Paris");
+ expect(settingsState.timeZone).not.toBe(null);
+ });
+
+ describe("User Actions - Changing Timezone", () => {
+ test("User enables browser default => Settings gets browser TZ, User gets null", async () => {
+ const { store } = renderWithProviders(, {
+ user: {
+ userData: { sub: "test" },
+ organiserData: null,
+ tokens: null,
+ coreConfig: {
+ language: "en",
+ datetime: { timeZone: "America/Los_Angeles" },
+ },
+ loading: false,
+ error: null,
+ },
+ settings: {
+ language: "en",
+ timeZone: "America/Los_Angeles",
+ isBrowserDefaultTimeZone: false,
+ view: "settings",
+ },
+ });
+
+ const browserDefaultSwitch = screen.getAllByLabelText(
+ "settings.timeZoneBrowserDefault"
+ )[0];
+ expect(browserDefaultSwitch).toBeInTheDocument();
+
+ // Enable browser default
+ fireEvent.click(browserDefaultSwitch);
+
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.isBrowserDefaultTimeZone).toBe(true);
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.timeZone).toBe(browserDefaultTimeZone);
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user.coreConfig.datetime.timeZone).toBe(null);
+ });
+ });
+
+ test("User selects specific timezone => Both states get the value", async () => {
+ const { store } = renderWithProviders(, {
+ user: {
+ userData: { sub: "test" },
+ organiserData: null,
+ tokens: null,
+ coreConfig: { language: "en", datetime: { timeZone: null } },
+ loading: false,
+ error: null,
+ },
+ settings: {
+ language: "en",
+ timeZone: browserDefaultTimeZone,
+ isBrowserDefaultTimeZone: true,
+ view: "settings",
+ },
+ });
+
+ const browserDefaultSwitch = screen.getAllByLabelText(
+ "settings.timeZoneBrowserDefault"
+ )[0];
+
+ // Disable browser default so manual selector appears
+ fireEvent.click(browserDefaultSwitch);
+
+ // Now timezone combobox is visible
+ const timezoneInput = screen.getAllByRole("combobox")[1];
+
+ // Type to filter options
+ fireEvent.change(timezoneInput, {
+ target: { value: "Australia/Sydney" },
+ });
+
+ // Select from autocomplete dropdown
+ const option = await screen.findByText(/Australia\/Sydney/i);
+ fireEvent.click(option);
+
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.isBrowserDefaultTimeZone).toBe(false);
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.timeZone).toBe("Australia/Sydney");
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user.coreConfig.datetime.timeZone).toBe(
+ "Australia/Sydney"
+ );
+ });
+ });
+ });
+
+ describe("LocalStorage Persistence", () => {
+ test("LocalStorage always stores concrete values (browser TZ or specific TZ)", () => {
+ // Scenario 1: Browser default
+ store.dispatch(setTimeZone(browserDefaultTimeZone));
+ expect(localStorage.getItem("timeZone")).toBe(browserDefaultTimeZone);
+ expect(localStorage.getItem("timeZone")).not.toBe("null");
+
+ // Scenario 2: Specific timezone
+ store.dispatch(setTimeZone("Africa/Cairo"));
+ expect(localStorage.getItem("timeZone")).toBe("Africa/Cairo");
+ });
+ });
+
+ test("Full user journey: Backend null -> User changes -> Backend saves", async () => {
+ // Step 1: Backend returns null
+ const backendResponseNull = {
+ firstname: "Test",
+ lastname: "User",
+ id: "999",
+ preferredEmail: "test@example.com",
+ configurations: {
+ modules: [
+ {
+ name: "core",
+ configurations: [
+ {
+ name: "datetime",
+ value: { timeZone: null },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ await store.dispatch(
+ getOpenPaasUserDataAsync.fulfilled(backendResponseNull, "", undefined)
+ );
+
+ // Verify initial state
+ expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone);
+ expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null);
+
+ // Step 2: User changes to specific timezone
+ const userSelectedTZ = "Europe/London";
+ store.dispatch(setIsBrowserDefaultTimeZone(false));
+ store.dispatch(setTimeZone(userSelectedTZ));
+ store.dispatch(setUserTimeZone(userSelectedTZ));
+
+ expect(store.getState().settings.timeZone).toBe(userSelectedTZ);
+ expect(store.getState().user.coreConfig.datetime.timeZone).toBe(
+ userSelectedTZ
+ );
+
+ // Step 3: User switches back to browser default
+ store.dispatch(setIsBrowserDefaultTimeZone(true));
+ store.dispatch(setUserTimeZone(null));
+ store.dispatch(setTimeZone(browserDefaultTimeZone));
+
+ expect(store.getState().settings.timeZone).toBe(browserDefaultTimeZone);
+ expect(store.getState().user.coreConfig.datetime.timeZone).toBe(null);
+ });
+});
diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx
index b6e5184..b387c1a 100644
--- a/__test__/features/Settings/SettingsPage.test.tsx
+++ b/__test__/features/Settings/SettingsPage.test.tsx
@@ -24,7 +24,7 @@ describe("SettingsPage", () => {
},
organiserData: null,
tokens: null,
- language: "en",
+ coreConfig: { language: "en" },
loading: false,
error: null,
},
@@ -47,6 +47,7 @@ describe("SettingsPage", () => {
const { container } = renderWithProviders(, preloadedState);
// Check sidebar navigation items
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeInTheDocument();
expect(screen.getAllByText(/settings.title/i).length).toBeGreaterThan(0);
@@ -56,6 +57,7 @@ describe("SettingsPage", () => {
it("highlights active navigation item", () => {
const { container } = renderWithProviders(, preloadedState);
+ // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
const settingsNavItem = container.querySelector(
".settings-nav-item.active"
);
@@ -108,7 +110,7 @@ describe("SettingsPage", () => {
},
organiserData: null,
tokens: null,
- language: "fr",
+ coreConfig: { language: "fr" },
loading: false,
error: null,
},
@@ -125,6 +127,7 @@ describe("SettingsPage", () => {
expect(languageSelect).toBeInTheDocument();
// Verify that the underlying native input reflects the user language ("fr")
+ // eslint-disable-next-line testing-library/no-node-access
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement | null;
@@ -140,6 +143,7 @@ describe("SettingsPage", () => {
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
+ // eslint-disable-next-line testing-library/no-node-access
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
@@ -155,7 +159,10 @@ describe("SettingsPage", () => {
// Language should be updated immediately (optimistic update)
await waitFor(() => {
const state = store.getState();
- expect(state.user?.language).toBe("fr");
+ expect(state.user?.coreConfig.language).toBe("fr");
+ });
+ await waitFor(() => {
+ const state = store.getState();
expect(state.settings.language).toBe("fr");
});
@@ -185,6 +192,7 @@ describe("SettingsPage", () => {
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
+ // eslint-disable-next-line testing-library/no-node-access
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
@@ -211,6 +219,7 @@ describe("SettingsPage", () => {
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
+ // eslint-disable-next-line testing-library/no-node-access
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
@@ -224,10 +233,13 @@ describe("SettingsPage", () => {
fireEvent.change(nativeInput, { target: { value: "fr" } });
// Wait for rollback - language should be rolled back to "en" after error
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user?.coreConfig.language).toBe("en");
+ });
await waitFor(
() => {
const state = store.getState();
- expect(state.user?.language).toBe("en");
expect(state.settings.language).toBe("en");
},
{ timeout: 3000 }
@@ -246,4 +258,135 @@ describe("SettingsPage", () => {
screen.getByText("settings.notifications.empty")
).toBeInTheDocument();
});
+
+ describe("Timezone Settings", () => {
+ it("displays timezone selector in Settings tab", () => {
+ renderWithProviders(, preloadedState);
+
+ expect(screen.getAllByRole("combobox")).toHaveLength(2);
+ });
+
+ it("displays timezone from user state", () => {
+ const stateWithUserTimeZone = {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ family_name: "Doe",
+ name: "John",
+ sid: "mockSid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: null,
+ tokens: null,
+ coreConfig: {
+ language: "en",
+ datetime: { timeZone: "America/New_York" },
+ },
+ loading: false,
+ error: null,
+ },
+ settings: {
+ language: "en",
+ timeZone: "UTC",
+ view: "settings",
+ },
+ };
+
+ renderWithProviders(, stateWithUserTimeZone);
+
+ expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined();
+ });
+
+ it("updates timezone immediately (optimistic update)", async () => {
+ const { store } = renderWithProviders(, preloadedState);
+
+ const timezoneInput = screen.getAllByRole("combobox")[1];
+
+ // Clear the input and type new timezone
+ fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } });
+
+ // Find and click the timezone option from the dropdown
+ const option = await screen.findByText(/Europe\/Paris/i);
+ fireEvent.click(option);
+
+ // Timezone should be updated immediately (optimistic update)
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user?.coreConfig.datetime.timeZone).toBe("Europe/Paris");
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.timeZone).toBe("Europe/Paris");
+ });
+ });
+
+ it("handles timezone change with different timezone values", async () => {
+ const { store } = renderWithProviders(, preloadedState);
+
+ const timezoneInput = screen.getAllByRole("combobox")[1];
+
+ // Test with Asia/Tokyo
+ fireEvent.change(timezoneInput, { target: { value: "Asia/Tokyo" } });
+ const tokyoOption = await screen.findByText(/Asia\/Tokyo/i);
+ fireEvent.click(tokyoOption);
+
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user?.coreConfig.datetime.timeZone).toBe("Asia/Tokyo");
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.timeZone).toBe("Asia/Tokyo");
+ });
+
+ // Test with America/Los_Angeles
+ fireEvent.change(timezoneInput, {
+ target: { value: "America/Los_Angeles" },
+ });
+ const laOption = await screen.findByText(/America\/Los Angeles/i);
+ fireEvent.click(laOption);
+
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.user?.coreConfig.datetime.timeZone).toBe(
+ "America/Los_Angeles"
+ );
+ });
+ await waitFor(() => {
+ const state = store.getState();
+ expect(state.settings.timeZone).toBe("America/Los_Angeles");
+ });
+ });
+
+ it("uses UTC as default timezone when no timezone is set", () => {
+ const stateWithoutTimeZone = {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ family_name: "Doe",
+ name: "John",
+ sid: "mockSid",
+ openpaasId: "667037022b752d0026472254",
+ },
+ organiserData: null,
+ tokens: null,
+ coreConfig: { language: "en", datetime: { timeZone: undefined } },
+ loading: false,
+ error: null,
+ },
+ settings: {
+ language: "en",
+ timeZone: undefined,
+ view: "settings",
+ },
+ };
+
+ renderWithProviders(, stateWithoutTimeZone);
+
+ const timezoneInput = screen.getByDisplayValue("(UTC) UTC");
+ expect(timezoneInput).toBeDefined();
+ });
+ });
});
diff --git a/__test__/features/user/LoginCallback.test.tsx b/__test__/features/user/LoginCallback.test.tsx
index 7ab4c79..30ed05b 100644
--- a/__test__/features/user/LoginCallback.test.tsx
+++ b/__test__/features/user/LoginCallback.test.tsx
@@ -23,15 +23,37 @@ jest.mock("../../../src/features/User/oidcAuth", () => ({
Callback: jest.fn(),
}));
-jest.mock("../../../src/features/User/userSlice", () => ({
- setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })),
- setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })),
- getOpenPaasUserDataAsync: jest.fn(() => ({ type: "GET_USER_ID" })),
-}));
+jest.mock("../../../src/features/User/userSlice", () => {
+ const mockGetUser = Object.assign(
+ jest.fn(() => ({ type: "GET_USER_ID" })),
+ {
+ pending: { type: "GET_USER_ID/pending" },
+ fulfilled: { type: "GET_USER_ID/fulfilled" },
+ rejected: { type: "GET_USER_ID/rejected" },
+ }
+ );
-jest.mock("../../../src/features/Calendars/CalendarSlice", () => ({
- getCalendarsListAsync: jest.fn(() => ({ type: "GET_CALENDARS" })),
-}));
+ return {
+ setUserData: jest.fn((data) => ({ type: "SET_USER", payload: data })),
+ setTokens: jest.fn((tokens) => ({ type: "SET_TOKENS", payload: tokens })),
+ getOpenPaasUserDataAsync: mockGetUser,
+ };
+});
+
+jest.mock("../../../src/features/Calendars/CalendarSlice", () => {
+ const mockGetCalendars = Object.assign(
+ jest.fn(() => ({ type: "GET_CALENDARS" })),
+ {
+ pending: { type: "GET_CALENDARS/pending" },
+ fulfilled: { type: "GET_CALENDARS/fulfilled" },
+ rejected: { type: "GET_CALENDARS/rejected" },
+ }
+ );
+
+ return {
+ getCalendarsListAsync: mockGetCalendars,
+ };
+});
describe("CallbackResume", () => {
const dispatch = jest.fn();
@@ -61,12 +83,26 @@ describe("CallbackResume", () => {
await waitFor(() => {
expect(oidcAuth.Callback).toHaveBeenCalledWith("verifier123", "state456");
+ });
+ await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setUserData(mockUserInfo));
+ });
+ await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(setTokens(mockTokenSet));
+ });
+ await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getOpenPaasUserDataAsync());
+ });
+ await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(getCalendarsListAsync());
+ });
+ await waitFor(() => {
expect(dispatch).toHaveBeenCalledWith(push("/"));
+ });
+ await waitFor(() => {
expect(sessionStorage.getItem("redirectState")).toBe(null);
+ });
+ await waitFor(() => {
expect(sessionStorage.getItem("tokenSet")).toEqual(
JSON.stringify(mockTokenSet)
);
diff --git a/__test__/features/user/userAPI.test.tsx b/__test__/features/user/userAPI.test.tsx
index da10527..80e2146 100644
--- a/__test__/features/user/userAPI.test.tsx
+++ b/__test__/features/user/userAPI.test.tsx
@@ -60,7 +60,7 @@ describe("updateUserConfigurations", () => {
name: "core",
configurations: [
{ name: "language", value: "fr" },
- { name: "timezone", value: "Europe/Paris" },
+ { name: "datetime", value: { timeZone: "Europe/Paris" } },
],
},
],
diff --git a/src/App.tsx b/src/App.tsx
index 4c68700..9cee1ef 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -41,7 +41,9 @@ const isValidLanguage = (
function App() {
const error = useAppSelector((state) => state.user.error);
- const userLanguage = useAppSelector((state) => state.user.language);
+ const userLanguage = useAppSelector(
+ (state) => state.user.coreConfig.language
+ );
const settingsLanguage = useAppSelector((state) => state.settings.language);
const savedLang = localStorage.getItem("lang");
const defaultLang = (window as any).LANG;
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index feda25a..8a1c77c 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -10,10 +10,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal";
import { CalendarEvent } from "../../features/Events/EventsTypes";
import CalendarSelection from "./CalendarSelection";
-import {
- getCalendarDetailAsync,
- setTimeZone,
-} from "../../features/Calendars/CalendarSlice";
+import { getCalendarDetailAsync } from "../../features/Calendars/CalendarSlice";
import ImportAlert from "../../features/Events/ImportAlert";
import {
formatDateToYYYYMMDDTHHMMSS,
@@ -46,6 +43,8 @@ import frLocale from "@fullcalendar/core/locales/fr";
import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import SearchResultsPage from "../../features/Search/SearchResultsPage";
+import { setTimeZone } from "../../features/Settings/SettingsSlice";
+import { browserDefaultTimeZone } from "../../utils/timezone";
const localeMap: Record = {
fr: frLocale,
@@ -108,7 +107,9 @@ export default function CalendarApp({
});
const [currentView, setCurrentView] = useState("timeGridWeek");
- const timezone = useAppSelector((state) => state.calendars.timeZone);
+ const timezone =
+ useAppSelector((state) => state.settings.timeZone) ??
+ browserDefaultTimeZone;
const fetchedRangesRef = useRef>({});
diff --git a/src/components/Calendar/TimezoneSelector.tsx b/src/components/Calendar/TimezoneSelector.tsx
index c181b01..b1ce042 100644
--- a/src/components/Calendar/TimezoneSelector.tsx
+++ b/src/components/Calendar/TimezoneSelector.tsx
@@ -2,6 +2,7 @@ import { Button, Popover } from "@mui/material";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import moment from "moment";
import { MouseEvent, useMemo, useState } from "react";
+import { browserDefaultTimeZone } from "../../utils/timezone";
import { TIMEZONES } from "../../utils/timezone-data";
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
@@ -18,14 +19,7 @@ export function TimezoneSelector({
}: TimezoneSelectProps) {
const [anchorEl, setAnchorEl] = useState(null);
- const timezoneList = useMemo(() => {
- const zones = Object.keys(TIMEZONES.zones).sort();
- const browserTz = resolveTimezone(
- Intl.DateTimeFormat().resolvedOptions().timeZone
- );
-
- return { zones, browserTz, getTimezoneOffset };
- }, []);
+ const timezoneList = useTimeZoneList();
const effectiveTimezone = value
? resolveTimezone(value)
@@ -97,6 +91,15 @@ export function TimezoneSelector({
);
}
+export function useTimeZoneList() {
+ return useMemo(() => {
+ const zones = Object.keys(TIMEZONES.zones).sort();
+ const browserTz = resolveTimezone(browserDefaultTimeZone);
+
+ return { zones, browserTz, getTimezoneOffset };
+ }, []);
+}
+
export function resolveTimezone(tzName: string): string {
if (TIMEZONES.zones[tzName]) {
return tzName;
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts
index 74c8835..6af7177 100644
--- a/src/features/Calendars/CalendarSlice.ts
+++ b/src/features/Calendars/CalendarSlice.ts
@@ -30,6 +30,7 @@ import { User } from "../../components/Attendees/PeopleSearch";
import { getCalendarVisibility } from "../../components/Calendar/utils/calendarUtils";
import { importFile } from "../../utils/apiUtils";
import { formatReduxError } from "../../utils/errorUtils";
+import { browserDefaultTimeZone } from "../../utils/timezone";
// Define error type for rejected actions
interface RejectedError {
@@ -653,13 +654,11 @@ const CalendarSlice = createSlice({
templist: {} as Record,
pending: false,
error: null as string | null,
- timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
} as {
list: Record;
templist: Record;
pending: boolean;
error: string | null;
- timeZone: string;
},
reducers: {
createCalendar: (
@@ -720,9 +719,6 @@ const CalendarSlice = createSlice({
const { calId, event } = action.payload;
state.list[calId].events[event.uid] = event;
},
- setTimeZone: (state, action: PayloadAction) => {
- state.timeZone = action.payload;
- },
clearFetchCache: (state, action: PayloadAction) => {
if (!state.list[action.payload]) return;
state.list[action.payload].lastCacheCleared = Date.now();
@@ -795,7 +791,7 @@ const CalendarSlice = createSlice({
action.payload.calId;
if (!state[type][action.payload.calId].events[id].timezone) {
state[type][action.payload.calId].events[id].timezone =
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ browserDefaultTimeZone;
}
}
);
@@ -831,7 +827,7 @@ const CalendarSlice = createSlice({
action.payload.calId;
if (!state[type][action.payload.calId].events[id].timezone) {
state[type][action.payload.calId].events[id].timezone =
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ browserDefaultTimeZone;
}
}
);
@@ -878,7 +874,7 @@ const CalendarSlice = createSlice({
action.payload.calId;
if (!state.list[action.payload.calId].events[id].timezone) {
state.list[action.payload.calId].events[id].timezone =
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ browserDefaultTimeZone;
}
});
}
@@ -1172,7 +1168,6 @@ export const {
updateEventLocal,
removeTempCal,
emptyEventsCal,
- setTimeZone,
clearFetchCache,
clearError,
updateCalColor,
diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx
index d2c0b93..f146206 100644
--- a/src/features/Events/EventDisplayPreview.tsx
+++ b/src/features/Events/EventDisplayPreview.tsx
@@ -49,6 +49,7 @@ import { CalendarEvent } from "./EventsTypes";
import EventUpdateModal from "./EventUpdateModal";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import { userAttendee } from "../User/userDataTypes";
+import { browserDefaultTimeZone } from "../../utils/timezone";
export default function EventPreviewModal({
eventId,
@@ -67,8 +68,8 @@ export default function EventPreviewModal({
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const timezone =
- useAppSelector((state) => state.calendars.timeZone) ??
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ useAppSelector((state) => state.settings.timeZone) ??
+ browserDefaultTimeZone;
const calendarList = Object.values(
useAppSelector((state) => state.calendars.list)
);
@@ -456,10 +457,10 @@ export default function EventPreviewModal({
)}
- {formatDate(event.start, t, event.allday)}
+ {formatDate(event.start, t, timezone, event.allday)}
{event.end &&
- formatEnd(event.start, event.end, t, event.allday) &&
- ` – ${formatEnd(event.start, event.end, t, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
+ formatEnd(event.start, event.end, t, timezone, event.allday) &&
+ ` – ${formatEnd(event.start, event.end, t, timezone, event.allday)} ${!event.allday ? getTimezoneOffset(timezone, new Date(event.start)) : ""}`}
>
}
@@ -952,13 +953,19 @@ function makeRecurrenceString(
return recur.join(", ");
}
-function formatDate(date: Date | string, t: Function, allday?: boolean) {
+function formatDate(
+ date: Date | string,
+ t: Function,
+ timeZone: string,
+ allday?: boolean
+) {
if (allday) {
return new Date(date).toLocaleDateString(t("locale"), {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
+ timeZone,
});
} else {
return new Date(date).toLocaleString(t("locale"), {
@@ -968,6 +975,7 @@ function formatDate(date: Date | string, t: Function, allday?: boolean) {
day: "numeric",
hour: "2-digit",
minute: "2-digit",
+ timeZone,
});
}
}
@@ -976,6 +984,7 @@ function formatEnd(
start: Date | string,
end: Date | string,
t: Function,
+ timeZone: string,
allday?: boolean
) {
const startDate = new Date(start);
@@ -993,12 +1002,14 @@ function formatEnd(
year: "numeric",
month: "short",
day: "numeric",
+ timeZone,
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(t("locale"), {
hour: "2-digit",
minute: "2-digit",
+ timeZone,
});
}
return endDate.toLocaleString(t("locale"), {
@@ -1007,6 +1018,7 @@ function formatEnd(
day: "numeric",
hour: "2-digit",
minute: "2-digit",
+ timeZone,
});
}
}
diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx
index 7d53109..93a3178 100644
--- a/src/features/Events/EventModal.tsx
+++ b/src/features/Events/EventModal.tsx
@@ -41,6 +41,7 @@ import {
restoreFormDataFromTemp,
EventFormState,
} from "../../utils/eventFormTempStorage";
+import { browserDefaultTimeZone } from "../../utils/timezone";
function EventPopover({
open,
@@ -84,17 +85,14 @@ function EventPopover({
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
- const browserTz = resolveTimezone(
- Intl.DateTimeFormat().resolvedOptions().timeZone
- );
+ const browserTz = resolveTimezone(browserDefaultTimeZone);
return { zones, browserTz, getTimezoneOffset };
}, []);
- const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
+ const calendarTimezone = useAppSelector((state) => state.settings.timeZone);
const resolvedCalendarTimezone = useMemo(() => {
- const tz =
- calendarTimezone || Intl.DateTimeFormat().resolvedOptions().timeZone;
+ const tz = calendarTimezone || browserDefaultTimeZone;
return resolveTimezone(tz);
}, [calendarTimezone]);
diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx
index 6d0275c..8572d9e 100644
--- a/src/features/Events/EventUpdateModal.tsx
+++ b/src/features/Events/EventUpdateModal.tsx
@@ -41,6 +41,7 @@ import {
EventFormState,
EventFormContext,
} from "../../utils/eventFormTempStorage";
+import { browserDefaultTimeZone } from "../../utils/timezone";
function EventUpdateModal({
eventId,
@@ -114,9 +115,7 @@ function EventUpdateModal({
const timezoneList = useMemo(() => {
const zones = Object.keys(TIMEZONES.zones).sort();
- const browserTz = resolveTimezone(
- Intl.DateTimeFormat().resolvedOptions().timeZone
- );
+ const browserTz = resolveTimezone(browserDefaultTimeZone);
const getTimezoneOffset = (tzName: string): string => {
const resolvedTz = resolveTimezone(tzName);
@@ -161,7 +160,7 @@ function EventUpdateModal({
const [busy, setBusy] = useState("OPAQUE");
const [eventClass, setEventClass] = useState("PUBLIC");
const [timezone, setTimezone] = useState(
- resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
+ resolveTimezone(browserDefaultTimeZone)
);
const [newCalId, setNewCalId] = useState(calId);
const defaultCalendarId = useMemo(
@@ -196,9 +195,7 @@ function EventUpdateModal({
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
- setTimezone(
- resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
- );
+ setTimezone(resolveTimezone(browserDefaultTimeZone));
setHasVideoConference(false);
setMeetingLink(null);
}, [defaultCalendarId]);
@@ -230,7 +227,7 @@ function EventUpdateModal({
// Get event's original timezone
const eventTimezone = event.timezone
? resolveTimezone(event.timezone)
- : resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
+ : resolveTimezone(browserDefaultTimeZone);
// Format dates based on all-day status
if (event.start) {
@@ -298,9 +295,7 @@ function EventUpdateModal({
const resolvedTimezone = resolveTimezone(event.timezone);
setTimezone(resolvedTimezone);
} else {
- const browserTz = resolveTimezone(
- Intl.DateTimeFormat().resolvedOptions().timeZone
- );
+ const browserTz = resolveTimezone(browserDefaultTimeZone);
setTimezone(browserTz);
}
setHasVideoConference(event.x_openpass_videoconference ? true : false);
diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts
index 1085026..994e472 100644
--- a/src/features/Events/formHelpers.ts
+++ b/src/features/Events/formHelpers.ts
@@ -4,6 +4,7 @@ import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { userAttendee } from "../User/userDataTypes";
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
+import { browserDefaultTimeZone } from "../../utils/timezone";
export interface TimezoneListResult {
zones: string[];
@@ -13,9 +14,7 @@ export interface TimezoneListResult {
export function createTimezoneList(): TimezoneListResult {
const zones = Object.keys(TIMEZONES.zones).sort();
- const browserTz = resolveTimezone(
- Intl.DateTimeFormat().resolvedOptions().timeZone
- );
+ const browserTz = resolveTimezone(browserDefaultTimeZone);
const getTimezoneOffset = (tzName: string): string => {
const resolvedTz = resolveTimezone(tzName);
@@ -101,8 +100,7 @@ export function populateFormFromEvent(
// Get event's timezone for formatting
const eventTimezone = event.timezone
? resolveTimezone(event.timezone)
- : calendarTimezone ||
- resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
+ : calendarTimezone || resolveTimezone(browserDefaultTimeZone);
// Format dates based on all-day status and timezone
if (event.start) {
diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx
index af273eb..ddbdb7b 100644
--- a/src/features/Settings/SettingsPage.tsx
+++ b/src/features/Settings/SettingsPage.tsx
@@ -13,19 +13,33 @@ import {
MenuItem,
Typography,
Snackbar,
+ FormControlLabel,
+ Switch,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SettingsIcon from "@mui/icons-material/Settings";
-import SyncIcon from "@mui/icons-material/Sync";
+// import SyncIcon from "@mui/icons-material/Sync";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
-import { setView, setLanguage as setSettingsLanguage } from "./SettingsSlice";
+import {
+ setView,
+ setLanguage as setSettingsLanguage,
+ setTimeZone as setSettingsTimeZone,
+ setIsBrowserDefaultTimeZone,
+} from "./SettingsSlice";
import {
updateUserConfigurationsAsync,
setLanguage as setUserLanguage,
+ setTimezone as setUserTimeZone,
} from "../User/userSlice";
import { AVAILABLE_LANGUAGES } from "./constants";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import "./SettingsPage.styl";
+import {
+ useTimeZoneList,
+ getTimezoneOffset,
+} from "../../components/Calendar/TimezoneSelector";
+import { TimezoneAutocomplete } from "../../components/Timezone/TimezoneAutocomplete";
+import { browserDefaultTimeZone } from "../../utils/timezone";
type SidebarNavItem = "settings" | "sync";
type SettingsSubTab = "settings" | "notifications";
@@ -33,14 +47,30 @@ type SettingsSubTab = "settings" | "notifications";
export default function SettingsPage() {
const dispatch = useAppDispatch();
const { t } = useI18n();
- const userLanguage = useAppSelector((state) => state.user?.language);
+ // const previousConfig = useAppSelector((state) => state.user.coreConfig);
+ const userLanguage = useAppSelector(
+ (state) => state.user?.coreConfig.language
+ );
const settingsLanguage = useAppSelector((state) => state.settings?.language);
const currentLanguage = userLanguage || settingsLanguage || "en";
+
+ const timezoneList = useTimeZoneList();
+ const userTimeZone = useAppSelector(
+ (state) => state.user?.coreConfig?.datetime?.timeZone
+ );
+ const settingTimeZone = useAppSelector((state) => state.settings?.timeZone);
+ const currentTimeZone =
+ userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone;
+ const isBrowserDefault = useAppSelector(
+ (state) => state.settings.isBrowserDefaultTimeZone
+ );
+
const [activeNavItem, setActiveNavItem] =
useState("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState("settings");
const [languageErrorOpen, setLanguageErrorOpen] = useState(false);
+ const [timeZoneErrorOpen, setTimeZoneErrorOpen] = useState(false);
const handleBackClick = () => {
dispatch(setView("calendar"));
@@ -84,6 +114,53 @@ export default function SettingsPage() {
setLanguageErrorOpen(false);
};
+ const handleTimeZoneChange = (newTimeZone: string) => {
+ // const previousTimeZone = currentTimeZone;
+
+ // Optimistic update - update UI immediately
+ dispatch(setUserTimeZone(newTimeZone));
+ dispatch(setSettingsTimeZone(newTimeZone));
+
+ // // Call API in background, don't wait for it
+ // dispatch(
+ // updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig })
+ // )
+ // .unwrap()
+ // .catch((error) => {
+ // console.error("Failed to update TimeZone:", error);
+ // // Rollback on error
+ // dispatch(setUserTimeZone(previousTimeZone));
+ // dispatch(setSettingsTimeZone(previousTimeZone));
+ // setTimeZoneErrorOpen(true);
+ // });
+ };
+
+ const handleTimeZoneDefaultChange = (isDefault: boolean) => {
+ // Optimistic update - update UI immediately
+ dispatch(setIsBrowserDefaultTimeZone(isDefault));
+ if (isDefault) {
+ dispatch(setUserTimeZone(null));
+ dispatch(setSettingsTimeZone(browserDefaultTimeZone));
+ }
+
+ // // Call API in background, don't wait for it
+ // dispatch(
+ // updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig })
+ // )
+ // .unwrap()
+ // .catch((error) => {
+ // console.error("Failed to update TimeZone:", error);
+ // // Rollback on error
+ // dispatch(setUserTimeZone(previousTimeZone));
+ // dispatch(setSettingsTimeZone(previousTimeZone));
+ // setTimeZoneErrorOpen(true);
+ // });
+ };
+
+ const handleTimeZoneErrorClose = () => {
+ setTimeZoneErrorOpen(false);
+ };
+
return (
@@ -138,33 +215,75 @@ export default function SettingsPage() {
<>
{activeSettingsSubTab === "settings" && (
-
- {t("settings.language") || "Language"}
-
-
- {t("settings.languageDescription") ||
- "This will be the language used in your Twake Calendar"}
-
-
-
-
+ {t("settings.languageDescription") ||
+ "This will be the language used in your Twake Calendar"}
+
+
+
+
+
+
+
+ {t("settings.timeZone")}
+
+
+
+
+ handleTimeZoneDefaultChange(!isBrowserDefault)
+ }
+ aria-label={t("settings.timeZoneBrowserDefault")}
+ />
+ }
+ label={t("settings.timeZoneBrowserDefault")}
+ labelPlacement="start"
+ sx={{
+ minWidth: 400,
+ justifyContent: "space-between",
+ marginLeft: 0,
+ mb: 2,
+ }}
+ />
+ {!isBrowserDefault && (
+
+ )}
+
+
+
)}
{activeSettingsSubTab === "notifications" && (
@@ -194,6 +313,12 @@ export default function SettingsPage() {
t("settings.languageUpdateError") || "Failed to update language"
}
/>
+
);
}
diff --git a/src/features/Settings/SettingsSlice.ts b/src/features/Settings/SettingsSlice.ts
index 9c36e46..3a036af 100644
--- a/src/features/Settings/SettingsSlice.ts
+++ b/src/features/Settings/SettingsSlice.ts
@@ -1,15 +1,26 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
+import { browserDefaultTimeZone } from "../../utils/timezone";
+import { getOpenPaasUserDataAsync } from "../User/userSlice";
export interface SettingsState {
language: string;
+ timeZone: string | null; // Allow null to represent browser default
+ isBrowserDefaultTimeZone: boolean;
view: "calendar" | "settings" | "search";
}
const savedLang = localStorage.getItem("lang");
const defaultLang = savedLang ?? (window as any).LANG ?? "en";
+const savedTimeZone = localStorage.getItem("timeZone");
+// If savedTimeZone is the string "null" or doesn't exist, use null
+const defaultTimeZone =
+ savedTimeZone === "null" || !savedTimeZone ? null : savedTimeZone;
+
const initialState: SettingsState = {
language: defaultLang,
+ timeZone: defaultTimeZone,
+ isBrowserDefaultTimeZone: defaultTimeZone === null,
view: "calendar",
};
@@ -21,6 +32,13 @@ export const settingsSlice = createSlice({
state.language = action.payload;
localStorage.setItem("lang", action.payload);
},
+ setTimeZone: (state, action: PayloadAction) => {
+ state.timeZone = action.payload;
+ localStorage.setItem("timeZone", action.payload);
+ },
+ setIsBrowserDefaultTimeZone: (state, action: PayloadAction) => {
+ state.isBrowserDefaultTimeZone = action.payload;
+ },
setView: (
state,
action: PayloadAction<"calendar" | "settings" | "search">
@@ -28,7 +46,33 @@ export const settingsSlice = createSlice({
state.view = action.payload;
},
},
+ extraReducers: (builder) => {
+ builder.addCase(getOpenPaasUserDataAsync.fulfilled, (state, action) => {
+ const coreModule = action.payload.configurations?.modules?.find(
+ (module: any) => module.name === "core"
+ );
+ const datetimeConfig = coreModule?.configurations?.find(
+ (config: any) => config.name === "datetime"
+ );
+ const timeZone = datetimeConfig?.value?.timeZone;
+
+ if (timeZone) {
+ state.timeZone = timeZone;
+ state.isBrowserDefaultTimeZone = false;
+ localStorage.setItem("timeZone", timeZone);
+ } else {
+ state.timeZone = browserDefaultTimeZone;
+ state.isBrowserDefaultTimeZone = true;
+ localStorage.setItem("timeZone", browserDefaultTimeZone);
+ }
+ });
+ },
});
-export const { setLanguage, setView } = settingsSlice.actions;
+export const {
+ setLanguage,
+ setTimeZone,
+ setView,
+ setIsBrowserDefaultTimeZone,
+} = settingsSlice.actions;
export default settingsSlice.reducer;
diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts
index 505aa6d..ff0c5d9 100644
--- a/src/features/User/userAPI.ts
+++ b/src/features/User/userAPI.ts
@@ -2,8 +2,8 @@ import { User } from "../../components/Attendees/PeopleSearch";
import { api } from "../../utils/apiUtils";
export async function getOpenPaasUser() {
- const user = await api.get(`api/user`).json();
- return user;
+ const user = await api.get(`api/user`);
+ return user.json();
}
export async function searchUsers(
@@ -38,6 +38,7 @@ export interface UserConfigurationUpdates {
language?: string;
notifications?: Record;
timezone?: string;
+ previousConfig?: Record;
}
export async function updateUserConfigurations(
@@ -52,7 +53,13 @@ export async function updateUserConfigurations(
coreConfigs.push({ name: "notifications", value: updates.notifications });
}
if (updates.timezone !== undefined) {
- coreConfigs.push({ name: "timezone", value: updates.timezone });
+ coreConfigs.push({
+ name: "datetime",
+ value: {
+ ...updates.previousConfig?.datetime,
+ timeZone: updates.timezone,
+ },
+ });
}
if (coreConfigs.length === 0) {
diff --git a/src/features/User/userDataTypes.ts b/src/features/User/userDataTypes.ts
index d490272..5f3b426 100644
--- a/src/features/User/userDataTypes.ts
+++ b/src/features/User/userDataTypes.ts
@@ -7,6 +7,7 @@ export interface userData {
sub: string;
openpaasId?: string;
language?: string;
+ timezone?: string | null;
}
export interface UserConfigurations {
diff --git a/src/features/User/userSlice.ts b/src/features/User/userSlice.ts
index 4e9ca75..19758f1 100644
--- a/src/features/User/userSlice.ts
+++ b/src/features/User/userSlice.ts
@@ -45,7 +45,12 @@ export const userSlice = createSlice({
userData: null as unknown as userData,
organiserData: null as unknown as userOrganiser,
tokens: null as unknown as Record,
- language: null as string | null,
+ coreConfig: {
+ language: null as string | null,
+ datetime: {
+ timeZone: null as string | null,
+ },
+ } as Record,
loading: true,
error: null as unknown as string | null,
},
@@ -63,11 +68,20 @@ export const userSlice = createSlice({
state.tokens = action.payload;
},
setLanguage: (state, action) => {
- state.language = action.payload;
+ state.coreConfig.language = action.payload;
if (state.userData) {
state.userData.language = action.payload;
}
},
+ setTimezone: (state, action) => {
+ if (!state.coreConfig.datetime) {
+ state.coreConfig.datetime = {};
+ }
+ state.coreConfig.datetime.timeZone = action.payload;
+ if (state.userData) {
+ state.userData.timezone = action.payload;
+ }
+ },
clearError: (state) => {
state.error = null;
},
@@ -90,18 +104,53 @@ export const userSlice = createSlice({
state.userData.email = action.payload.preferredEmail;
}
- // Extract language from configurations.modules
+ // Extract data from configurations.modules
if (action.payload.configurations?.modules) {
const coreModule = action.payload.configurations.modules.find(
(module: any) => module.name === "core"
);
if (coreModule?.configurations) {
+ const newCoreConfig = Object.fromEntries(
+ coreModule.configurations.map(
+ (e: { name: string; value: any }) => [e.name, e.value]
+ )
+ );
+
+ state.coreConfig = {
+ ...state.coreConfig,
+ ...newCoreConfig,
+ };
const languageConfig = coreModule.configurations.find(
(config: any) => config.name === "language"
);
if (languageConfig?.value) {
- state.language = languageConfig.value;
- state.userData.language = languageConfig.value;
+ state.coreConfig.language = languageConfig.value;
+ if (state.userData)
+ state.userData.language = languageConfig.value;
+ }
+
+ const datetimeConfig = coreModule.configurations.find(
+ (config: any) => config.name === "datetime"
+ );
+ if (datetimeConfig?.value) {
+ const serverTimeZone = datetimeConfig.value.timeZone;
+ state.coreConfig.datetime = {
+ ...state.coreConfig.datetime,
+ ...datetimeConfig.value,
+ timeZone: serverTimeZone !== undefined ? serverTimeZone : null,
+ };
+ if (state.userData) {
+ state.userData.timezone =
+ serverTimeZone !== undefined ? serverTimeZone : null;
+ }
+ } else {
+ state.coreConfig.datetime = {
+ ...state.coreConfig.datetime,
+ timeZone: null,
+ };
+ if (state.userData) {
+ state.userData.timezone = null;
+ }
}
}
}
@@ -118,11 +167,20 @@ export const userSlice = createSlice({
})
.addCase(updateUserConfigurationsAsync.fulfilled, (state, action) => {
if (action.payload.language !== undefined) {
- state.language = action.payload.language;
+ state.coreConfig.language = action.payload.language;
if (state.userData) {
state.userData.language = action.payload.language;
}
}
+ if (action.payload.timezone !== undefined) {
+ if (!state.coreConfig.datetime) {
+ state.coreConfig.datetime = {};
+ }
+ state.coreConfig.datetime.timeZone = action.payload.timezone;
+ if (state.userData) {
+ state.userData.timezone = action.payload.timezone;
+ }
+ }
})
.addCase(updateUserConfigurationsAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) {
@@ -134,7 +192,7 @@ export const userSlice = createSlice({
});
// Action creators are generated for each case reducer function
-export const { setUserData, setTokens, setLanguage, clearError } =
+export const { setUserData, setTokens, setLanguage, setTimezone, clearError } =
userSlice.actions;
export default userSlice.reducer;
diff --git a/src/locales/en.json b/src/locales/en.json
index d6ae4d1..3875ed0 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -212,6 +212,10 @@
"language": "Language",
"languageDescription": "This will be the language used in your Twake Calendar",
"languageSelector": "Language selector",
+ "languageUpdateError": "Failed to update language",
+ "timeZone": "Timezone",
+ "timeZoneBrowserDefault": "Detect time zone automatically",
+ "timeZoneUpdateError": "Failed to update time zone",
"notifications.empty": "Notifications settings coming soon",
"sync.empty": "Sync settings coming soon",
"back": "Back to calendar"
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 37ee3cb..785d9ab 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -212,6 +212,10 @@
"language": "Langue",
"languageDescription": "Ce sera la langue utilisée dans votre Twake Calendar",
"languageSelector": "Sélecteur de langue",
+ "languageUpdateError": "Échec de la mise à jour de la langue",
+ "timeZone": "Fuseau horaire",
+ "timeZoneBrowserDefault": "Détection automatique du fuseau horaire",
+ "timeZoneUpdateError": "Échec de la mise à jour du fuseau horaire",
"notifications.empty": "Paramètres de notifications à venir",
"sync.empty": "Paramètres de synchronisation à venir",
"back": "Retour au calendrier"
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 35d77fc..9ee5aa8 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -212,6 +212,10 @@
"language": "Язык",
"languageDescription": "Это будет язык, используемый в вашем Twake Calendar",
"languageSelector": "Выбор языка",
+ "languageUpdateError": "Не удалось обновить язык",
+ "timeZone": "Часовой пояс",
+ "timeZoneBrowserDefault": "Определять часовой пояс автоматически",
+ "timeZoneUpdateError": "Не удалось обновить часовой пояс",
"notifications.empty": "Настройки уведомлений скоро появятся",
"sync.empty": "Настройки синхронизации скоро появятся",
"back": "Вернуться к календарю"
diff --git a/src/locales/vi.json b/src/locales/vi.json
index f9aafbc..2ac32f9 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -212,6 +212,10 @@
"language": "Ngôn ngữ",
"languageDescription": "Đây sẽ là ngôn ngữ được sử dụng trong Twake Calendar của bạn",
"languageSelector": "Chọn ngôn ngữ",
+ "languageUpdateError": "Không cập nhật được ngôn ngữ",
+ "timeZone": "Múi giờ",
+ "timeZoneBrowserDefault": "Tự động phát hiện múi giờ",
+ "timeZoneUpdateError": "Không cập nhật được múi giờ",
"notifications.empty": "Cài đặt thông báo sắp có",
"sync.empty": "Cài đặt đồng bộ sắp có",
"back": "Quay lại lịch"
diff --git a/src/utils/timezone.ts b/src/utils/timezone.ts
index f5922ea..6c7f92f 100644
--- a/src/utils/timezone.ts
+++ b/src/utils/timezone.ts
@@ -6,6 +6,9 @@ import ICAL from "ical.js";
// TIMEZONES data must be imported or defined separately.
import { TIMEZONES } from "./timezone-data";
+export const browserDefaultTimeZone =
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
+
// Core timezone registration functionality
export function registerTimezones() {
for (const [key, data] of Object.entries(TIMEZONES.zones)) {