diff --git a/webapp-next/src/components/CalendarPage.tsx b/webapp-next/src/components/CalendarPage.tsx
index b403fb9..542c992 100644
--- a/webapp-next/src/components/CalendarPage.tsx
+++ b/webapp-next/src/components/CalendarPage.tsx
@@ -14,10 +14,9 @@ import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
-import { DutyList, DutyListSkeleton } from "@/components/duty/DutyList";
+import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
-import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied";
@@ -129,15 +128,14 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
retry();
}, [setCurrentMonth, retry]);
- const isInitialLoad =
- loading && duties.length === 0 && calendarEvents.length === 0;
-
- // Signal Telegram to hide loading when calendar first load finishes.
+ const readyCalledRef = useRef(false);
+ // Signal Telegram to hide loading when the first load finishes (loading goes true -> false).
useEffect(() => {
- if (!isInitialLoad) {
+ if (!loading && !readyCalledRef.current) {
+ readyCalledRef.current = true;
callMiniAppReadyOnce();
}
- }, [isInitialLoad]);
+ }, [loading]);
return (
@@ -154,19 +152,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
- {isInitialLoad ? (
-
- ) : (
-
- )}
+
{accessDenied && (
@@ -175,14 +166,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
{!accessDenied && error && (
)}
- {!accessDenied && !error && loading && !isInitialLoad ? (
-
- ) : !accessDenied && !error && !isInitialLoad ? (
+ {!accessDenied && !error && (
- ) : null}
+ )}
(null);
- const { currentMonth, duties } = useAppStore(
- useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties }))
+ const { currentMonth, duties, loading } = useAppStore(
+ useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties, loading: s.loading }))
);
const { filtered, dates, dutiesByDateKey } = useMemo(() => {
@@ -142,8 +142,14 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
if (filtered.length === 0) {
return (
-
{t("duty.none_this_month")}
-
{t("duty.none_this_month_hint")}
+ {loading ? (
+
{t("loading")}
+ ) : (
+ <>
+
{t("duty.none_this_month")}
+
{t("duty.none_this_month_hint")}
+ >
+ )}
);
}
diff --git a/webapp-next/src/hooks/use-month-data.ts b/webapp-next/src/hooks/use-month-data.ts
index 2f22c53..0d6fc9d 100644
--- a/webapp-next/src/hooks/use-month-data.ts
+++ b/webapp-next/src/hooks/use-month-data.ts
@@ -70,6 +70,9 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
const store = useAppStore.getState();
const currentMonthNow = store.currentMonth;
+ const monthKey = `${currentMonthNow.getFullYear()}-${String(currentMonthNow.getMonth() + 1).padStart(2, "0")}`;
+ const dataForMonthKey = store.dataForMonthKey;
+ const isNewMonth = dataForMonthKey !== monthKey;
if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController();
@@ -80,6 +83,9 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
accessDeniedDetail: null,
loading: true,
error: null,
+ ...(isNewMonth
+ ? { duties: [], calendarEvents: [], dataForMonthKey: null }
+ : {}),
});
const first = firstDayOfMonth(currentMonthNow);
@@ -107,6 +113,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
useAppStore.getState().batchUpdate({
duties: dutiesInMonth,
calendarEvents: events,
+ dataForMonthKey: monthKey,
loading: false,
error: null,
});
diff --git a/webapp-next/src/store/app-store.ts b/webapp-next/src/store/app-store.ts
index 86d3690..6887155 100644
--- a/webapp-next/src/store/app-store.ts
+++ b/webapp-next/src/store/app-store.ts
@@ -9,11 +9,16 @@ import { getStartParamFromUrl } from "@/lib/launch-params";
export type CurrentView = "calendar" | "currentDuty";
+/** YYYY-MM key for the month that duties/calendarEvents belong to; null when none loaded. */
+export type DataForMonthKey = string | null;
+
export interface AppState {
currentMonth: Date;
lang: "ru" | "en";
duties: DutyWithUser[];
calendarEvents: CalendarEvent[];
+ /** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */
+ dataForMonthKey: DataForMonthKey;
loading: boolean;
error: string | null;
accessDenied: boolean;
@@ -35,7 +40,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();
@@ -52,6 +57,7 @@ export const useAppStore = create((set) => ({
lang: "en",
duties: [],
calendarEvents: [],
+ dataForMonthKey: null,
loading: false,
error: null,
accessDenied: false,
diff --git a/webapp-next/src/test/setup.ts b/webapp-next/src/test/setup.ts
index 9bbea98..fd2cf45 100644
--- a/webapp-next/src/test/setup.ts
+++ b/webapp-next/src/test/setup.ts
@@ -1,6 +1,13 @@
import "@testing-library/jest-dom/vitest";
import { vi } from "vitest";
+// jsdom does not provide ResizeObserver (used by CalendarPage for sticky block height).
+global.ResizeObserver = vi.fn().mockImplementation(() => ({
+ observe: vi.fn(),
+ unobserve: vi.fn(),
+ disconnect: vi.fn(),
+}));
+
// jsdom does not provide window.matchMedia (used by use-media-query and use-telegram-theme).
Object.defineProperty(window, "matchMedia", {
writable: true,
diff --git a/webapp-next/src/test/test-utils.tsx b/webapp-next/src/test/test-utils.tsx
index 076466b..e08192f 100644
--- a/webapp-next/src/test/test-utils.tsx
+++ b/webapp-next/src/test/test-utils.tsx
@@ -18,6 +18,7 @@ export function resetAppStore() {
state.setAccessDenied(false);
state.setCurrentView("calendar");
state.setSelectedDay(null);
+ state.batchUpdate({ dataForMonthKey: null });
}
function AllTheProviders({ children }: { children: React.ReactNode }) {