Files
duty-teller/webapp-next/src/components/CalendarPage.tsx
Nikolay Tatarinov 3b68e29d7b 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.
2026-03-03 16:17:24 +03:00

185 lines
5.5 KiB
TypeScript

/**
* Calendar view layout: header, grid, duty list, day detail.
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
*/
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe";
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 } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { ErrorState } from "@/components/states/ErrorState";
import { AccessDenied } from "@/components/states/AccessDenied";
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
const STICKY_HEIGHT_FALLBACK_PX = 268;
export interface CalendarPageProps {
/** Whether the user is allowed (for data loading). */
isAllowed: boolean;
/** Raw initData string for API auth. */
initDataRaw: string | undefined;
}
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const dayDetailRef = useRef<DayDetailHandle>(null);
const calendarStickyRef = useRef<HTMLDivElement>(null);
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
useEffect(() => {
const el = calendarStickyRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setStickyBlockHeight(entry.contentRect.height);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const {
currentMonth,
loading,
error,
accessDenied,
accessDeniedDetail,
duties,
calendarEvents,
selectedDay,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
accessDeniedDetail: s.accessDeniedDetail,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
}))
);
const { retry } = useMonthData({
initDataRaw,
enabled: isAllowed,
});
const now = new Date();
const isCurrentMonth =
currentMonth.getFullYear() === now.getFullYear() &&
currentMonth.getMonth() === now.getMonth();
useAutoRefresh(retry, isCurrentMonth);
const navDisabled = loading || accessDenied || selectedDay !== null;
const handlePrevMonth = useCallback(() => {
if (navDisabled) return;
prevMonth();
}, [navDisabled, prevMonth]);
const handleNextMonth = useCallback(() => {
if (navDisabled) return;
nextMonth();
}, [navDisabled, nextMonth]);
useSwipe(
calendarStickyRef,
handleNextMonth,
handlePrevMonth,
{ threshold: 50, disabled: navDisabled }
);
useStickyScroll(calendarStickyRef);
const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => {
const [y, m] = dateKey.split("-").map(Number);
if (
y !== currentMonth.getFullYear() ||
m !== currentMonth.getMonth() + 1
) {
return;
}
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
},
[currentMonth]
);
const handleCloseDayDetail = useCallback(() => {
setSelectedDay(null);
}, [setSelectedDay]);
const handleGoToToday = useCallback(() => {
setCurrentMonth(new Date());
retry();
}, [setCurrentMonth, retry]);
const readyCalledRef = useRef(false);
// Signal Telegram to hide loading when the first load finishes (loading goes true -> false).
useEffect(() => {
if (!loading && !readyCalledRef.current) {
readyCalledRef.current = true;
callMiniAppReadyOnce();
}
}, [loading]);
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
ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
>
<CalendarHeader
month={currentMonth}
isLoading={loading}
disabled={navDisabled}
onGoToToday={handleGoToToday}
onRefresh={retry}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
/>
<CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
</div>
{accessDenied && (
<AccessDenied serverDetail={accessDeniedDetail} className="my-3" />
)}
{!accessDenied && error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!accessDenied && !error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
);
}