[#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 <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-12-04 15:14:43 +01:00
committed by GitHub
parent d8f5f9ae46
commit 193c1a7166
23 changed files with 1021 additions and 109 deletions
@@ -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(
<CalendarApp calendarRef={mockCalendarRef} />,
@@ -226,8 +225,7 @@ describe("EventDisplayPreview - Timezone Display", () => {
for (const { tz, expectedOffset } of timezones) {
const state = {
...baseState,
calendars: {
...baseState.calendars,
settings: {
timeZone: tz,
},
};
@@ -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<string, string> = {};
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(<SettingsPage />, {
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(<SettingsPage />, {
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);
});
});
@@ -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(<SettingsPage />, 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(<SettingsPage />, 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(<SettingsPage />, 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(<SettingsPage />, stateWithUserTimeZone);
expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined();
});
it("updates timezone immediately (optimistic update)", async () => {
const { store } = renderWithProviders(<SettingsPage />, 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(<SettingsPage />, 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(<SettingsPage />, stateWithoutTimeZone);
const timezoneInput = screen.getByDisplayValue("(UTC) UTC");
expect(timezoneInput).toBeDefined();
});
});
});
+44 -8
View File
@@ -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)
);
+1 -1
View File
@@ -60,7 +60,7 @@ describe("updateUserConfigurations", () => {
name: "core",
configurations: [
{ name: "language", value: "fr" },
{ name: "timezone", value: "Europe/Paris" },
{ name: "datetime", value: { timeZone: "Europe/Paris" } },
],
},
],
+3 -1
View File
@@ -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;
+6 -5
View File
@@ -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<string, any> = {
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<Record<string, string>>({});
+11 -8
View File
@@ -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<HTMLElement | null>(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;
+4 -9
View File
@@ -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<string, Calendars>,
pending: false,
error: null as string | null,
timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone,
} as {
list: Record<string, Calendars>;
templist: Record<string, Calendars>;
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<string>) => {
state.timeZone = action.payload;
},
clearFetchCache: (state, action: PayloadAction<string>) => {
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,
+18 -6
View File
@@ -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({
)}
</Box>
<Typography color="text.secondaryContainer" gutterBottom>
{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)) : ""}`}
</Typography>
</>
}
@@ -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,
});
}
}
+4 -6
View File
@@ -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]);
+6 -11
View File
@@ -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);
+3 -5
View File
@@ -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) {
+154 -29
View File
@@ -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<SidebarNavItem>("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState<SettingsSubTab>("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 (
<main className="main-layout settings-layout">
<Box className="settings-sidebar">
@@ -138,33 +215,75 @@ export default function SettingsPage() {
<>
{activeSettingsSubTab === "settings" && (
<Box className="settings-tab-content">
<Typography variant="h6" sx={{ mb: 1 }}>
{t("settings.language") || "Language"}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 3 }}
>
{t("settings.languageDescription") ||
"This will be the language used in your Twake Calendar"}
</Typography>
<FormControl size="small" sx={{ minWidth: 500 }}>
<Select
value={currentLanguage}
onChange={handleLanguageChange}
variant="outlined"
aria-label={
t("settings.languageSelector") || "Language selector"
}
<Box sx={{ mb: 6 }}>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("settings.language") || "Language"}
</Typography>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 3 }}
>
{AVAILABLE_LANGUAGES.map(({ code, label }) => (
<MenuItem key={code} value={code}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
{t("settings.languageDescription") ||
"This will be the language used in your Twake Calendar"}
</Typography>
<FormControl size="small" sx={{ minWidth: 500 }}>
<Select
value={currentLanguage}
onChange={handleLanguageChange}
variant="outlined"
aria-label={
t("settings.languageSelector") || "Language selector"
}
>
{AVAILABLE_LANGUAGES.map(({ code, label }) => (
<MenuItem key={code} value={code}>
{label}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<Box>
<Typography variant="h6" sx={{ mb: 1 }}>
{t("settings.timeZone")}
</Typography>
<Box
sx={{
mb: 6,
}}
>
<FormControl size="small" sx={{ minWidth: 500 }}>
<FormControlLabel
control={
<Switch
checked={isBrowserDefault}
onChange={() =>
handleTimeZoneDefaultChange(!isBrowserDefault)
}
aria-label={t("settings.timeZoneBrowserDefault")}
/>
}
label={t("settings.timeZoneBrowserDefault")}
labelPlacement="start"
sx={{
minWidth: 400,
justifyContent: "space-between",
marginLeft: 0,
mb: 2,
}}
/>
{!isBrowserDefault && (
<TimezoneAutocomplete
value={currentTimeZone}
zones={timezoneList.zones}
getTimezoneOffset={getTimezoneOffset}
onChange={handleTimeZoneChange}
/>
)}
</FormControl>
</Box>
</Box>
</Box>
)}
{activeSettingsSubTab === "notifications" && (
@@ -194,6 +313,12 @@ export default function SettingsPage() {
t("settings.languageUpdateError") || "Failed to update language"
}
/>
<Snackbar
open={timeZoneErrorOpen}
autoHideDuration={4000}
onClose={handleTimeZoneErrorClose}
message={t("settings.timeZoneUpdateError")}
/>
</main>
);
}
+45 -1
View File
@@ -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<string>) => {
state.timeZone = action.payload;
localStorage.setItem("timeZone", action.payload);
},
setIsBrowserDefaultTimeZone: (state, action: PayloadAction<boolean>) => {
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;
+10 -3
View File
@@ -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<string, unknown>;
timezone?: string;
previousConfig?: Record<string, any>;
}
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) {
+1
View File
@@ -7,6 +7,7 @@ export interface userData {
sub: string;
openpaasId?: string;
language?: string;
timezone?: string | null;
}
export interface UserConfigurations {
+65 -7
View File
@@ -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<string, string>,
language: null as string | null,
coreConfig: {
language: null as string | null,
datetime: {
timeZone: null as string | null,
},
} as Record<string, any>,
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;
+4
View File
@@ -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"
+4
View File
@@ -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"
+4
View File
@@ -212,6 +212,10 @@
"language": "Язык",
"languageDescription": "Это будет язык, используемый в вашем Twake Calendar",
"languageSelector": "Выбор языка",
"languageUpdateError": "Не удалось обновить язык",
"timeZone": "Часовой пояс",
"timeZoneBrowserDefault": "Определять часовой пояс автоматически",
"timeZoneUpdateError": "Не удалось обновить часовой пояс",
"notifications.empty": "Настройки уведомлений скоро появятся",
"sync.empty": "Настройки синхронизации скоро появятся",
"back": "Вернуться к календарю"
+4
View File
@@ -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"
+3
View File
@@ -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)) {