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
month={currentMonth}
isLoading={loading}
isLoading={false}
disabled={navDisabled}
onGoToToday={handleGoToToday}
onRefresh={retry}

View File

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

View File

@@ -35,17 +35,27 @@ describe("DutyList", () => {
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().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />);
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", () => {
useAppStore.getState().setDuties([
duty("Иванов", "2025-02-25T09:00:00Z", "2025-02-25T18:00:00Z"),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 1, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-02" });
render(<DutyList />);
expect(screen.getByText("Иванов")).toBeInTheDocument();
});
@@ -58,6 +68,7 @@ describe("DutyList", () => {
}),
]);
useAppStore.getState().setCurrentMonth(new Date(2025, 2, 1));
useAppStore.getState().batchUpdate({ dataForMonthKey: "2025-03" });
render(<DutyList />);
expect(screen.getAllByText("Alice").length).toBeGreaterThanOrEqual(1);
expect(document.querySelector('a[href^="tel:"]')).toBeInTheDocument();

View File

@@ -64,9 +64,15 @@ export interface DutyListProps {
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
const { t } = useTranslation();
const listRef = useRef<HTMLDivElement>(null);
const { currentMonth, duties, loading } = useAppStore(
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties, loading: s.loading }))
const { currentMonth, duties, dataForMonthKey } = useAppStore(
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 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);
return () => clearInterval(id);
}, []);
const monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
const scrolledForMonthRef = useRef<string | null>(null);
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
const prevMonthKeyRef = useRef<string>(monthKey);
@@ -139,17 +144,25 @@ export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
});
}, [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) {
return (
<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-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>
);
}