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
38 changes: 27 additions & 11 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -5575,6 +5578,19 @@ with a flat 422 `{"detail": "<sentence>"}` 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
Expand Down
29 changes: 29 additions & 0 deletions src/components/dives/dive-file-import.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 15 additions & 3 deletions src/components/dives/dive-file-import.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import {
formatDiveStartTime,
formatDurationForForm,
isDateOnlyStartTime,
normalizeParsedStartTime,
} from "@/lib/date-time";
import { getApiErrorMessage } from "@/lib/api/error";
Expand Down Expand Up @@ -134,9 +135,20 @@ export function applyParsedDiveToForm<TFieldValues extends DiveFormValues>(
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<TFieldValues>) 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);
}
Expand Down
71 changes: 70 additions & 1 deletion src/components/dives/dive-start-time-field.render.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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(<Field initial="2002-06-18" />);

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(<Field initial="2002-06-18" />);

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(<Field initial="2002-06-18" />);

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(<Field initial="2002-06-18" />);

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 (
<>
<button type="button" onClick={() => setValue("2002-06-18")}>
load
</button>
<DiveStartTimeField value={value} onChange={setValue} />
</>
);
}
render(<Late />);

fireEvent.click(screen.getByRole("button", { name: "load" }));

expect(box()).toHaveValue("2002-06-18");
expect(time()).toHaveValue("");
});
});
111 changes: 111 additions & 0 deletions src/components/dives/dive-start-time-field.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand All @@ -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 (
<DateOnlyStartTimeField
{...slotProps}
value={value}
onChange={onChange}
disabled={disabled}
/>
);
}

// 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
Expand Down Expand Up @@ -84,3 +111,87 @@ export function DiveStartTimeField({
</div>
);
}

// "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 (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
<div className="grid grid-cols-[3fr_2fr] gap-2">
<DatePicker
{...slotProps}
value={date}
onChange={(next) =>
onChange(next ? withTime(next, time, offsetMinutes) : "")
}
disabled={disabled}
/>
<Input
type="time"
step={1}
aria-label="Time"
value={heldTime}
onChange={(e) => {
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}
/>
</div>
<UtcOffsetSelect
value={offsetMinutes}
onChange={(next) => time && onChange(withTime(date, time, next))}
disabled={disabled || !time}
allowUnknown={offsetMinutes === null}
/>
</div>
);
}
7 changes: 7 additions & 0 deletions src/lib/api/dives.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -773,6 +777,9 @@ export interface DiveUpdate {
// included - it refuses with 422 and a flat `{"detail": "<sentence>"}`, 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;
Expand Down
Loading
Loading