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:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user