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 }),