diff --git a/__tests__/TimePicker.spec.tsx b/__tests__/TimePicker.spec.tsx index bd0da865..744996c6 100644 --- a/__tests__/TimePicker.spec.tsx +++ b/__tests__/TimePicker.spec.tsx @@ -3,6 +3,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { useForm } from "react-hook-form"; import { Form, FormField, FormItem } from "@/components/ui/form"; +import { ClockFormat } from "@/models/userSettings.model"; // jsdom lacks scrollIntoView; the columns also read layout boxes on open Element.prototype.scrollIntoView = vi.fn(); @@ -13,9 +14,11 @@ const user = userEvent.setup({ skipHover: true }); // the same way it is used in ActivityForm. function Harness({ initialValue = "09:05 AM", + clockFormat = "12h", onChange = vi.fn(), }: { initialValue?: string; + clockFormat?: ClockFormat; onChange?: (value: string) => void; }) { const form = useForm({ @@ -32,6 +35,7 @@ function Harness({ render={({ field }) => ( { @@ -50,142 +54,225 @@ function Harness({ const column = (name: string) => within(screen.getByRole("group", { name })); -async function openPicker(initialValue?: string) { +async function openPicker( + initialValue = "09:05 AM", + clockFormat: ClockFormat = "12h" +) { const onChange = vi.fn(); - render(); + render( + + ); await user.click( - screen.getByRole("button", { name: initialValue === "" ? "Pick a time" : "09:05 AM" }) + screen.getByRole("button", { + name: initialValue === "" ? "Pick a time" : initialValue, + }) ); return { onChange }; } describe("TimePicker", () => { - it("shows the current value on the trigger", () => { - render(); + describe("12-hour mode", () => { + it("shows the current value on the trigger", () => { + render(); - expect( - screen.getByRole("button", { name: "09:05 AM" }) - ).toBeInTheDocument(); - }); + expect( + screen.getByRole("button", { name: "09:05 AM" }) + ).toBeInTheDocument(); + }); - it("prompts when there is no value yet", () => { - render(); + it("prompts when there is no value yet", () => { + render(); - expect( - screen.getByRole("button", { name: "Pick a time" }) - ).toBeInTheDocument(); - }); + expect( + screen.getByRole("button", { name: "Pick a time" }) + ).toBeInTheDocument(); + }); - it("marks the current parts as pressed when opened", async () => { - await openPicker(); - - expect(column("Hour").getByRole("button", { name: "09" })).toHaveAttribute( - "aria-pressed", - "true" - ); - expect(column("Minute").getByRole("button", { name: "05" })).toHaveAttribute( - "aria-pressed", - "true" - ); - expect(column("AM/PM").getByRole("button", { name: "AM" })).toHaveAttribute( - "aria-pressed", - "true" - ); - }); + it("marks the current parts as pressed when opened", async () => { + await openPicker(); - it("keeps the minute and meridiem when the hour changes", async () => { - const { onChange } = await openPicker(); + expect(column("Hour").getByRole("button", { name: "09" })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(column("Minute").getByRole("button", { name: "05" })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(column("AM/PM").getByRole("button", { name: "AM" })).toHaveAttribute( + "aria-pressed", + "true" + ); + }); - await user.click(column("Hour").getByRole("button", { name: "11" })); + it("keeps the minute and meridiem when the hour changes", async () => { + const { onChange } = await openPicker(); - expect(onChange).toHaveBeenCalledWith("11:05 AM"); - }); + await user.click(column("Hour").getByRole("button", { name: "11" })); - it("keeps the hour and meridiem when the minute changes", async () => { - const { onChange } = await openPicker(); + expect(onChange).toHaveBeenCalledWith("11:05 AM"); + }); - await user.click(column("Minute").getByRole("button", { name: "42" })); + it("keeps the hour and meridiem when the minute changes", async () => { + const { onChange } = await openPicker(); - expect(onChange).toHaveBeenCalledWith("09:42 AM"); - }); + await user.click(column("Minute").getByRole("button", { name: "42" })); - it("keeps the hour and minute when the meridiem changes", async () => { - const { onChange } = await openPicker(); + expect(onChange).toHaveBeenCalledWith("09:42 AM"); + }); - await user.click(column("AM/PM").getByRole("button", { name: "PM" })); + it("keeps the hour and minute when the meridiem changes", async () => { + const { onChange } = await openPicker(); - expect(onChange).toHaveBeenCalledWith("09:05 PM"); - }); + await user.click(column("AM/PM").getByRole("button", { name: "PM" })); - it("emits a padded value the schema regex accepts", async () => { - const { onChange } = await openPicker(""); + expect(onChange).toHaveBeenCalledWith("09:05 PM"); + }); - await user.click(column("Hour").getByRole("button", { name: "03" })); + it("emits a padded value the schema regex accepts", async () => { + const { onChange } = await openPicker(""); - // Unset parts fall back to 12:00 AM rather than emitting a partial string - expect(onChange).toHaveBeenCalledWith("03:00 AM"); - expect(onChange.mock.calls[0][0]).toMatch( - /^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/ - ); - }); + await user.click(column("Hour").getByRole("button", { name: "03" })); - it("reflects the new value on the trigger", async () => { - await openPicker(); + // Unset parts fall back to 12:00 AM rather than emitting a partial string + expect(onChange).toHaveBeenCalledWith("03:00 AM"); + expect(onChange.mock.calls[0][0]).toMatch( + /^(?:(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)|([01][0-9]|2[0-3]):[0-5][0-9])$/ + ); + }); - await user.click(column("AM/PM").getByRole("button", { name: "PM" })); - // The popover is modal, so the trigger stays aria-hidden until it closes - await user.keyboard("{Escape}"); + it("reflects the new value on the trigger", async () => { + await openPicker(); - expect( - screen.getByRole("button", { name: "09:05 PM" }) - ).toBeInTheDocument(); - }); + await user.click(column("AM/PM").getByRole("button", { name: "PM" })); + // The popover is modal, so the trigger stays aria-hidden until it closes + await user.keyboard("{Escape}"); - it("steps the time forward and back by five minutes", async () => { - const onChange = vi.fn(); - render(); + expect( + screen.getByRole("button", { name: "09:05 PM" }) + ).toBeInTheDocument(); + }); - await user.click(screen.getByRole("button", { name: "5 minutes later" })); - expect(onChange).toHaveBeenLastCalledWith("09:10 AM"); + it("steps the time forward and back by five minutes", async () => { + const onChange = vi.fn(); + render(); - await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); - expect(onChange).toHaveBeenLastCalledWith("09:05 AM"); - }); + await user.click(screen.getByRole("button", { name: "5 minutes later" })); + expect(onChange).toHaveBeenLastCalledWith("09:10 AM"); - it("wraps the clock when a step crosses midnight", async () => { - const onChange = vi.fn(); - render(); + await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); + expect(onChange).toHaveBeenLastCalledWith("09:05 AM"); + }); - await user.click(screen.getByRole("button", { name: "5 minutes later" })); + it("wraps the clock when a step crosses midnight", async () => { + const onChange = vi.fn(); + render(); - expect(onChange).toHaveBeenLastCalledWith("12:03 AM"); - }); + await user.click(screen.getByRole("button", { name: "5 minutes later" })); - it("wraps backwards across midnight too", async () => { - const onChange = vi.fn(); - render(); + expect(onChange).toHaveBeenLastCalledWith("12:03 AM"); + }); - await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); + it("wraps backwards across midnight too", async () => { + const onChange = vi.fn(); + render(); - expect(onChange).toHaveBeenLastCalledWith("11:57 PM"); - }); + await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); + + expect(onChange).toHaveBeenLastCalledWith("11:57 PM"); + }); + + it("steps from midnight when there is no value yet", async () => { + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "5 minutes later" })); + + expect(onChange).toHaveBeenLastCalledWith("12:05 AM"); + }); - it("steps from midnight when there is no value yet", async () => { - const onChange = vi.fn(); - render(); + it("marks the field touched once the trigger loses focus", async () => { + render(); + expect(screen.getByTestId("touched")).toHaveTextContent("false"); - await user.click(screen.getByRole("button", { name: "5 minutes later" })); + await user.click(screen.getByRole("button", { name: "09:05 AM" })); - expect(onChange).toHaveBeenLastCalledWith("12:05 AM"); + expect(screen.getByTestId("touched")).toHaveTextContent("true"); + }); }); - // The onTouched validation mode never arms unless the trigger forwards blur - it("marks the field touched once the trigger loses focus", async () => { - render(); - expect(screen.getByTestId("touched")).toHaveTextContent("false"); + describe("24-hour mode", () => { + it("shows the 24h value on the trigger", () => { + render(); + + expect( + screen.getByRole("button", { name: "14:30" }) + ).toBeInTheDocument(); + }); + + it("renders 00-23 hours and hides the AM/PM column", async () => { + await openPicker("14:30", "24h"); + + expect(column("Hour").getByRole("button", { name: "00" })).toBeInTheDocument(); + expect(column("Hour").getByRole("button", { name: "14" })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(column("Hour").getByRole("button", { name: "23" })).toBeInTheDocument(); + expect(column("Minute").getByRole("button", { name: "30" })).toHaveAttribute( + "aria-pressed", + "true" + ); + expect(screen.queryByRole("group", { name: "AM/PM" })).not.toBeInTheDocument(); + }); + + it("updates hour in 24h format", async () => { + const { onChange } = await openPicker("14:30", "24h"); + + await user.click(column("Hour").getByRole("button", { name: "08" })); + + expect(onChange).toHaveBeenCalledWith("08:30"); + }); + + it("updates minute in 24h format", async () => { + const { onChange } = await openPicker("14:30", "24h"); + + await user.click(column("Minute").getByRole("button", { name: "45" })); + + expect(onChange).toHaveBeenCalledWith("14:45"); + }); + + it("steps 24h time forward and backwards by 5 minutes", async () => { + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "5 minutes later" })); + expect(onChange).toHaveBeenLastCalledWith("14:35"); + + await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); + expect(onChange).toHaveBeenLastCalledWith("14:30"); + }); + + it("wraps forward across midnight in 24h mode", async () => { + const onChange = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "5 minutes later" })); + + expect(onChange).toHaveBeenLastCalledWith("00:03"); + }); + + it("wraps backward across midnight in 24h mode", async () => { + const onChange = vi.fn(); + render(); - await user.click(screen.getByRole("button", { name: "09:05 AM" })); + await user.click(screen.getByRole("button", { name: "5 minutes earlier" })); - expect(screen.getByTestId("touched")).toHaveTextContent("true"); + expect(onChange).toHaveBeenLastCalledWith("23:57"); + }); }); }); diff --git a/__tests__/clockFormat.spec.ts b/__tests__/clockFormat.spec.ts new file mode 100644 index 00000000..597aaf75 --- /dev/null +++ b/__tests__/clockFormat.spec.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; +import { combineDateAndTime, formatTime, formatDateTime } from "@/lib/utils"; +import { AddActivityFormSchema } from "@/models/addActivityForm.schema"; +import { defaultUserSettings } from "@/models/userSettings.model"; + +describe("Clock Format Utilities", () => { + describe("defaultUserSettings", () => { + it("defaults to 12h clock format", () => { + expect(defaultUserSettings.display.clockFormat).toBe("12h"); + }); + }); + + describe("combineDateAndTime", () => { + it("parses 12-hour AM/PM time strings correctly", () => { + const baseDate = new Date(2026, 8, 7); // Sept 7, 2026 + const result = combineDateAndTime(baseDate, "09:30 AM"); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(8); + expect(result.getDate()).toBe(7); + expect(result.getHours()).toBe(9); + expect(result.getMinutes()).toBe(30); + + const resultPM = combineDateAndTime(baseDate, "02:45 PM"); + expect(resultPM.getHours()).toBe(14); + expect(resultPM.getMinutes()).toBe(45); + }); + + it("parses 24-hour time strings correctly", () => { + const baseDate = new Date(2026, 8, 7); + const result = combineDateAndTime(baseDate, "14:30"); + expect(result.getFullYear()).toBe(2026); + expect(result.getMonth()).toBe(8); + expect(result.getDate()).toBe(7); + expect(result.getHours()).toBe(14); + expect(result.getMinutes()).toBe(30); + + const resultMidnight = combineDateAndTime(baseDate, "00:15"); + expect(resultMidnight.getHours()).toBe(0); + expect(resultMidnight.getMinutes()).toBe(15); + }); + }); + + describe("formatTime", () => { + it("formats dates in 12h format by default", () => { + const date = new Date(2026, 8, 7, 14, 30); + expect(formatTime(date, "12h")).toBe("02:30 PM"); + expect(formatTime(date)).toBe("02:30 PM"); + }); + + it("formats dates in 24h format when requested", () => { + const date = new Date(2026, 8, 7, 14, 30); + expect(formatTime(date, "24h")).toBe("14:30"); + + const morning = new Date(2026, 8, 7, 9, 5); + expect(formatTime(morning, "24h")).toBe("09:05"); + }); + + it("handles null / invalid dates gracefully", () => { + expect(formatTime("invalid")).toBe(""); + }); + }); + + describe("formatDateTime", () => { + it("formats date and time in 12h format", () => { + const date = new Date(2026, 8, 7, 14, 30); + expect(formatDateTime(date, "12h")).toBe("Sep 7, 2026 2:30 PM"); + }); + + it("formats date and time in 24h format", () => { + const date = new Date(2026, 8, 7, 14, 30); + expect(formatDateTime(date, "24h")).toBe("Sep 7, 2026 14:30"); + }); + }); + + describe("AddActivityFormSchema", () => { + const baseValid = { + activityName: "Job Research", + activityType: "job-search", + startDate: new Date("2026-09-01T00:00:00.000Z"), + startTime: "09:00 AM", + endDate: new Date("2026-09-01T00:00:00.000Z"), + endTime: "10:00 AM", + }; + + it("accepts valid 12-hour start and end times", () => { + const result = AddActivityFormSchema.safeParse(baseValid); + expect(result.success).toBe(true); + }); + + it("accepts valid 24-hour start and end times", () => { + const result = AddActivityFormSchema.safeParse({ + ...baseValid, + startTime: "14:00", + endTime: "15:30", + }); + expect(result.success).toBe(true); + }); + + it("rejects invalid time strings", () => { + const result = AddActivityFormSchema.safeParse({ + ...baseValid, + startTime: "25:00", + }); + expect(result.success).toBe(false); + + const result2 = AddActivityFormSchema.safeParse({ + ...baseValid, + startTime: "9:00", + }); + expect(result2.success).toBe(false); + }); + + it("validates end time is after start time with 24h clock", () => { + const result = AddActivityFormSchema.safeParse({ + ...baseValid, + startTime: "15:00", + endTime: "14:00", + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some((i) => i.path.includes("endTime"))).toBe(true); + } + }); + }); +}); diff --git a/src/actions/userSettings.actions.ts b/src/actions/userSettings.actions.ts index 8d603ab6..725705cc 100644 --- a/src/actions/userSettings.actions.ts +++ b/src/actions/userSettings.actions.ts @@ -36,6 +36,14 @@ export const getUserSettings = async (): Promise => { settings: { ...defaultUserSettings, ...settings, + ai: { + ...defaultUserSettings.ai, + ...settings?.ai, + }, + display: { + ...defaultUserSettings.display, + ...settings?.display, + }, }, }, }; diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx index 4caab31a..04da8a72 100644 --- a/src/app/dashboard/layout.tsx +++ b/src/app/dashboard/layout.tsx @@ -14,6 +14,9 @@ import { getCurrentUser } from "@/utils/user.utils"; import StaleSessionSignOut from "@/components/StaleSessionSignOut"; import { signOut } from "@/auth"; +import { getUserSettings } from "@/actions/userSettings.actions"; +import { UserSettingsProvider } from "@/context/UserSettingsContext"; + export default async function RootLayout({ children, }: Readonly<{ @@ -27,6 +30,7 @@ export default async function RootLayout({ // is merged against an empty transcript and then persisted. const conversation = await getChatConversation(); const user = await getCurrentUser(); + const userSettingsResult = await getUserSettings(); const signOutAction = async () => { "use server"; @@ -41,10 +45,11 @@ export default async function RootLayout({ } return ( - - - - + + + + +
@@ -66,5 +71,6 @@ export default async function RootLayout({ + ); } diff --git a/src/components/TimePicker.tsx b/src/components/TimePicker.tsx index 0ce47611..2e081ba1 100644 --- a/src/components/TimePicker.tsx +++ b/src/components/TimePicker.tsx @@ -13,19 +13,23 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { FormControl } from "./ui/form"; +import { ClockFormat } from "@/models/userSettings.model"; +import { useClockFormat } from "@/context/UserSettingsContext"; -const HOURS = Array.from({ length: 12 }, (_, i) => +const HOURS_12 = Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, "0") ); +const HOURS_24 = Array.from({ length: 24 }, (_, i) => + String(i).padStart(2, "0") +); const MINUTES = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0") ); const MERIDIEMS = ["AM", "PM"]; -const TIME_PATTERN = /^(0[1-9]|1[0-2]):([0-5][0-9]) (AM|PM)$/; +const TIME_12_PATTERN = /^(0[1-9]|1[0-2]):([0-5][0-9]) (AM|PM)$/; +const TIME_24_PATTERN = /^([01][0-9]|2[0-3]):([0-5][0-9])$/; -const TIME_FORMAT = "hh:mm a"; -const FALLBACK_TIME = "12:00 AM"; const STEP_MINUTES = 5; type TimeParts = { @@ -34,11 +38,44 @@ type TimeParts = { meridiem: string | null; }; -function parseTime(value: unknown): TimeParts { - const match = typeof value === "string" ? value.match(TIME_PATTERN) : null; - return match - ? { hour: match[1], minute: match[2], meridiem: match[3] } - : { hour: null, minute: null, meridiem: null }; +function parseTime(value: unknown, is24h: boolean): TimeParts { + if (typeof value !== "string") { + return { hour: null, minute: null, meridiem: null }; + } + + const match12 = value.match(TIME_12_PATTERN); + if (match12) { + if (is24h) { + let h = parseInt(match12[1], 10); + const isPM = match12[3] === "PM"; + if (isPM && h < 12) h += 12; + if (!isPM && h === 12) h = 0; + return { + hour: String(h).padStart(2, "0"), + minute: match12[2], + meridiem: null, + }; + } + return { hour: match12[1], minute: match12[2], meridiem: match12[3] }; + } + + const match24 = value.match(TIME_24_PATTERN); + if (match24) { + if (is24h) { + return { hour: match24[1], minute: match24[2], meridiem: null }; + } + let h = parseInt(match24[1], 10); + const meridiem = h >= 12 ? "PM" : "AM"; + if (h > 12) h -= 12; + if (h === 0) h = 12; + return { + hour: String(h).padStart(2, "0"), + minute: match24[2], + meridiem, + }; + } + + return { hour: null, minute: null, meridiem: null }; } interface TimeColumnProps { @@ -92,28 +129,50 @@ function TimeColumn({ label, options, selected, onSelect }: TimeColumnProps) { interface TimePickerProps { field: ControllerRenderProps; + clockFormat?: ClockFormat; } -export function TimePicker({ field }: TimePickerProps) { +export function TimePicker({ field, clockFormat }: TimePickerProps) { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const { hour, minute, meridiem } = parseTime(field.value); + const contextFormat = useClockFormat(); + const activeFormat: ClockFormat = clockFormat || contextFormat || "12h"; + const is24h = activeFormat === "24h"; + + const { hour, minute, meridiem } = parseTime(field.value, is24h); const update = (parts: Partial) => { - const next = { - hour: parts.hour ?? hour ?? "12", - minute: parts.minute ?? minute ?? "00", - meridiem: parts.meridiem ?? meridiem ?? "AM", - }; - field.onChange(`${next.hour}:${next.minute} ${next.meridiem}`); + if (is24h) { + const nextHour = parts.hour ?? hour ?? "00"; + const nextMinute = parts.minute ?? minute ?? "00"; + field.onChange(`${nextHour}:${nextMinute}`); + } else { + const next = { + hour: parts.hour ?? hour ?? "12", + minute: parts.minute ?? minute ?? "00", + meridiem: parts.meridiem ?? meridiem ?? "AM", + }; + field.onChange(`${next.hour}:${next.minute} ${next.meridiem}`); + } }; // Stepping past midnight wraps the clock only — the date fields own the day const shift = (minutes: number) => { - const current = TIME_PATTERN.test(field.value) - ? field.value - : FALLBACK_TIME; - const stepped = addMinutes(parse(current, TIME_FORMAT, new Date()), minutes); - field.onChange(format(stepped, TIME_FORMAT)); + const timeFormat = is24h ? "HH:mm" : "hh:mm a"; + const fallback = is24h ? "00:00" : "12:00 AM"; + let baseDate: Date; + if (typeof field.value === "string" && field.value) { + let parsed = parse(field.value, "hh:mm a", new Date()); + if (isNaN(parsed.getTime())) { + parsed = parse(field.value, "HH:mm", new Date()); + } + baseDate = isNaN(parsed.getTime()) + ? parse(fallback, timeFormat, new Date()) + : parsed; + } else { + baseDate = parse(fallback, timeFormat, new Date()); + } + const stepped = addMinutes(baseDate, minutes); + field.onChange(format(stepped, timeFormat)); }; return ( @@ -138,7 +197,7 @@ export function TimePicker({ field }: TimePickerProps) { update({ hour: value })} /> @@ -148,12 +207,14 @@ export function TimePicker({ field }: TimePickerProps) { selected={minute} onSelect={(value) => update({ minute: value })} /> - update({ meridiem: value })} - /> + {!is24h && ( + update({ meridiem: value })} + /> + )}
@@ -310,10 +310,7 @@ export function TaskForm({

Updated

{editTask.updatedAt - ? format( - new Date(editTask.updatedAt), - "MMM d, yyyy h:mm a", - ) + ? formatDateTime(editTask.updatedAt, clockFormat) : "N/A"}

diff --git a/src/context/UserSettingsContext.tsx b/src/context/UserSettingsContext.tsx new file mode 100644 index 00000000..86d16260 --- /dev/null +++ b/src/context/UserSettingsContext.tsx @@ -0,0 +1,185 @@ +"use client"; + +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react"; +import { + ClockFormat, + defaultUserSettings, + DisplaySettings, + UserSettingsData, +} from "@/models/userSettings.model"; +import { + getUserSettings, + updateDisplaySettings, +} from "@/actions/userSettings.actions"; +import { formatDateTime, formatTime } from "@/lib/utils"; + +interface UserSettingsContextType { + settings: UserSettingsData; + clockFormat: ClockFormat; + setClockFormat: (clockFormat: ClockFormat) => Promise; + updateDisplay: (display: Partial) => Promise; + refreshSettings: () => Promise; + formatTime: (date: Date | string | number) => string; + formatDateTime: (date: Date | string | number) => string; +} + +const UserSettingsContext = createContext( + undefined +); + +interface UserSettingsProviderProps { + children: React.ReactNode; + initialSettings?: UserSettingsData; +} + +export function UserSettingsProvider({ + children, + initialSettings, +}: UserSettingsProviderProps) { + const [settings, setSettings] = useState(() => ({ + ...defaultUserSettings, + ...initialSettings, + display: { + ...defaultUserSettings.display, + ...initialSettings?.display, + }, + ai: { + ...defaultUserSettings.ai, + ...initialSettings?.ai, + }, + })); + + const refreshSettings = useCallback(async () => { + try { + const res = await getUserSettings(); + if (res?.success && res.data?.settings) { + setSettings({ + ...defaultUserSettings, + ...res.data.settings, + display: { + ...defaultUserSettings.display, + ...res.data.settings.display, + }, + ai: { + ...defaultUserSettings.ai, + ...res.data.settings.ai, + }, + }); + } + } catch (err) { + console.error("Failed to load user settings:", err); + } + }, []); + + useEffect(() => { + if (!initialSettings) { + refreshSettings(); + } + }, [initialSettings, refreshSettings]); + + const clockFormat: ClockFormat = settings.display?.clockFormat || "12h"; + + const updateDisplay = useCallback( + async (display: Partial): Promise => { + const mergedDisplay = { + ...settings.display, + ...display, + }; + setSettings((prev) => ({ + ...prev, + display: mergedDisplay, + })); + + try { + const res = await updateDisplaySettings(mergedDisplay); + if (!res?.success) { + await refreshSettings(); + return false; + } + return true; + } catch (err) { + console.error("Failed to update display settings:", err); + await refreshSettings(); + return false; + } + }, + [settings.display, refreshSettings] + ); + + const setClockFormat = useCallback( + async (newFormat: ClockFormat): Promise => { + return updateDisplay({ clockFormat: newFormat }); + }, + [updateDisplay] + ); + + const formatTimeHelper = useCallback( + (date: Date | string | number) => { + return formatTime(date, clockFormat); + }, + [clockFormat] + ); + + const formatDateTimeHelper = useCallback( + (date: Date | string | number) => { + return formatDateTime(date, clockFormat); + }, + [clockFormat] + ); + + const contextValue = useMemo( + () => ({ + settings, + clockFormat, + setClockFormat, + updateDisplay, + refreshSettings, + formatTime: formatTimeHelper, + formatDateTime: formatDateTimeHelper, + }), + [ + settings, + clockFormat, + setClockFormat, + updateDisplay, + refreshSettings, + formatTimeHelper, + formatDateTimeHelper, + ] + ); + + return ( + + {children} + + ); +} + +export function useUserSettings(): UserSettingsContextType { + const context = useContext(UserSettingsContext); + if (!context) { + // Fallback for tests or out-of-provider components + return { + settings: defaultUserSettings, + clockFormat: "12h", + setClockFormat: async () => false, + updateDisplay: async () => false, + refreshSettings: async () => {}, + formatTime: (date) => formatTime(date, "12h"), + formatDateTime: (date) => formatDateTime(date, "12h"), + }; + } + return context; +} + +export function useClockFormat(): ClockFormat { + const { clockFormat } = useUserSettings(); + return clockFormat; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 44ae6b88..3c880bd2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,7 @@ import { type ClassValue, clsx } from "clsx"; import { format, parse } from "date-fns"; import { NextApiRequest } from "next"; import { twMerge } from "tailwind-merge"; +import { ClockFormat } from "@/models/userSettings.model"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -81,8 +82,13 @@ export function getTimestampedFileName(originalName: string): string { export const combineDateAndTime = (date: Date, time: string): Date => { // Parse the time string into a `Date` object using a reference date - const parsedTime = parse(time, "hh:mm a", new Date()); - // if (isNaN(parsedTime.getTime())) throw new Error("Invalid time format"); + let parsedTime = parse(time, "hh:mm a", new Date()); + if (isNaN(parsedTime.getTime())) { + parsedTime = parse(time, "HH:mm", new Date()); + } + if (isNaN(parsedTime.getTime())) { + parsedTime = parse(time, "h:mm a", new Date()); + } return new Date( date.getFullYear(), @@ -93,6 +99,29 @@ export const combineDateAndTime = (date: Date, time: string): Date => { ); }; +export const formatTime = ( + date: Date | string | number, + clockFormat: ClockFormat = "12h" +): string => { + const d = + typeof date === "string" || typeof date === "number" ? new Date(date) : date; + if (!d || isNaN(d.getTime())) return ""; + return format(d, clockFormat === "24h" ? "HH:mm" : "hh:mm a"); +}; + +export const formatDateTime = ( + date: Date | string | number, + clockFormat: ClockFormat = "12h" +): string => { + const d = + typeof date === "string" || typeof date === "number" ? new Date(date) : date; + if (!d || isNaN(d.getTime())) return ""; + return format( + d, + clockFormat === "24h" ? "MMM d, yyyy HH:mm" : "MMM d, yyyy h:mm a" + ); +}; + export const formatElapsedTime = (ms: number) => { const totalSeconds = Math.floor(ms / 1000); const hours = Math.floor(totalSeconds / 3600); diff --git a/src/models/addActivityForm.schema.ts b/src/models/addActivityForm.schema.ts index 96004f24..9d2101de 100644 --- a/src/models/addActivityForm.schema.ts +++ b/src/models/addActivityForm.schema.ts @@ -21,15 +21,15 @@ export const AddActivityFormSchema = z startTime: z .string() .regex( - /^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/, - "Start time must be in hh:mm AM/PM format" + /^(?:(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)|([01][0-9]|2[0-3]):[0-5][0-9])$/, + "Start time must be in valid 12-hour (hh:mm AM/PM) or 24-hour (HH:mm) format" ), endDate: z.date().optional(), endTime: z .string() .regex( - /^(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)$/, - "End time must be in hh:mm AM/PM format" + /^(?:(0[1-9]|1[0-2]):[0-5][0-9] (AM|PM)|([01][0-9]|2[0-3]):[0-5][0-9])$/, + "End time must be in valid 12-hour (hh:mm AM/PM) or 24-hour (HH:mm) format" ) .optional(), duration: z diff --git a/src/models/userSettings.model.ts b/src/models/userSettings.model.ts index 73726a6b..fdb764ed 100644 --- a/src/models/userSettings.model.ts +++ b/src/models/userSettings.model.ts @@ -5,8 +5,11 @@ export interface AiSettings { model: string | undefined; } +export type ClockFormat = "12h" | "24h"; + export interface DisplaySettings { theme: "light" | "dark" | "system"; + clockFormat?: ClockFormat; } export interface UserSettingsData { @@ -26,5 +29,7 @@ export const defaultUserSettings: UserSettingsData = { }, display: { theme: "system", + clockFormat: "12h", }, }; +