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:
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div key={key} role="gridcell" className="min-h-0">
|
||||
<div key={`cell-${i}`} role="gridcell" className="min-h-0">
|
||||
<CalendarDay
|
||||
dateKey={key}
|
||||
dayOfMonth={date.getDate()}
|
||||
|
||||
@@ -147,13 +147,13 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
||||
if (!hasDataForMonth) {
|
||||
return (
|
||||
<div
|
||||
className={cn("min-h-[120px]", className)}
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
aria-label={t("loading")}
|
||||
>
|
||||
<span className="sr-only">{t("loading")}</span>
|
||||
<DutyListSkeleton className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AbortController | null>(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;
|
||||
|
||||
@@ -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<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();
|
||||
@@ -54,6 +56,7 @@ function getInitialView(): CurrentView {
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
currentMonth: initialMonth,
|
||||
pendingMonth: null,
|
||||
lang: "en",
|
||||
duties: [],
|
||||
calendarEvents: [],
|
||||
@@ -67,17 +70,13 @@ export const useAppStore = create<AppState>((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 }),
|
||||
|
||||
Reference in New Issue
Block a user