Ctrl K
ComunidadGuías…

src/components/dives/dive-mixtures-card.tsx

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

Archivo original ↗Preguntar a KiBot
import type { ReactNode } from "react";
import Link from "next/link";
import { Dive, DiveMixture } from "@/lib/api/dives";
import { gearItemLabel } from "@/lib/api/gear";
import { labelOrFallback } from "@/lib/api/labels";
import { useWithReturnTo } from "@/hooks/useReturnTo";
import { Badge } from "@/components/ui/badge";
import { CARD_SECTION_CONTENT, Card, CardContent } from "@/components/ui/card";
import {
  GAS_ROLE_LABELS,
  TANK_USAGE_LABELS,
  diveModWarning,
  gasName,
  isNameableMix,
  isSingleGasParallelSet,
  mod,
  ppO2Limit,
} from "@/lib/dive-mixtures";
import { AlertTriangle } from "lucide-react";
import { MixtureTankIcon } from "@/components/dives/mixture-tank-icon";
import { CardFigure, CardFigures } from "@/components/dives/card-figure";
import { DiveCardHeader } from "@/components/dives/dive-card-header";
import { FactsLine } from "@/components/ui/icon-fact";
import { useUnits } from "@/hooks/useUnits";
import { cn } from "@/lib/utils";
import {
  formatComparableDepth,
  formatPressure,
  type UnitSystem,
} from "@/lib/units";

interface DiveMixturesCardProps {
  dive: Dive;
  /** Opens the Tanks edit dialog; absent, the card has no edit control. */
  onEdit?: () => void;
}

interface TankCardProps {
  mixture: DiveMixture;
  // Its 1-based position, which the consumption card below numbers its rows by.
  number: number;
  units: UnitSystem;
  showHelium: boolean;
  // The dive's MOD warning is about this cylinder's gas, so its MOD is marked.
  warned: boolean;
  // The first cylinder flagged parallel, drawn as the left of the pair.
  left: boolean;
  // A path with the way back to this dive attached - see `useWithReturnTo`.
  withReturnTo: (href: string) => string;
}

// One cylinder, drawn as a dive's card is - its name, a line under it and its
// figures - without the backdrop, and with its icon where the dive's hero puts the
// dive's own: beside the name and the line, as tall as the two.
function TankCard({
  mixture,
  number,
  units,
  showHelium,
  warned,
  left,
  withReturnTo,
}: TankCardProps) {
  const name = gasName(mixture.oxygen, mixture.helium);
  // A MOD only means something for a mix that could exist - the rule `gasHintParts`
  // applies to the form hint, shared through `isNameableMix`.
  const limit = ppO2Limit(mixture);
  const workingMod = isNameableMix(mixture.oxygen, mixture.helium)
    ? mod(mixture.oxygen, limit)
    : null;

  // The fractions unrounded, matching the API's two decimals: the name is the
  // rounded shorthand. Litres in both systems: a cylinder's litres are its water
  // capacity, and its cubic feet would be gas at a rated pressure it does not record.
  // The vessel leads, by name, where the tank links one of the diver's own.
  const facts: ReactNode[] = [];
  if (mixture.gear_item) {
    facts.push(
      <Link
        href={withReturnTo(`/gear/${mixture.gear_item.uuid}`)}
        className="relative hover:underline touch:tap-target"
      >
        {gearItemLabel(mixture.gear_item)}
      </Link>,
    );
  }
  if (mixture.volume != null) facts.push(`${mixture.volume} L`);
  if (mixture.oxygen != null) facts.push(`O₂ ${mixture.oxygen}%`);
  if (showHelium && mixture.helium != null) facts.push(`He ${mixture.helium}%`);
  if (mixture.usage) {
    facts.push(labelOrFallback(TANK_USAGE_LABELS, mixture.usage));
  }
  return (
    <li className="rounded-lg border p-3">
      <div className="flex items-start gap-3">
        <MixtureTankIcon
          volume={mixture.volume}
          oxygen={mixture.oxygen}
          helium={mixture.helium}
          left={left}
          className="size-10 shrink-0 stroke-[1.5]"
        />
        <div className="min-w-0">
          <div className="flex items-center gap-1.5">
            <h3 className="truncate font-medium">
              #{number}{" "}
              {name ?? (
                <span className="text-muted-foreground">Gas not recorded</span>
              )}
            </h3>
            {mixture.role && (
              // De-snaked where `GAS_ROLE_LABELS`, kept in step with the API's
              // `GasRole` by hand, has not caught up yet.
              <Badge variant="teal">
                {labelOrFallback(GAS_ROLE_LABELS, mixture.role)}
              </Badge>
            )}
          </div>
          {facts.length > 0 && (
            <p className="text-xs">
              <FactsLine facts={facts} />
            </p>
          )}
        </div>
      </div>
      <CardFigures className="mt-3">
        <CardFigure
          label="Start"
          value={
            mixture.start_pressure != null
              ? formatPressure(mixture.start_pressure, units)
              : null
          }
        />
        <CardFigure
          label="End"
          value={
            mixture.end_pressure != null
              ? formatPressure(mixture.end_pressure, units)
              : null
          }
        />
        {/* The limit beside the label rather than the depth, so the depth is the
            figure: muted, as the condition the depth holds under. */}
        <CardFigure
          label={
            <>
              MOD
              {workingMod != null && (
                <span className="text-muted-foreground">{` @ ${limit}`}</span>
              )}
            </>
          }
          value={
            workingMod != null ? (
              <span
                className={cn(warned && "flex items-center gap-1 text-warning")}
              >
                {warned && (
                  <AlertTriangle className="size-3.5 shrink-0" aria-hidden />
                )}
                {/* Rounded down and at the scale the warning under the cards
                    uses, so where the two differ for one gas it is the ppO₂
                    each was computed at - see `formatComparableDepth`. */}
                {formatComparableDepth(workingMod, units, { floor: true })}
              </span>
            ) : null
          }
        />
      </CardFigures>
    </li>
  );
}

