fix: update loading state handling in CalendarPage and DutyList components

- Set isLoading to false in CalendarPage to prevent unnecessary loading indicators.
- Removed the loading spinner from CalendarHeader and adjusted rendering logic in DutyList to show a placeholder when data is not yet loaded for the month.
- Enhanced tests in DutyList to verify behavior when data is empty and when data is loading, ensuring accurate user feedback during data fetching.
This commit is contained in:
2026-03-03 16:21:27 +03:00
parent 3b68e29d7b
commit 95f65141e1
4 changed files with 38 additions and 34 deletions

View File

@@ -145,7 +145,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
> >
<CalendarHeader <CalendarHeader
month={currentMonth} month={currentMonth}
isLoading={loading} isLoading={false}
disabled={navDisabled} disabled={navDisabled}
onGoToToday={handleGoToToday} onGoToToday={handleGoToToday}
onRefresh={retry} onRefresh={retry}

View File

@@ -30,13 +30,6 @@ export interface CalendarHeaderProps {
className?: string; className?: string;
} }
const HeaderSpinner = () => (
<span
className="block size-4 shrink-0 rounded-full border-2 border-transparent border-t-accent motion-reduce:animate-none animate-spin"
aria-hidden
/>
);
function isCurrentMonth(month: Date): boolean { function isCurrentMonth(month: Date): boolean {
const now = new Date(); const now = new Date();
return ( return (
@@ -82,16 +75,6 @@ export function CalendarHeader({
aria-atomic="true" aria-atomic="true"
> >
{monthName(monthIndex)} {year} {monthName(monthIndex)} {year}
{isLoading && (
<span
role="status"
aria-live="polite"
aria-label={t("loading")}
className="flex items-center"
>
<HeaderSpinner />
</span>
)}
</h1> </h1>
{showToday && ( {showToday && (
<button <button
@@ -116,10 +99,7 @@ export function CalendarHeader({
disabled={disabled || isLoading} disabled={disabled || isLoading}
onClick={onRefresh} onClick={onRefresh}
> >
<RefreshCwIcon <RefreshCwIcon className="size-5" aria-hidden />
className={cn("size-5", isLoading && "motion-reduce:animate-none animate-spin")}
aria-hidden
/>
</Button> </Button>
)} )}
<Button <Button

View File

@@ -35,17 +35,27 @@ describe("DutyList", () => {
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025 useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); // Feb 2025
}); });
it("renders no duties message when duties empty", () => { it("renders no duties message when duties empty and data loaded for month", () => {
useAppStore.getState().setDuties([]); useAppStore.getState().setDuties([]);
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />); render(<DutyList />);
expect(screen.getByText(/No duties this month/i)).toBeInTheDocument(); expect(screen.getByText(/No duties this month/i)).toBeInTheDocument();
}); });
it("renders quiet placeholder when data not yet loaded for month", () => {
useAppStore.getState().setDuties([]);
useAppStore.getState().batchUpdate({ dataForMonthKey: null });
render(<DutyList />);
expect(screen.queryByText(/No duties this month/i)).not.toBeInTheDocument();
expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument();
});
it("renders duty with full_name and time range", () => { it("renders duty with full_name and time range", () => {
useAppStore.getState().setDuties([ useAppStore.getState().setDuties([
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"), duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
]); ]);
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1)); useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />); render(<DutyList />);
expect(screen.getByText("Иванов")).toBeInTheDocument(); expect(screen.getByText("Иванов")).toBeInTheDocument();
}); });
@@ -58,6 +68,7 @@ describe("DutyList", () => {
}), }),
]); ]);
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1)); useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-03" });
render(<DutyList />); render(<DutyList />);
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument(); expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();

View File

@@ -64,9 +64,15 @@ 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, loading } = useAppStore( const { currentMonth, duties, dataForMonthKey } = useAppStore(
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties, loading: s.loading })) useShallow((s) => ({
currentMonth: s.currentMonth,
duties: s.duties,
dataForMonthKey: s.dataForMonthKey,
}))
); );
const monthKey = `${currentMonth.getFullYear()}-${String(currentMonth.getMonth() + 1).padStart(2, "0")}`;
const hasDataForMonth = dataForMonthKey === monthKey;
const { filtered, dates, dutiesByDateKey } = useMemo(() => { const { filtered, dates, dutiesByDateKey } = useMemo(() => {
const filteredList = duties.filter((d) => d.event_type === "duty"); const filteredList = duties.filter((d) => d.event_type === "duty");
@@ -105,7 +111,6 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const id = setInterval(() => setNow(new Date()), 60_000); const id = setInterval(() => setNow(new Date()), 60_000);
return () => clearInterval(id); return () => clearInterval(id);
}, []); }, []);
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
const scrolledForMonthRef = useRef<string | null>(null); const scrolledForMonthRef = useRef<string | null>(null);
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop); const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
const prevMonthKeyRef = useRef<string>(monthKey); const prevMonthKeyRef = useRef<string>(monthKey);
@@ -139,17 +144,25 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
}); });
}, [filtered, dates.length, scrollMarginTop, monthKey]); }, [filtered, dates.length, scrollMarginTop, monthKey]);
if (!hasDataForMonth) {
return (
<div
className={cn("min-h-[120px]", className)}
role="status"
aria-busy="true"
aria-live="polite"
aria-label={t("loading")}
>
<span className="sr-only">{t("loading")}</span>
</div>
);
}
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("duty.none_this_month")}</p>
<p className="text-sm text-muted m-0">{t("loading")}</p> <p className="text-xs text-muted m-0">{t("duty.none_this_month_hint")}</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>
</>
)}
</div> </div>
); );
} }