"use client"; import { Dive } from "@/lib/api/dives"; import { gasAttributionNote, gasUseUnavailableReason, tankGasUseRows, } from "@/lib/dive-gas"; import { GAS_BADGE_CLASS, GAS_ROLE_LABELS } from "@/lib/dive-mixtures"; import { formatDurationHoursMinutes } from "@/lib/date-time"; import { Badge } from "@/components/ui/badge"; import { CARD_SECTION_CONTENT, Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Activity } from "lucide-react"; import { CardFigure, CardFigures } from "@/components/dives/card-figure"; import { useUnits } from "@/hooks/useUnits"; import { formatDepth, formatGasVolume, formatRmv, formatSac, type UnitSystem, } from "@/lib/units"; import { labelOrFallback } from "@/lib/api/labels"; interface DiveGasConsumptionCardProps { dive: Dive; } /** * Gas consumption for the dive, derived by the API from dive time, depth and cylinder * pressures. * * Two shapes, chosen by whether the API attributed the dive per cylinder. Three headline * figures where it did not, which is one tank as it always was - and now also a flagged * parallel set, where the API summed the cylinders' litres against the dive's own * dive time and average depth, so there is one whole-dive figure to show and no split to * show it against. An attributed dive is a table with a row per cylinder and a total * beneath, because the per-tank numbers *are* the answer there - a deco bottle emptied * at 6 m and a back gas breathed at 40 m have almost nothing to say to each other, and a * single averaged RMV would hide exactly the comparison a technical diver opened the * page for. * * Unlike the other optional cards on the detail page, this one still renders when the * figures couldn't be derived — and says why. The others are absent because the diver * didn't record something they'd know they hadn't; this one can be absent despite the * pressures being filled in (a missing average depth, an import that can't say which * tank was breathed when), where silence would read as a bug. */ export function DiveGasConsumptionCard({ dive }: DiveGasConsumptionCardProps) { const units = useUnits(); // Null both when the figures are present and when the dive was never a candidate for // any, so `dive.gas_use || reason` is the whole "is there anything to show" test. const reason = gasUseUnavailableReason(dive); const gasUse = dive.gas_use; if (!gasUse && !reason) return null; // Empty on every single-tank dive, so this is also the switch between the two // layouts. Deliberately not `tanks.length > 1`: one attributed cylinder on a // two-cylinder dive is the commonest multi-gas shape in the corpus, and it // still needs the table to show the cylinder that got nothing. const rows = gasUse ? tankGasUseRows(dive) : []; const attributionNote = gasUse ? gasAttributionNote(gasUse) : null; // The total is only worth a row when it totals more than one thing. With a // single attributed cylinder it restates that row's figures verbatim and // dashes a SAC the row above prints - a line that adds nothing and casts // doubt on a number that is right there. const attributedCount = rows.filter((row) => row.use).length; return ( Gas Consumption {!gasUse ? (

{reason}

) : rows.length > 0 ? ( <> {/* No wrapper of its own: shadcn's `Table` brings its own scroll container, and a second one never scrolls. `tabular-nums` because five columns of figures read down a column. */} {/* "per gas", not "per cylinder": this table can also carry a `Gas N` row for a tank the profile attributed that matches none of the dive's cylinders, and the caption is the only place that shape is stated rather than shown. */} Gas consumption, one row per gas {/* `#` and `Gas`, numbering the rows as the Tanks card above names its tanks, so the two are read against each other. The gas name is therefore on the page twice; this table has to be legible on its own, since "which of these is the deco bottle" is the question its rows exist to answer. `#` is punctuation to a screen reader, so the word is spelled out for one. */} # Tank Gas {/* The two inputs of the split, before the three figures derived from them, so a row reads left to right as the arithmetic it is: this long, this deep, therefore this much. */} Time Avg depth {/* "Used", not the "Gas Used" this said before the table grew a Gas column: two headers three apart both leading with the same word, one naming a mix and one a volume. The unit is in every cell under it, so the noun was never doing the work. */} Used RMV SAC {rows.map((row) => ( // Nothing in a body row wraps: a broken "18.4 L/min" reads as // two values. {/* Muted and unweighted: the position addresses the row rather than saying anything about it. */} {row.label}
{row.gas ? ( {row.gas} ) : ( // A tank matching no mixture has no gas to name. - )} {row.role && ( // Same hand-maintained map and same fallback as the // Tanks card - a role the API has and this build // hasn't renders de-snaked rather than as an empty // badge. {labelOrFallback(GAS_ROLE_LABELS, row.role)} )}
{row.use ? ( <> {formatTimeOnGas(row.use.seconds_on_gas)} {formatDepth(row.use.mean_depth, units)} {formatGasVolume(row.use.gas_used, units)} {formatRmv(row.use.rmv, units)} {formatSac(row.use.sac_bar_per_min, units)} ) : ( // Spelled out rather than left as five blank cells, // which reads as a rendering fault. // // Not "Not attributed", which names only one of the two // states behind an empty `use` (see `TankGasUseRow`) and // would be contradicted by the coverage note three lines // below on the other. Where the cylinder records no // pressures the browser can say something both certain // and useful — that is the corpus's deco bottle with no // transmitter, and the only unattributed row any dive // here actually renders. Otherwise it says what it // knows, which is that there are no figures. {row.hasPressures ? "No figures" : "No pressures recorded"} )}
))}
{/* "Which of these is the dive's RMV" is the question a table of per-tank rates raises, and it deserves an answer on screen rather than a mental sum - but only once there is more than one rate to reconcile. A `tfoot`, not a seventh body row: this summarizes the rows above rather than joining them, and `TableFooter` brings the separator and weight that were otherwise hand-applied. */} {attributedCount > 1 && ( )}

Each tank's figures cover the stretch of the dive it was breathed for, at that stretch's average depth — which is what makes them comparable with each other and with a single-tank dive. Assumes salt water at sea level. Lower is better.

{/* Attribution is inferred from the profile, so a dive it only partly covers has to say so; figures presented as the whole dive when they describe 38 of its 42 minutes understate every one of them. */} {attributionNote && (

{attributionNote}

)} ) : ( <> {/* The two rates named for what they each are, rather than the old "SAC / RMV" over the litres and "Pressure Rate" over the bar - which had the hedged label on the one figure that is unambiguous. RMV is the volume you breathe and is the same number whatever cylinder you were on; SAC is how fast *this* cylinder emptied, and is the field the API already calls `sac_bar_per_min`. Units stay with the values, never doubled in the label. */} {/* Nullable: an additive parallel set whose cylinders are of *unequal* volume gets a null SAC alongside an empty `tanks`, because a mean pressure drop across two different volumes is not a rate of anything. `rmv` and `gas_used` still compute and are still worth showing, so the figure renders its muted dash rather than vanishing. */} {/* **Not** gated on the SAC. This sentence *names the denominator*, so on a figure derived per cylinder it would assert the dive's average depth produced a rate that was never divided by it. A null SAC doesn't mark that derivation: an unequal-volume parallel set has a null SAC and a whole-dive RMV that genuinely *was* divided by `dive.avg_depth`, so a SAC gate would hide a true sentence. What guarantees the claim is position: this whole arm renders only when `tanks` came back empty, which is precisely the set of derivations taken against the dive's own average depth - so a gate on empty `tanks` could never be false here either. `avg_depth` is checked for an unrelated reason: a null would render "an average depth of m". The API declines to derive anything without an average depth on either whole-dive path, so it is a belt on the sentence's own premise rather than a case anyone has seen. */} {dive.avg_depth != null && (

What you'd have breathed doing the same dive at the surface, from an average depth of{" "} {formatDepth(dive.avg_depth, units)}. Assumes salt water at sea level. Lower is better.

)} )}
); } /** * A stretch of time on one cylinder, for the table's Time column. * * `formatDurationHoursMinutes` rounds to whole minutes, which is right for a dive and * wrong at the bottom of this column: a cylinder breathed for twenty seconds — two gas * switches close together, which nothing stops a diver making — would print `0min` beside * real litres and an RMV derived from that stretch, and a row reading "0 minutes, 40 * litres" looks broken rather than brief. * * `<1min` says the same thing honestly. Deliberately not a change to the shared formatter, * which is also what prints the dive time, where a sub-minute value is not a * shape worth spending a special case on. */ function formatTimeOnGas(seconds: number): string { return seconds < 30 ? "<1min" : formatDurationHoursMinutes(seconds); } /** * The dive-wide total under the per-tank rows. Rendered only where two or more cylinders * were attributed — see the caller for why one makes it redundant. * * Time is the attributed span rather than the dive time, so it adds up with the * column above it. Depth and SAC are dashed rather than filled: a mean depth across * cylinders would be a fourth kind of average on a card that already has three, and a * bar/min summed over an 11 L stage and a 22 L twinset is not a rate of anything - the * API sends `sac_bar_per_min: null` on precisely these dives for that reason, which is * what the dash is rendering. */ function TotalRow({ gasUse, units, }: { gasUse: NonNullable; units: UnitSystem; }) { const attributed = gasUse.attributed_seconds; return ( // No `font-medium` here - `TableFooter` already carries it, along with the // separator. {/* "Total", not "All tanks" - under a `#` header that answers a question nobody asked, and this is the word a `tfoot` conventionally carries. */} Total {/* The total is across gases, so the Gas column has nothing to say for it. */} - {attributed != null ? formatTimeOnGas(attributed) : "-"} - {formatGasVolume(gasUse.gas_used, units)} {formatRmv(gasUse.rmv, units)} {gasUse.sac_bar_per_min != null ? formatSac(gasUse.sac_bar_per_min, units) : "-"} ); }