/**
 * The dive's tanks, one card per cylinder. Renders nothing when the dive has none,
 * which is the common case for a dive logged by hand.
 *
 * Sits between the depth figures and the gas-consumption card on the detail page: the
 * pressures here are the inputs that card's RMV/SAC are derived from.
 *
 * Each card carries its gas's name, role and maximum operating depth alongside the
 * recorded fractions, and says so when the dive went past that depth. The name and the
 * MOD are derived rather than stored, so they are computed here from
 * `lib/dive-mixtures.ts` rather than asked of the API - see that module's header for
 * why the maths lives client-side.
 */
export function DiveMixturesCard({ dive, onEdit }: DiveMixturesCardProps) {
  const units = useUnits();
  const withReturnTo = useWithReturnTo();

  if (!dive.mixtures || dive.mixtures.length === 0) return null;

  // One warning for the dive, not one per cylinder - see `diveModWarning` for why a
  // multi-cylinder dive usually cannot blame any single mix, and for the parallel
  // single-gas set that is the exception. Spelled out under the cards
  // rather than hidden in a `title`, which would put a safety note behind a hover and
  // out of reach on touch entirely.
  const warning = diveModWarning(dive.mixtures, dive.max_depth, units);

  // The amber MOD is only meaningful when the warning is actually about that
  // cylinder's gas. That is the single-cylinder case, and a parallel set holding one
  // gas: `diveModWarning` judges that as the single mix it is, every cylinder holds
  // that mix, so marking every one points at exactly what the sentence is about.
  // With any other multi-cylinder dive the sentence is about the dive, and marking a
  // cylinder would be pointing at the wrong thing.
  const attributable =
    warning !== null &&
    (dive.mixtures.length === 1 || isSingleGasParallelSet(dive.mixtures));

  // Helium is the exception among the fractions: air and nitrox record a flat 0, so
  // it is stated only on a dive where some cylinder has any - and then on every card,
  // since the 0 of an air cylinder carried alongside a trimix is a real contrast.
  // `> 0` rather than `!== 0` so a NaN fraction can't summon it.
  const showHelium = dive.mixtures.some(
    (mixture) => mixture.helium != null && mixture.helium > 0,
  );

  const firstParallel = dive.mixtures.findIndex(
    (mixture) => mixture.usage === "parallel",
  );

  return (
    <Card>
      <DiveCardHeader group="Tanks" title="Tanks" onEdit={onEdit} />
      <CardContent className={CARD_SECTION_CONTENT}>
        {/* Columns from the card's own width rather than the window's: the slot
            this card sits in is narrowest at `lg`, and 19rem holds a card's
            three figures on one line. One column never outgrows the card. */}
        <ul className="grid grid-cols-[repeat(auto-fill,minmax(min(19rem,100%),1fr))] gap-3">
          {dive.mixtures.map((mixture, index) => (
            <TankCard
              key={mixture.id ?? index}
              mixture={mixture}
              number={index + 1}
              units={units}
              showHelium={showHelium}
              warned={attributable}
              left={index === firstParallel}
              withReturnTo={withReturnTo}
            />
          ))}
        </ul>

        {warning && (
          <p className="mt-4 flex items-start gap-2 text-sm text-warning">
            <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" aria-hidden />
            <span>{warning}</span>
          </p>
        )}
      </CardContent>
    </Card>
  );
}