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