diff --git a/bun.lockb b/bun.lockb index 9d8cbc3..6213102 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index c40ef39..fa5703f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", "docx": "^9.6.1", "embla-carousel-react": "^8.6.0", "file-saver": "^2.0.5", diff --git a/src/integrations/supabase/types.ts b/src/integrations/supabase/types.ts index dbb610a..6487e8a 100644 --- a/src/integrations/supabase/types.ts +++ b/src/integrations/supabase/types.ts @@ -1326,6 +1326,7 @@ export type Database = { events: { Row: { all_day: boolean + assigned_to: string | null case_id: string | null client_id: string | null created_at: string @@ -1342,6 +1343,7 @@ export type Database = { } Insert: { all_day?: boolean + assigned_to?: string | null case_id?: string | null client_id?: string | null created_at?: string @@ -1358,6 +1360,7 @@ export type Database = { } Update: { all_day?: boolean + assigned_to?: string | null case_id?: string | null client_id?: string | null created_at?: string @@ -1373,6 +1376,13 @@ export type Database = { updated_at?: string } Relationships: [ + { + foreignKeyName: "events_assigned_to_fkey" + columns: ["assigned_to"] + isOneToOne: false + referencedRelation: "profiles" + referencedColumns: ["id"] + }, { foreignKeyName: "events_case_id_fkey" columns: ["case_id"] diff --git a/src/lib/tz.ts b/src/lib/tz.ts new file mode 100644 index 0000000..6f656a1 --- /dev/null +++ b/src/lib/tz.ts @@ -0,0 +1,32 @@ +import { fromZonedTime, toZonedTime, format as formatTz } from "date-fns-tz"; + +/** + * Firm timezone — all calendar entries are entered and displayed in EST/EDT + * regardless of the browser's local timezone. + */ +export const FIRM_TZ = "America/New_York"; + +/** + * Convert a "wall clock" date + HH:mm string entered in EST into an ISO UTC + * timestamp suitable for Postgres `timestamptz`. Handles DST automatically. + * + * Example: ("2024-07-04", "09:00") → "2024-07-04T13:00:00.000Z" (EDT, -04:00) + * ("2024-01-15", "09:00") → "2024-01-15T14:00:00.000Z" (EST, -05:00) + */ +export function estWallTimeToUTC(dateStr: string, timeStr: string): string { + // Build a naive local-style string and tell date-fns-tz to interpret it as EST. + const naive = `${dateStr}T${timeStr.length === 5 ? `${timeStr}:00` : timeStr}`; + return fromZonedTime(naive, FIRM_TZ).toISOString(); +} + +/** Format a UTC ISO timestamp in EST using a date-fns format token. */ +export function formatInEST(iso: string | Date, fmt: string): string { + const d = typeof iso === "string" ? new Date(iso) : iso; + return formatTz(toZonedTime(d, FIRM_TZ), fmt, { timeZone: FIRM_TZ }); +} + +/** Get a Date object representing the EST wall-clock for a UTC ISO. */ +export function toESTDate(iso: string | Date): Date { + const d = typeof iso === "string" ? new Date(iso) : iso; + return toZonedTime(d, FIRM_TZ); +} diff --git a/src/routes/calendar.index.tsx b/src/routes/calendar.index.tsx index 66e8e1e..42e7263 100644 --- a/src/routes/calendar.index.tsx +++ b/src/routes/calendar.index.tsx @@ -53,6 +53,7 @@ import { } from "date-fns"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; +import { estWallTimeToUTC, formatInEST, toESTDate } from "@/lib/tz"; export const Route = createFileRoute("/calendar/")({ component: CalendarPage, @@ -164,7 +165,7 @@ function CalendarPage() { supabase .from("events") .select( - "id,title,start_at,end_at,all_day,event_type,case_id,client_id,location,description,created_by", + "id,title,start_at,end_at,all_day,event_type,case_id,client_id,location,description,created_by,assigned_to", ) .gte("start_at", `${winStart}T00:00:00`) .lte("start_at", `${winEnd}T23:59:59`) @@ -204,7 +205,8 @@ function CalendarPage() { const evts: Evt[] = []; for (const e of eventsRes.data ?? []) { - const owner = e.created_by ? profileMap.get(e.created_by) : null; + const ownerId = (e.assigned_to as string | null) ?? e.created_by ?? null; + const owner = ownerId ? profileMap.get(ownerId) : null; const c = e.case_id ? caseMap.get(e.case_id) : null; const clientId = e.client_id ?? c?.client_id ?? null; const cl = clientId ? clientMap.get(clientId) : null; @@ -220,7 +222,7 @@ function CalendarPage() { case_label: c ? `${c.case_number} — ${c.title}` : null, client_id: clientId, client_label: cl?.name ?? null, - owner_id: e.created_by, + owner_id: ownerId, owner_name: owner?.full_name ?? null, location: e.location ?? null, description: e.description ?? null, @@ -299,7 +301,8 @@ function CalendarPage() { const eventsByDay = useMemo(() => { const m = new Map(); for (const e of visibleEvents) { - const key = e.start.slice(0, 10); + // Bucket events by their EST calendar day, not the raw UTC ISO date. + const key = e.all_day ? e.start.slice(0, 10) : formatInEST(e.start, "yyyy-MM-dd"); const arr = m.get(key) ?? []; arr.push(e); m.set(key, arr); @@ -361,12 +364,14 @@ function CalendarPage() { setEditingId(evt.id); setFTitle(evt.title); setFType((EVENT_TYPE_MAP[evt.event_type] ? evt.event_type : "other") as EventTypeKey); - const dt = parseISO(evt.start); - setFStart(dt); + // Convert UTC timestamp into the EST wall-clock so the date pickers and + // HH:mm inputs reflect what the user originally entered (in EST). + const estStart = toESTDate(evt.start); + setFStart(estStart); setFAllDay(evt.all_day); if (!evt.all_day) { - setFStartTime(format(dt, "HH:mm")); - setFEndTime(evt.end ? format(parseISO(evt.end), "HH:mm") : format(dt, "HH:mm")); + setFStartTime(formatInEST(evt.start, "HH:mm")); + setFEndTime(evt.end ? formatInEST(evt.end, "HH:mm") : formatInEST(evt.start, "HH:mm")); } setFCase(evt.case_id ?? "none"); setFClient(evt.client_id ?? "none"); @@ -383,8 +388,12 @@ function CalendarPage() { } setSaving(true); const dateStr = format(fStart, "yyyy-MM-dd"); - const startISO = fAllDay ? `${dateStr}T00:00:00` : `${dateStr}T${fStartTime}:00`; - const endISO = fAllDay ? null : `${dateStr}T${fEndTime}:00`; + // Times are entered as EST wall-clock; convert to a UTC ISO for storage so + // Postgres `timestamptz` records the correct instant regardless of browser TZ. + const startISO = fAllDay + ? estWallTimeToUTC(dateStr, "00:00") + : estWallTimeToUTC(dateStr, fStartTime); + const endISO = fAllDay ? null : estWallTimeToUTC(dateStr, fEndTime); const ownerId = fAssignee === "self" ? user.id : fAssignee; const payload = { @@ -397,11 +406,12 @@ function CalendarPage() { client_id: fClient === "none" ? null : fClient, location: fLocation.trim() || null, description: fNotes.trim() || null, + assigned_to: ownerId, }; const { error } = editingId ? await supabase.from("events").update(payload).eq("id", editingId) - : await supabase.from("events").insert({ ...payload, created_by: ownerId }); + : await supabase.from("events").insert({ ...payload, created_by: user.id }); setSaving(false); if (error) { toast.error(error.message); @@ -705,7 +715,7 @@ function CalendarPage() { {!fAllDay && (
- +
- + setFEndTime(e.target.value)} />
+

