feat: enhance CalendarPage and DutyList components for improved loading state handling

- Removed the loading state placeholder from CalendarPage, directly rendering the CalendarGrid component.
- Updated DutyList to display a loading message when data is being fetched, enhancing user experience during data loading.
- Introduced a new dataForMonthKey in the app store to manage month-specific data more effectively.
- Refactored useMonthData hook to reset duties and calendarEvents when a new month is detected, ensuring accurate data representation.
- Added tests to verify the new loading state behavior in both components.
This commit is contained in:
2026-03-03 16:17:24 +03:00
parent 16bf1a1043
commit 3b68e29d7b
6 changed files with 46 additions and 30 deletions

View File

@@ -14,10 +14,9 @@ import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh"; import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { CalendarHeader } from "@/components/calendar/CalendarHeader"; import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid"; 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 { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready"; import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState"; import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied"; import { AccessDenied } from "@/components/states/AccessDenied";
@@ -129,15 +128,14 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
retry(); retry();
}, [setCurrentMonth, retry]); }, [setCurrentMonth, retry]);
const isInitialLoad = const readyCalledRef = useRef(false);
loading && duties.length === 0 && calendarEvents.length === 0; // Signal Telegram to hide loading when the first load finishes (loading goes true -> false).
// Signal Telegram to hide loading when calendar first load finishes.
useEffect(() => { useEffect(() => {
if (!isInitialLoad) { if (!loading && !readyCalledRef.current) {
readyCalledRef.current = true;
callMiniAppReadyOnce(); callMiniAppReadyOnce();
} }
}, [isInitialLoad]); }, [loading]);
return ( return (
<div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe"> <div className="mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6 pt-safe">
@@ -154,19 +152,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
onPrevMonth={handlePrevMonth} onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth} onNextMonth={handleNextMonth}
/> />
{isInitialLoad ? (
<LoadingState
asPlaceholder
className="min-h-[var(--calendar-block-min-height)]"
/>
) : (
<CalendarGrid <CalendarGrid
currentMonth={currentMonth} currentMonth={currentMonth}
duties={duties} duties={duties}
calendarEvents={calendarEvents} calendarEvents={calendarEvents}
onDayClick={handleDayClick} onDayClick={handleDayClick}
/> />
)}
</div> </div>
{accessDenied && ( {accessDenied && (
@@ -175,14 +166,12 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
{!accessDenied && error && ( {!accessDenied && error && (
<ErrorState message={error} onRetry={retry} className="my-3" /> <ErrorState message={error} onRetry={retry} className="my-3" />
)} )}
{!accessDenied && !error && loading && !isInitialLoad ? ( {!accessDenied && !error && (
<DutyListSkeleton className="mt-2" />
) : !accessDenied && !error && !isInitialLoad ? (
<DutyList <DutyList
scrollMarginTop={stickyBlockHeight} scrollMarginTop={stickyBlockHeight}
className="mt-2" className="mt-2"
/> />
) : null} )}
<DayDetail <DayDetail
ref={dayDetailRef} ref={dayDetailRef}

View File

@@ -64,8 +64,8 @@ export interface DutyListProps {
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) { export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const { currentMonth, duties } = useAppStore( const { currentMonth, duties, loading } = useAppStore(
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties })) useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties, loading: s.loading }))
); );
const { filtered, dates, dutiesByDateKey } = useMemo(() => { const { filtered, dates, dutiesByDateKey } = useMemo(() => {
@@ -142,8 +142,14 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
if (filtered.length === 0) { if (filtered.length === 0) {
return ( return (
<div className={cn("flex flex-col gap-1", className)}> <div className={cn("flex flex-col gap-1", className)}>
{loading ? (
<p className="text-sm text-muted m-0">{t("loading")}</p>
) : (
<>
<p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p> <p className="text-sm text-muted m-0">{t("duty.none_this_month")}</p>
<p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p> <p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</p>
</>
)}
</div> </div>
); );
} }

View File

@@ -70,6 +70,9 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
const store = useAppStore.getState(); const store = useAppStore.getState();
const currentMonthNow = store.currentMonth; 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(); if (abortRef.current) abortRef.current.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
@@ -80,6 +83,9 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
accessDeniedDetail: null, accessDeniedDetail: null,
loading: true, loading: true,
error: null, error: null,
...(isNewMonth
? { duties: [], calendarEvents: [], dataForMonthKey: null }
: {}),
}); });
const first = firstDayOfMonth(currentMonthNow); const first = firstDayOfMonth(currentMonthNow);
@@ -107,6 +113,7 @@ export function useMonthData(options: UseMonthDataOptions): { retry: () => void
useAppStore.getState().batchUpdate({ useAppStore.getState().batchUpdate({
duties: dutiesInMonth, duties: dutiesInMonth,
calendarEvents: events, calendarEvents: events,
dataForMonthKey: monthKey,
loading: false, loading: false,
error: null, error: null,
}); });

View File

@@ -9,11 +9,16 @@ import { getStartParamFromUrl } from "@/lib/launch-params";
export type CurrentView = "calendar" | "currentDuty"; 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 { export interface AppState {
currentMonth: Date; currentMonth: Date;
lang: "ru" | "en"; lang: "ru" | "en";
duties: DutyWithUser[]; duties: DutyWithUser[];
calendarEvents: CalendarEvent[]; calendarEvents: CalendarEvent[];
/** YYYY-MM: duties and calendarEvents are for this month; null when loading or no data. */
dataForMonthKey: DataForMonthKey;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
accessDenied: boolean; accessDenied: boolean;
@@ -35,7 +40,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" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay">>) => void; batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay">>) => void;
} }
const now = new Date(); const now = new Date();
@@ -52,6 +57,7 @@ export const useAppStore = create<AppState>((set) => ({
lang: "en", lang: "en",
duties: [], duties: [],
calendarEvents: [], calendarEvents: [],
dataForMonthKey: null,
loading: false, loading: false,
error: null, error: null,
accessDenied: false, accessDenied: false,

View File

@@ -1,6 +1,13 @@
import "@testing-library/jest-dom/vitest"; import "@testing-library/jest-dom/vitest";
import { vi } from "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). // jsdom does not provide window.matchMedia (used by use-media-query and use-telegram-theme).
Object.defineProperty(window, "matchMedia", { Object.defineProperty(window, "matchMedia", {
writable: true, writable: true,

View File

@@ -18,6 +18,7 @@ export function resetAppStore() {
state.setAccessDenied(false); state.setAccessDenied(false);
state.setCurrentView("calendar"); state.setCurrentView("calendar");
state.setSelectedDay(null); state.setSelectedDay(null);
state.batchUpdate({ dataForMonthKey: null });
} }
function AllTheProviders({ children }: { children: React.ReactNode }) { function AllTheProviders({ children }: { children: React.ReactNode }) {