From 4de0ce46f72089f2feeb67459964cdc602686580 Mon Sep 17 00:00:00 2001 From: "Leonardo B." Date: Sun, 22 Mar 2026 16:17:53 -0300 Subject: [PATCH] Add appointment calendar helpers & views Extract appointment calendar logic and time formatting, add custom ScheduleX event views, and wire locale/time preferences through calendar UI. - Add lib/appointment-calendar.ts: shared calendar colors, ISO->ZonedDateTime conversion, event conversion helpers, and month range helper. - Add components/schedule-x/appointment-event-views.tsx: custom ScheduleX components for month grid, month agenda and time grid that use locale/timeFormat and show icons, people and location. - Refactor calendar pages (app/calendar/page.tsx) and dashboard strip (app/dashboard/CalendarStrip.tsx) to use the new helpers, plug in events service, pass locale/timeFormat/timezone, and use custom components. Replace inline color config and event conversion logic with shared utilities. - Update display utilities (lib/display.ts): add TimeFormat type and functions for formatting date/time and ScheduleX time-axis options. Add helpers used by components and tests. - Update StageHistoryDialog to read locale/timeFormat preferences and use formatDateTime for entry timestamps. - Add tests: __tests__/display.test.ts for formatting/time-axis behavior and extend StageHistoryDialog.test.tsx to cover 24-hour preference rendering. Also clear localStorage in test setup and refine mocked updateHistoryEntry shape. These changes centralize calendar/event formatting, support user preferences (locale/time format/timezone) consistently across calendar UIs, and add custom visual event components for ScheduleX. --- .../web/__tests__/StageHistoryDialog.test.tsx | 29 ++- frontend/apps/web/__tests__/display.test.ts | 59 ++++++ frontend/apps/web/app/calendar/page.tsx | 169 ++++++++---------- .../apps/web/app/dashboard/CalendarStrip.tsx | 146 +++++++-------- .../web/components/StageHistoryDialog.tsx | 18 +- .../schedule-x/appointment-event-views.tsx | 159 ++++++++++++++++ frontend/apps/web/lib/appointment-calendar.ts | 79 ++++++++ frontend/apps/web/lib/display.ts | 34 ++++ 8 files changed, 519 insertions(+), 174 deletions(-) create mode 100644 frontend/apps/web/__tests__/display.test.ts create mode 100644 frontend/apps/web/components/schedule-x/appointment-event-views.tsx create mode 100644 frontend/apps/web/lib/appointment-calendar.ts diff --git a/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx b/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx index 62ccc36..fb57819 100644 --- a/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx +++ b/frontend/apps/web/__tests__/StageHistoryDialog.test.tsx @@ -45,6 +45,7 @@ describe("StageHistoryDialog", () => { beforeEach(() => { vi.clearAllMocks() mockRefetch.mockResolvedValue(undefined) + localStorage.clear() }) it("shows stage history title and entries when open", () => { @@ -60,8 +61,16 @@ describe("StageHistoryDialog", () => { it("opens edit dialog and calls updateHistoryEntry on save", async () => { const user = userEvent.setup() const onStageChanged = vi.fn() + const entry = mockHistory[0]! vi.mocked(applicationsService.updateHistoryEntry).mockResolvedValue({ - data: { ...mockHistory[0], stage: "offer", notes: "First round" }, + data: { + id: 10, + application_id: 1, + stage: "offer", + date: entry.date, + notes: "First round", + created_at: entry.created_at, + }, error: null, }) @@ -148,4 +157,22 @@ describe("StageHistoryDialog", () => { expect(applicationsService.advanceStage).not.toHaveBeenCalled() expect(toast.error).toHaveBeenCalledWith("Stage is required") }) + + describe("with 24-hour display preference", () => { + beforeEach(() => { + localStorage.setItem("display.timeFormat", JSON.stringify("24h")) + localStorage.setItem("display.locale", JSON.stringify("en-US")) + }) + + it("renders history timestamps without AM/PM", async () => { + render( + {}} />, + ) + + const dialog = screen.getByRole("dialog", { name: /stage history/i }) + await waitFor(() => { + expect(dialog.textContent).not.toMatch(/\b(AM|PM)\b/) + }) + }) + }) }) diff --git a/frontend/apps/web/__tests__/display.test.ts b/frontend/apps/web/__tests__/display.test.ts new file mode 100644 index 0000000..cae7ed0 --- /dev/null +++ b/frontend/apps/web/__tests__/display.test.ts @@ -0,0 +1,59 @@ +import "temporal-polyfill/global" + +import { describe, expect, it } from "vitest" +import { + formatDateTime, + formatTimeFromEpochMs, + formatTimeRangeFromZoned, + scheduleXTimeAxisFormatOptions, +} from "@/lib/display" + +describe("scheduleXTimeAxisFormatOptions", () => { + it("uses 12-hour clock for 12h preference", () => { + expect(scheduleXTimeAxisFormatOptions("12h")).toMatchObject({ + hour: "2-digit", + minute: "2-digit", + hour12: true, + }) + }) + + it("uses 24-hour clock for 24h preference", () => { + expect(scheduleXTimeAxisFormatOptions("24h")).toMatchObject({ + hour: "2-digit", + minute: "2-digit", + hour12: false, + }) + }) +}) + +describe("formatDateTime", () => { + it("omits AM/PM for 24h in en-US", () => { + const s = formatDateTime("2025-06-15T18:30:00.000Z", "en-US", "24h") + expect(s).not.toMatch(/\b(AM|PM)\b/i) + expect(s).toMatch(/2025/) + }) + + it("can include AM/PM for 12h in en-US", () => { + const s = formatDateTime("2025-06-15T18:30:00.000Z", "en-US", "12h") + expect(s).toMatch(/\b(AM|PM)\b/i) + }) +}) + +describe("formatTimeFromEpochMs", () => { + it("formats with explicit 24h", () => { + const ms = Date.UTC(2025, 5, 15, 18, 30, 0) + const s = formatTimeFromEpochMs(ms, "en-US", "24h") + expect(s).not.toMatch(/\b(AM|PM)\b/i) + }) +}) + +describe("formatTimeRangeFromZoned", () => { + it("joins start and end with en dash", () => { + const tz = Temporal.Now.timeZoneId() + const start = Temporal.ZonedDateTime.from(`2025-06-15T10:00:00[${tz}]`) + const end = Temporal.ZonedDateTime.from(`2025-06-15T11:30:00[${tz}]`) + const s = formatTimeRangeFromZoned(start, end, "en-US", "24h") + expect(s).toContain("–") + expect(s).not.toMatch(/\b(AM|PM)\b/i) + }) +}) diff --git a/frontend/apps/web/app/calendar/page.tsx b/frontend/apps/web/app/calendar/page.tsx index 87dbceb..533c3d8 100644 --- a/frontend/apps/web/app/calendar/page.tsx +++ b/frontend/apps/web/app/calendar/page.tsx @@ -1,6 +1,7 @@ "use client" import "temporal-polyfill/global" + import { useEffect, useMemo, useState } from "react" import { useCalendarApp, ScheduleXCalendar } from "@schedule-x/react" import { createViewMonthGrid, createViewMonthAgenda } from "@schedule-x/calendar" @@ -38,83 +39,82 @@ import { TooltipProvider, TooltipTrigger, } from "@workspace/ui/components/tooltip" +import { createAppointmentScheduleXCustomComponents } from "@/components/schedule-x/appointment-event-views" import { AppointmentDialog } from "@/components/AppointmentDialog" import { useAppointments } from "@/hooks/useAppointments" import { useApplications } from "@/hooks/useApplications" -import { formatDate, formatTimeRange, type TimeFormat } from "@/lib/display" +import { + APPOINTMENT_CALENDAR_COLORS, + appointmentsToScheduleXEvents, + getMonthRange, + resolveTimeZone, +} from "@/lib/appointment-calendar" +import { formatDate, formatTimeRange, scheduleXTimeAxisFormatOptions, type TimeFormat } from "@/lib/display" import { usePreference } from "@/hooks/usePreference" import { deleteAppointment, getAppointment } from "@/services/appointments.service" import type { AppointmentResponse } from "@/types" -const CALENDAR_COLORS = { - interview: { - colorName: "interview", - lightColors: { main: "#4f46e5", container: "#e0e7ff", onContainer: "#3730a3" }, - darkColors: { main: "#818cf8", container: "#312e81", onContainer: "#c7d2fe" }, - }, - assessment: { - colorName: "assessment", - lightColors: { main: "#d97706", container: "#fef3c7", onContainer: "#92400e" }, - darkColors: { main: "#fbbf24", container: "#78350f", onContainer: "#fde68a" }, - }, - project: { - colorName: "project", - lightColors: { main: "#0d9488", container: "#ccfbf1", onContainer: "#115e59" }, - darkColors: { main: "#2dd4bf", container: "#134e4a", onContainer: "#99f6e4" }, - }, - meeting: { - colorName: "meeting", - lightColors: { main: "#7c3aed", container: "#ede9fe", onContainer: "#5b21b6" }, - darkColors: { main: "#a78bfa", container: "#4c1d95", onContainer: "#ddd6fe" }, - }, - other: { - colorName: "other", - lightColors: { main: "#64748b", container: "#f1f5f9", onContainer: "#334155" }, - darkColors: { main: "#94a3b8", container: "#1e293b", onContainer: "#cbd5e1" }, - }, -} +type AppointmentEventsService = ReturnType -function isoToZonedDateTime(iso: string, timeZoneId: string): Temporal.ZonedDateTime { - let cleaned = iso - if (cleaned.endsWith("Z")) cleaned = cleaned.slice(0, -1) - const plusIdx = cleaned.indexOf("+") - if (plusIdx !== -1) cleaned = cleaned.slice(0, plusIdx) - return Temporal.PlainDateTime.from(cleaned).toZonedDateTime(timeZoneId) +type CalendarPageScheduleXProps = { + timeZoneId: string + locale: string + timeFormat: TimeFormat + isDark: boolean + resolvedTheme: string | undefined + eventsService: AppointmentEventsService + onEventClick: (eventId: string) => void + onCalendarRangeMonthChange: (monthStart: Date) => void } -function toEvents(appointments: AppointmentResponse[], timeZoneId: string) { - return appointments.map((a) => ({ - id: String(a.id), - title: a.title, - start: isoToZonedDateTime(a.starts_at, timeZoneId), - end: a.ends_at - ? isoToZonedDateTime(a.ends_at, timeZoneId) - : isoToZonedDateTime(a.starts_at, timeZoneId).add({ hours: 1 }), - calendarId: CALENDAR_COLORS[a.type as keyof typeof CALENDAR_COLORS] ? a.type : "other", - })) -} +function CalendarPageScheduleX({ + timeZoneId, + locale, + timeFormat, + isDark, + resolvedTheme, + eventsService, + onEventClick, + onCalendarRangeMonthChange, +}: CalendarPageScheduleXProps) { + const customComponents = useMemo( + () => createAppointmentScheduleXCustomComponents(locale, timeFormat), + [locale, timeFormat], + ) -function formatLocalDateTime(date: Date): string { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, "0") - const day = String(date.getDate()).padStart(2, "0") - const hours = String(date.getHours()).padStart(2, "0") - const minutes = String(date.getMinutes()).padStart(2, "0") - const seconds = String(date.getSeconds()).padStart(2, "0") - return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` -} + const calendar = useCalendarApp({ + theme: "shadcn", + isDark, + locale, + views: [createViewMonthGrid(), createViewMonthAgenda()], + calendars: APPOINTMENT_CALENDAR_COLORS, + timezone: timeZoneId, + dayBoundaries: { start: "06:00", end: "22:00" }, + weekOptions: { + timeAxisFormatOptions: scheduleXTimeAxisFormatOptions(timeFormat), + }, + events: [], + plugins: [eventsService], + callbacks: { + onEventClick(calendarEvent: { id: unknown }) { + onEventClick(String(calendarEvent.id)) + }, + onRangeUpdate(newRange) { + const start = newRange.start as unknown as Temporal.ZonedDateTime + const mid = start.add({ days: 15 }) + onCalendarRangeMonthChange(new Date(mid.year, mid.month - 1, 1)) + }, + }, + }) -function getMonthRange(date: Date): { start: string; end: string } { - const y = date.getFullYear() - const m = date.getMonth() - const first = new Date(y, m, 1, 0, 0, 0, 0) - const last = new Date(y, m + 1, 0, 23, 59, 59, 999) - return { start: formatLocalDateTime(first), end: formatLocalDateTime(last) } -} + useEffect(() => { + if (!calendar) return + if (resolvedTheme === "light" || resolvedTheme === "dark") { + calendar.setTheme(resolvedTheme) + } + }, [calendar, resolvedTheme]) -function resolveTimeZone(pref: string | undefined): string { - if (pref && pref !== "auto") return pref - return Temporal.Now.timeZoneId() + return } export default function CalendarPage() { @@ -142,37 +142,12 @@ export default function CalendarPage() { const [eventsService] = useState(() => createEventsServicePlugin()) - const calendar = useCalendarApp({ - theme: "shadcn", - isDark: resolvedTheme === "dark", - views: [createViewMonthGrid(), createViewMonthAgenda()], - calendars: CALENDAR_COLORS, - timezone: timeZoneId, - dayBoundaries: { start: "06:00", end: "22:00" }, - events: [], - plugins: [eventsService], - callbacks: { - onEventClick(calendarEvent: { id: unknown }) { - handleEventClick(calendarEvent.id as string) - }, - onRangeUpdate(newRange) { - const start = newRange.start as unknown as Temporal.ZonedDateTime - const mid = start.add({ days: 15 }) - setCurrentDate(new Date(mid.year, mid.month - 1, 1)) - }, - }, - }) + const isDark = resolvedTheme === "dark" useEffect(() => { - eventsService.set(toEvents(appointments, timeZoneId)) + eventsService.set(appointmentsToScheduleXEvents(appointments, timeZoneId)) }, [appointments, eventsService, timeZoneId]) - useEffect(() => { - if (calendar && resolvedTheme && (resolvedTheme === "light" || resolvedTheme === "dark")) { - calendar.setTheme(resolvedTheme) - } - }, [calendar, resolvedTheme]) - async function handleEventClick(eventId: string) { const result = await getAppointment(Number(eventId)) if (result.error !== null) return @@ -221,7 +196,17 @@ export default function CalendarPage() {
- +
diff --git a/frontend/apps/web/app/dashboard/CalendarStrip.tsx b/frontend/apps/web/app/dashboard/CalendarStrip.tsx index 6d18db6..0bb9a46 100644 --- a/frontend/apps/web/app/dashboard/CalendarStrip.tsx +++ b/frontend/apps/web/app/dashboard/CalendarStrip.tsx @@ -1,6 +1,7 @@ "use client" import "temporal-polyfill/global" + import { useEffect, useMemo, useState } from "react" import { useCalendarApp, ScheduleXCalendar } from "@schedule-x/react" import { createViewWeek } from "@schedule-x/calendar" @@ -15,62 +16,74 @@ import { CollapsibleTrigger, } from "@workspace/ui/components/collapsible" import { ChevronDown, ChevronUp } from "lucide-react" +import { createAppointmentScheduleXCustomComponents } from "@/components/schedule-x/appointment-event-views" import { AppointmentDialog } from "@/components/AppointmentDialog" import { usePreference } from "@/hooks/usePreference" import { useAppointments } from "@/hooks/useAppointments" +import { + APPOINTMENT_CALENDAR_COLORS, + appointmentsToScheduleXEvents, + resolveTimeZone, +} from "@/lib/appointment-calendar" +import { scheduleXTimeAxisFormatOptions, type TimeFormat } from "@/lib/display" import { getAppointment } from "@/services/appointments.service" import type { AppointmentResponse } from "@/types" -const CALENDAR_COLORS = { - interview: { - colorName: "interview", - lightColors: { main: "#4f46e5", container: "#e0e7ff", onContainer: "#3730a3" }, - darkColors: { main: "#818cf8", container: "#312e81", onContainer: "#c7d2fe" }, - }, - assessment: { - colorName: "assessment", - lightColors: { main: "#d97706", container: "#fef3c7", onContainer: "#92400e" }, - darkColors: { main: "#fbbf24", container: "#78350f", onContainer: "#fde68a" }, - }, - project: { - colorName: "project", - lightColors: { main: "#0d9488", container: "#ccfbf1", onContainer: "#115e59" }, - darkColors: { main: "#2dd4bf", container: "#134e4a", onContainer: "#99f6e4" }, - }, - meeting: { - colorName: "meeting", - lightColors: { main: "#7c3aed", container: "#ede9fe", onContainer: "#5b21b6" }, - darkColors: { main: "#a78bfa", container: "#4c1d95", onContainer: "#ddd6fe" }, - }, - other: { - colorName: "other", - lightColors: { main: "#64748b", container: "#f1f5f9", onContainer: "#334155" }, - darkColors: { main: "#94a3b8", container: "#1e293b", onContainer: "#cbd5e1" }, - }, -} +type AppointmentEventsService = ReturnType -function getLocalTZ(): string { - return Temporal.Now.timeZoneId() +type CalendarStripScheduleXProps = { + timeZoneId: string + locale: string + timeFormat: TimeFormat + isDark: boolean + resolvedTheme: string | undefined + eventsService: AppointmentEventsService + onEventClick: (eventId: string) => void } -function isoToZonedDateTime(iso: string): Temporal.ZonedDateTime { - let cleaned = iso - if (cleaned.endsWith("Z")) cleaned = cleaned.slice(0, -1) - const plusIdx = cleaned.indexOf("+") - if (plusIdx !== -1) cleaned = cleaned.slice(0, plusIdx) - return Temporal.PlainDateTime.from(cleaned).toZonedDateTime(getLocalTZ()) -} +function CalendarStripScheduleX({ + timeZoneId, + locale, + timeFormat, + isDark, + resolvedTheme, + eventsService, + onEventClick, +}: CalendarStripScheduleXProps) { + const customComponents = useMemo( + () => createAppointmentScheduleXCustomComponents(locale, timeFormat), + [locale, timeFormat], + ) -function toEvents(appointments: AppointmentResponse[]) { - return appointments.map((a) => ({ - id: String(a.id), - title: a.title, - start: isoToZonedDateTime(a.starts_at), - end: a.ends_at - ? isoToZonedDateTime(a.ends_at) - : isoToZonedDateTime(a.starts_at).add({ hours: 1 }), - calendarId: CALENDAR_COLORS[a.type as keyof typeof CALENDAR_COLORS] ? a.type : "other", - })) + const calendar = useCalendarApp({ + theme: "shadcn", + isDark, + locale, + views: [createViewWeek()], + calendars: APPOINTMENT_CALENDAR_COLORS, + timezone: timeZoneId, + weekOptions: { + gridHeight: 2000, + nDays: 7, + timeAxisFormatOptions: scheduleXTimeAxisFormatOptions(timeFormat), + }, + events: [], + plugins: [eventsService], + callbacks: { + onEventClick(calendarEvent: { id: unknown }) { + onEventClick(String(calendarEvent.id)) + }, + }, + }) + + useEffect(() => { + if (!calendar) return + if (resolvedTheme === "light" || resolvedTheme === "dark") { + calendar.setTheme(resolvedTheme) + } + }, [calendar, resolvedTheme]) + + return } function getWeekRange(): { start: string; end: string } { @@ -88,6 +101,10 @@ function getWeekRange(): { start: string; end: string } { export function CalendarStrip() { const { resolvedTheme } = useTheme() + const [timeZonePref] = usePreference("display.timeZone", "auto") + const [locale] = usePreference("display.locale", "en-US") + const [timeFormat] = usePreference("display.timeFormat", "12h") + const timeZoneId = resolveTimeZone(timeZonePref) const range = useMemo(() => getWeekRange(), []) const { data: appointments, refetch } = useAppointments(range) @@ -102,31 +119,11 @@ export function CalendarStrip() { const [eventsService] = useState(() => createEventsServicePlugin()) - const calendar = useCalendarApp({ - theme: "shadcn", - isDark: resolvedTheme === "dark", - views: [createViewWeek()], - calendars: CALENDAR_COLORS, - timezone: getLocalTZ(), - weekOptions: { gridHeight: 2000, nDays: 7 }, - events: [], - plugins: [eventsService], - callbacks: { - onEventClick(calendarEvent: { id: unknown }) { - handleEventClick(calendarEvent.id as string) - }, - }, - }) - - useEffect(() => { - eventsService.set(toEvents(appointments)) - }, [appointments, eventsService]) + const isDark = resolvedTheme === "dark" useEffect(() => { - if (calendar && resolvedTheme && (resolvedTheme === "light" || resolvedTheme === "dark")) { - calendar.setTheme(resolvedTheme) - } - }, [calendar, resolvedTheme]) + eventsService.set(appointmentsToScheduleXEvents(appointments, timeZoneId)) + }, [appointments, eventsService, timeZoneId]) async function handleEventClick(eventId: string) { const result = await getAppointment(Number(eventId)) @@ -171,7 +168,16 @@ export function CalendarStrip() {
- +
diff --git a/frontend/apps/web/components/StageHistoryDialog.tsx b/frontend/apps/web/components/StageHistoryDialog.tsx index 8f89201..78fb3c7 100644 --- a/frontend/apps/web/components/StageHistoryDialog.tsx +++ b/frontend/apps/web/components/StageHistoryDialog.tsx @@ -21,6 +21,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Separator } from "@workspace/ui/components/separator" import { Textarea } from "@workspace/ui/components/textarea" import { useApplicationHistory } from "@/hooks/useApplicationHistory" +import { usePreference } from "@/hooks/usePreference" +import { formatDateTime, type TimeFormat } from "@/lib/display" import { advanceStage, deleteHistoryEntry, updateHistoryEntry } from "@/services/applications.service" import type { ApplicationHistoryResponse } from "@/types" @@ -33,17 +35,9 @@ interface Props { onStageChanged?: () => void } -function formatDateTime(iso: string): string { - return new Intl.DateTimeFormat("en-US", { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }).format(new Date(iso)) -} - export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageChanged }: Props) { + const [locale] = usePreference("display.locale", "en-US") + const [timeFormat] = usePreference("display.timeFormat", "12h") const { data: history, isLoading, refetch } = useApplicationHistory(open ? applicationId : null) const [newStage, setNewStage] = useState("") @@ -146,7 +140,9 @@ export function StageHistoryDialog({ applicationId, open, onOpenChange, onStageC

{entry.stage}

-

{formatDateTime(entry.date)}

+

+ {formatDateTime(entry.date, locale, timeFormat)} +

{entry.notes &&

{entry.notes}

}
diff --git a/frontend/apps/web/components/schedule-x/appointment-event-views.tsx b/frontend/apps/web/components/schedule-x/appointment-event-views.tsx new file mode 100644 index 0000000..945f21c --- /dev/null +++ b/frontend/apps/web/components/schedule-x/appointment-event-views.tsx @@ -0,0 +1,159 @@ +"use client" + +import "temporal-polyfill/global" + +import type { CSSProperties } from "react" +import type { CalendarEvent } from "@schedule-x/calendar" +import { Clock, MapPin, User } from "lucide-react" +import { + formatTimeFromEpochMs, + formatTimeRangeFromZoned, + type TimeFormat, +} from "@/lib/display" + +function calendarColorId(event: CalendarEvent): string { + return event.calendarId ?? "other" +} + +function monthGridEventStyles(event: CalendarEvent, hasStartDate: boolean): CSSProperties { + const id = calendarColorId(event) + return { + display: "flex", + flexDirection: "column", + alignItems: "flex-start", + width: "100%", + height: "100%", + boxSizing: "border-box", + padding: "var(--sx-spacing-padding1)", + borderRadius: "var(--sx-rounding-extra-small)", + overflow: "hidden", + borderInlineStart: hasStartDate ? `4px solid var(--sx-color-${id})` : undefined, + color: `var(--sx-color-on-${id}-container)`, + backgroundColor: `var(--sx-color-${id}-container)`, + } +} + +function monthAgendaStyles(event: CalendarEvent): CSSProperties { + const id = calendarColorId(event) + return { + boxSizing: "border-box", + width: "100%", + height: "100%", + padding: "var(--sx-spacing-padding1)", + backgroundColor: `var(--sx-color-${id}-container)`, + color: `var(--sx-color-on-${id}-container)`, + borderInlineStart: `4px solid var(--sx-color-${id})`, + } +} + +function timeGridInnerStyles(event: CalendarEvent): CSSProperties { + const id = calendarColorId(event) + return { + boxSizing: "border-box", + width: "100%", + height: "100%", + padding: "var(--sx-spacing-padding2, 4px)", + backgroundColor: `var(--sx-color-${id}-container)`, + color: `var(--sx-color-on-${id}-container)`, + borderInlineStart: `4px solid var(--sx-color-${id})`, + display: "flex", + flexDirection: "column", + gap: "2px", + overflow: "hidden", + } +} + +function agendaTimeLabel(event: CalendarEvent, locale: string, timeFormat: TimeFormat): string { + if (event.start instanceof Temporal.ZonedDateTime && event.end instanceof Temporal.ZonedDateTime) { + return formatTimeRangeFromZoned(event.start, event.end, locale, timeFormat) + } + if (event.start instanceof Temporal.ZonedDateTime) { + return formatTimeFromEpochMs(Number(event.start.toInstant().epochMilliseconds), locale, timeFormat) + } + return "" +} + +export function createAppointmentScheduleXCustomComponents(locale: string, timeFormat: TimeFormat) { + const iconStroke = "currentColor" + + function AppointmentMonthGridEvent({ + calendarEvent, + hasStartDate, + }: { + calendarEvent: CalendarEvent + hasStartDate: boolean + }) { + const time = + calendarEvent.start instanceof Temporal.ZonedDateTime + ? formatTimeFromEpochMs( + Number(calendarEvent.start.toInstant().epochMilliseconds), + locale, + timeFormat, + ) + : null + return ( +
+ {time ?
{time}
: null} +
{calendarEvent.title}
+
+ ) + } + + function AppointmentMonthAgendaEvent({ calendarEvent }: { calendarEvent: CalendarEvent }) { + const timeLabel = agendaTimeLabel(calendarEvent, locale, timeFormat) + return ( +
+
{calendarEvent.title}
+ {timeLabel ? ( +
+ +
{timeLabel}
+
+ ) : null} +
+ ) + } + + function AppointmentTimeGridEvent({ calendarEvent }: { calendarEvent: CalendarEvent }) { + const id = calendarColorId(calendarEvent) + const stroke = `var(--sx-color-on-${id}-container)` + let timeLine: string | null = null + if ( + calendarEvent.start instanceof Temporal.ZonedDateTime && + calendarEvent.end instanceof Temporal.ZonedDateTime + ) { + timeLine = formatTimeRangeFromZoned(calendarEvent.start, calendarEvent.end, locale, timeFormat) + } + return ( +
+ {calendarEvent.title ? ( +
{calendarEvent.title}
+ ) : null} + {timeLine ? ( +
+ + {timeLine} +
+ ) : null} + {calendarEvent.people && calendarEvent.people.length > 0 ? ( +
+ + {calendarEvent.people.join(", ")} +
+ ) : null} + {calendarEvent.location ? ( +
+ + {calendarEvent.location} +
+ ) : null} +
+ ) + } + + return { + monthGridEvent: AppointmentMonthGridEvent, + monthAgendaEvent: AppointmentMonthAgendaEvent, + timeGridEvent: AppointmentTimeGridEvent, + } +} diff --git a/frontend/apps/web/lib/appointment-calendar.ts b/frontend/apps/web/lib/appointment-calendar.ts new file mode 100644 index 0000000..0f847e6 --- /dev/null +++ b/frontend/apps/web/lib/appointment-calendar.ts @@ -0,0 +1,79 @@ +import "temporal-polyfill/global" + +import type { AppointmentResponse } from "@/types" + +export const APPOINTMENT_CALENDAR_COLORS = { + interview: { + colorName: "interview", + lightColors: { main: "#4f46e5", container: "#e0e7ff", onContainer: "#3730a3" }, + darkColors: { main: "#818cf8", container: "#312e81", onContainer: "#c7d2fe" }, + }, + assessment: { + colorName: "assessment", + lightColors: { main: "#d97706", container: "#fef3c7", onContainer: "#92400e" }, + darkColors: { main: "#fbbf24", container: "#78350f", onContainer: "#fde68a" }, + }, + project: { + colorName: "project", + lightColors: { main: "#0d9488", container: "#ccfbf1", onContainer: "#115e59" }, + darkColors: { main: "#2dd4bf", container: "#134e4a", onContainer: "#99f6e4" }, + }, + meeting: { + colorName: "meeting", + lightColors: { main: "#7c3aed", container: "#ede9fe", onContainer: "#5b21b6" }, + darkColors: { main: "#a78bfa", container: "#4c1d95", onContainer: "#ddd6fe" }, + }, + other: { + colorName: "other", + lightColors: { main: "#64748b", container: "#f1f5f9", onContainer: "#334155" }, + darkColors: { main: "#94a3b8", container: "#1e293b", onContainer: "#cbd5e1" }, + }, +} as const + +export function resolveTimeZone(pref: string | undefined): string { + if (pref && pref !== "auto") return pref + return Temporal.Now.timeZoneId() +} + +export function isoToZonedDateTime(iso: string, timeZoneId: string): Temporal.ZonedDateTime { + let cleaned = iso + if (cleaned.endsWith("Z")) cleaned = cleaned.slice(0, -1) + const plusIdx = cleaned.indexOf("+") + if (plusIdx !== -1) cleaned = cleaned.slice(0, plusIdx) + return Temporal.PlainDateTime.from(cleaned).toZonedDateTime(timeZoneId) +} + +export function appointmentsToScheduleXEvents( + appointments: AppointmentResponse[], + timeZoneId: string, +) { + return appointments.map((a) => ({ + id: String(a.id), + title: a.title, + start: isoToZonedDateTime(a.starts_at, timeZoneId), + end: a.ends_at + ? isoToZonedDateTime(a.ends_at, timeZoneId) + : isoToZonedDateTime(a.starts_at, timeZoneId).add({ hours: 1 }), + calendarId: APPOINTMENT_CALENDAR_COLORS[a.type as keyof typeof APPOINTMENT_CALENDAR_COLORS] + ? a.type + : "other", + })) +} + +export function formatLocalDateTime(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, "0") + const day = String(date.getDate()).padStart(2, "0") + const hours = String(date.getHours()).padStart(2, "0") + const minutes = String(date.getMinutes()).padStart(2, "0") + const seconds = String(date.getSeconds()).padStart(2, "0") + return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}` +} + +export function getMonthRange(date: Date): { start: string; end: string } { + const y = date.getFullYear() + const m = date.getMonth() + const first = new Date(y, m, 1, 0, 0, 0, 0) + const last = new Date(y, m + 1, 0, 23, 59, 59, 999) + return { start: formatLocalDateTime(first), end: formatLocalDateTime(last) } +} diff --git a/frontend/apps/web/lib/display.ts b/frontend/apps/web/lib/display.ts index 73c4d97..f7de114 100644 --- a/frontend/apps/web/lib/display.ts +++ b/frontend/apps/web/lib/display.ts @@ -1,5 +1,39 @@ export type TimeFormat = "12h" | "24h" +export function scheduleXTimeAxisFormatOptions(format: TimeFormat): Intl.DateTimeFormatOptions { + return { + hour: "2-digit", + minute: "2-digit", + hour12: format === "12h", + } +} + +export function formatDateTime(iso: string, locale: string, format: TimeFormat): string { + return new Intl.DateTimeFormat(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + hour12: format === "12h", + }).format(new Date(iso)) +} + +export function formatTimeFromEpochMs(epochMs: number, locale: string, format: TimeFormat): string { + return formatTime(new Date(epochMs).toISOString(), locale, format) +} + +export function formatTimeRangeFromZoned( + start: { toInstant: () => { epochMilliseconds: number } }, + end: { toInstant: () => { epochMilliseconds: number } }, + locale: string, + format: TimeFormat, +): string { + const a = formatTimeFromEpochMs(Number(start.toInstant().epochMilliseconds), locale, format) + const b = formatTimeFromEpochMs(Number(end.toInstant().epochMilliseconds), locale, format) + return `${a} – ${b}` +} + export function formatDate(iso: string, locale: string): string { return new Intl.DateTimeFormat(locale, { year: "numeric",