/** * 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 (
{[1, 2, 3, 4].map((i) => (
))}
); } 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(null); const { currentMonth, duties } = useAppStore( useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties })) ); 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(); 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 = {}; 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 monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`; const scrolledForMonthRef = useRef(null); const prevScrollMarginTopRef = useRef(scrollMarginTop); const prevMonthKeyRef = useRef(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("[data-current-duty]"); const todayBlock = el.querySelector("[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 (filtered.length === 0) { return (

{t("duty.none_this_month")}

{t("duty.none_this_month_hint")}

); } return (
{/* Vertical track line */}
{dates.map((date) => { const isToday = date === todayKey; const dateLabel = dateKeyToDDMM(date); const dayDuties = dutiesByDateKey[date] ?? []; return (
{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 (
); }) ) : (
)}
); })}
); } function TimelineDateCell({ dateLabel, isToday, }: { dateLabel: string; isToday: boolean; }) { const { t } = useTranslation(); return ( {isToday ? ( <> {t("duty.today")} {dateLabel} ) : ( dateLabel )} ); }