1"use client";
2
3
13import { useMemo, useState } from "react";
14import { ChevronLeft, ChevronRight } from "lucide-react";
15import { cn } from "@/lib/utils";
16import {
17 DatabaseProperty,
18 DatabaseView,
19 RecordDto,
20 UseDatabaseRecordsResult,
21 valueToText
22} from "@/hooks/use-database";
23import { formatDateValue } from "@/components/database/cell-renderer";
24
25export interface CalendarProps {
26 databaseId: string;
27 view: DatabaseView;
28 properties: DatabaseProperty[];
29 records: UseDatabaseRecordsResult;
30 onOpenRecord: (record: RecordDto) => void;
31}
32
33
34function dayKey(d: Date): string {
35 return d.toISOString().slice(0, 10);
36}
37
38function recordDay(record: RecordDto, dateProp: DatabaseProperty): string | null {
39 const start = record.properties[dateProp.id]?.start;
40 return start ? start.slice(0, 10) : null;
41}
42
43export function CalendarView({ view, properties, records, onOpenRecord }: CalendarProps) {
44 const { data, isLoading } = records;
45 const [cursor, setCursor] = useState(() => {
46 const now = new Date();
47 return new Date(Date.UTC(now.getFullYear(), now.getMonth(), 1));
48 });
49 const [selectedDay, setSelectedDay] = useState<string | null>(null);
50
51 const dateProp = useMemo<DatabaseProperty | undefined>(() => {
52 const groupProp = view.group?.[0] ? properties.find((p) => p.id === view.group[0].propertyId) : undefined;
53 if (groupProp && groupProp.type === "date") return groupProp;
54 if (view.config.datePropertyId) {
55 const p = properties.find((x) => x.id === view.config.datePropertyId);
56 if (p) return p;
57 }
58 return properties.find((p) => p.type === "date");
59 }, [view.group, view.config.datePropertyId, properties]);
60
61 const titleProp = properties.find((p) => p.isTitle);
62
63
64 const days = useMemo(() => {
65 const year = cursor.getUTCFullYear();
66 const month = cursor.getUTCMonth();
67 const firstWeekday = new Date(Date.UTC(year, month, 1)).getUTCDay();
68 const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
69 const cells: (Date | null)[] = [];
70 for (let i = 0; i < firstWeekday; i++) cells.push(null);
71 for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(Date.UTC(year, month, d)));
72 while (cells.length % 7 !== 0) cells.push(null);
73 return cells;
74 }, [cursor]);
75
76
77 const byDay = useMemo(() => {
78 const map = new Map<string, RecordDto[]>();
79 if (!dateProp) return map;
80 for (const r of data.records) {
81 const k = recordDay(r, dateProp);
82 if (!k) continue;
83 const arr = map.get(k) ?? [];
84 arr.push(r);
85 map.set(k, arr);
86 }
87 return map;
88 }, [data.records, dateProp]);
89
90 const todayKey = dayKey(new Date());
91 const monthLabel = cursor.toLocaleDateString("en-US", {
92 timeZone: "UTC",
93 month: "long",
94 year: "numeric"
95 });
96
97 const move = (delta: number) =>
98 setCursor((c) => new Date(Date.UTC(c.getUTCFullYear(), c.getUTCMonth() + delta, 1)));
99
100 const selectedRecords = selectedDay ? byDay.get(selectedDay) ?? [] : [];
101
102 if (!dateProp) {
103 return (
104 <div className="flex h-full items-center justify-center p-6 text-center text-sm text-muted-foreground">
105 This calendar needs a date property. Set the view's group to a date property (or add one).
106 </div>
107 );
108 }
109
110 return (
111 <div className="flex h-full">
112 <div className="flex min-w-0 flex-1 flex-col p-3">
113 {}
114 <div className="mb-2 flex items-center gap-2">
115 <h3 className="text-sm font-medium">{monthLabel}</h3>
116 <div className="ml-auto flex items-center gap-1">
117 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={() => move(-1)} aria-label="Previous month">
118 <ChevronLeft className="size-4" />
119 </button>
120 <button
121 type="button"
122 className="rounded border px-2 py-0.5 text-xs hover:bg-muted"
123 onClick={() => {
124 const now = new Date();
125 setCursor(new Date(Date.UTC(now.getFullYear(), now.getMonth(), 1)));
126 setSelectedDay(dayKey(now));
127 }}
128 >
129 Today
130 </button>
131 <button type="button" className="rounded p-1 text-muted-foreground hover:bg-muted" onClick={() => move(1)} aria-label="Next month">
132 <ChevronRight className="size-4" />
133 </button>
134 </div>
135 </div>
136
137 {}
138 <div className="min-h-0 flex-1 overflow-y-auto">
139 <div className="grid grid-cols-7 border-b border-border text-[11px] text-muted-foreground">
140 {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map((d) => (
141 <div key={d} className="py-1 text-center">
142 {d}
143 </div>
144 ))}
145 </div>
146 <div className="grid grid-cols-7">
147 {days.map((d, i) => {
148 if (!d) return <div key={i} className="min-h-16 border-b border-r border-border/40 bg-muted/20" />;
149 const k = dayKey(d);
150 const dayRecords = byDay.get(k) ?? [];
151 return (
152 <button
153 key={i}
154 type="button"
155 className={cn(
156 "min-h-16 border-b border-r border-border/40 p-1 text-left align-top hover:bg-muted/40",
157 k === selectedDay && "bg-primary/10",
158 k === todayKey && "ring-1 ring-inset ring-primary/40"
159 )}
160 onClick={() => setSelectedDay(k)}
161 aria-label={`Day ${d.getUTCDate()}, ${dayRecords.length} records`}
162 >
163 <span
164 className={cn(
165 "inline-flex size-5 items-center justify-center rounded-full text-[11px]",
166 k === todayKey && "bg-primary text-primary-foreground"
167 )}
168 >
169 {d.getUTCDate()}
170 </span>
171 <div className="mt-0.5 flex flex-wrap gap-0.5">
172 {dayRecords.slice(0, 5).map((r) => (
173 <span key={r.id} className="size-1.5 rounded-full bg-primary/70" title={titleProp ? valueToText(r.properties[titleProp.id], titleProp) : r.id} />
174 ))}
175 {dayRecords.length > 5 && (
176 <span className="text-[10px] text-muted-foreground">+{dayRecords.length - 5}</span>
177 )}
178 </div>
179 </button>
180 );
181 })}
182 </div>
183 </div>
184 </div>
185
186 {}
187 {selectedDay && (
188 <aside className="flex w-72 shrink-0 flex-col border-l border-border">
189 <div className="border-b border-border px-3 py-2 text-xs font-medium">
190 {formatDateValue(`${selectedDay}T00:00:00Z`)} · {selectedRecords.length} record(s)
191 </div>
192 <div className="min-h-0 flex-1 overflow-y-auto">
193 {isLoading && <div className="p-3 text-xs text-muted-foreground">Loading…</div>}
194 {selectedRecords.length === 0 && !isLoading && (
195 <p className="p-3 text-xs text-muted-foreground">No records on this day.</p>
196 )}
197 {selectedRecords.map((r) => (
198 <button
199 key={r.id}
200 type="button"
201 className="flex w-full items-center gap-2 border-b border-border/40 px-3 py-1.5 text-left text-sm hover:bg-muted/50"
202 onClick={() => onOpenRecord(r)}
203 >
204 <span className="w-4 text-center" aria-hidden>
205 {r.icon ?? "•"}
206 </span>
207 <span className="truncate">
208 {titleProp ? valueToText(r.properties[titleProp.id], titleProp) || "Untitled" : r.id.slice(-6)}
209 </span>
210 </button>
211 ))}
212 </div>
213 </aside>
214 )}
215 </div>
216 );
217}
218