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:
@@ -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",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user