- 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.
185 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
}
|