* [#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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>>({});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface userData {
|
||||
sub: string;
|
||||
openpaasId?: string;
|
||||
language?: string;
|
||||
timezone?: string | null;
|
||||
}
|
||||
|
||||
export interface UserConfigurations {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -212,6 +212,10 @@
|
||||
"language": "Язык",
|
||||
"languageDescription": "Это будет язык, используемый в вашем Twake Calendar",
|
||||
"languageSelector": "Выбор языка",
|
||||
"languageUpdateError": "Не удалось обновить язык",
|
||||
"timeZone": "Часовой пояс",
|
||||
"timeZoneBrowserDefault": "Определять часовой пояс автоматически",
|
||||
"timeZoneUpdateError": "Не удалось обновить часовой пояс",
|
||||
"notifications.empty": "Настройки уведомлений скоро появятся",
|
||||
"sync.empty": "Настройки синхронизации скоро появятся",
|
||||
"back": "Вернуться к календарю"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user