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( {gearItemLabel(mixture.gear_item)} , ); } 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 (
  • #{number}{" "} {name ?? ( Gas not recorded )}

    {mixture.role && ( // De-snaked where `GAS_ROLE_LABELS`, kept in step with the API's // `GasRole` by hand, has not caught up yet. {labelOrFallback(GAS_ROLE_LABELS, mixture.role)} )}
    {facts.length > 0 && (

    )}
    {/* The limit beside the label rather than the depth, so the depth is the figure: muted, as the condition the depth holds under. */} MOD {workingMod != null && ( {` @ ${limit}`} )} } value={ workingMod != null ? ( {warned && ( )} {/* 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 })} ) : null } />
  • ); } /** * 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 ( {/* 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. */}
      {dive.mixtures.map((mixture, index) => ( ))}
    {warning && (

    {warning}

    )}
    ); }