"use client"; /** * TimelineView — horizontal timeline for TIMELINE views. * * Bars = records spanning start→end date properties (view.config: * startDatePropertyId / endDatePropertyId, defaulting to the first date * properties found). Width ∝ duration, day/week/month scale, a today line, * a month axis header, optional rows per group property (lanePropertyId), * and bar click → record panel. * * Purely presentational over the loaded record pages (the query engine stays * server-side); the time window is the min/max of loaded records. */ import { useMemo, useRef } from "react"; import { ChevronLeft, ChevronRight } from "lucide-react"; import { cn } from "@/lib/utils"; import { DatabaseProperty, DatabaseView, RecordDto, UseDatabaseRecordsResult, valueToText } from "@/hooks/use-database"; export interface TimelineProps { databaseId: string; view: DatabaseView; properties: DatabaseProperty[]; records: UseDatabaseRecordsResult; onOpenRecord: (record: RecordDto) => void; } const DAY_MS = 86_400_000; function pickDateProp(properties: DatabaseProperty[], id?: string, fallback: "first" | "last"): DatabaseProperty | undefined { if (id) { const p = properties.find((x) => x.id === id); if (p) return p; } const dates = properties.filter((p) => p.type === "date" || p.type === "date_range"); return fallback === "first" ? dates[0] : dates[dates.length - 1] ?? dates[0]; } export function TimelineView({ view, properties, records, onOpenRecord }: TimelineProps) { const { data, isLoading } = records; const titleProp = properties.find((p) => p.isTitle); const startProp = pickDateProp(properties, view.config.startDatePropertyId, "first"); const endProp = pickDateProp(properties, view.config.endDatePropertyId, "last") ?? startProp; const scale = (view.config.scale as "day" | "week" | "month" | "quarter") ?? "month"; const pxPerDay = scale === "day" ? 48 : scale === "week" ? 14 : scale === "quarter" ? 4 : 8; const laneProp = useMemo( () => (view.config.lanePropertyId ? properties.find((p) => p.id === view.config.lanePropertyId) : undefined), [view.config.lanePropertyId, properties] ); const scrollRef = useRef(null); // ---- window ----------------------------------------------------------------- const window_ = useMemo(() => { const times: number[] = []; for (const r of data.records) { const s = r.properties[startProp?.id ?? ""]?.start; const e = r.properties[endProp?.id ?? ""]?.end ?? r.properties[startProp?.id ?? ""]?.start; if (s) times.push(new Date(s).getTime()); if (e) times.push(new Date(e).getTime()); } if (times.length === 0) { const now = Date.now(); return { min: now - 30 * DAY_MS, max: now + 60 * DAY_MS }; } const min = Math.min(...times) - 7 * DAY_MS; const max = Math.max(...times) + 7 * DAY_MS; return { min, max }; }, [data.records, startProp, endProp]); const totalDays = Math.max(7, Math.ceil((window_.max - window_.min) / DAY_MS)); const today = new Date(); const todayX = ((today.getTime() - window_.min) / DAY_MS) * pxPerDay; // ---- month axis --------------------------------------------------------------- const months = useMemo(() => { const list: { label: string; x: number; w: number }[] = []; const d = new Date(window_.min); d.setUTCDate(1); if (d.getTime() < window_.min) d.setUTCMonth(d.getUTCMonth() + 1); while (d.getTime() <= window_.max) { const next = new Date(d); next.setUTCMonth(d.getUTCMonth() + 1); const x = ((d.getTime() - window_.min) / DAY_MS) * pxPerDay; const w = ((next.getTime() - d.getTime()) / DAY_MS) * pxPerDay; list.push({ label: d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" }), x, w }); d.setTime(next.getTime()); } return list; }, [window_.min, window_.max, pxPerDay]); // ---- lanes ---------------------------------------------------------------------- const lanes = useMemo(() => { if (!laneProp) return [{ label: "", records: data.records }]; const map = new Map(); for (const r of data.records) { const key = valueToText(r.properties[laneProp.id], laneProp) || "—"; const arr = map.get(key) ?? []; arr.push(r); map.set(key, arr); } return Array.from(map.entries()).map(([label, recs]) => ({ label, records: recs })); }, [data.records, laneProp]); const scrollBy = (days: number) => scrollRef.current?.scrollBy({ left: days * pxPerDay, behavior: "smooth" }); if (!startProp) { return (
This timeline needs at least one date property (start of each bar).
); } const barFor = (r: RecordDto) => { const s = r.properties[startProp.id]?.start; const e = r.properties[endProp.id]?.end ?? s; if (!s) return null; const startMs = new Date(s).getTime(); const endMs = e ? new Date(e).getTime() : startMs + DAY_MS; const x = ((startMs - window_.min) / DAY_MS) * pxPerDay; const w = Math.max(8, ((endMs - startMs) / DAY_MS) * pxPerDay); return { x, w }; }; return (
{/* controls */}
{new Date(window_.min).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" })} –{" "} {new Date(window_.max).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" })}
{/* month axis */}
{months.map((m, i) => (
{m.label}
))}
{/* today line */} {todayX >= 0 && todayX <= totalDays * pxPerDay && (
)} {/* lanes */}
{lanes.map((lane, li) => (
{lane.label && (
{lane.label}
)}
{lane.records.map((r) => { const bar = barFor(r); if (!bar) return null; return ( ); })}
))}
{isLoading && (
Loading…
)} {!isLoading && data.records.length === 0 && (
No dated records in this view.
)}
); }