From 5805488db8a81afcd9dcb519a56aba70e524fc57 Mon Sep 17 00:00:00 2001 From: Aleksei Vesnin Date: Fri, 25 Sep 2026 11:20:48 +0300 Subject: [PATCH 1/2] feat(dives)!: the profile chart on a millisecond axis, readouts and salinity per recording, and the widened agency list Co-Authored-By: Claude Opus 5.5 --- DECISIONS.md | 86 +++--- README.md | 5 +- src/app/dives/new/page.render.test.tsx | 19 +- .../dives/dive-detail-sidebar.render.test.tsx | 4 +- .../dives/dive-exposure-card.render.test.tsx | 73 ++++- src/components/dives/dive-exposure-card.tsx | 53 ++-- .../dives/dive-file-import.render.test.tsx | 2 +- src/components/dives/dive-file-import.test.ts | 26 +- src/components/dives/dive-file-import.tsx | 12 +- src/components/dives/dive-form-fields.tsx | 4 +- .../dives/dive-merge-action.render.test.tsx | 7 +- src/components/dives/dive-merge-action.tsx | 8 +- .../dives/dive-profile-card.render.test.tsx | 4 +- .../dives/dive-profile-chart.render.test.tsx | 181 +++++++++--- src/components/dives/dive-profile-chart.tsx | 132 ++++----- .../dive-recording-files.render.test.tsx | 6 +- .../dive-recordings-card.render.test.tsx | 71 ++++- src/components/dives/dive-recordings-card.tsx | 21 +- src/lib/api/certifications.test.ts | 42 ++- src/lib/api/certifications.ts | 57 +++- src/lib/api/dives.ts | 152 +++++----- src/lib/api/logbook-import.ts | 5 + src/lib/dive-gas.test.ts | 2 +- src/lib/dive-profile.test.ts | 279 +++++++++++------- src/lib/dive-profile.ts | 148 ++++++---- src/lib/dive-recordings.test.ts | 129 ++++++-- src/lib/dive-recordings.ts | 103 +++++-- src/lib/logbook-import.test.ts | 3 + src/lib/logbook-import.ts | 7 +- src/lib/validations/certification.test.ts | 3 +- src/lib/validations/certification.ts | 6 +- src/lib/validations/course.ts | 6 +- src/lib/validations/dive-site.test.ts | 3 +- src/lib/validations/dive-site.ts | 6 +- src/lib/validations/dive.ts | 11 +- src/lib/validations/gear-service.test.ts | 3 +- src/lib/validations/gear-service.ts | 6 +- src/lib/validations/gear.ts | 6 +- src/lib/validations/notes.test.ts | 25 ++ src/lib/validations/notes.ts | 16 + src/lib/validations/trip.ts | 6 +- 41 files changed, 1160 insertions(+), 578 deletions(-) create mode 100644 src/lib/validations/notes.test.ts create mode 100644 src/lib/validations/notes.ts diff --git a/DECISIONS.md b/DECISIONS.md index 17f3b151..f4cd9966 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1044,8 +1044,8 @@ domain, since 21.6–21.9 °C is flat on a depth-wide axis. Pressure shares the labels; every cylinder shares one pressure domain. One hovered time, not index: channels are sampled independently, so a full-plot transparent `` -maps the cursor to seconds once and each channel resolves its own sample with `nearestSampleIndex`. -Readouts are real readings, never interpolations. +maps the cursor to an instant on the profile's millisecond axis once and each channel resolves its +own sample with `nearestSampleIndex`. Readouts are real readings, never interpolations. `tooltipVerticalAnchor` pins the card to the plot's top or bottom edge, whichever keeps it off the readings: card height depends on channel count, so offsetting from a point overflows the clipping @@ -1056,15 +1056,15 @@ Keyboard scrubbing is out of scope. The `aria-label` uses `formatDurationHoursMi ## The profile's line breaks are derived from the series' own cadence -`segmentByTimeGap` is `segmentByGap` in seconds: never draw a line across data that is not there, or -a ten-minute transmitter dropout reads as a smooth pressure fall. `Dive_2025-03-08-1440.xml` has a 1 -341-second hole in its pressure series. +`segmentByTimeGap` is `segmentByGap` on the profile's millisecond axis: never draw a line across +data that is not there, or a ten-minute transmitter dropout reads as a smooth pressure fall. +`Dive_2025-03-08-1440.xml` has a 1 341-second hole in its pressure series. The threshold is derived (`gapThreshold`), not fixed: cadence ranges from 1 s (Suunto Ocean temperature) to 10 s (Suunto depth series), and the API's min/max downsampling stretches it unevenly, so a fixed value either breaks every downsampled line into confetti or draws through a real dropout. Three times the median delta sits above jitter and below any dropout worth showing; -`MIN_GAP_SECONDS` keeps a regular 1 Hz series from breaking on a rounding wobble. +`MIN_GAP_MS` (15 s) keeps a fast, regular series from breaking on the jitter in its own stamps. ## The profile card fetches on mount and needs no `onChanged` @@ -2025,14 +2025,15 @@ looked at. ## The Exposure card renders stored numbers and derives nothing -`components/dives/dive-exposure-card.tsx` shows CNS, OTU and surface pressure unchanged. CNS and OTU -are the output of whichever algorithm the device ran; a "corrected" figure disagreeing with the +`components/dives/dive-exposure-card.tsx` shows the primary recording's CNS, OTU and surface +pressure unchanged; each recording carries its own, and the recordings card lists every one. CNS and +OTU are the output of whichever algorithm the device ran; a "corrected" figure disagreeing with the diver's wrist is worse than useless. The fields are read-only; the API keeps them off its create/update schemas. -Start → end is shown, a missing half as an em dash. The card renders nothing when a dive has none of -the three: a diver never had the option to enter them. It is titled "Exposure & Pressure" because a -lone surface pressure is common and gating on CNS/OTU would drop it. +Start → end is shown, a missing half as an em dash. The card renders nothing when the primary +recording has none of the three: a diver never had the option to enter them. It is titled "Exposure +& Pressure" because a lone surface pressure is common and gating on CNS/OTU would drop it. Past 100% CNS is `text-warning` emphasis on the number, not a warning sentence. The second channel is an `sr-only` span, not an `AlertTriangle`, because a visible icon is visible advice; it is @@ -2172,38 +2173,37 @@ violet for its `gas_number`. A stop is not the ceiling's red: both types arrive Resting opacity is 0.9; 0.55 puts `--ceiling` and `--pressure` under 3:1. -## The crosshair quotes a channel only within its `gapSeconds`, the threshold that breaks its line +## The crosshair quotes a channel only within its `gapMs`, the threshold that breaks its line `nearestSampleIndex` clamps at both ends, so a naive readout quotes a channel across stretches it has no samples for — on the ceiling that invents a deco obligation. -`sampleIndexAt(t, seconds, maxDeltaSeconds)` is `nearestSampleIndex` bounded by the channel's -`gapSeconds`, the same threshold that breaks its line, so plot and readout cut the same stretch; -beyond it the channel drops out of the card as its line drops out of the plot. `PlottedChannel` -carries `gapSeconds`, and every call site goes through one local `runs(t)` returning segments and -threshold together. +`sampleIndexAt(t, at, maxDeltaMs)` is `nearestSampleIndex` bounded by the channel's `gapMs`, the +same threshold that breaks its line, so plot and readout cut the same stretch; beyond it the channel +drops out of the card as its line drops out of the plot. `PlottedChannel` carries `gapMs`, and every +call site goes through one local `runs(t)` returning segments and threshold together. `gapThreshold` answers `Infinity` below three samples — right for segmenting ("never break this line"), wrong for quoting ("no distance is too far"). `readoutTolerance(t)` replaces that infinity -with `MIN_GAP_SECONDS`. A sentinel meaning "unbounded" is safe in "should I split here?" and -dangerous in "is this close enough?". The floor errs toward silence: no number beats an invented one -on the ceiling. +with `MIN_GAP_MS`. A sentinel meaning "unbounded" is safe in "should I split here?" and dangerous in +"is this close enough?". The floor errs toward silence: no number beats an invented one on the +ceiling. -## Crosshair readout: `runs()` derives segments and `gapSeconds` from one threshold, and drops single-point runs +## Crosshair readout: `runs()` derives segments and `gapMs` from one threshold, and drops single-point runs Segmenting the ceiling at an infinite threshold joins two isolated deco samples into one run, and `ceilingAreas` shades a forbidden zone across water the diver owed nothing — the readout's lie in -pixels, which the readout then contradicts. `runs()` derives segments and `gapSeconds` from one -threshold so the two cannot disagree. Two samples ten seconds apart still join and draw; twenty -minutes apart they become single-point runs, and single-point runs are dropped rather than emitted: -a one-sample `` draws nothing and `buildAreaPath` turns one point into a zero-width shape, -so dropping them makes "no ceiling here" true of the DOM, which is what a test can assert. -`max_ceiling` still puts the obligation in the card's description. +pixels, which the readout then contradicts. `runs()` derives segments and `gapMs` from one threshold +so the two cannot disagree. Two samples ten seconds apart still join and draw; twenty minutes apart +they become single-point runs, and single-point runs are dropped rather than emitted: a one-sample +`` draws nothing and `buildAreaPath` turns one point into a zero-width shape, so dropping +them makes "no ceiling here" true of the DOM, which is what a test can assert. `max_ceiling` still +puts the obligation in the card's description. ## Crosshair readout: a tolerance cannot answer "was this drawn?", so `PlottedChannel.drawn` does -`gapSeconds` answers whether a sample is near enough to quote, and a sample dropped for sitting in -an undrawable run of one is trivially near itself — so a readout could name a ceiling, plant a dot -and write "deco ceiling to 3.0 meters" into the `aria-label` over a chart that drew none. Distance +`gapMs` answers whether a sample is near enough to quote, and a sample dropped for sitting in an +undrawable run of one is trivially near itself — so a readout could name a ceiling, plant a dot and +write "deco ceiling to 3.0 meters" into the `aria-label` over a chart that drew none. Distance cannot answer a membership question. `PlottedChannel.drawn` is the set of sample indices that reached the picture, and a readout needs both `sampleIndexAt` and `drawn.has(index)`. @@ -2247,11 +2247,11 @@ profile's `duration` spans the samples, a device keeps recording after the last `user_marker` can be pressed after surfacing. The API's contract closes with "A chart that draws past its x domain is the chart's to clip", and that sentence is the requirement. -Unclipped, `x(6000)` on a 3 000 s dive lands outside the viewBox and `x(3200)` inside it, in the -axis-label gutter aligned with no time — while `describeProfile` names both. One filtered list feeds -the glyphs, the crosshair and the summary, so they cannot disagree. Markers past the domain are -dropped, not clamped to the last second (clamping invents a time), and not left to SVG clipping, -which hides what leaves the viewBox and draws what merely leaves the plot. +Unclipped, a marker at 6 000 s on a 3 000 s dive lands outside the viewBox and one at 3 200 s inside +it, in the axis-label gutter aligned with no time — while `describeProfile` names both. One filtered +list feeds the glyphs, the crosshair and the summary, so they cannot disagree. Markers past the +domain are dropped, not clamped to the last second (clamping invents a time), and not left to SVG +clipping, which hides what leaves the viewBox and draws what merely leaves the plot. ## A dive-wide bar/min is not a rate, so multi-tank `sac_bar_per_min` is null @@ -5531,10 +5531,11 @@ consistency, both meaning the profile's span. Rejected: export-local schemas on vocabularies forever. `duration` is overloaded: `Dive.duration` is the logged length, the others the profile's span, which -runs longer; comments in `lib/api/dives.ts` say which. `gasAttributionNote` reads -`DiveGasUse.duration` from `schemas/dive.py`, not the profile schemas. Events past `duration` are -not clamped (spec §6.4); clipping is the chart's job. `ChannelSeries` in `lib/dive-profile.ts` keeps -`t`, being the chart's shape, not the wire's. +runs longer, in milliseconds on the profile shapes and in seconds on `DiveGasUse`; comments in +`lib/api/dives.ts` say which. `gasAttributionNote` reads `DiveGasUse.duration` from +`schemas/dive.py`, not the profile schemas. Events past `duration` are not clamped (spec §6.4); +clipping is the chart's job. `ChannelSeries` in `lib/dive-profile.ts` keeps `t`, being the chart's +shape, not the wire's. No CI job runs both repos (`CONTRIBUTING.md`, _Changes that span both repos_) and they deploy independently, so a cross-repo contract break lands on the hosted instance in halves, minutes apart; @@ -5881,8 +5882,8 @@ the file, never one per call. ## A dive has recordings, and the first one is primary, picked only by `primaryRecording()` -A dive has `recordings`, an ordered list in which ordinal 0 is primary: its files write the dive's -oxygen-exposure readings, its profile opens the chart, and its samples go into the UDDF export. +A dive has `recordings`, an ordered list in which ordinal 0 is primary: its readouts are the ones +the exposure card shows, its profile opens the chart, and its samples go into the UDDF export. `primaryRecording()` in `lib/dive-recordings.ts` is the one place that picks it, rather than an `[0]` per card. `diveRecordings()` normalizes two facts rather than trusting the call site: the list is absent, not `[]`, on a list row and on a cached detail payload (the same `?.` discipline as @@ -5952,7 +5953,8 @@ step away. It renders nothing on a dive with no recording, since the API refuses hand-entered dive. The dialog says two things the word "merge" hides. Which dive survives is the server's answer — the earlier by the match gates' clock rule — so the action navigates to whatever comes back; the losing uuid is soft-deleted and would 404. And the oxygen-exposure readings are not -combined: CNS and OTU are the device's running accounting, and the API leaves `cns_end` as it is. +combined: CNS and OTU are each device's running accounting, so every recording keeps its own, and a +fold of one computer's two records only fills the surviving recording's blanks. ## The neighbours go stale without the uuid changing, so the page carries a reload token diff --git a/README.md b/README.md index 70cf55d3..bbe07b17 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,9 @@ one. - **Technical diving** — trimix and nitrox mixes get derived gas names and per-mix **MOD** at your ppO₂ limit, plus END/EAD; the profile chart shades the **deco ceiling**, plots the computer's own **NDL, TTS, ppO₂, CNS and gradient factors** and marks dive events; each recording says the mode - it ran in and the **decompression model** behind those numbers; **CNS/OTU** oxygen exposure and - surface pressure are kept from imports, per-cylinder ppO₂ limits and gas roles included. + it ran in, the salinity it was set to and the **decompression model** behind those numbers, and + keeps its own **CNS/OTU** oxygen exposure and surface pressure from the import; per-cylinder ppO₂ + limits and gas roles are kept too. - **Dive-computer import** — upload a FIT file (Garmin Descent, Suunto Ocean/D5) or a Suunto XML/JSON export and the form pre-fills itself, keeping the file's own UTC offset where it records one (FIT and the JSON exports do; Suunto's XML carries no offset at all, so those fall back to diff --git a/src/app/dives/new/page.render.test.tsx b/src/app/dives/new/page.render.test.tsx index 30b7975b..e617992d 100644 --- a/src/app/dives/new/page.render.test.tsx +++ b/src/app/dives/new/page.render.test.tsx @@ -523,15 +523,28 @@ describe("the water type on the way to the API", () => { await userEvent.selectOptions( screen.getByLabelText(/water type/i), - "en13319", + "fresh", ); await logDive(); await waitFor(() => expect(divesAPI.createDive).toHaveBeenCalled()); expect(vi.mocked(divesAPI.createDive).mock.calls[0][0].water_type).toBe( - "en13319", + "fresh", ); }); + + it("offers the three waters and no device calibration", async () => { + // EN13319 is the density a computer divides pressure by, a setting that lives + // on its recording - not an answer to "what water was this". + render(); + await screen.findByLabelText(/duration/i); + + const options = [ + ...screen.getByLabelText(/water type/i).querySelectorAll("option"), + ].map((option) => option.value); + + expect(options).toEqual(["", "salt", "fresh", "brackish"]); + }); }); describe("what the create form carries over from the last dive", () => { @@ -1681,7 +1694,7 @@ describe("importing a file onto a form with fields hidden", () => { max_depth: 32.1, avg_depth: null, bottom_temperature: null, - water_type: null, + salinity: null, mixtures: [ { volume: 11.1, oxygen: 32, helium: 0, start_pressure: 200 }, { volume: 11.1, oxygen: 50, helium: 0, start_pressure: 180 }, diff --git a/src/components/dives/dive-detail-sidebar.render.test.tsx b/src/components/dives/dive-detail-sidebar.render.test.tsx index 07369b99..75d7ec52 100644 --- a/src/components/dives/dive-detail-sidebar.render.test.tsx +++ b/src/components/dives/dive-detail-sidebar.render.test.tsx @@ -204,11 +204,11 @@ describe("DiveDetailSidebar locations", () => { // its own `!= null` guard. describe("DiveDetailSidebar environment", () => { it("names the water type rather than showing the wire value", () => { - renderSidebar(dive({ water_type: "en13319" })); + renderSidebar(dive({ water_type: "salt" })); expect(screen.getByText("Environment")).toBeInTheDocument(); expect(screen.getByText("Water Type")).toBeInTheDocument(); - expect(screen.getByText("EN13319")).toBeInTheDocument(); + expect(screen.getByText("Salt water")).toBeInTheDocument(); }); it("shows the altitude in meters", () => { diff --git a/src/components/dives/dive-exposure-card.render.test.tsx b/src/components/dives/dive-exposure-card.render.test.tsx index eb4c6d55..b7856a4c 100644 --- a/src/components/dives/dive-exposure-card.render.test.tsx +++ b/src/components/dives/dive-exposure-card.render.test.tsx @@ -1,14 +1,26 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; import { DiveExposureCard } from "./dive-exposure-card"; -import type { Dive } from "@/lib/api/dives"; +import type { Dive, Recording, RecordingReadouts } from "@/lib/api/dives"; // Nothing here is derived - the card renders five stored numbers exactly as the device -// recorded them - so what a render actually tests is the three decisions layered on top -// of that: whether the card appears at all, how a half-recorded pair reads, and whether -// passing the CNS line reaches a diver who cannot see the colour it is drawn in. +// recorded them - so what a render actually tests is the decisions layered on top of +// that: which recording it reads, whether the card appears at all, how a half-recorded +// pair reads, and whether passing the CNS line reaches a diver who cannot see the +// colour it is drawn in. + +function recording( + readouts: RecordingReadouts, + overrides: Partial = {}, +): Recording { + return { uuid: "r0", ordinal: 0, files: [], ...readouts, ...overrides }; +} -function dive(exposure: Partial): Dive { +// A dive whose one recording - the primary - carries `readouts`. +function dive( + readouts: RecordingReadouts, + recordings: Recording[] = [recording(readouts)], +): Dive { return { uuid: "test", dive_number: 1, @@ -16,12 +28,49 @@ function dive(exposure: Partial): Dive { duration: 3600, max_depth: 30, mixtures: [], - ...exposure, - } as Dive; + recordings, + } as unknown as Dive; } +describe("DiveExposureCard's recording", () => { + it("reads the primary recording's, not a second computer's", () => { + // Listed out of order on purpose: ordinal 0 is primary however the list + // arrives, and the backup's own clock is a different machine's accounting. + render( + , + ); + + expect(screen.getByText("9%")).toBeInTheDocument(); + expect(screen.queryByText("50%")).not.toBeInTheDocument(); + }); + + it("renders nothing when only a second computer reported anything", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing on a dive logged by hand, which has no recording", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); + describe("DiveExposureCard visibility", () => { - it("renders nothing when the dive carries none of the three", () => { + it("renders nothing when the recording carries none of the three", () => { // Every hand-logged dive, and every FIT or 2026 Suunto Ocean import. An empty card // headed "Oxygen Exposure" would read as something the diver forgot to fill in, // for fields no form lets them fill in at all. @@ -31,9 +80,9 @@ describe("DiveExposureCard visibility", () => { }); it("renders nothing when the API spells those five out as null", () => { - // The shape actually on the wire: `DiveTechScalars` declares all five - // `float | None` with no `exclude_none`, so a hand-logged dive sends explicit - // nulls rather than omitting the keys. The `!= null` guards cover both, and + // The shape actually on the wire: `RecordingReadouts` declares all five + // `float | None` with no `exclude_none`, so a recording that reported none + // sends explicit nulls rather than omitting the keys. The `!= null` guards cover both, and // this is here so they keep having to - a fixture built from absent keys is // how three mixture fields shipped a form that could not be saved. const { container } = render( @@ -74,7 +123,7 @@ describe("DiveExposureCard visibility", () => { expect(screen.getByText("1.057 bar")).toBeInTheDocument(); }); - it("shows only the readings the dive actually has", () => { + it("shows only the readings the recording actually has", () => { render(); expect(screen.getByText("1.057 bar")).toBeInTheDocument(); diff --git a/src/components/dives/dive-exposure-card.tsx b/src/components/dives/dive-exposure-card.tsx index c4b46a29..bfc14d39 100644 --- a/src/components/dives/dive-exposure-card.tsx +++ b/src/components/dives/dive-exposure-card.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import { Dive } from "@/lib/api/dives"; +import { primaryRecording } from "@/lib/dive-recordings"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Gauge } from "lucide-react"; @@ -17,22 +18,20 @@ const CNS_LIMIT_PERCENT = 100; /** * Oxygen exposure and surface pressure, as the dive computer recorded them. * - * **The primary recording's, and nothing here combines two.** These columns are - * written from the files of the recording at ordinal 0, and promoting a different - * recording re-derives them from that one instead - so on a dive with two - * computers this card reports one machine's accounting, which is the only form - * it has. A merge is the case that makes this worth stating: folding a dive's two - * halves together leaves the readings exactly as they were rather than carrying - * the second half's end values across, because a `cns_end` an import filled in is - * not a quantity that can be added to another and the API deliberately declines - * to rewrite it. Nothing on this card may present the result as a total. + * **The primary recording's, and nothing here combines two.** Every recording + * carries its own readouts, and this card reads the one at ordinal 0 - so on a dive + * with two computers it reports one machine's accounting, promoting the other + * recording shows that one's instead, and the recordings card lists each. A CNS + * clock is the device's running total over an exposure history only it saw, not a + * quantity that can be added to another computer's, so nothing on this card may + * present one as a total. * - * Renders nothing unless the dive carries at least one of them, which is every dive - * logged by hand and every dive imported from a format that doesn't record them - a FIT - * file has no surface pressure at all, and a 2026 Suunto Ocean export has none of the - * three. Silence is right here, unlike on the gas-consumption card: these are readings - * the diver never had the option to enter, so an absent card cannot read as something - * they forgot to fill in. + * Renders nothing unless the primary recording carries at least one of them, which is + * every dive logged by hand and every dive imported from a format that doesn't record + * them - a FIT file has no surface pressure at all, and a 2026 Suunto Ocean export has + * none of the three. Silence is right here, unlike on the gas-consumption card: these + * are readings the diver never had the option to enter, so an absent card cannot read + * as something they forgot to fill in. * * Sits between the mixtures and the gas consumption on the detail page, following that * file's ordering rule - the gas that produced this exposure is directly above it. @@ -44,9 +43,12 @@ const CNS_LIMIT_PERCENT = 100; * them. See the API's DECISIONS.md for why they are import-only columns. */ export function DiveExposureCard({ dive }: DiveExposureCardProps) { - const hasCns = dive.cns_start != null || dive.cns_end != null; - const hasOtu = dive.otu_start != null || dive.otu_end != null; - const hasPressure = dive.surface_pressure_bar != null; + const readouts = primaryRecording(dive); + if (!readouts) return null; + + const hasCns = readouts.cns_start != null || readouts.cns_end != null; + const hasOtu = readouts.otu_start != null || readouts.otu_end != null; + const hasPressure = readouts.surface_pressure_bar != null; if (!hasCns && !hasOtu && !hasPressure) return null; return ( @@ -67,27 +69,32 @@ export function DiveExposureCard({ dive }: DiveExposureCardProps) { {hasCns && ( = CNS_LIMIT_PERCENT + readouts.cns_end != null && + readouts.cns_end >= CNS_LIMIT_PERCENT ? `over the ${CNS_LIMIT_PERCENT}% single-dive limit` : undefined } /> )} {hasOtu && ( - + )} {hasPressure && (
Surface pressure
- {dive.surface_pressure_bar} bar + {readouts.surface_pressure_bar} bar
)} diff --git a/src/components/dives/dive-file-import.render.test.tsx b/src/components/dives/dive-file-import.render.test.tsx index b8f7e6a7..f1d4bb25 100644 --- a/src/components/dives/dive-file-import.render.test.tsx +++ b/src/components/dives/dive-file-import.render.test.tsx @@ -47,8 +47,8 @@ function parsedDive( max_depth: null, avg_depth: null, bottom_temperature: null, - water_type: null, mixtures, + salinity: null, // Returned by the parse but never applied to the form - the API writes these // itself when the file is attached. Spelled out so this fixture stays a complete // `ParsedDive` rather than a partial one the compiler happens to accept. diff --git a/src/components/dives/dive-file-import.test.ts b/src/components/dives/dive-file-import.test.ts index 0e6d768c..f395887f 100644 --- a/src/components/dives/dive-file-import.test.ts +++ b/src/components/dives/dive-file-import.test.ts @@ -47,10 +47,10 @@ function parsedDive( max_depth: null, avg_depth: null, bottom_temperature: null, - // Applied to the form like the scalars above, unlike the import-owned block - // below - null is the ordinary case, since only a FIT file records it at all. - water_type: null, mixtures, + // A setting of the device, never applied to the form - null is the ordinary + // case, since only a FIT file records it at all. + salinity: null, // Returned by the parse but never applied to the form - the API writes these // itself when the file is attached. Spelled out so this fixture stays a complete // `ParsedDive` rather than a partial one the compiler happens to accept. @@ -230,28 +230,18 @@ describe("applyParsedDiveToForm", () => { }); }); - it("applies the water type a FIT file recorded", () => { + it("never takes the dive's water type from a file's salinity", () => { + // A FIT file's `dive_settings.water_type` is the density the computer was + // set to - `en13319` is a calibration, not a kind of water - so it stays the + // recording's setting, and the dive's water type stays the diver's answer. const { form, written } = recordingForm(); applyParsedDiveToForm( form, - parsedDive([], { water_type: "en13319" }), + parsedDive([], { salinity: "en13319" }), () => {}, ); - // `en13319` verbatim, not folded into "salt": it is what the computer was - // actually set to, and the parser refuses to substitute a plausible value - // for a recorded one. The diver corrects it on the form if it is wrong. - expect(written.water_type).toBe("en13319"); - }); - - it("leaves the water type alone for a file that records none", () => { - // Every Suunto export, and any FIT file set to `custom`. Writing `""` here - // would clear a value the edit form was seeded with from the dive itself. - const { form, written } = recordingForm(); - - applyParsedDiveToForm(form, parsedDive([]), () => {}); - expect(written).not.toHaveProperty("water_type"); }); }); diff --git a/src/components/dives/dive-file-import.tsx b/src/components/dives/dive-file-import.tsx index 3747d24f..2dfaa6e2 100644 --- a/src/components/dives/dive-file-import.tsx +++ b/src/components/dives/dive-file-import.tsx @@ -124,8 +124,10 @@ export function applyParsedDiveToForm( replaceMixtures: (mixtures: DiveMixtureInput[]) => void, mode: ParsedDiveApplyMode = "prefill", ): MixtureImportNotes { - // One gate for all seven scalar fields below, so "fill-only" cannot be - // honoured by six of them and forgotten by the seventh. + // One gate for every scalar field below, so "fill-only" cannot be honoured by + // some of them and forgotten by one. The dive's water type is not among them: + // a file's salinity is a setting of the device, never a kind of water, and the + // recording keeps it on attach. const writes = (name: TName) => mode === "prefill" || isDiveFormFieldEmpty(form, name); @@ -150,12 +152,6 @@ export function applyParsedDiveToForm( if (parsed.bottom_temperature != null && writes("bottom_temperature")) { setDiveFormValue(form, "bottom_temperature", parsed.bottom_temperature); } - // No "guessed field" note for this one, unlike the mixtures below: the - // computer's own salinity setting is either in the file or it isn't, and - // nothing here invents a plausible value for an absent one. - if (parsed.water_type != null && writes("water_type")) { - setDiveFormValue(form, "water_type", parsed.water_type); - } if (parsed.mixtures.length === 0) { return { guessed: {}, keptPressures: false, discardedPressures: false }; } diff --git a/src/components/dives/dive-form-fields.tsx b/src/components/dives/dive-form-fields.tsx index 7b747f01..14127642 100644 --- a/src/components/dives/dive-form-fields.tsx +++ b/src/components/dives/dive-form-fields.tsx @@ -555,8 +555,8 @@ export function DiveFormFields({ // `""` straight through, not `|| undefined`: react-hook-form // re-displays a field's default whenever its value resolves // to `undefined`, so mapping "Not recorded" to it would snap - // an imported water type back the moment it was cleared. The - // submit paths convert the sentinel away. + // the dive's stored water type back the moment it was + // cleared. The submit paths convert the sentinel away. onChange={(e) => field.onChange(e.target.value)} > diff --git a/src/components/dives/dive-merge-action.render.test.tsx b/src/components/dives/dive-merge-action.render.test.tsx index 45e0d7a2..613e93d4 100644 --- a/src/components/dives/dive-merge-action.render.test.tsx +++ b/src/components/dives/dive-merge-action.render.test.tsx @@ -110,16 +110,15 @@ describe("DiveMergeAction", () => { it("says plainly that the oxygen-exposure readings are not combined", async () => { // The natural reading of a merge is that everything comes along. CNS and OTU - // are the device's own running accounting rather than a per-dive quantity - // that can be added up, and the API deliberately leaves them alone rather - // than rewriting a `cns_end` an import had filled in. + // are each device's own running accounting rather than a per-dive quantity + // that can be added up, so every recording keeps its own. render(); await userEvent.click( await screen.findByRole("button", { name: /merge/i }), ); expect( - screen.getByText(/oxygen-exposure readings are left as they are/i), + screen.getByText(/keeps its own oxygen-exposure readings/i), ).toBeVisible(); }); diff --git a/src/components/dives/dive-merge-action.tsx b/src/components/dives/dive-merge-action.tsx index 72611f97..2a7b9265 100644 --- a/src/components/dives/dive-merge-action.tsx +++ b/src/components/dives/dive-merge-action.tsx @@ -170,11 +170,11 @@ export function DiveMergeAction({ }} title="Merge with a neighbouring dive" // Says plainly what does *not* come along, because the natural reading - // is that everything does. The oxygen-exposure readings are the + // is that everything does. The oxygen-exposure readings are each // device's own running accounting, not a per-dive quantity that can be - // added up, and the API deliberately leaves them as they are rather - // than rewriting a `cns_end` that an import had filled in. - description="Their recordings, cylinders, sites, gear, species and notes all end up on one dive — the earlier of the two — and the other is deleted for good. The oxygen-exposure readings are left as they are rather than combined." + // added up: every recording keeps its own, and a fold of one computer's + // two records only fills the blanks the surviving one had. + description="Their recordings, cylinders, sites, gear, species and notes all end up on one dive — the earlier of the two — and the other is deleted for good. Each recording keeps its own oxygen-exposure readings rather than having them combined." confirmText="Merge" variant="default" isLoading={isMerging} diff --git a/src/components/dives/dive-profile-card.render.test.tsx b/src/components/dives/dive-profile-card.render.test.tsx index a99ff73f..dfd8de9f 100644 --- a/src/components/dives/dive-profile-card.render.test.tsx +++ b/src/components/dives/dive-profile-card.render.test.tsx @@ -29,7 +29,7 @@ const { divesAPI } = await import("@/lib/api/dives"); function profileInfo(uuid: string) { return { uuid, - duration: 2940, + duration: 2_940_000, depth_sample_count: 314, // This card draws samples and never asks where they came from, so every // fixture here is the ordinary file-backed case. @@ -78,7 +78,7 @@ const twoComputers = dive({ beforeEach(() => { vi.clearAllMocks(); vi.mocked(divesAPI.getRecordingProfile).mockResolvedValue({ - duration: 2940, + duration: 2_940_000, pressures: [], events: [], } as unknown as DiveProfile); diff --git a/src/components/dives/dive-profile-chart.render.test.tsx b/src/components/dives/dive-profile-chart.render.test.tsx index 48328333..3d28d000 100644 --- a/src/components/dives/dive-profile-chart.render.test.tsx +++ b/src/components/dives/dive-profile-chart.render.test.tsx @@ -27,9 +27,9 @@ afterEach(() => { function profile(overrides: Partial = {}): DiveProfile { return { - duration: 300, + duration: 300_000, depth: { - times: [0, 60, 120, 180, 240, 300], + times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [0, 1800, 3000, 2400, 800, 0], }, temperature: null, @@ -43,9 +43,12 @@ function profile(overrides: Partial = {}): DiveProfile { // depth series spanning all of it so the crosshair always has something to report. function longProfile(overrides: Partial = {}): DiveProfile { return profile({ - duration: 3000, + duration: 3_000_000, depth: { - times: [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000], + times: [ + 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, + 2_100_000, 2_400_000, 2_700_000, 3_000_000, + ], values: [0, 2000, 3000, 3000, 3000, 3000, 3000, 2000, 1000, 500, 0], }, ...overrides, @@ -61,17 +64,23 @@ const PLOT = { left: 0, width: 1000 }; // Every channel this chart can plot, all of them drawable, so a test can pick any // subset of them and get exactly that subset back. function everyChannel(overrides: Partial = {}): DiveProfile { - const times = [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]; + const times = [ + 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, + 2_400_000, 2_700_000, 3_000_000, + ]; return { - duration: 3000, + duration: 3_000_000, depth: { times, values: [0, 2000, 3000, 3000, 3000, 3000, 3000, 2000, 1000, 500, 0], }, // Four adjacent samples: an obligation short enough to be one run and long // enough to be drawn, which is what keeps the ceiling in `available`. - ceiling: { times: [1200, 1210, 1220, 1230], values: [300, 300, 320, 300] }, + ceiling: { + times: [1_200_000, 1_210_000, 1_220_000, 1_230_000], + values: [300, 300, 320, 300], + }, temperature: { times, values: [260, 250, 240, 235, 232, 230, 230, 232, 238, 245, 252], @@ -106,6 +115,13 @@ function everyChannel(overrides: Partial = {}): DiveProfile { }; } +// The x of the first point of the first curve a selector matches, in viewBox units. +const firstX = (root: HTMLElement, selector: string) => + Number( + root.querySelector(selector)?.getAttribute("points")?.split(",")[0] ?? + Number.NaN, + ); + // The toggles, addressed the way the legend names them. const CHANNEL_BUTTONS = { depth: /^Depth/, @@ -204,6 +220,58 @@ function readoutText(): string { return screen.queryByRole("presentation")?.textContent ?? ""; } +describe("DiveProfileChart on the millisecond axis", () => { + // Every time on the wire is milliseconds from the recording's own start, so a + // Suunto app export's first depth - stamped 160 ms after its header in + // `suunto-ocean.json` - sits 160 ms along the axis rather than at its origin. + const suuntoOcean = profile({ + duration: 4_000_020, + depth: { times: [160, 1_200_000, 4_000_020], values: [120, 2400, 0] }, + }); + + it("draws a sample at the sub-second offset its export states", () => { + const { container } = render(); + + // 160 ms of a 4 000 020 ms profile over the 630-unit plot, past the 44-unit + // left padding - a hair right of the axis, and not on it. + const x = firstX(container, "g[class*='text-teal'] polyline"); + expect(x).toBeGreaterThan(44); + expect(x).toBeCloseTo(44 + (160 / 4_000_020) * 630, 6); + }); + + it("labels the elapsed axis in minutes and seconds", () => { + const { container } = render(); + + const labels = [ + ...container.querySelectorAll("text[text-anchor='middle']"), + ].map((label) => label.textContent); + + expect(labels).toEqual([ + "0:00", + "10:00", + "20:00", + "30:00", + "40:00", + "50:00", + ]); + }); + + it("says the crosshair's instant in minutes and seconds", () => { + render(); + hoverAt(0.5); + + expect(readoutText()).toMatch(/^25:00 elapsed/); + }); + + it("reads the span aloud in minutes", () => { + render(); + + expect(screen.getByRole("img").getAttribute("aria-label")).toMatch( + /^Dive profile over 50min,/, + ); + }); +}); + describe("DiveProfileChart in imperial", () => { // The conversion happens once, where the wire scale is divided out, so what the // legend, the crosshair and the accessible summary all read is already in feet. @@ -211,13 +279,13 @@ describe("DiveProfileChart in imperial", () => { // Sampled on depth's own clock, so the crosshair has a reading of each to // quote at the moment it is over. temperature: { - times: [0, 60, 120, 180, 240, 300], + times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [250, 245, 240, 238, 236, 235], }, pressures: [ { gas_number: 1, - times: [0, 60, 120, 180, 240, 300], + times: [0, 60_000, 120_000, 180_000, 240_000, 300_000], values: [2000, 1800, 1600, 1400, 1200, 1000], }, ], @@ -267,7 +335,7 @@ describe("DiveProfileChart with an event type this build doesn't know", () => { // `src/app`, that took out the whole dive detail route rather than one tick. const rogue = (label?: string) => ({ - time: 120, + time: 120_000, type: "ndl_violation", label, }) as unknown as DiveProfileEvent; @@ -315,7 +383,7 @@ describe("DiveProfileChart ceiling", () => { // back. The chart must shade each separately rather than spanning the middle, // where the diver owed nothing. ceiling: { - times: [60, 70, 80, 200, 210, 220], + times: [60_000, 70_000, 80_000, 200_000, 210_000, 220_000], values: [300, 320, 340, 300, 310, 320], }, }); @@ -371,7 +439,7 @@ describe("DiveProfileChart crosshair over a short deco obligation", () => { // Two samples ten seconds apart: a dive that tipped into deco briefly, which // still draws (they are inside the 15 s floor) and still reads out. const shortObligation = longProfile({ - ceiling: { times: [1400, 1410], values: [300, 300] }, + ceiling: { times: [1_400_000, 1_410_000], values: [300, 300] }, }); it("does not quote the ceiling from the far side of the dive", () => { @@ -395,7 +463,7 @@ describe("DiveProfileChart crosshair over a short deco obligation", () => { // a fact about this file and nothing asserted it. render( , ); @@ -414,7 +482,7 @@ describe("DiveProfileChart shading a brief obligation", () => { // minutes of forbidden zone across a stretch the diver owed nothing, while the // crosshair (already floored to 15 s) reported no ceiling there at all. const twoMoments = longProfile({ - ceiling: { times: [1400, 2600], values: [300, 300] }, + ceiling: { times: [1_400_000, 2_600_000], values: [300, 300] }, }); it("does not span twenty minutes between two isolated deco samples", () => { @@ -438,7 +506,7 @@ describe("DiveProfileChart shading a brief obligation", () => { const { container } = render( , ); @@ -449,7 +517,7 @@ describe("DiveProfileChart shading a brief obligation", () => { }); it("agrees with the crosshair about where the obligation was", () => { - // The property `gapSeconds` exists for: the two halves are cut at one + // The property `gapMs` exists for: the two halves are cut at one // threshold, so there is no stretch that is shaded but unquotable. render(); hoverAt(2000 / 3000); // mid-span, between the two isolated samples @@ -459,7 +527,7 @@ describe("DiveProfileChart shading a brief obligation", () => { it("quotes nothing while hovering the isolated sample itself", () => { // Mid-span was the easy half, and testing only that is how this survived a - // round: `gapSeconds` still resolved an index within 15 s of a sample that + // round: `gapMs` still resolved an index within 15 s of a sample that // had been dropped from the drawing, so the crosshair named a 3.0 m ceiling // and planted a dot on it over a chart with no ceiling on it. A ~5 px band, // narrower than the whole dive and no less wrong. @@ -497,7 +565,10 @@ describe("DiveProfileChart summary over a partly-drawn channel", () => { // raw series to the summary and to the shared axis. Here 2000 s is an isolated // 9.0 m sample that draws nothing, while the drawn run tops out at 3.0 m. const partlyDrawn = longProfile({ - ceiling: { times: [600, 610, 620, 2000], values: [300, 300, 300, 900] }, + ceiling: { + times: [600_000, 610_000, 620_000, 2_000_000], + values: [300, 300, 300, 900], + }, }); it("quotes the deepest ceiling that was drawn, not the deepest recorded", () => { @@ -551,8 +622,8 @@ describe("DiveProfileChart with a two-sample measured channel", () => { const { container } = render( { render( , ); @@ -590,7 +661,7 @@ describe("DiveProfileChart with a ceiling too sparse to plot", () => { // up with nothing drawable. When it does, it must not claim a legend entry, an // axis in its own colour, or a line in the summary. const sparseCeiling = longProfile({ - ceiling: { times: [100, 1600], values: [300, 300] }, + ceiling: { times: [100_000, 1_600_000], values: [300, 300] }, }); it("draws no curve, and says so consistently everywhere", () => { @@ -642,9 +713,9 @@ describe("DiveProfileChart depth fill across a dropout", () => { // this 1 500 s hole is well clear of it. Modelled on `Dive_2025-03-08-1440.xml`, // which has a 1 341-second one. const withDropout = profile({ - duration: 3000, + duration: 3_000_000, depth: { - times: [0, 300, 600, 900, 2400, 2700, 3000], + times: [0, 300_000, 600_000, 900_000, 2_400_000, 2_700_000, 3_000_000], values: [0, 2000, 3000, 3000, 1000, 500, 0], }, }); @@ -679,7 +750,7 @@ describe("DiveProfileChart depth fill across a dropout", () => { }) .sort((first, second) => first[0] - second[0]); - // x(900) and x(2400) on a 3000 s dive over the 630-unit plot, offset by the + // 900 s and 2 400 s on a 3 000 s dive over the 630-unit plot, offset by the // 44-unit left padding: 233 and 548. expect(spans[0][1]).toBeCloseTo(233, 0); expect(spans[1][0]).toBeCloseTo(548, 0); @@ -919,14 +990,14 @@ describe("DiveProfileChart with every channel switched off", () => { describe("DiveProfileChart markers past the end of the recorded profile", () => { // The API clamps event times at zero and deliberately leaves the high end // alone, signing off with "a chart that draws past its x domain is the chart's - // to clip" (`_rebase_events`). Undrawn, `x(6000)` on a 3000 s dive is 1304 in a - // 720-unit viewBox; `x(3200)` is 716, which is inside the viewBox but in the + // to clip" (`_rebase_events`). Undrawn, 6 000 s on a 3 000 s dive lands at 1304 in + // a 720-unit viewBox; 3 200 s lands at 716, which is inside the viewBox but in the // right-hand axis-label gutter, aligned with no time on the axis. const late = longProfile({ events: [ - { time: 1500, type: "bookmark" }, - { time: 3200, type: "bookmark" }, - { time: 6000, type: "gas_switch", gas_number: 1 }, + { time: 1_500_000, type: "bookmark" }, + { time: 3_200_000, type: "bookmark" }, + { time: 6_000_000, type: "gas_switch", gas_number: 1 }, ], }); @@ -963,9 +1034,9 @@ describe("DiveProfileChart marker toggle", () => { // annotates, and the diver had no way to put it down. const withMarkers = longProfile({ events: [ - { time: 600, type: "gas_switch", gas_number: 2 }, - { time: 1500, type: "bookmark" }, - { time: 2400, type: "safety_stop" }, + { time: 600_000, type: "gas_switch", gas_number: 2 }, + { time: 1_500_000, type: "bookmark" }, + { time: 2_400_000, type: "safety_stop" }, ], }); @@ -1086,11 +1157,17 @@ describe("DiveProfileChart selection across dives", () => { // failure the `-v3` key bump exists to fix once, at migration; writing a narrowed // selection back would reopen it on every dive that lacks a key. const noMarkers = longProfile({ - temperature: { times: [0, 300, 600, 900], values: [260, 250, 240, 235] }, + temperature: { + times: [0, 300_000, 600_000, 900_000], + values: [260, 250, 240, 235], + }, }); const withMarkers = longProfile({ - temperature: { times: [0, 300, 600, 900], values: [260, 250, 240, 235] }, - events: [{ time: 600, type: "bookmark" }], + temperature: { + times: [0, 300_000, 600_000, 900_000], + values: [260, 250, 240, 235], + }, + events: [{ time: 600_000, type: "bookmark" }], }); it("does not let a marker-less dive switch the markers off for the next one", () => { @@ -1120,7 +1197,10 @@ describe("DiveProfileChart selection across dives", () => { render( , ); @@ -1151,7 +1231,7 @@ describe("DiveProfileChart legend group name", () => { // this chart polices everywhere else, one level up in the tree. const { rerender } = render( , ); expect( @@ -1170,11 +1250,11 @@ describe("DiveProfileChart with markers but nothing plottable", () => { // axis, a row of ticks and a legend reading only "Markers", with nothing // saying why it is bare. const markersOnly: DiveProfile = { - duration: 3000, + duration: 3_000_000, depth: null, temperature: null, pressures: [], - events: [{ time: 600, type: "bookmark" }], + events: [{ time: 600_000, type: "bookmark" }], }; it("says the file recorded no samples, which markers are not", () => { @@ -1194,7 +1274,7 @@ describe("DiveProfileChart remembered selection that plots no curve here", () => // `events` leaves the chart with marker ticks, no curve, no axis - and no message, // since the overlay stands down while the markers are up. const depthAndMarkers = longProfile({ - events: [{ time: 600, type: "bookmark" }], + events: [{ time: 600_000, type: "bookmark" }], }); it("falls back to the curves this dive does have", () => { @@ -1314,7 +1394,7 @@ describe("DiveProfileChart deco readouts", () => { { // sends a null, so a marker with no type is the ordinary case rather than a // broken payload - and it has to draw, and read, on its own wording. const withTypeless = longProfile({ - events: [{ time: 1500, type: null, label: "Violated Deep Stop" }], + events: [{ time: 1_500_000, type: null, label: "Violated Deep Stop" }], }); it("draws it and names it by the device's own wording", () => { @@ -1363,7 +1443,11 @@ describe("DiveProfileChart with a marker carrying no type", () => { , @@ -1381,8 +1465,8 @@ describe("DiveProfileChart deco panel scales", () => { // made-up shape: a Suunto Ocean's `gf99` reaches five figures on a // decompression ascent, and the API stores it as the device wrote it. const lopsided = everyChannel({ - gradient_factor: { times: [0, 300, 600], values: [40, 12575, 90] }, - cns: { times: [0, 300, 600], values: [0, 80, 124] }, + gradient_factor: { times: [0, 300_000, 600_000], values: [40, 12575, 90] }, + cns: { times: [0, 300_000, 600_000], values: [0, 80, 124] }, }); const topTick = (container: HTMLElement, axis: string) => @@ -1437,7 +1521,10 @@ describe("DiveProfileChart with a gradient factor past the percent axis's bound" // // No CNS, which is not tidiness: the Suunto JSON carries none, so on this dive // the percent row holds the two gradient factors and nothing else. - const times = [0, 300, 600, 900, 1200, 1500, 1800, 2100, 2400, 2700, 3000]; + const times = [ + 0, 300_000, 600_000, 900_000, 1_200_000, 1_500_000, 1_800_000, 2_100_000, + 2_400_000, 2_700_000, 3_000_000, + ]; const brokenGf = everyChannel({ cns: null, gradient_factor: { diff --git a/src/components/dives/dive-profile-chart.tsx b/src/components/dives/dive-profile-chart.tsx index 04bd429d..a48dfe3b 100644 --- a/src/components/dives/dive-profile-chart.tsx +++ b/src/components/dives/dive-profile-chart.tsx @@ -35,6 +35,8 @@ import { describeEvent, elapsedTicks, formatChannelValue, + formatElapsed, + formatElapsedSpoken, drawnSampleIndexAt, gapThreshold, nearestEvent, @@ -53,10 +55,6 @@ import { toggleSeries, writeSeriesVisibility, } from "@/lib/chart-series-view"; -import { - formatDurationForForm, - formatDurationHoursMinutes, -} from "@/lib/date-time"; import { cn } from "@/lib/utils"; import { useUnits } from "@/hooks/useUnits"; import type { UnitSystem } from "@/lib/units"; @@ -117,8 +115,9 @@ const scaleY = }; // How close the crosshair has to be to an event marker before the readout names -// it, in viewBox units - converted to seconds per dive, so it stays the same -// distance on screen whether the dive lasted 20 minutes or three hours. +// it, in viewBox units - converted to the axis's milliseconds per dive, so it +// stays the same distance on screen whether the dive lasted 20 minutes or three +// hours. // Comfortably wider than the glyph itself (4 units), because the diver is // aiming at a marker with a crosshair that has no snap. const EVENT_HOVER_UNITS = 8; @@ -167,11 +166,11 @@ interface PlottedChannel { // The threshold those runs were cut at, kept so the crosshair can refuse to // quote a reading from a stretch the line refuses to cross - see // `sampleIndexAt`. - gapSeconds: number; + gapMs: number; // Which sample indices actually reached the picture. // // The tolerance alone is not enough to keep the readout honest, which is the - // second time this invariant has had to be tightened. `gapSeconds` says "is + // second time this invariant has had to be tightened. `gapMs` says "is // there a sample near enough to quote", and a sample dropped for being an // undrawable run of one is near enough to itself - so the crosshair went on // naming a 3.0 m ceiling, with a red dot on it, over a chart that had drawn no @@ -237,10 +236,11 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // whole chart - the same call `GasUseChart` makes, for the same reason. It // can't be an index here: the channels are independently sampled and don't // share a time axis, so "the sample under the cursor" is a different index per - // channel. The cursor's x maps to seconds once, and each channel resolves its - // own nearest sample from that (`sampleIndexAt`), or none at all where it - // recorded nothing near enough to be quoted. - const [hoveredSeconds, setHoveredSeconds] = useState(null); + // channel. The cursor's x maps to an axis instant once, and each channel + // resolves its own nearest sample from that (`sampleIndexAt`), or none at all + // where it recorded nothing near enough to be quoted. + // Milliseconds on the profile's axis, like every time below. + const [hoveredMs, setHoveredMs] = useState(null); // What the diver picked in *this* visit, and null until they pick - which is // what leaves room for the remembered selection underneath. Channels and the @@ -281,8 +281,8 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { const pressure = toPressureSeries(profile, units); const duration = profile.duration; - const x = (seconds: number) => - PADDING.left + (duration > 0 ? seconds / duration : 0) * PLOT_WIDTH; + const x = (at: number) => + PADDING.left + (duration > 0 ? at / duration : 0) * PLOT_WIDTH; // Markers that land inside the plot, which is this chart's job rather than the // API's and is stated as such at the other end: `_rebase_events` clamps the low @@ -294,8 +294,8 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // arrangement (§6.4), so the rename that brought `duration` here changed the // word and nothing about which markers exist. // - // Dropped rather than clamped to the last second, which would invent a time to - // keep a marker on screen, and rather than left to the SVG's own clipping, + // Dropped rather than clamped to the plot's last instant, which would invent a + // time to keep a marker on screen, and rather than left to the SVG's own clipping, // which is not clipping at all: `x(time)` past `duration` lands in the // right-hand axis-label gutter first (aligned with no time on the axis) and only // leaves the viewBox further out. @@ -317,7 +317,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // The two things every channel derives from its own cadence, from *one* // threshold - which is the whole point of `PlottedChannel` carrying - // `gapSeconds`, and which an earlier version of this quietly gave up by + // `gapMs`, and which an earlier version of this quietly gave up by // passing `gapThreshold` to the segmenter and `readoutTolerance` to the // readout. // @@ -347,7 +347,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { ); return { - gapSeconds: tolerance, + gapMs: tolerance, segments, drawn: new Set(segments.flat()), }; @@ -395,7 +395,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // meaning of any on the chart: a break in this series is a stretch of // the dive with *no* decompression obligation, not a sensor dropping // out. Drawing through one - or quoting a ceiling into one, which is - // what `gapSeconds` stops the crosshair doing - would claim the diver + // what `gapMs` stops the crosshair doing - would claim the diver // was held to a ceiling they were free of. ...ceilingRuns, }); @@ -467,7 +467,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // are actually shown. domain: null, segments: entry.segments, - gapSeconds: entry.gapSeconds, + gapMs: entry.gapMs, drawn: entry.drawn, }); } @@ -740,14 +740,14 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { ), ) ?? []; - // The marker the crosshair is close enough to be naming, if any. In seconds, - // from a distance in viewBox units - see `EVENT_HOVER_UNITS`. + // The marker the crosshair is close enough to be naming, if any. In + // milliseconds, from a distance in viewBox units - see `EVENT_HOVER_UNITS`. const hoveredEvent = - hoveredSeconds === null || !eventsShown + hoveredMs === null || !eventsShown ? null : nearestEvent( events, - hoveredSeconds, + hoveredMs, (duration / PLOT_WIDTH) * EVENT_HOVER_UNITS, ); @@ -759,7 +759,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // channel simply drops out of the card there, exactly as its line drops out of // the plot. const readouts: Readout[] = - hoveredSeconds === null + hoveredMs === null ? [] : shown .map((channel): Readout | null => { @@ -768,8 +768,8 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { // can't mask a drawn one just behind it. See `drawnSampleIndexAt`. const index = drawnSampleIndexAt( channel.series.t, - hoveredSeconds, - channel.gapSeconds, + hoveredMs, + channel.gapMs, channel.drawn, ); if (index < 0) return null; @@ -777,7 +777,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { key: channel.key, label: channel.label, channel: channel.series.channel, - seconds: channel.series.t[index], + at: channel.series.t[index], value: channel.series.values[index], cy: channel.y(channel.series.values[index]), // Whether the reading is one this row's axis can hold - see @@ -1002,7 +1002,7 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { fill="currentColor" className="text-muted-foreground" > - {formatDurationForForm(tick)} + {formatElapsed(tick)} ))} @@ -1092,10 +1092,10 @@ export function DiveProfileChart({ profile }: DiveProfileChartProps) { instant of one dive, and a crosshair that stopped at the depth plot's baseline would leave the diver reading a panel dot with no line to place it on. */} - {hoveredSeconds !== null && ( + {hoveredMs !== null && ( ( { const bounds = event.currentTarget.getBoundingClientRect(); const ratio = (event.clientX - bounds.left) / bounds.width; - setHoveredSeconds( + setHoveredMs( Math.min(duration, Math.max(0, ratio * duration)), ); }} - onMouseLeave={() => setHoveredSeconds(null)} + onMouseLeave={() => setHoveredMs(null)} /> - {hoveredSeconds !== null && - (readouts.length > 0 || hoveredEvent) && ( - 0 - ? Math.min(...dots.map((readout) => readout.cy)) - : PLOT_BOTTOM - } - /> - )} + {hoveredMs !== null && (readouts.length > 0 || hoveredEvent) && ( + 0 + ? Math.min(...dots.map((readout) => readout.cy)) + : PLOT_BOTTOM + } + /> + )} @@ -1211,7 +1210,8 @@ interface Readout { key: string; label: string; channel: (typeof PROFILE_CHANNELS)[keyof typeof PROFILE_CHANNELS]; - seconds: number; + // The sample's own instant on the axis, which is where its dot goes. + at: number; value: number; cy: number; // The reading is outside its row's axis, so the curve carrying it has left the @@ -1391,14 +1391,14 @@ function EventMarker({ // inline style *attribute*, which the CSP allows (`style-src-attr // 'unsafe-inline'`); an injected `