+ All times are entered and displayed in Eastern Time. +

)}
@@ -866,7 +879,7 @@ function MonthGrid({ )} title={e.title} > - {!e.all_day && format(parseISO(e.start), "h:mma ")} + {!e.all_day && formatInEST(e.start, "h:mma ")} {e.title}
); @@ -933,7 +946,7 @@ function WeekStrip({ className={cn("text-[11px] px-1.5 py-1 rounded truncate text-white", meta.bg)} title={e.title} > - {!e.all_day && format(parseISO(e.start), "h:mma ")} + {!e.all_day && formatInEST(e.start, "h:mma ")} {e.title} ); @@ -1001,8 +1014,8 @@ function EventRow({
- {showDate && {format(parseISO(evt.start), "EEE, MMM d")}} - {!evt.all_day && {format(parseISO(evt.start), "h:mm a")}} + {showDate && {formatInEST(evt.start, "EEE, MMM d")}} + {!evt.all_day && {formatInEST(evt.start, "h:mm a")} EST} {evt.all_day && All day} {evt.case_label && · {evt.case_label}} {evt.client_label && !evt.case_label && ( diff --git a/supabase/migrations/20260418225247_e83c1d7f-92ee-48c2-93dc-ceedd507c6a1.sql b/supabase/migrations/20260418225247_e83c1d7f-92ee-48c2-93dc-ceedd507c6a1.sql new file mode 100644 index 0000000..15e6b69 --- /dev/null +++ b/supabase/migrations/20260418225247_e83c1d7f-92ee-48c2-93dc-ceedd507c6a1.sql @@ -0,0 +1,7 @@ +ALTER TABLE public.events +ADD COLUMN IF NOT EXISTS assigned_to uuid REFERENCES public.profiles(id) ON DELETE SET NULL; + +-- Backfill existing rows: assignee = creator +UPDATE public.events SET assigned_to = created_by WHERE assigned_to IS NULL AND created_by IS NOT NULL; + +CREATE INDEX IF NOT EXISTS events_assigned_to_idx ON public.events(assigned_to); \ No newline at end of file