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.
This commit is contained in:
2026-03-03 17:20:23 +03:00
parent fd527917e0
commit 6e2188787e
5 changed files with 53 additions and 29 deletions

View File

@@ -48,6 +48,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const { const {
currentMonth, currentMonth,
pendingMonth,
loading, loading,
error, error,
accessDenied, accessDenied,
@@ -62,6 +63,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
} = useAppStore( } = useAppStore(
useShallow((s) => ({ useShallow((s) => ({
currentMonth: s.currentMonth, currentMonth: s.currentMonth,
pendingMonth: s.pendingMonth,
loading: s.loading, loading: s.loading,
error: s.error, error: s.error,
accessDenied: s.accessDenied, accessDenied: s.accessDenied,

View File

@@ -69,13 +69,13 @@ export function CalendarGrid({
role="grid" role="grid"
aria-label="Calendar" aria-label="Calendar"
> >
{cells.map(({ date, key, month }) => { {cells.map(({ date, key, month }, i) => {
const isOtherMonth = month !== currentMonth.getMonth(); const isOtherMonth = month !== currentMonth.getMonth();
const dayDuties = dutiesByDateMap[key] ?? []; const dayDuties = dutiesByDateMap[key] ?? [];
const eventSummaries = calendarEventsByDateMap[key] ?? []; const eventSummaries = calendarEventsByDateMap[key] ?? [];
return ( return (
<div key={key} role="gridcell" className="min-h-0"> <div key={`cell-${i}`} role="gridcell" className="min-h-0">
<CalendarDay <CalendarDay
dateKey={key} dateKey={key}
dayOfMonth={date.getDate()} dayOfMonth={date.getDate()}

View File

@@ -147,13 +147,13 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
if (!hasDataForMonth) { if (!hasDataForMonth) {
return ( return (
<div <div
className={cn("min-h-[120px]", className)}
role="status" role="status"
aria-busy="true" aria-busy="true"
aria-live="polite" aria-live="polite"
aria-label={t("loading")} aria-label={t("loading")}
> >
<span className="sr-only">{t("loading")}</span> <span className="sr-only">{t("loading")}</span>
<DutyListSkeleton className={className} />
</div> </div>
); );
} }

View File

@@ -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. * 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. * On ACCESS_DENIED, shows access denied and retries once after RETRY_AFTER_ACCESS_DENIED_MS.
* Returns retry() to manually trigger a reload. * 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 } { export function useMonthData(options: UseMonthDataOptions): { retry: () => void } {
const { initDataRaw, enabled } = options; const { initDataRaw, enabled } = options;
const currentMonth = useAppStore((s) => s.currentMonth); const currentMonth = useAppStore((s) => s.currentMonth);
const pendingMonth = useAppStore((s) => s.pendingMonth);
const lang = useAppStore((s) => s.lang); const lang = useAppStore((s) => s.lang);
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
@@ -61,18 +59,26 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
const load = useCallback(() => { const load = useCallback(() => {
const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current; const { initDataRaw: initDataRawOpt, enabled: enabledOpt, lang: langOpt } = optionsRef.current;
if (!enabledOpt) return; if (!enabledOpt) {
useAppStore.getState().batchUpdate({ pendingMonth: null });
return;
}
const initData = initDataRawOpt ?? ""; const initData = initDataRawOpt ?? "";
if (!initData && typeof window !== "undefined") { if (!initData && typeof window !== "undefined") {
const h = window.location.hostname; 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 store = useAppStore.getState();
const currentMonthNow = store.currentMonth; const pending = store.pendingMonth;
const monthKey = `${currentMonthNow.getFullYear()}-${String(currentMonthNow.getMonth() + 1).padStart(2, "0")}`; const monthToLoad = pending ?? store.currentMonth;
const monthKey = `${monthToLoad.getFullYear()}-${String(monthToLoad.getMonth() + 1).padStart(2, "0")}`;
const dataForMonthKey = store.dataForMonthKey; const dataForMonthKey = store.dataForMonthKey;
const isNewMonth = dataForMonthKey !== monthKey; const isDeferredSwitch = pending !== null;
const isNewMonth = !isDeferredSwitch && dataForMonthKey !== monthKey;
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController(); 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 start = getMonday(first);
const gridEnd = new Date(start); const gridEnd = new Date(start);
gridEnd.setDate(gridEnd.getDate() + 41); gridEnd.setDate(gridEnd.getDate() + 41);
@@ -103,14 +109,23 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
fetchCalendarEvents(from, to, initData, langOpt, signal), fetchCalendarEvents(from, to, initData, langOpt, signal),
]); ]);
const last = lastDayOfMonth(currentMonthNow); const last = lastDayOfMonth(monthToLoad);
const firstKey = localDateString(first); const firstKey = localDateString(first);
const lastKey = localDateString(last); const lastKey = localDateString(last);
const dutiesInMonth = duties.filter((d) => const dutiesInMonth = duties.filter((d) =>
dutyOverlapsLocalRange(d, firstKey, lastKey) dutyOverlapsLocalRange(d, firstKey, lastKey)
); );
const storeAfter = useAppStore.getState();
const switchedMonth =
storeAfter.currentMonth.getFullYear() !== monthToLoad.getFullYear() ||
storeAfter.currentMonth.getMonth() !== monthToLoad.getMonth();
useAppStore.getState().batchUpdate({ useAppStore.getState().batchUpdate({
...(switchedMonth
? { currentMonth: new Date(monthToLoad.getFullYear(), monthToLoad.getMonth(), 1) }
: {}),
pendingMonth: null,
duties: dutiesInMonth, duties: dutiesInMonth,
calendarEvents: events, calendarEvents: events,
dataForMonthKey: monthKey, dataForMonthKey: monthKey,
@@ -118,13 +133,20 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
error: null, error: null,
}); });
} catch (e) { } 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) { if (e instanceof AccessDeniedError) {
logger.warn("Access denied in loadMonth", e.serverDetail); logger.warn("Access denied in loadMonth", e.serverDetail);
useAppStore.getState().batchUpdate({ useAppStore.getState().batchUpdate({
accessDenied: true, accessDenied: true,
accessDeniedDetail: e.serverDetail ?? null, accessDeniedDetail: e.serverDetail ?? null,
loading: false, loading: false,
pendingMonth: null,
}); });
if (!initDataRetriedRef.current) { if (!initDataRetriedRef.current) {
initDataRetriedRef.current = true; initDataRetriedRef.current = true;
@@ -147,6 +169,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
useAppStore.getState().batchUpdate({ useAppStore.getState().batchUpdate({
error: translate(langOpt, "error_generic"), error: translate(langOpt, "error_generic"),
loading: false, loading: false,
pendingMonth: null,
}); });
} }
}; };
@@ -169,7 +192,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
if (abortRef.current) abortRef.current.abort(); if (abortRef.current) abortRef.current.abort();
abortRef.current = null; abortRef.current = null;
}; };
}, [enabled, load, currentMonth, lang, initDataRaw]); }, [enabled, load, currentMonth, pendingMonth, lang, initDataRaw]);
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;

View File

@@ -14,6 +14,8 @@ export type DataForMonthKey = string | null;
export interface AppState { export interface AppState {
currentMonth: Date; currentMonth: Date;
/** When set, we are loading this month; currentMonth and data stay until load completes. */
pendingMonth: Date | null;
lang: "ru" | "en"; lang: "ru" | "en";
duties: DutyWithUser[]; duties: DutyWithUser[];
calendarEvents: CalendarEvent[]; calendarEvents: CalendarEvent[];
@@ -40,7 +42,7 @@ export interface AppState {
setCurrentView: (v: CurrentView) => void; setCurrentView: (v: CurrentView) => void;
setSelectedDay: (key: string | null) => void; setSelectedDay: (key: string | null) => void;
/** Batch multiple state updates into a single re-render. */ /** Batch multiple state updates into a single re-render. */
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay">>) => void; batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay">>) => void;
} }
const now = new Date(); const now = new Date();
@@ -54,6 +56,7 @@ function getInitialView(): CurrentView {
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
currentMonth: initialMonth, currentMonth: initialMonth,
pendingMonth: null,
lang: "en", lang: "en",
duties: [], duties: [],
calendarEvents: [], calendarEvents: [],
@@ -67,17 +70,13 @@ export const useAppStore = create<AppState>((set) => ({
setCurrentMonth: (d) => set({ currentMonth: d }), setCurrentMonth: (d) => set({ currentMonth: d }),
nextMonth: () => nextMonth: () =>
set((s) => { set((s) => ({
const next = new Date(s.currentMonth); pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() + 1, 1),
next.setMonth(next.getMonth() + 1); })),
return { currentMonth: next };
}),
prevMonth: () => prevMonth: () =>
set((s) => { set((s) => ({
const prev = new Date(s.currentMonth); pendingMonth: new Date(s.currentMonth.getFullYear(), s.currentMonth.getMonth() - 1, 1),
prev.setMonth(prev.getMonth() - 1); })),
return { currentMonth: prev };
}),
setDuties: (d) => set({ duties: d }), setDuties: (d) => set({ duties: d }),
setCalendarEvents: (e) => set({ calendarEvents: e }), setCalendarEvents: (e) => set({ calendarEvents: e }),
setLoading: (v) => set({ loading: v }), setLoading: (v) => set({ loading: v }),