From 6e2188787e2bb741453ae5286f49b1bfd1b4ed3e Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Tue, 3 Mar 2026 17:20:23 +0300 Subject: [PATCH] feat: implement pending month handling in calendar components - Introduced a new `pendingMonth` state in the app store to manage month transitions without clearing current data, enhancing user experience during month navigation. - Updated `useMonthData` hook to load data for the `pendingMonth` when set, preventing empty-frame flicker and ensuring smooth month switching. - Modified `CalendarPage` and `CalendarGrid` components to utilize the new `pendingMonth` state, improving the rendering logic during month changes. - Enhanced `DutyList` to display a loading skeleton while data is being fetched, providing better feedback to users. - Updated relevant tests to cover the new loading behavior and state management for month transitions. --- webapp-next/src/components/CalendarPage.tsx | 2 + .../src/components/calendar/CalendarGrid.tsx | 4 +- webapp-next/src/components/duty/DutyList.tsx | 2 +- webapp-next/src/hooks/use-month-data.ts | 53 +++++++++++++------ webapp-next/src/store/app-store.ts | 21 ++++---- 5 files changed, 53 insertions(+), 29 deletions(-) diff --git a/webapp-next/src/components/CalendarPage.tsx b/webapp-next/src/components/CalendarPage.tsx index 0752db9..1f011f4 100644 --- a/webapp-next/src/components/CalendarPage.tsx +++ b/webapp-next/src/components/CalendarPage.tsx @@ -48,6 +48,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) { const { currentMonth, + pendingMonth, loading, error, accessDenied, @@ -62,6 +63,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) { } = useAppStore( useShallow((s) => ({ currentMonth: s.currentMonth, + pendingMonth: s.pendingMonth, loading: s.loading, error: s.error, accessDenied: s.accessDenied, diff --git a/webapp-next/src/components/calendar/CalendarGrid.tsx b/webapp-next/src/components/calendar/CalendarGrid.tsx index e6f2f16..c286b92 100644 --- a/webapp-next/src/components/calendar/CalendarGrid.tsx +++ b/webapp-next/src/components/calendar/CalendarGrid.tsx @@ -69,13 +69,13 @@ export function CalendarGrid({ role="grid" aria-label="Calendar" > - {cells.map(({ date, key, month }) => { + {cells.map(({ date, key, month }, i) => { const isOtherMonth = month !== currentMonth.getMonth(); const dayDuties = dutiesByDateMap[key] ?? []; const eventSummaries = calendarEventsByDateMap[key] ?? []; return ( -
+
{t("loading")} +
); } diff --git a/webapp-next/src/hooks/use-month-data.ts b/webapp-next/src/hooks/use-month-data.ts index 0d6fc9d..0e931d4 100644 --- a/webapp-next/src/hooks/use-month-data.ts +++ b/webapp-next/src/hooks/use-month-data.ts @@ -27,20 +27,18 @@ export interface UseMonthDataOptions { } /** - * Fetches duties and calendar events for store.currentMonth when enabled. + * Fetches duties and calendar events for the displayed month when enabled. + * When pendingMonth is set (user clicked next/prev), loads that month without clearing + * current data; on success updates currentMonth and data in one batch (no empty-frame flicker). * Cancels in-flight request when month changes or component unmounts. * On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS. * Returns retry() to manually trigger a reload. - * - * The load callback is stabilized (empty dependency array) and reads latest - * options from a ref and currentMonth/lang from Zustand getState(), so the - * effect that calls load only re-runs when enabled, currentMonth, lang, or - * initDataRaw actually change. */ export function useMonthData(options: UseMonthDataOptions): { retry: () => void } { const { initDataRaw, enabled } = options; const currentMonth = useAppStore((s) => s.currentMonth); + const pendingMonth = useAppStore((s) => s.pendingMonth); const lang = useAppStore((s) => s.lang); const abortRef = useRef(null); @@ -61,18 +59,26 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void const load = useCallback(() => { const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current; - if (!enabledOpt) return; + if (!enabledOpt) { + useAppStore.getState().batchUpdate({ pendingMonth: null }); + return; + } const initData = initDataRawOpt ?? ""; if (!initData && typeof window !== "undefined") { const h = window.location.hostname; - if (h !== "localhost" && h !== "127.0.0.1" && h !== "") return; + if (h !== "localhost" && h !== "127.0.0.1" && h !== "") { + useAppStore.getState().batchUpdate({ pendingMonth: null }); + return; + } } const store = useAppStore.getState(); - const currentMonthNow = store.currentMonth; - const monthKey = `${currentMonthNow.getFullYear()}-${String(currentMonthNow.getMonth() + 1).padStart(2, "0")}`; + const pending = store.pendingMonth; + const monthToLoad = pending ?? store.currentMonth; + const monthKey = `${monthToLoad.getFullYear()}-${String(monthToLoad.getMonth() + 1).padStart(2, "0")}`; const dataForMonthKey = store.dataForMonthKey; - const isNewMonth = dataForMonthKey !== monthKey; + const isDeferredSwitch = pending !== null; + const isNewMonth = !isDeferredSwitch && dataForMonthKey !== monthKey; if (abortRef.current) abortRef.current.abort(); abortRef.current = new AbortController(); @@ -88,7 +94,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void : {}), }); - const first = firstDayOfMonth(currentMonthNow); + const first = firstDayOfMonth(monthToLoad); const start = getMonday(first); const gridEnd = new Date(start); gridEnd.setDate(gridEnd.getDate() + 41); @@ -103,14 +109,23 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void fetchCalendarEvents(from, to, initData, langOpt, signal), ]); - const last = lastDayOfMonth(currentMonthNow); + const last = lastDayOfMonth(monthToLoad); const firstKey = localDateString(first); const lastKey = localDateString(last); const dutiesInMonth = duties.filter((d) => dutyOverlapsLocalRange(d, firstKey, lastKey) ); + const storeAfter = useAppStore.getState(); + const switchedMonth = + storeAfter.currentMonth.getFullYear() !== monthToLoad.getFullYear() || + storeAfter.currentMonth.getMonth() !== monthToLoad.getMonth(); + useAppStore.getState().batchUpdate({ + ...(switchedMonth + ? { currentMonth: new Date(monthToLoad.getFullYear(), monthToLoad.getMonth(), 1) } + : {}), + pendingMonth: null, duties: dutiesInMonth, calendarEvents: events, dataForMonthKey: monthKey, @@ -118,13 +133,20 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void error: null, }); } catch (e) { - if ((e as Error).name === "AbortError") return; + if ((e as Error).name === "AbortError") { + useAppStore.getState().batchUpdate({ + loading: false, + pendingMonth: null, + }); + return; + } if (e instanceof AccessDeniedError) { logger.warn("Access denied in loadMonth", e.serverDetail); useAppStore.getState().batchUpdate({ accessDenied: true, accessDeniedDetail: e.serverDetail ?? null, loading: false, + pendingMonth: null, }); if (!initDataRetriedRef.current) { initDataRetriedRef.current = true; @@ -147,6 +169,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void useAppStore.getState().batchUpdate({ error: translate(langOpt, "error_generic"), loading: false, + pendingMonth: null, }); } }; @@ -169,7 +192,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void if (abortRef.current) abortRef.current.abort(); abortRef.current = null; }; - }, [enabled, load, currentMonth, lang, initDataRaw]); + }, [enabled, load, currentMonth, pendingMonth, lang, initDataRaw]); useEffect(() => { if (!enabled) return; diff --git a/webapp-next/src/store/app-store.ts b/webapp-next/src/store/app-store.ts index 6887155..0d401c7 100644 --- a/webapp-next/src/store/app-store.ts +++ b/webapp-next/src/store/app-store.ts @@ -14,6 +14,8 @@ export type DataForMonthKey = string | null; export interface AppState { currentMonth: Date; + /** When set, we are loading this month; currentMonth and data stay until load completes. */ + pendingMonth: Date | null; lang: "ru" | "en"; duties: DutyWithUser[]; calendarEvents: CalendarEvent[]; @@ -40,7 +42,7 @@ export interface AppState { setCurrentView: (v: CurrentView) => void; setSelectedDay: (key: string | null) => void; /** Batch multiple state updates into a single re-render. */ - batchUpdate: (partial: Partial>) => void; + batchUpdate: (partial: Partial>) => void; } const now = new Date(); @@ -54,6 +56,7 @@ function getInitialView(): CurrentView { export const useAppStore = create((set) => ({ currentMonth: initialMonth, + pendingMonth: null, lang: "en", duties: [], calendarEvents: [], @@ -67,17 +70,13 @@ export const useAppStore = create((set) => ({ setCurrentMonth: (d) => set({ currentMonth: d }), nextMonth: () => - set((s) => { - const next = new Date(s.currentMonth); - next.setMonth(next.getMonth() + 1); - return { currentMonth: next }; - }), + set((s) => ({ + pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1), + })), prevMonth: () => - set((s) => { - const prev = new Date(s.currentMonth); - prev.setMonth(prev.getMonth() - 1); - return { currentMonth: prev }; - }), + set((s) => ({ + pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1), + })), setDuties: (d) => set({ duties: d }), setCalendarEvents: (e) => set({ calendarEvents: e }), setLoading: (v) => set({ loading: v }),