From dbddfe4c74208266f4d5b8b3c542f34d4e15ef98 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 11:53:12 +0300 Subject: [PATCH] feat(dives): a dive whose time of day is unknown shows and edits as a date Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 38 ++++-- src/components/dives/dive-file-import.test.ts | 29 +++++ src/components/dives/dive-file-import.tsx | 18 ++- .../dive-start-time-field.render.test.tsx | 71 ++++++++++- .../dives/dive-start-time-field.tsx | 111 ++++++++++++++++++ src/lib/api/dives.ts | 7 ++ src/lib/date-time.test.ts | 52 ++++++++ src/lib/date-time.ts | 91 +++++++++++--- src/lib/validations/dive.test.ts | 21 ++++ src/lib/validations/dive.ts | 3 +- 10 files changed, 409 insertions(+), 32 deletions(-) diff --git a/DECISIONS.md b/DECISIONS.md index 6858d7be..ee857d27 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -59,8 +59,10 @@ the `""` placeholder type leaks into the inferred return type and breaks assigna Americas) displays as the previous day. Every date-only field (a trip part's `start_date`/`end_date`, a course's) is formatted via `formatDateOnly()`/`formatTripDateRange()` in `lib/date-time.ts`, which split the string and construct a local `Date(year, month-1, day)`. -`Dive.start_time` is a full ISO datetime and has no off-by-one-day problem, but displaying and -editing it still cannot go through `new Date(dateString)` and local getters — see the next section. +`Dive.start_time` is usually a full ISO datetime and has no off-by-one-day problem, but displaying +and editing it still cannot go through `new Date(dateString)` and local getters — see the next +section. A bare date there is its own state — see "A bare-date `start_time` is a fourth state, and +never midnight". ## A dive's `start_time` displays/edits in its own timezone, never the browser's @@ -2393,15 +2395,16 @@ data. ## The dive's clock sits in the page header, and one `Duration & Depth` card holds the rest The start time belongs with the date, already in the page header: `formatDiveStartTime` prints date, -clock time and, where the dive records one, its offset as one line. The offset stays because a dive -displays in its own timezone (see "A dive's `start_time` displays/edits in its own timezone, never -the browser's") and `10:04` alone cannot be checked; a DiveJSON import may carry no offset, and the -line then stops after the clock rather than inventing `(UTC+00:00)` — see "An unknown UTC offset is -a third state, and `new Date()` never sees an offset-less string". It is composed from -`formatDiveDateTime` + `formatDiveTimeOnly` rather than one `Intl` call: the separator a locale -picks is an ICU detail, and the offset is appended by hand regardless. What remains is one -`Duration & Depth` card: three stat blocks at one weight, `md:grid-cols-3`, depths individually -conditional so a hand-logged dive leaves duration alone. +clock time and, where the dive records one, its offset as one line — or the date alone, where the +dive records no time of day. The offset stays because a dive displays in its own timezone (see "A +dive's `start_time` displays/edits in its own timezone, never the browser's") and `10:04` alone +cannot be checked; a DiveJSON import may carry no offset, and the line then stops after the clock +rather than inventing `(UTC+00:00)` — see "An unknown UTC offset is a third state, and `new Date()` +never sees an offset-less string". It is composed from `formatDiveDateTime` + `formatDiveTimeOnly` +rather than one `Intl` call: the separator a locale picks is an ICU detail, and the offset is +appended by hand regardless. What remains is one `Duration & Depth` card: three stat blocks at one +weight, `md:grid-cols-3`, depths individually conditional so a hand-logged dive leaves duration +alone. ## The ppO₂ limit is picked from a list, and an unlisted one is added to it @@ -5575,6 +5578,19 @@ with a flat 422 `{"detail": ""}` read via `getApiErrorMessage`. the browser's offset. Mocking `getTimezoneOffset()` cannot catch a parsing defect, so the regression tests round-trip a naive string and fail in every zone. +## A bare-date `start_time` is a fourth state, and never midnight + +An imported dive may carry only its day: `start_time` is `"2002-06-18"`, with no flag beside it, and +`isDateOnlyStartTime` names the shape. `formatDiveDateTime` drops every time-of-day option for it, +`formatDiveStartTime` stops after the date, and `diveWallClockTime` places it at the start of its +day, where the API sorts it. `splitStartTime` returns the date with no time, so `combineStartTime` +gives it back unchanged and the edit form echoes it; the API keeps the state only for a bare date +sent to a dive already in it. + +`DiveStartTimeField` shows the date, an empty time and a disabled offset select. A typed time sends +an offsetless date-time, ending the state; the layout stays for the form's life so the time box is +not swapped out mid-entry. The create form never takes a bare date. + ## The logbook import card renders a plan, not a result, and the two are one shape Import is `POST /import/logbook/preview` then `POST /import/logbook` with the same file and the diff --git a/src/components/dives/dive-file-import.test.ts b/src/components/dives/dive-file-import.test.ts index f395887f..cb7c1705 100644 --- a/src/components/dives/dive-file-import.test.ts +++ b/src/components/dives/dive-file-import.test.ts @@ -266,6 +266,35 @@ function formHoldingValues( return { form, written }; } +describe("applyParsedDiveToForm with a file that states only a day", () => { + it("keeps a date-only dive a date rather than giving it a midnight", () => { + const { form, written } = formHoldingValues({ start_time: "2002-06-18" }); + + applyParsedDiveToForm( + form, + parsedDive([], { start_time: "2002-06-19" }), + () => {}, + ); + + expect(written.start_time).toBe("2002-06-19"); + }); + + it("still gives a dive with a clock the file's day at midnight", () => { + // The create form needs an instant, and a diver can correct the hour. + const { form, written } = formHoldingValues({ + start_time: "2026-04-17T11:49:23+02:00", + }); + + applyParsedDiveToForm( + form, + parsedDive([], { start_time: "2002-06-19" }), + () => {}, + ); + + expect(written.start_time).toMatch(/^2002-06-19T00:00:00[+-]\d{2}:\d{2}$/); + }); +}); + describe("applyParsedDiveToForm in fill-only mode", () => { it("leaves a figure the form already carries exactly as it is", () => { // The case the rule exists for: the Suunto app's JSON has been imported, and diff --git a/src/components/dives/dive-file-import.tsx b/src/components/dives/dive-file-import.tsx index 2dfaa6e2..209c6d15 100644 --- a/src/components/dives/dive-file-import.tsx +++ b/src/components/dives/dive-file-import.tsx @@ -17,6 +17,7 @@ import { import { formatDiveStartTime, formatDurationForForm, + isDateOnlyStartTime, normalizeParsedStartTime, } from "@/lib/date-time"; import { getApiErrorMessage } from "@/lib/api/error"; @@ -134,9 +135,20 @@ export function applyParsedDiveToForm( if (parsed.dive_number != null && writes("dive_number")) { setDiveFormValue(form, "dive_number", parsed.dive_number); } - const normalizedStartTime = parsed.start_time - ? normalizeParsedStartTime(parsed.start_time) - : undefined; + // A file that states only a day, applied to a dive that has only a day, keeps + // it a day: the midnight `normalizeParsedStartTime` supplies for a new dive + // would end the state with a time neither of them recorded. + const keepsDateOnly = + isDateOnlyStartTime(parsed.start_time) && + isDateOnlyStartTime( + form.getValues("start_time" as unknown as Path) as + string | undefined, + ); + const normalizedStartTime = keepsDateOnly + ? parsed.start_time + : parsed.start_time + ? normalizeParsedStartTime(parsed.start_time) + : undefined; if (normalizedStartTime && writes("start_time")) { setDiveFormValue(form, "start_time", normalizedStartTime); } diff --git a/src/components/dives/dive-start-time-field.render.test.tsx b/src/components/dives/dive-start-time-field.render.test.tsx index 5ca3c98d..45305ae2 100644 --- a/src/components/dives/dive-start-time-field.render.test.tsx +++ b/src/components/dives/dive-start-time-field.render.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { useState } from "react"; -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { DiveStartTimeField } from "./dive-start-time-field"; @@ -47,3 +47,72 @@ describe("DiveStartTimeField", () => { expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); }); }); + +// A dive imported with only its date. Only a logbook import makes one, and the +// API keeps the state for as long as the form sends the bare date back. +describe("DiveStartTimeField on a dive whose time of day was never recorded", () => { + const time = () => screen.getByLabelText("Time"); + const offset = () => screen.getByRole("combobox", { name: "UTC offset" }); + + it("shows the date, an empty time and no offset to choose", () => { + render(); + + expect(box()).toHaveValue("2002-06-18"); + expect(time()).toHaveValue(""); + expect(offset()).toBeDisabled(); + expect(offset()).toHaveTextContent("Not recorded"); + expect(screen.queryByDisplayValue(/00:00/)).not.toBeInTheDocument(); + }); + + it("re-dates the dive and keeps it a date", async () => { + render(); + + await userEvent.clear(box()); + await userEvent.type(box(), "2002-06-19"); + await userEvent.tab(); + + expect(committed()).toBe("2002-06-19"); + }); + + it("becomes a date-time with no offset once a time is typed", () => { + render(); + + fireEvent.change(time(), { target: { value: "10:30" } }); + + expect(committed()).toBe("2002-06-18T10:30:00"); + // The same controls stay on screen, now with a clock to put a zone on. + expect(time()).toHaveValue("10:30:00"); + expect(offset()).toBeEnabled(); + }); + + it("goes back to the bare date when the typed time is emptied", () => { + render(); + + fireEvent.change(time(), { target: { value: "10:30" } }); + fireEvent.change(time(), { target: { value: "" } }); + + expect(committed()).toBe("2002-06-18"); + expect(offset()).toBeDisabled(); + }); + + it("takes the date-only shape when the value arrives after mount", () => { + // The edit form mounts with `""` and resets to the dive once it loads. + function Late() { + const [value, setValue] = useState(""); + return ( + <> + + + + ); + } + render(); + + fireEvent.click(screen.getByRole("button", { name: "load" })); + + expect(box()).toHaveValue("2002-06-18"); + expect(time()).toHaveValue(""); + }); +}); diff --git a/src/components/dives/dive-start-time-field.tsx b/src/components/dives/dive-start-time-field.tsx index 4ed20e1c..85eeb0fd 100644 --- a/src/components/dives/dive-start-time-field.tsx +++ b/src/components/dives/dive-start-time-field.tsx @@ -1,10 +1,14 @@ "use client"; +import { useState } from "react"; +import { DatePicker } from "@/components/ui/date-picker"; import { DateTimePicker } from "@/components/ui/date-time-picker"; +import { Input } from "@/components/ui/input"; import { UtcOffsetSelect } from "@/components/ui/utc-offset-select"; import { combineStartTime, getBrowserUtcOffsetMinutes, + isDateOnlyStartTime, splitStartTime, } from "@/lib/date-time"; import type { FormControlSlotProps } from "@/components/ui/form"; @@ -17,6 +21,10 @@ export interface DiveStartTimeFieldProps extends FormControlSlotProps { // It may carry no offset ("2026-04-17T11:49:23"), which is a dive imported // from a DiveJSON document whose zone was never recorded. That is a state to // preserve, not a value to repair - see `lib/date-time.ts`. + // + // Or it may be a bare date ("2002-06-18"): a dive whose time of day was never + // recorded. That renders as a date, an empty time and no offset - see + // `DateOnlyStartTimeField` below. value?: string; onChange: (value: string) => void; disabled?: boolean; @@ -39,6 +47,25 @@ export function DiveStartTimeField({ // its own `aria-label`, since "Start time" would describe it only vaguely. ...slotProps }: DiveStartTimeFieldProps) { + // Sticky for the life of the form: typing a time ends the date-only state, and + // swapping the controls at that keystroke would take the time box out from + // under the diver's cursor. Adjusted during render, since the edit form's + // value arrives after mount. + const [dateOnlyLayout, setDateOnlyLayout] = useState( + isDateOnlyStartTime(value), + ); + if (!dateOnlyLayout && isDateOnlyStartTime(value)) setDateOnlyLayout(true); + if (dateOnlyLayout) { + return ( + + ); + } + // The browser's offset is reached only when there is no value at all - a brand // new dive, which `nowStartTime()` is about to give a real offset anyway. It is // never a *fallback* for a value that has none: that is the unknown state, and @@ -84,3 +111,87 @@ export function DiveStartTimeField({ ); } + +// "HH:mm" as the OS time controls hand it back, padded to "HH:mm:ss"; `""` for a +// time box that is empty or only partly filled in. +function withSeconds(time: string): string { + const match = time.match(/^(\d{2}):(\d{2})(?::(\d{2}))?/); + if (!match) return ""; + const [, hours, minutes, seconds] = match; + return `${hours}:${minutes}:${seconds ?? "00"}`; +} + +// A dive that arrived with only its date. The date stays editable and stays +// bare - the API keeps the state for a bare `start_time` on such a dive - while +// the time box starts empty rather than at a midnight nobody recorded. Typing a +// time turns the value into a date-time with no offset, which the API accepts +// and which ends the state; only then is there a clock for the offset select to +// qualify. Emptying the time again returns to the bare date. +function DateOnlyStartTimeField({ + value, + onChange, + disabled, + ...slotProps +}: DiveStartTimeFieldProps) { + // An emptied date box is `""`, and stays in this layout: picking a date again + // gives the bare date back rather than a date-time in the browser's zone. + const { localDateTime, offsetMinutes } = value + ? splitStartTime(value) + : { localDateTime: "", offsetMinutes: null }; + const date = localDateTime.slice(0, 10); + const time = localDateTime.slice(11); + + // What the time box shows: the committed time, or a partial one the diver is + // still typing, which the native control reports as `""`. Re-synced during + // render, as `NativeDateTimePicker` does. + const [heldTime, setHeldTime] = useState(time); + const [syncedValue, setSyncedValue] = useState(value ?? ""); + if (syncedValue !== (value ?? "")) { + setSyncedValue(value ?? ""); + setHeldTime(time); + } + + const withTime = ( + nextDate: string, + nextTime: string, + offset: number | null, + ) => + nextTime ? combineStartTime(`${nextDate} ${nextTime}`, offset) : nextDate; + + return ( +
+
+ + onChange(next ? withTime(next, time, offsetMinutes) : "") + } + disabled={disabled} + /> + { + setHeldTime(e.target.value); + const next = withSeconds(e.target.value); + // A partial entry reads as `""` too, so only a box that held a + // committed time is taken back to the bare date by it. + if (date && (next || time)) { + onChange(withTime(date, next, offsetMinutes)); + } + }} + disabled={disabled} + /> +
+ time && onChange(withTime(date, time, next))} + disabled={disabled || !time} + allowUnknown={offsetMinutes === null} + /> +
+ ); +} diff --git a/src/lib/api/dives.ts b/src/lib/api/dives.ts index 01b39b47..e2d0c4aa 100644 --- a/src/lib/api/dives.ts +++ b/src/lib/api/dives.ts @@ -242,6 +242,10 @@ export interface Dive { // Read it with the `formatDive*` helpers, which render that state as the clock // alone; never with `new Date(...)` and local getters, which would silently // reinterpret it in the viewer's own timezone. + // + // **Or it may be a bare date** ("2002-06-18"): an imported dive whose time of + // day was never recorded. The shape is the whole signal - no flag comes with + // it - and the same helpers render it as the date alone. start_time: string; duration: number; max_depth?: number; @@ -773,6 +777,9 @@ export interface DiveUpdate { // included - it refuses with 422 and a flat `{"detail": ""}`, so // render the failure through `getApiErrorMessage`. Adopting a real offset is // always allowed and is the only way out of the unknown state. + // + // A bare date is accepted the same way, only on a dive that already has one; + // any date-time ends that state. start_time?: string; duration?: number; max_depth?: number | null; diff --git a/src/lib/date-time.test.ts b/src/lib/date-time.test.ts index b91dcb95..4173eedd 100644 --- a/src/lib/date-time.test.ts +++ b/src/lib/date-time.test.ts @@ -13,7 +13,9 @@ import { formatTripDateRange, formatUtcOffset, getBrowserUtcOffsetMinutes, + diveWallClockTime, greetingForHour, + isDateOnlyStartTime, normalizeParsedStartTime, parseFormDateTime, parseFormDuration, @@ -276,6 +278,56 @@ describe("splitStartTime/combineStartTime", () => { const { localDateTime, offsetMinutes } = splitStartTime(original); expect(combineStartTime(localDateTime, offsetMinutes)).toBe(original); }); + + // The save path for a dive whose time of day was never recorded, and it fails + // in every zone if the date goes through `Date`: UTC gives back + // "2002-06-18T00:00:00", a midnight the API would store as the dive's time. + it("round-trips a bare date back to itself, with no time and no offset", () => { + const { localDateTime, offsetMinutes } = splitStartTime("2002-06-18"); + expect(localDateTime).toBe("2002-06-18"); + expect(offsetMinutes).toBeNull(); + expect(combineStartTime(localDateTime, offsetMinutes)).toBe("2002-06-18"); + }); +}); + +describe("isDateOnlyStartTime", () => { + it("is true for a bare date and nothing else", () => { + expect(isDateOnlyStartTime("2002-06-18")).toBe(true); + expect(isDateOnlyStartTime("2002-06-18T00:00:00")).toBe(false); + expect(isDateOnlyStartTime("2002-06-18T10:00:00+02:00")).toBe(false); + expect(isDateOnlyStartTime("")).toBe(false); + expect(isDateOnlyStartTime(undefined)).toBe(false); + expect(isDateOnlyStartTime(null)).toBe(false); + }); +}); + +describe("a dive whose time of day was never recorded", () => { + it("lists as its date, with no clock", () => { + // The default options ask for an hour and a minute; a bare date has neither + // and would print "00:00" in every zone. + expect(formatDiveDateTime("2002-06-18")).toBe("Jun 18, 2002"); + expect( + formatDiveDateTime("2002-06-18", { hour: "2-digit", minute: "2-digit" }), + ).toBe("Jun 18, 2002"); + expect( + formatDiveDateTime("2002-06-18", { + year: "numeric", + month: "short", + day: "numeric", + }), + ).toBe("Jun 18, 2002"); + }); + + it("heads its page with the date and stops", () => { + const line = formatDiveStartTime("2002-06-18"); + expect(line).toBe("Tuesday, June 18, 2002"); + expect(line).not.toMatch(/ at |UTC/); + }); + + it("sits at the start of its own day on a timeline", () => { + const at = new Date(diveWallClockTime("2002-06-18")); + expect(at.toISOString()).toBe("2002-06-18T00:00:00.000Z"); + }); }); describe("formatDiveDateTime/formatDiveTimeOnly", () => { diff --git a/src/lib/date-time.ts b/src/lib/date-time.ts index f4172625..14a98d85 100644 --- a/src/lib/date-time.ts +++ b/src/lib/date-time.ts @@ -43,6 +43,13 @@ export function parseFormDateTime(value: string): Date { // and `formatDiveStartTime()` prints no zone at all. See DECISIONS.md, "An // unknown UTC offset is a third state, and `new Date()` never sees an // offset-less string". +// +// **A fourth state is a bare date** ("2002-06-18"): the day was recorded and the +// time of day was not, so there is no clock to show and no offset to have. +// `isDateOnlyStartTime()` names it, `formatDiveDateTime()` and +// `formatDiveStartTime()` print the date and stop, and `splitStartTime()` hands +// the date back without a time for `combineStartTime()` to return unchanged. +// Midnight is never a stand-in for it: that is a time nobody recorded. // Matches a trailing UTC offset ("Z", "+HH:MM", "+HHMM", or "+HH") on an ISO // 8601 datetime string. @@ -106,9 +113,11 @@ const NAIVE_DATE_TIME_REGEX = // browser from the calculation entirely. // // A bare "YYYY-MM-DD" is left alone: ECMAScript already parses the date-only -// form as UTC, and "2026-04-17Z" is not a date-time at all. Anything else is -// handed to `Date` unchanged, so an unparseable value still comes back NaN -// exactly as it did before. +// form as UTC, and "2026-04-17Z" is not a date-time at all. The instant that +// gives is the start of the day, which is where a date-only dive sorts; it +// places the dive on a timeline and is never read back as a time of day. Anything +// else is handed to `Date` unchanged, so an unparseable value still comes back +// NaN exactly as it did before. function parseAsWallClockUtc(isoString: string): Date { return NAIVE_DATE_TIME_REGEX.test(isoString) ? new Date(`${isoString.replace(" ", "T")}Z`) @@ -151,6 +160,19 @@ export function diveWallClockTime(startTime: string): number { return shiftByEmbeddedOffset(startTime).shifted.getTime(); } +// A `start_time` that carries only a date, with no time at all, e.g. +// "2021-04-04". +const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; + +// Whether a dive's `start_time` is a bare date - a dive whose time of day was +// never recorded, which only a logbook import can create. The API marks the +// state by this shape alone; no read carries a flag beside it. +export function isDateOnlyStartTime( + startTime: string | null | undefined, +): startTime is string { + return startTime != null && DATE_ONLY_REGEX.test(startTime); +} + // Splits an ISO 8601 datetime string (e.g. the API's dive `start_time`) into its // wall-clock component - formatted like `formatDateTimeForForm()` - and its UTC // offset in minutes, *without* ever converting through the browser's own @@ -161,10 +183,17 @@ export function diveWallClockTime(startTime: string): number { // recorded" option, and `combineStartTime()` takes it straight back. It used to // come back as `0` here, which is how an imported dive's first save wrote both // the wrong hour and a "+00:00" nobody chose. +// +// A bare date comes back as the date alone, `localDateTime` "YYYY-MM-DD" with no +// time after it, so the first ten characters are still the day and nothing +// downstream is handed a midnight to display or save. export function splitStartTime(isoString: string): { localDateTime: string; offsetMinutes: number | null; } { + if (isDateOnlyStartTime(isoString)) { + return { localDateTime: isoString, offsetMinutes: null }; + } const { shifted, offsetMinutes } = shiftByEmbeddedOffset(isoString); const localDateTime = `${shifted.getUTCFullYear()}-${pad(shifted.getUTCMonth() + 1)}-${pad( shifted.getUTCDate(), @@ -190,18 +219,16 @@ export function combineStartTime( : `${wallClock}${formatUtcOffset(offsetMinutes)}`; } -// A `start_time` that carries only a date, with no time at all, e.g. -// "2021-04-04". -const DATE_ONLY_REGEX = /^\d{4}-\d{2}-\d{2}$/; - // Normalizes a dive-computer file's raw `start_time` into the single // offset-aware `start_time` string the form (`DiveStartTimeField`) and the API // both expect. Dive computers export it in three shapes: // // - With an explicit offset, e.g. "2021-04-04T10:04:47.910+02:00" - already the // shape we want, so it's used as-is. -// - Date-only, e.g. "2021-04-04" - taken as midnight wall-clock. Checked -// *before* the naive branch below, because `new Date("2021-04-04")` parses as +// - Date-only, e.g. "2021-04-04" - taken as midnight wall-clock, because a new +// dive needs an instant and a diver can correct the time. It is never applied +// to a dive already carrying only its date: `applyParsedDiveToForm` keeps the +// bare date there. Checked *before* the naive branch below, because `new Date("2021-04-04")` parses as // UTC midnight and reading it back with local getters shows the previous day // west of Greenwich (see DECISIONS.md, "Bare `YYYY-MM-DD` dates must not go // through `new Date(dateString)`"). @@ -251,23 +278,51 @@ export function nowStartTime(): string { // from. Only use this for a dive's `start_time`; other timestamps (e.g. // `created_at`) should keep using `formatDateTime()` below, which // intentionally shows the viewer's own local time. +// +// A bare-date `start_time` prints its date and no clock, whatever `options` +// asks for: the time-of-day parts are dropped rather than rendered as midnight. export function formatDiveDateTime( startTime: string, options?: Intl.DateTimeFormatOptions, ): string { + const requested = options ?? { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }; return shiftByEmbeddedOffset(startTime).shifted.toLocaleDateString("en-US", { hour12: false, - ...(options ?? { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }), + ...(isDateOnlyStartTime(startTime) ? withoutClock(requested) : requested), timeZone: "UTC", }); } +// `options` with every time-of-day part removed, so that `Intl` prints the date +// alone - and the date parts put back when that leaves none, since +// `toLocaleDateString` would otherwise fall back to a numeric date. +function withoutClock( + options: Intl.DateTimeFormatOptions, +): Intl.DateTimeFormatOptions { + const date = { ...options }; + for (const key of CLOCK_OPTIONS) delete date[key]; + return Object.keys(date).length > 0 + ? date + : { year: "numeric", month: "short", day: "numeric" }; +} + +const CLOCK_OPTIONS = [ + "hour", + "minute", + "second", + "dayPeriod", + "hour12", + "hourCycle", + "timeStyle", + "timeZoneName", +] as const satisfies readonly (keyof Intl.DateTimeFormatOptions)[]; + // Time-of-day counterpart to `formatDiveDateTime()` - see its docs above. export function formatDiveTimeOnly( startTime: string, @@ -297,6 +352,9 @@ export function formatDiveTimeOnly( // real state, and a wall clock with no zone beside it is exactly what it means. // Saying "UTC" would be a claim about where the dive happened, which is the one // thing nothing here knows. +// +// A bare-date `start_time` stops earlier still, after the date: "Tuesday, June +// 18, 2002" and no "at" at all. export function formatDiveStartTime(startTime: string): string { const date = formatDiveDateTime(startTime, { weekday: "long", @@ -304,6 +362,7 @@ export function formatDiveStartTime(startTime: string): string { month: "long", day: "numeric", }); + if (isDateOnlyStartTime(startTime)) return date; const offsetMinutes = parseUtcOffsetMinutes(startTime); const zone = offsetMinutes === null ? "" : ` (UTC${formatUtcOffset(offsetMinutes)})`; diff --git a/src/lib/validations/dive.test.ts b/src/lib/validations/dive.test.ts index a47fe293..87b756b9 100644 --- a/src/lib/validations/dive.test.ts +++ b/src/lib/validations/dive.test.ts @@ -102,6 +102,27 @@ describe("diveUpdateSchema start_time", () => { }); }); +// A dive whose time of day was never recorded: its edit form holds the bare date +// and sends it back untouched, which is what keeps the state on the server. A new +// dive still needs an instant. +describe("a bare-date start_time", () => { + it("is accepted by the update schema and refused by the create schema", () => { + expect( + diveUpdateSchema.safeParse({ start_time: "2002-06-18" }).success, + ).toBe(true); + expect( + diveCreateSchema.safeParse({ ...validDive, start_time: "2002-06-18" }) + .success, + ).toBe(false); + }); + + it("goes back to the API exactly as it came", () => { + expect(buildDiveUpdate({ start_time: "2002-06-18" }).start_time).toBe( + "2002-06-18", + ); + }); +}); + describe("diveCreateSchema duration", () => { it("accepts MM:SS with 1-3 digit minutes", () => { expect( diff --git a/src/lib/validations/dive.ts b/src/lib/validations/dive.ts index 0659a004..32757a77 100644 --- a/src/lib/validations/dive.ts +++ b/src/lib/validations/dive.ts @@ -45,7 +45,8 @@ const dateTimeField = ( // An *edit* accepts both shapes, and that is a deliberate relaxation rather than // a gap. `PATCH /dive/{uuid}` takes an offsetless `start_time` on a dive whose // own offset is already unknown - the state a DiveJSON import creates - and -// refuses one on a dive that has an offset, `0` included. Only the server can +// refuses one on a dive that has an offset, `0` included. A bare date follows +// the same rule on a dive whose time of day is unknown. Only the server can // apply that rule: it turns on the *stored* offset, which this schema cannot // see, and re-deriving it in the form from a value the form itself is editing is // how a client ends up refusing what the API accepts.