"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 { FormControlA11yProps } from "@/components/ui/form"; export interface DiveStartTimeFieldProps extends FormControlA11yProps { // An ISO 8601 string, e.g. "2021-04-04T10:04:47+02:00" - the same shape as the // API's `Dive.start_time` - or `""`/`undefined` while still empty (e.g. the // edit form before the dive has loaded). // // 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; } // A date/time picker plus a UTC offset select, combined into the single // `start_time` string the API expects - offset-aware, or carrying no offset // where the dive's own zone was never recorded. This is the *only* place that // splits/recombines that string into the wall-clock + offset pair the two // underlying inputs actually edit (`splitStartTime()`/`combineStartTime()` in // `lib/date-time.ts`) - every caller of this component (the dive create/edit // forms, file import) only ever has to deal with one field, in exactly the // format the API already uses. export function DiveStartTimeField({ value, onChange, disabled, // A composite field behind one "Start time" label, so the slot props go on the // *primary* control - the date/time picker. The offset select beside it carries // 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 // `splitStartTime` hands it back as `null` for both controls to carry. const { localDateTime, offsetMinutes } = value ? splitStartTime(value) : { localDateTime: undefined, offsetMinutes: getBrowserUtcOffsetMinutes() }; // Offered only while the value in hand has no offset, so it can never be used // to *remove* one: the moment the diver adopts a real offset the option is // gone, which is the API's rule (an offsetless `start_time` is accepted only on // a dive whose stored offset is already NULL) made true in the UI rather than // merely reported by a 422. The cost is that adopting an offset is one-way // within an unsaved edit - cancel and reopen the form to get back - and that is // the right way round for a state import is the sole origin of. const offsetUnknown = offsetMinutes === null; return (
{/* Stays live while the offset is unknown, deliberately: the wall clock is the half that *was* recorded, so a typo in it is still the diver's to fix. The API accepts an offsetless `start_time` on such a dive and leaves the offset NULL, so this saves without inventing a zone. */} onChange(next ? combineStartTime(next, offsetMinutes) : "") } disabled={disabled} /> localDateTime && onChange(combineStartTime(localDateTime, next)) } disabled={disabled} allowUnknown={offsetUnknown} />
); } // "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} />
); }