import { Dive } from "@/lib/api/dives"; import { primaryRecording } from "@/lib/dive-recordings"; import { CARD_SECTION_CONTENT, Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Gauge } from "lucide-react"; import { CardFigure, CardFigures } from "@/components/dives/card-figure"; interface DiveExposureCardProps { dive: Dive; } // The CNS clock past which a diver is over the accepted single-dive exposure. Not a // hard physiological edge - the tables it comes from are conservative and individual // susceptibility varies - which is why passing it is rendered as emphasis on a number // rather than as a warning sentence. The diver knows what their computer told them; // this is their log agreeing with it, not advice. 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.** 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 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 under the tanks and the gas consumption on the detail page, following that file's * ordering rule - the gas that produced this exposure is already on screen above it. * * Everything here is displayed exactly as stored, with no derivation at all. That is * unusual for this page and deliberate: CNS and OTU are the output of whichever * decompression algorithm the device ran, over an exposure history that includes dives * this log may not hold, so there is nothing to recompute them from and no way to check * them. See the API's DECISIONS.md for why they are import-only columns. */ export function DiveExposureCard({ dive }: DiveExposureCardProps) { 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 ( {/* Not "Oxygen Exposure", which is what two of the three readings are: 42 of the 384 XML exports in the corpus record a surface pressure and neither CNS nor OTU, and that card would have been headed "Oxygen Exposure" over a lone barometer reading. Gating the card on CNS/OTU instead would drop a stored reading for those 42 dives. */} Exposure & Pressure {hasCns && ( = CNS_LIMIT_PERCENT ? `over the ${CNS_LIMIT_PERCENT}% single-dive limit` : undefined } /> )} {hasOtu && ( )} {hasPressure && ( )} ); } /** * One exposure reading as a start → end pair. * * The pair is the point. A repetitive dive that began at CNS 8 % and ended at 9 % * added almost nothing, and one that began at 0 % and ended at 9 % is a different * dive with the same final number - which is why both halves are shown rather than * just the figure the diver is left carrying. * * A missing half renders as an em dash rather than collapsing the pair, so the arrow * still reads as "from, to" on a format that records only the end (every FIT file * does exactly that: the profile has no start-OTU field at all). */ function Reading({ label, start, end, unit = "", alert, }: { label: string; start: number | null | undefined; end: number | null | undefined; unit?: string; // The reason this reading is emphasized, or absent when it isn't - one prop rather // than a boolean plus a message, so a caller cannot light the value up without // saying why. Rendered `sr-only`; the colour is the sighted half of the same fact. alert?: string; }) { return ( {start != null ? `${start}${unit}` : "—"} {/* Decorative: the accessible name comes from the label above and the two values read in order, so announcing "right arrow" between them adds nothing. */} {" → "} {/* `text-warning`, not `text-warning-foreground` - the latter is the white that sits *on* `bg-warning` and is invisible as text on a card in light mode. Same trap as the MOD warnings on the Tanks card. */} {end != null ? `${end}${unit}` : "—"} {/* The colour is emphasis, and emphasis alone is not a channel: greyscale, red/green-deficient and screen-reader users all get "105%" with nothing distinguishing it (WCAG 2.1 SC 1.4.1). An `sr-only` span rather than the `AlertTriangle` used elsewhere on this feature, because the card's whole argument is that passing this line is emphasis rather than advice - and an icon would be visible advice. This says the same thing to a reader who cannot see the colour, and nothing to one who can. */} {alert && — {alert}} } /> ); }