Ctrl K
ComunidadGuías…

src/components/dives/gas-use-card.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useMemo } from "react";
import { Activity, Info, Minus, TrendingDown, TrendingUp } from "lucide-react";
import {
  CARD_TITLE_ROW,
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { ChartPeriodControls } from "@/components/dives/chart-period-controls";
import { ChartSkeleton } from "@/components/dives/chart-skeleton";
import { ChartStat, ChartStatRow } from "@/components/dives/chart-stat";
import { GasUseChart } from "@/components/dives/gas-use-chart";
import { diveStatsAPI, DiveGasUsePoint } from "@/lib/api/dive-stats";
import { type GasUseSummary, summarizeGasUse } from "@/lib/dive-gas";
import { GAS_USE_VIEW_KEY } from "@/lib/chart-period-view";
import { diveWallClockTime } from "@/lib/date-time";
import { cn } from "@/lib/utils";
import {
  type ChartPeriodSource,
  useChartPeriodView,
} from "@/hooks/useChartPeriodView";
import { useUnits } from "@/hooks/useUnits";
import { displayNumber, unitLabel, type UnitSystem } from "@/lib/units";

const GAS_USE_SOURCE: ChartPeriodSource<DiveGasUsePoint> = {
  load: () => diveStatsAPI.getGasUseHistory(),
  failure: "Failed to fetch gas use history:",
  // The chart plots dives, so the anchor is one of their own times rather than
  // an arbitrary date.
  anchors: (points) =>
    points.map((point) => diveWallClockTime(point.start_time)),
  storageKey: GAS_USE_VIEW_KEY,
  defaultScope: "year",
};

// The Home page's gas-consumption trend.
//
// This renders even with nothing to plot. An empty service-due list means nothing
// needs attention, which is genuinely nothing to say,
// whereas an empty series here means the dives are missing pressures or an
// average depth - something the diver can act on, and won't discover otherwise.
// `GasUseChart` owns that message, since it's the component that knows two
// points are the minimum.
export function GasUseCard() {
  const units = useUnits();
  const view = useChartPeriodView(GAS_USE_SOURCE);
  const { points, times, scope, anchor } = view;

  // The figures above the chart. Same threshold as the chart's own: with one
  // dive there is no trend to describe, and a "Median" over a single dive
  // sitting above "log at least two dives" would be a contradiction.
  const summary = useMemo(
    () =>
      points && points.length >= 2
        ? summarizeGasUse(
            times,
            points.map((point) => point.gas_use.rmv),
            scope,
            anchor,
          )
        : null,
    [points, times, scope, anchor],
  );

  return (
    <Card>
      <CardHeader>
        <div className={cn(CARD_TITLE_ROW, "@container")}>
          <CardTitle
            as="h2"
            className="flex min-w-0 grow basis-0 items-center gap-2"
          >
            <Activity className="h-5 w-5" />
            Gas Consumption
          </CardTitle>
          <ChartPeriodControls name="Gas consumption" view={view} />
        </div>
      </CardHeader>
      <CardContent>
        {points === null ? (
          <ChartSkeleton stats={3} legend />
        ) : (
          <>
            {summary && <GasUseSummaryRow summary={summary} units={units} />}
            <GasUseChart points={points} scope={scope} anchor={anchor} />
          </>
        )}
        {/* "Surface-equivalent" is carrying the S of SAC here. The title
            deliberately doesn't: "air" is wrong the moment you breathe nitrox or
            trimix, and the whole data model already says gas (`gas_use`,
            `dive-gas.ts`). The normalisation belongs in the sentence that has
            room to state it.

            The trend is not named here: the legend already labels it, with
            the bin this sentence could not state (it varies by scope - see
            `binUnit`). */}
        <p className="mt-4 flex items-start gap-2 text-sm text-muted-foreground">
          <Info className="mt-0.5 h-4 w-4 shrink-0" aria-hidden />
          Surface-equivalent gas volume breathed per minute (RMV). Lower is
          better.
        </p>
      </CardContent>
    </Card>
  );
}

// The headline figures for the period on screen.
//
// The chart shows the shape; this answers "am I improving", which is the
// question the card exists for and the one a scatter of dots is worst at
// answering at a glance.
function GasUseSummaryRow({
  summary,
  units,
}: {
  summary: GasUseSummary;
  units: UnitSystem;
}) {
  return (
    <ChartStatRow className="mb-5">
      <ChartStat label="Median" detail={<Change summary={summary} />}>
        <Figure value={summary.median} units={units} />
      </ChartStat>
      <ChartStat label="Best dive">
        <Figure value={summary.best} units={units} />
      </ChartStat>
      <ChartStat label="Dives">
        <span className="text-base font-semibold tabular-nums">
          {summary.dives}
        </span>
      </ChartStat>
    </ChartStatRow>
  );
}

// One decimal in metric. The API returns two, which is more resolution than a
// figure derived from a hand-read pressure gauge honestly has - and these are
// medians over a whole period, where the second decimal is noise about noise.
// (The hover card on the chart still shows a single dive's own two, as it always
// has.)
//
// Imperial keeps both of its own, and that is not an inconsistency: a cubic foot
// is 28 litres, so 0.64 cuft/min is already coarser than the 18.2 L/min this
// prints beside it. Rounding it further would merge rates a diver can tell apart.
//
// Value and unit are drawn at different sizes, so this builds the string from the
// two halves `lib/units.ts` exports rather than calling `formatRmv` - the numbers
// and the label are the same either way.
function Figure({ value, units }: { value: number; units: UnitSystem }) {
  return (
    <>
      <span className="text-base font-semibold tabular-nums">
        {displayNumber(value, "rmv", units, { decimals: 1 })}
      </span>
      <span className="text-sm text-muted-foreground">
        {unitLabel("rmv", units)}
      </span>
    </>
  );
}

// Change against the previous period with dives.
//
// Deliberately uncolored. The app has no good/bad status tokens, and the two
// candidates are both already spoken for on this card - coral is the bins and
// teal is a dive - so a green/red pair here would either collide with the
// chart's own meanings or have to be invented for one label. The arrow and the
// "vs <period>" text carry it instead, which is also what keeps the direction
// legible to anyone who can't separate the two hues.
function Change({ summary }: { summary: GasUseSummary }) {
  if (summary.changePercent === null) return null;

  // Rounded before it's judged, so a 0.4% drift isn't announced as an
  // improvement by an arrow pointing at a number that reads "0%".
  const percent = Math.round(summary.changePercent);
  const Icon = percent === 0 ? Minus : percent < 0 ? TrendingDown : TrendingUp;

  return (
    <div className="flex items-center gap-1 text-xs text-muted-foreground">
      <Icon className="h-3.5 w-3.5 shrink-0" aria-hidden />
      {percent === 0
        ? `level with ${summary.previousLabel}`
        : `${Math.abs(percent)}% vs ${summary.previousLabel}`}
    </div>
  );
}