1"use client";
2
3
15import { useMemo, useRef } from "react";
16import { ChevronLeft, ChevronRight } from "lucide-react";
17import { cn } from "@/lib/utils";
18import {
19 DatabaseProperty,
20 DatabaseView,
21 RecordDto,
22 UseDatabaseRecordsResult,
23 valueToText
24} from "@/hooks/use-database";
25
26export interface TimelineProps {
27 databaseId: string;
28 view: DatabaseView;
29 properties: DatabaseProperty[];
30 records: UseDatabaseRecordsResult;
31 onOpenRecord: (record: RecordDto) => void;
32}
33
34const DAY_MS = 86_400_000;
35
36function pickDateProp(properties: DatabaseProperty[], id?: string, fallback: "first" | "last"): DatabaseProperty | undefined {
37 if (id) {
38 const p = properties.find((x) => x.id === id);
39 if (p) return p;
40 }
41 const dates = properties.filter((p) => p.type === "date" || p.type === "date_range");
42 return fallback === "first" ? dates[0] : dates[dates.length - 1] ?? dates[0];
43}
44
45export function TimelineView({ view, properties, records, onOpenRecord }: TimelineProps) {
46 const { data, isLoading } = records;
47 const titleProp = properties.find((p) => p.isTitle);
48 const startProp = pickDateProp(properties, view.config.startDatePropertyId, "first");
49 const endProp = pickDateProp(properties, view.config.endDatePropertyId, "last") ?? startProp;
50 const scale = (view.config.scale as "day" | "week" | "month" | "quarter") ?? "month";
51 const pxPerDay = scale === "day" ? 48 : scale === "week" ? 14 : scale === "quarter" ? 4 : 8;
52
53 const laneProp = useMemo(
54 () => (view.config.lanePropertyId ? properties.find((p) => p.id === view.config.lanePropertyId) : undefined),
55 [view.config.lanePropertyId, properties]
56 );
57
58 const scrollRef = useRef<HTMLDivElement>(null);
59
60
61 const window_ = useMemo(() => {
62 const times: number[] = [];
63 for (const r of data.records) {
64 const s = r.properties[startProp?.id ?? ""]?.start;
65 const e = r.properties[endProp?.id ?? ""]?.end ?? r.properties[startProp?.id ?? ""]?.start;
66 if (s) times.push(new Date(s).getTime());
67 if (e) times.push(new Date(e).getTime());
68 }
69 if (times.length === 0) {
70 const now = Date.now();
71 return { min: now - 30 * DAY_MS, max: now + 60 * DAY_MS };
72 }
73 const min = Math.min(...times) - 7 * DAY_MS;
74 const max = Math.max(...times) + 7 * DAY_MS;
75 return { min, max };
76 }, [data.records, startProp, endProp]);
77
78 const totalDays = Math.max(7, Math.ceil((window_.max - window_.min) / DAY_MS));
79 const today = new Date();
80 const todayX = ((today.getTime() - window_.min) / DAY_MS) * pxPerDay;
81
82
83 const months = useMemo(() => {
84 const list: { label: string; x: number; w: number }[] = [];
85 const d = new Date(window_.min);
86 d.setUTCDate(1);
87 if (d.getTime() < window_.min) d.setUTCMonth(d.getUTCMonth() + 1);
88 while (d.getTime() <= window_.max) {
89 const next = new Date(d);
90 next.setUTCMonth(d.getUTCMonth() + 1);
91 const x = ((d.getTime() - window_.min) / DAY_MS) * pxPerDay;
92 const w = ((next.getTime() - d.getTime()) / DAY_MS) * pxPerDay;
93 list.push({ label: d.toLocaleDateString("en-US", { month: "short", year: "2-digit", timeZone: "UTC" }), x, w });
94 d.setTime(next.getTime());
95 }
96 return list;
97 }, [window_.min, window_.max, pxPerDay]);
98
99
100 const lanes = useMemo(() => {
101 if (!laneProp) return [{ label: "", records: data.records }];
102 const map = new Map<string, RecordDto[]>();
103 for (const r of data.records) {
104 const key = valueToText(r.properties[laneProp.id], laneProp) || "—";
105 const arr = map.get(key) ?? [];
106 arr.push(r);
107 map.set(key, arr);
108 }
109 return Array.from(map.entries()).map(([label, recs]) => ({ label, records: recs }));
110 }, [data.records, laneProp]);
111
112 const scrollBy = (days: number) => scrollRef.current?.scrollBy({ left: days * pxPerDay, behavior: "smooth" });
113
114 if (!startProp) {
115 return (
116 <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
117 This timeline needs at least one date property (start of each bar).
118 </div>
119 );
120 }
121
122 const barFor = (r: RecordDto) => {
123 const s = r.properties[startProp.id]?.start;
124 const e = r.properties[endProp.id]?.end ?? s;
125 if (!s) return null;
126 const startMs = new Date(s).getTime();
127 const endMs = e ? new Date(e).getTime() : startMs + DAY_MS;
128 const x = ((startMs - window_.min) / DAY_MS) * pxPerDay;
129 const w = Math.max(8, ((endMs - startMs) / DAY_MS) * pxPerDay);
130 return { x, w };
131 };
132
133 return (
134 <div className="flex h-full flex-col">
135 {}
136 <div className="flex items-center gap-1 border-b border-border px-3 py-1.5">
137 <span className="text-xs text-muted-foreground">
138 {new Date(window_.min).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" })} –{" "}
139 {new Date(window_.max).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" })}
140 </span>
141 <div className="ml-auto flex items-center gap-1">
142 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={() => scrollBy(-30)} aria-label="Scroll left">
143 <ChevronLeft className="size-4" />
144 </button>
145 <button
146 type="button"
147 className="rounded border px-2 py-0.5 text-xs hover:bg-muted"
148 onClick={() =>
149 scrollRef.current?.scrollTo({
150 left: Math.max(0, todayX - (scrollRef.current.clientWidth ?? 600) / 2),
151 behavior: "smooth"
152 })
153 }
154 >
155 Today
156 </button>
157 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={() => scrollBy(30)} aria-label="Scroll right">
158 <ChevronRight className="size-4" />
159 </button>
160 </div>
161 </div>
162
163 <div className="relative min-h-0 flex-1 overflow-y-auto">
164 <div ref={scrollRef} className="relative overflow-x-auto" style={{ height: "100%" }}>
165 <div className="relative" style={{ width: totalDays * pxPerDay, minWidth: "100%", height: "100%" }}>
166 {}
167 <div className="sticky top-0 z-10 flex h-7 border-b border-border bg-background/90 backdrop-blur">
168 {months.map((m, i) => (
169 <div key={i} className="absolute top-0 flex h-7 items-center border-r border-border/40 text-[11px] text-muted-foreground" style={{ left: m.x, width: m.w }}>
170 <span className="px-1 truncate">{m.label}</span>
171 </div>
172 ))}
173 </div>
174
175 {}
176 {todayX >= 0 && todayX <= totalDays * pxPerDay && (
177 <div className="pointer-events-none absolute bottom-0 top-7 z-[5] w-px bg-primary/60" style={{ left: todayX }} title="Today" />
178 )}
179
180 {}
181 <div className="flex flex-col">
182 {lanes.map((lane, li) => (
183 <div key={li} className="relative flex min-h-14 border-b border-border/40">
184 {lane.label && (
185 <div className="sticky left-0 z-[6] flex w-40 shrink-0 items-center border-r border-border/40 bg-background px-2 text-xs font-medium">
186 {lane.label}
187 </div>
188 )}
189 <div className="relative flex-1">
190 {lane.records.map((r) => {
191 const bar = barFor(r);
192 if (!bar) return null;
193 return (
194 <button
195 key={r.id}
196 type="button"
197 className={cn(
198 "absolute top-2 flex h-7 items-center overflow-hidden rounded-md border border-primary/30 bg-primary/20 px-2 text-xs font-medium hover:bg-primary/30"
199 )}
200 style={{ left: bar.x, width: bar.w }}
201 title={titleProp ? valueToText(r.properties[titleProp.id], titleProp) : r.id}
202 onClick={() => onOpenRecord(r)}
203 >
204 <span className="truncate">
205 {r.icon ? `${r.icon} ` : ""}
206 {titleProp ? valueToText(r.properties[titleProp.id], titleProp) || "Untitled" : ""}
207 </span>
208 </button>
209 );
210 })}
211 </div>
212 </div>
213 ))}
214 </div>
215
216 {isLoading && (
217 <div className="absolute inset-0 flex items-center justify-center text-xs text-muted-foreground">Loading…</div>
218 )}
219 {!isLoading && data.records.length === 0 && (
220 <div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
221 No dated records in this view.
222 </div>
223 )}
224 </div>
225 </div>
226 </div>
227 </div>
228 );
229}
230