From 193c1a7166ad969009d0132f37ffa1d219c0e5dc Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Thu, 4 Dec 2025 15:14:43 +0100 Subject: [PATCH] [#337] added timezone loading from backend (#386) * [#337] added timezone loading from backend * [#337] changed data structure to allow patching timezone while keeping other datetime params value * [#337] fixed gap, event create modale sync with settings and added checkbox * [#337] isBrowserDefaultTimezone is set to true when API return null timezone Co-authored-by: Camille Moussu --- .../Calendars/TimezoneInGrid.test.tsx | 8 +- .../Settings/SettingUserSync.test.tsx | 477 ++++++++++++++++++ .../features/Settings/SettingsPage.test.tsx | 151 +++++- __test__/features/user/LoginCallback.test.tsx | 52 +- __test__/features/user/userAPI.test.tsx | 2 +- src/App.tsx | 4 +- src/components/Calendar/Calendar.tsx | 11 +- src/components/Calendar/TimezoneSelector.tsx | 19 +- src/features/Calendars/CalendarSlice.ts | 13 +- src/features/Events/EventDisplayPreview.tsx | 24 +- src/features/Events/EventModal.tsx | 10 +- src/features/Events/EventUpdateModal.tsx | 17 +- src/features/Events/formHelpers.ts | 8 +- src/features/Settings/SettingsPage.tsx | 183 +++++-- src/features/Settings/SettingsSlice.ts | 46 +- src/features/User/userAPI.ts | 13 +- src/features/User/userDataTypes.ts | 1 + src/features/User/userSlice.ts | 72 ++- src/locales/en.json | 4 + src/locales/fr.json | 4 + src/locales/ru.json | 4 + src/locales/vi.json | 4 + src/utils/timezone.ts | 3 + 23 files changed, 1021 insertions(+), 109 deletions(-) create mode 100644 __test__/features/Settings/SettingUserSync.test.tsx 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)) {