src/components/dives/dive-gas-consumption-card.tsx
Rama main · réplica de lectura del repositorio original.
"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 (
<Card>
<CardHeader>
<CardTitle as="h2" className="flex items-center gap-2">
<Activity className="h-5 w-5" />
Gas Consumption
</CardTitle>
</CardHeader>
<CardContent className={CARD_SECTION_CONTENT}>
{!gasUse ? (
<p className="text-sm text-muted-foreground">{reason}</p>
) : 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. */}
<Table className="tabular-nums">
{/* "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. */}
<TableCaption className="sr-only">
Gas consumption, one row per gas
</TableCaption>
<TableHeader>
<TableRow>
{/* `#` 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. */}
<TableHead>
<span aria-hidden>#</span>
<span className="sr-only">Tank</span>
</TableHead>
<TableHead>Gas</TableHead>
{/* 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. */}
<TableHead>Time</TableHead>
<TableHead>Avg depth</TableHead>
{/* "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. */}
<TableHead>Used</TableHead>
<TableHead>RMV</TableHead>
<TableHead>SAC</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
// Nothing in a body row wraps: a broken "18.4 L/min" reads as
// two values.
<TableRow key={row.key} className="whitespace-nowrap">
{/* Muted and unweighted: the position addresses the row
rather than saying anything about it. */}
<TableCell className="text-muted-foreground">
{row.label}
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
{row.gas ? (
<Badge
variant="secondary"
className={GAS_BADGE_CLASS}
>
{row.gas}
</Badge>
) : (
// A tank matching no mixture has no gas to name.
<span className="text-muted-foreground">-</span>
)}
{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.
<Badge variant="teal">
{labelOrFallback(GAS_ROLE_LABELS, row.role)}
</Badge>
)}
</div>
</TableCell>
{row.use ? (
<>
<TableCell>
{formatTimeOnGas(row.use.seconds_on_gas)}
</TableCell>
<TableCell>
{formatDepth(row.use.mean_depth, units)}
</TableCell>
<TableCell>
{formatGasVolume(row.use.gas_used, units)}
</TableCell>
<TableCell className="font-medium">
{formatRmv(row.use.rmv, units)}
</TableCell>
<TableCell>
{formatSac(row.use.sac_bar_per_min, units)}
</TableCell>
</>
) : (
// 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.
<TableCell colSpan={5} className="text-muted-foreground">
{row.hasPressures
? "No figures"
: "No pressures recorded"}
</TableCell>
)}
</TableRow>
))}
</TableBody>
{/* "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 && (
<TableFooter>
<TotalRow gasUse={gasUse} units={units} />
</TableFooter>
)}
</Table>
<p className="text-xs text-muted-foreground mt-4">
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.
</p>
{/* 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 && (
<p className="text-xs text-muted-foreground mt-2">
{attributionNote}
</p>
)}
</>
) : (
<>
<CardFigures>
{/* 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. */}
<CardFigure label="RMV" value={formatRmv(gasUse.rmv, units)} />
{/* 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. */}
<CardFigure
label="SAC"
value={
gasUse.sac_bar_per_min != null
? formatSac(gasUse.sac_bar_per_min, units)
: null
}
/>
<CardFigure
label="Gas used"
value={formatGasVolume(gasUse.gas_used, units)}
/>
</CardFigures>
{/* **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 && (
<p className="text-xs text-muted-foreground mt-4">
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.
</p>
)}
</>
)}
</CardContent>
</Card>
);
}
/**
* 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<Dive["gas_use"]>;
units: UnitSystem;
}) {
const attributed = gasUse.attributed_seconds;
return (
// No `font-medium` here - `TableFooter` already carries it, along with the
// separator.
<TableRow className="whitespace-nowrap">
{/* "Total", not "All tanks" - under a `#` header that answers a
question nobody asked, and this is the word a `tfoot` conventionally
carries. */}
<TableCell>Total</TableCell>
{/* The total is across gases, so the Gas column has nothing to say for it. */}
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell
className={attributed == null ? "text-muted-foreground" : undefined}
>
{attributed != null ? formatTimeOnGas(attributed) : "-"}
</TableCell>
<TableCell className="text-muted-foreground">-</TableCell>
<TableCell>{formatGasVolume(gasUse.gas_used, units)}</TableCell>
<TableCell>{formatRmv(gasUse.rmv, units)}</TableCell>
<TableCell
className={
gasUse.sac_bar_per_min == null ? "text-muted-foreground" : undefined
}
>
{gasUse.sac_bar_per_min != null
? formatSac(gasUse.sac_bar_per_min, units)
: "-"}
</TableCell>
</TableRow>
);
}