Fixed calendar timezone/assignee

X-Lovable-Edit-ID: edt-ae3d9db1-4da6-4b8a-9e15-e9f5fecf80a7
Co-authored-by: renee-png <262607627+renee-png@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-04-18 22:54:58 +00:00
co-authored by renee-png
6 changed files with 80 additions and 17 deletions
BIN
View File
Binary file not shown.
+1
View File
@@ -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",
+10
View File
@@ -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"]
+32
View File
@@ -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);
}
+30 -17
View File
@@ -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<string, Evt[]>();
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 && (
<div className="grid grid-cols-2 gap-3">
<div>
<Label>Start time</Label>
<Label>Start time (EST)</Label>
<Input
type="time"
value={fStartTime}
@@ -713,13 +723,16 @@ function CalendarPage() {
/>
</div>
<div>
<Label>End time</Label>
<Label>End time (EST)</Label>
<Input
type="time"
value={fEndTime}
onChange={(e) => setFEndTime(e.target.value)}
/>
</div>
<p className="col-span-2 text-xs text-muted-foreground -mt-1">
All times are entered and displayed in Eastern Time.
</p>
</div>
)}
<div className="grid grid-cols-2 gap-3">
@@ -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}
</div>
);
@@ -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}
</div>
);
@@ -1001,8 +1014,8 @@ function EventRow({
</Badge>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-3 flex-wrap mt-0.5">
{showDate && <span>{format(parseISO(evt.start), "EEE, MMM d")}</span>}
{!evt.all_day && <span>{format(parseISO(evt.start), "h:mm a")}</span>}
{showDate && <span>{formatInEST(evt.start, "EEE, MMM d")}</span>}
{!evt.all_day && <span>{formatInEST(evt.start, "h:mm a")} EST</span>}
{evt.all_day && <span>All day</span>}
{evt.case_label && <span className="truncate">· {evt.case_label}</span>}
{evt.client_label && !evt.case_label && (
@@ -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);