- 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.
276 lines
9.4 KiB
TypeScript
276 lines
9.4 KiB
TypeScript
/**
|
|
* Duty timeline list for the current month: dates, track line, flip cards.
|
|
* Scrolls to current duty or today on mount. Ported from webapp/js/dutyList.js renderDutyList.
|
|
*/
|
|
|
|
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import { useAppStore } from "@/store/app-store";
|
|
import { useShallow } from "zustand/react/shallow";
|
|
import { useTranslation } from "@/i18n/use-translation";
|
|
import {
|
|
localDateString,
|
|
firstDayOfMonth,
|
|
lastDayOfMonth,
|
|
dateKeyToDDMM,
|
|
} from "@/lib/date-utils";
|
|
import { cn } from "@/lib/utils";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { DutyTimelineCard } from "./DutyTimelineCard";
|
|
|
|
/** Extra offset so the sticky calendar slightly overlaps the target card (card sits a bit under the calendar). */
|
|
const SCROLL_OVERLAP_PX = 14;
|
|
|
|
/**
|
|
* Skeleton placeholder for duty list (e.g. when loading a new month).
|
|
* Shows 4 card-shaped placeholders in timeline layout.
|
|
*/
|
|
export function DutyListSkeleton({ className }: { className?: string }) {
|
|
return (
|
|
<div className={cn("duty-timeline relative text-[0.9rem]", className)}>
|
|
<div
|
|
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
|
aria-hidden
|
|
/>
|
|
{[1, 2, 3, 4].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
|
style={{
|
|
gridTemplateColumns:
|
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
|
}}
|
|
>
|
|
<Skeleton className="h-14 w-12 rounded" />
|
|
<span className="min-w-0" aria-hidden />
|
|
<Skeleton className="h-20 w-full min-w-0 rounded-lg" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface DutyListProps {
|
|
/** Offset from viewport top for scroll target (sticky calendar height + its padding, e.g. 268px). */
|
|
scrollMarginTop?: number;
|
|
className?: string;
|
|
}
|
|
|
|
/**
|
|
* Renders duty timeline (duty type only), grouped by date. Shows "Today" label and
|
|
* auto-scrolls to current duty or today block. Uses CSS variables --timeline-date-width, --timeline-track-width.
|
|
*/
|
|
export function DutyList({ scrollMarginTop = 268, className }: DutyListProps) {
|
|
const { t } = useTranslation();
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
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");
|
|
const todayKey = localDateString(new Date());
|
|
const firstKey = localDateString(firstDayOfMonth(currentMonth));
|
|
const lastKey = localDateString(lastDayOfMonth(currentMonth));
|
|
const showTodayInMonth = todayKey >= firstKey && todayKey <= lastKey;
|
|
|
|
const dateSet = new Set<string>();
|
|
filteredList.forEach((d) =>
|
|
dateSet.add(localDateString(new Date(d.start_at)))
|
|
);
|
|
if (showTodayInMonth) dateSet.add(todayKey);
|
|
const datesList = Array.from(dateSet).sort();
|
|
|
|
const byDate: Record<string, typeof filteredList> = {};
|
|
datesList.forEach((date) => {
|
|
byDate[date] = filteredList
|
|
.filter((d) => localDateString(new Date(d.start_at)) === date)
|
|
.sort(
|
|
(a, b) =>
|
|
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
|
|
);
|
|
});
|
|
|
|
return {
|
|
filtered: filteredList,
|
|
dates: datesList,
|
|
dutiesByDateKey: byDate,
|
|
};
|
|
}, [currentMonth, duties]);
|
|
|
|
const todayKey = localDateString(new Date());
|
|
const [now, setNow] = useState(() => new Date());
|
|
useEffect(() => {
|
|
const id = setInterval(() => setNow(new Date()), 60_000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
const scrolledForMonthRef = useRef<string | null>(null);
|
|
const prevScrollMarginTopRef = useRef<number>(scrollMarginTop);
|
|
const prevMonthKeyRef = useRef<string>(monthKey);
|
|
|
|
useEffect(() => {
|
|
if (scrollMarginTop !== prevScrollMarginTopRef.current) {
|
|
scrolledForMonthRef.current = null;
|
|
prevScrollMarginTopRef.current = scrollMarginTop;
|
|
}
|
|
if (prevMonthKeyRef.current !== monthKey) {
|
|
scrolledForMonthRef.current = null;
|
|
prevMonthKeyRef.current = monthKey;
|
|
}
|
|
|
|
const el = listRef.current;
|
|
if (!el) return;
|
|
const currentCard = el.querySelector<HTMLElement>("[data-current-duty]");
|
|
const todayBlock = el.querySelector<HTMLElement>("[data-today-block]");
|
|
const target = currentCard ?? todayBlock;
|
|
if (!target || scrolledForMonthRef.current === monthKey) return;
|
|
|
|
const effectiveMargin = Math.max(0, scrollMarginTop + SCROLL_OVERLAP_PX);
|
|
const scrollTo = () => {
|
|
const rect = target.getBoundingClientRect();
|
|
const scrollTop = window.scrollY + rect.top - effectiveMargin;
|
|
window.scrollTo({ top: scrollTop, behavior: "smooth" });
|
|
scrolledForMonthRef.current = monthKey;
|
|
};
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(scrollTo);
|
|
});
|
|
}, [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)}>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div ref={listRef} className={className}>
|
|
<div className="duty-timeline relative text-[0.9rem]">
|
|
{/* Vertical track line */}
|
|
<div
|
|
className="absolute left-[calc(var(--timeline-date-width)+var(--timeline-track-width)/2-1px)] top-0 bottom-0 w-0.5 pointer-events-none bg-gradient-to-b from-muted from-0% to-[85%] to-[var(--muted-fade)]"
|
|
aria-hidden
|
|
/>
|
|
{dates.map((date) => {
|
|
const isToday = date === todayKey;
|
|
const dateLabel = dateKeyToDDMM(date);
|
|
const dayDuties = dutiesByDateKey[date] ?? [];
|
|
|
|
return (
|
|
<div
|
|
key={date}
|
|
className="mb-0"
|
|
style={isToday ? { scrollMarginTop: `${scrollMarginTop}px` } : undefined}
|
|
data-date={date}
|
|
data-today-block={isToday ? true : undefined}
|
|
>
|
|
{dayDuties.length > 0 ? (
|
|
dayDuties.map((duty) => {
|
|
const start = new Date(duty.start_at);
|
|
const end = new Date(duty.end_at);
|
|
const isCurrent = start <= now && now < end;
|
|
return (
|
|
<div
|
|
key={duty.id}
|
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
|
style={{
|
|
gridTemplateColumns:
|
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
|
}}
|
|
>
|
|
<TimelineDateCell
|
|
dateLabel={dateLabel}
|
|
isToday={isToday}
|
|
/>
|
|
<span
|
|
className="min-w-0"
|
|
aria-hidden
|
|
/>
|
|
<div
|
|
className="min-w-0 overflow-hidden"
|
|
{...(isCurrent ? { "data-current-duty": true } : {})}
|
|
>
|
|
<DutyTimelineCard duty={duty} isCurrent={isCurrent} />
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div
|
|
className="grid gap-x-1 items-start mb-2 min-h-0"
|
|
style={{
|
|
gridTemplateColumns:
|
|
"var(--timeline-date-width) var(--timeline-track-width) 1fr",
|
|
}}
|
|
>
|
|
<TimelineDateCell
|
|
dateLabel={dateLabel}
|
|
isToday={isToday}
|
|
/>
|
|
<span className="min-w-0" aria-hidden />
|
|
<div className="min-w-0" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function TimelineDateCell({
|
|
dateLabel,
|
|
isToday,
|
|
}: {
|
|
dateLabel: string;
|
|
isToday: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"duty-timeline-date relative text-[0.8rem] text-muted pt-2.5 pb-2.5 flex-shrink-0 overflow-visible",
|
|
isToday && "duty-timeline-date--today flex flex-col items-start pt-1 text-today font-semibold"
|
|
)}
|
|
>
|
|
{isToday ? (
|
|
<>
|
|
<span className="duty-timeline-date-label text-today block leading-tight">
|
|
{t("duty.today")}
|
|
</span>
|
|
<span className="duty-timeline-date-day text-muted font-normal text-[0.75rem] block self-start text-left">
|
|
{dateLabel}
|
|
</span>
|
|
</>
|
|
) : (
|
|
dateLabel
|
|
)}
|
|
</span>
|
|
);
|
|
}
|