Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion frontend/apps/web/__tests__/StageHistoryDialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("StageHistoryDialog", () => {
beforeEach(() => {
vi.clearAllMocks()
mockRefetch.mockResolvedValue(undefined)
localStorage.clear()
})

it("shows stage history title and entries when open", () => {
Expand All @@ -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,
})

Expand Down Expand Up @@ -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(
<StageHistoryDialog applicationId={1} open onOpenChange={() => {}} />,
)

const dialog = screen.getByRole("dialog", { name: /stage history/i })
await waitFor(() => {
expect(dialog.textContent).not.toMatch(/\b(AM|PM)\b/)
})
})
})
})
59 changes: 59 additions & 0 deletions frontend/apps/web/__tests__/display.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
169 changes: 77 additions & 92 deletions frontend/apps/web/app/calendar/page.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<typeof createEventsServicePlugin>

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 <ScheduleXCalendar calendarApp={calendar} customComponents={customComponents} />
}

export default function CalendarPage() {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -221,7 +196,17 @@ export default function CalendarPage() {
</div>

<div className="sx-calendar-page-wrapper" style={{ isolation: "isolate" }}>
<ScheduleXCalendar calendarApp={calendar} />
<CalendarPageScheduleX
key={`${locale}-${timeFormat}-${timeZoneId}`}
timeZoneId={timeZoneId}
locale={locale}
timeFormat={timeFormat}
isDark={isDark}
resolvedTheme={resolvedTheme}
eventsService={eventsService}
onEventClick={handleEventClick}
onCalendarRangeMonthChange={setCurrentDate}
/>
</div>

<Card>
Expand Down
Loading
Loading