Ctrl K
ComunidadGuías…

src/components/dives/dive-gas-consumption-card.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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&apos;s figures cover the stretch of the dive it was
              breathed for, at that stretch&apos;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&apos;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>
  );
}