- 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.
263 lines
9.1 KiB
TypeScript
263 lines
9.1 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, loading } = useAppStore(
|
|
useShallow((s) => ({ currentMonth: s.currentMonth, duties: s.duties, loading: s.loading }))
|
|
);
|
|
|
|
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 monthKey = `${currentMonth.getFullYear()}-${currentMonth.getMonth()}`;
|
|
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 (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>
|
|
</>
|
|
)}
|
|
</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>
|
|
);
|
|
}
|