Ctrl K
ComunidadGuías…

src/components/dives/mixture-fields.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useEffect, useState } from "react";
import {
  Control,
  FieldValues,
  Path,
  UseFieldArrayReturn,
  useFieldArray,
  useFormContext,
  useWatch,
} from "react-hook-form";
import { AddRowButton, RepeatableRow } from "@/components/ui/repeatable-row";
import { Input } from "@/components/ui/input";
import { DecimalInput } from "@/components/ui/decimal-input";
import { NativeSelect } from "@/components/ui/native-select";
import {
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import { AlertTriangle } from "lucide-react";
import { DiveMixtureInput } from "@/lib/validations/dive";
import {
  DEFAULT_MIXTURE,
  GAS_ROLE_LABELS,
  PPO2_WORKING,
  diveModWarning,
  gasHintParts,
} from "@/lib/dive-mixtures";
import { GAS_ROLES, TANK_USAGE } from "@/lib/api/dives";
import { VolumeCombobox } from "@/components/dives/volume-combobox";
import { CylinderCombobox } from "@/components/dives/cylinder-combobox";
import type { FormControlA11yProps } from "@/components/ui/form";
import type { GearItemInfo } from "@/lib/api/gear";
import {
  isVolumeFromVessel,
  linkedVessel,
  tankVolume,
} from "@/lib/tank-vessel";
import { MixtureTankIcon } from "@/components/dives/mixture-tank-icon";
import { DiveUnitNumberField } from "@/components/dives/dive-unit-number-field";
import { useEntryUnits } from "@/hooks/useEntryUnits";
import type { DiveFormFieldKey } from "@/lib/dive-form-fields";

export { DEFAULT_MIXTURE };

// The minimal field shape `MixtureFields` needs: any form values type that
// has a `mixtures` array (both `DiveCreateInput` and `DiveUpdateInput` from
// `lib/validations/dive.ts` qualify). Keeping this generic - rather than
// falling back to `Control<any, any, any>` - preserves type safety between
// the create/update form shapes at the `control` prop boundary.
//
// `max_depth` is here for the same reason `mixtures` is: `MixtureGasHint` reads it
// to place a mix's END/EAD and to decide whether its MOD has been exceeded. Optional
// because the hint degrades to just the gas name and MOD without it, which is what a
// dive whose depth hasn't been filled in yet should show.
export interface MixtureFieldsValues extends FieldValues {
  mixtures?: DiveMixtureInput[];
  max_depth?: number | null;
}

// The `useFieldArray` return type for `mixtures`, keyed to `MixtureFieldsValues`
// rather than any particular concrete form type. Exported so a single instance
// can be created once at a common ancestor (the create/edit dive pages) and
// passed down to both `MixtureFields` and `DiveFileImport` - `useFieldArray`
// doesn't reliably keep multiple separate instances watching the same
// `control`/`name` in sync with each other (e.g. `replace()` called on one
// instance doesn't shrink another instance's `fields` when the new array is
// shorter - see DECISIONS.md), so there must only ever be one.
export type MixtureFieldArray = UseFieldArrayReturn<
  MixtureFieldsValues,
  "mixtures"
>;

// Creates the single `mixtures` field array instance a page needs, already
// cast to `MixtureFieldArray` - pass `form.control` in directly. Call this
// once per form, at the same level as the `useForm()` call, and pass the
// result down to both `DiveFormFields`/`MixtureFields` and `DiveFileImport`
// (e.g. via `DiveForm`) - see `MixtureFieldArray`'s doc comment above for
// why there must only ever be one instance per form.
export function useMixtureFieldArray<TFieldValues extends MixtureFieldsValues>(
  control: Control<TFieldValues>,
): MixtureFieldArray {
  // Narrowing `control` to `MixtureFieldsValues` is sound: it's exactly the
  // shape `TFieldValues` is constrained to extend, and only affects this
  // hook's internal typing, not what callers pass in.
  return useFieldArray<MixtureFieldsValues, "mixtures">({
    control: control as unknown as Control<MixtureFieldsValues>,
    name: "mixtures",
  });
}

// What one cylinder's gas works out to, live, under the boxes it was typed into:
// the gas's name, how deep it can be breathed, and - when the dive logs this as its
// only cylinder - how deep it *feels* (END) or decompresses like (EAD).
//
// Its own component rather than inline in the `fields.map()` below because it needs
// three `useWatch` subscriptions per row, and hooks cannot be called from a loop
// body. Watching only these fields - rather than reading `form.watch()` wholesale -
// also keeps a keystroke in the O₂ box from re-rendering every other tank's card.
//
// Renders nothing until there is something true to say. A half-typed O₂ field is
// `undefined`, and a hint that flickers "EAN3" into "EAN32" as the diver types would
// be worse than one that waits.
//
// `isOnlyMixture` gates everything depth-dependent. The name and the MOD are
// properties of the gas alone and always hold; END and EAD are statements about a
// depth this gas was breathed at, which is only known when there is one cylinder to
// breathe. Printing "EAD 22.6 m at 45.91 m" against a deco bottle staged for the
// ascent describes a breath nobody took - see `diveModWarning`.
function MixtureGasHint({
  control,
  index,
  isOnlyMixture,
}: {
  control: Control<MixtureFieldsValues>;
  index: number;
  isOnlyMixture: boolean;
}) {
  const oxygen = useWatch({ control, name: `mixtures.${index}.oxygen` });
  const helium = useWatch({ control, name: `mixtures.${index}.helium` });
  const po2Limit = useWatch({ control, name: `mixtures.${index}.po2_limit` });
  const maxDepth = useWatch({ control, name: "max_depth" });
  // The hint's MOD/END/EAD are *depths*, so they follow the depth entry units
  // rather than the account's: a diver typing depths in feet must not be warned
  // about a MOD in metres mid-entry. Everything outside this form still renders
  // in account units.
  const units = useEntryUnits().entryUnits("depth");

  // `depth` is null unless this is the only cylinder, which is what keeps END/EAD
  // off a staged deco bottle - `gasHintParts` documents the rule.
  const parts = gasHintParts({
    // `""` is the cleared state on all three of these, not a fraction of zero or a
    // limit of zero - normalized here so `gasHintParts` deals only in numbers and
    // nulls, the way every other caller hands it values. On the two fractions that
    // is what keeps a cleared O₂ box from naming the cylinder "EAN0" while the
    // diver is still deciding what was in it.
    oxygen: oxygen === "" ? null : oxygen,
    helium: helium === "" ? null : helium,
    depth: isOnlyMixture ? maxDepth : null,
    ppO2: po2Limit === "" ? null : po2Limit,
    // The depths in the hint are converted; `maxDepth` itself is metric form
    // state and stays that way.
    units,
  });
  if (parts.length === 0) return null;

  return <p className="text-xs text-muted-foreground">{parts.join(" · ")}</p>;
}

// The cylinder drawn beside a tank's title, as the dive page draws it, redrawn as its
// volume, mix and usage are typed. `""` is the form's cleared state, unrecorded here
// as it is on the wire, so a blank box draws the single in air colours.
//
// Its own component for `MixtureGasHint`'s reason. Whether it is the left of a
// parallel pair depends on the rows above it, so it watches their usage and nothing
// else of theirs.
function MixtureTitleIcon({
  control,
  index,
}: {
  control: Control<MixtureFieldsValues>;
  index: number;
}) {
  const volume = useWatch({ control, name: `mixtures.${index}.volume` });
  const oxygen = useWatch({ control, name: `mixtures.${index}.oxygen` });
  const helium = useWatch({ control, name: `mixtures.${index}.helium` });
  const usages = useWatch({
    control,
    name: Array.from(
      { length: index + 1 },
      (_, row) => `mixtures.${row}.usage` as const,
    ),
  });
  const left = usages.findIndex((usage) => usage === "parallel") === index;

  return (
    <MixtureTankIcon
      volume={volume === "" ? null : volume}
      oxygen={oxygen === "" ? null : oxygen}
      helium={helium === "" ? null : helium}
      left={left}
      className="size-6 shrink-0 stroke-[1.5] text-foreground"
    />
  );
}

// Which of the diver's cylinders a tank is. A pick copies the vessel's capacity into
// Volume, which then reads it off the vessel; unlinking leaves the figure the box was
// showing, editable again. A vessel another tank links is not offered.
//
// Its own component for `MixtureGasHint`'s reason, and it writes three fields of the
// row at once, which is why it reaches the form through its context.
function MixtureCylinderPicker({
  control,
  index,
  count,
  until,
  ...slotProps
}: {
  control: Control<MixtureFieldsValues>;
  index: number;
  count: number;
  until?: string;
} & FormControlA11yProps) {
  const { getValues, setValue } = useFormContext<MixtureFieldsValues>();
  const gearItemUuid = useWatch({
    control,
    name: `mixtures.${index}.gear_item_uuid`,
  });
  const gearItem = useWatch({ control, name: `mixtures.${index}.gear_item` });
  const links = useWatch({
    control,
    name: Array.from(
      { length: count },
      (_, other) => `mixtures.${other}.gear_item_uuid` as const,
    ),
  });
  const vessel = linkedVessel({
    gear_item_uuid: gearItemUuid,
    gear_item: gearItem,
  });
  const linkedElsewhere = links.filter(
    (uuid, other): uuid is string => other !== index && !!uuid,
  );

  const write = { shouldDirty: true, shouldValidate: true } as const;
  const handleChange = (picked: GearItemInfo | null) => {
    if (picked) {
      setValue(`mixtures.${index}.gear_item_uuid`, picked.uuid, write);
      setValue(`mixtures.${index}.gear_item`, picked, write);
      if (picked.volume != null) {
        setValue(`mixtures.${index}.volume`, picked.volume, write);
      }
      return;
    }
    // The figure the read-only box was showing stays, now the tank's own.
    const shown = tankVolume(getValues(`mixtures.${index}`) ?? {});
    setValue(`mixtures.${index}.volume`, shown, write);
    setValue(`mixtures.${index}.gear_item_uuid`, "", write);
    setValue(`mixtures.${index}.gear_item`, null, write);
  };

  return (
    <CylinderCombobox
      {...slotProps}
      value={vessel}
      onChange={handleChange}
      excludeIds={linkedElsewhere}
      until={until}
    />
  );
}

// The Volume box, read-only while the tank's link stands to a vessel recording its
// capacity, and then showing the figure the save sends - `tankVolume` says which.
function MixtureVolumeInput({
  control,
  index,
  value,
  onChange,
  until,
  ...slotProps
}: {
  control: Control<MixtureFieldsValues>;
  index: number;
  value: number | "" | undefined;
  onChange: (value: number | "") => void;
  until?: string;
} & FormControlA11yProps) {
  const gearItemUuid = useWatch({
    control,
    name: `mixtures.${index}.gear_item_uuid`,
  });
  const gearItem = useWatch({ control, name: `mixtures.${index}.gear_item` });
  const link = {
    volume: value,
    gear_item_uuid: gearItemUuid,
    gear_item: gearItem,
  };
  const fromVessel = isVolumeFromVessel(link);

  return (
    <VolumeCombobox
      {...slotProps}
      value={fromVessel ? tankVolume(link) : value}
      onChange={onChange}
      readOnly={fromVessel}
      until={until}
    />
  );
}

// The ppO₂ ceilings a cylinder is actually planned to, offered in place of a free
// number box. The schema's band is 0.4-2.0 - wide because it exists to catch a unit
// error (a Suunto JSON export writes 140000 Pa for 1.4 bar), not because a diver picks
// from all of it - and every value in it that a diver would ever choose is one of
// these seven: 1.4 for a back gas, 1.6 for a deco bottle, and the conservative steps
// below them that a few agencies and most CCR plans use.
//
// Strings rather than numbers, which is the one thing here worth stating: an
// `<option>`'s value is a string either way, and going through `Number` in both
// directions puts float formatting between the stored value and the option that has to
// match it - `String(1.0)` is `"1"`, so a `1.0` option labelled `"1.0"` would never
// match its own value. Written as the tokens they are displayed as, the label, the
// value and the round trip are all the same characters.
//
// **The narrowing is a one-way door, and that is accepted.** `ppO2LimitChoices` below
// keeps an unlisted recorded value selectable, but only while it is still the field's
// value: change an imported 1.45 to 1.4 and there is no way back to it, and nothing
// here can reach the 0.4-0.9 band the API's `ck_dive_mixture_po2_limit_range` allows.
// The floor is 1.0 on purpose rather than by inheritance - below that a figure is a
// CCR *setpoint* rather than a limit a MOD is worked out at, and this field feeds a
// MOD. A diver who genuinely needs a number this list doesn't offer has lost
// something; a diver who fat-fingers 14 into a free text box gets an unreadable 422 on
// save, and there are many more of the second.
const PPO2_LIMIT_OPTIONS = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5", "1.6"];

// The options to offer for a cylinder currently holding `value`.
//
// Normally just the list above. A limit that came from a file and isn't on it -
// anything in the schema's band is possible on the wire - is inserted in its place,
// because a `<select>` whose value matches no option renders blank while the form goes
// on holding the value: the box would say "not recorded" over a cylinder that records
// 1.45, and the diver's only way to find out would be to save and watch the MOD not
// move.
function ppO2LimitChoices(value: number | "" | undefined): string[] {
  if (typeof value !== "number" || !Number.isFinite(value)) {
    return PPO2_LIMIT_OPTIONS;
  }
  if (PPO2_LIMIT_OPTIONS.some((option) => Number(option) === value)) {
    return PPO2_LIMIT_OPTIONS;
  }

  return [...PPO2_LIMIT_OPTIONS, String(value)].sort(
    (first, second) => Number(first) - Number(second),
  );
}

// The usage options as the form spells them, which is not how the dive page does.
// `TANK_USAGE_LABELS` is one word each because the tank card quotes back a choice
// already made; here the diver is making it, and "Parallel" alone does not say what it
// claims about the dive. The parenthetical is the definition the diver is being asked
// to agree to - the flag changes what the API computes, so choosing it by guessing at
// the word is the one outcome worth spending width to prevent.
const TANK_USAGE_OPTION_LABELS: Record<(typeof TANK_USAGE)[number], string> = {
  parallel: "Parallel (sidemount / independent)",
  staged: "Staged (own depth)",
};

// How long the gas warning has to hold still before it is announced. Long enough to
// cover typing a two-digit depth without a pause being mistaken for a finished edit.
const ANNOUNCE_SETTLE_MS = 700;

// The one oxygen-exposure warning the form can honestly make, under the whole set of
// cylinders rather than under any one of them. `diveModWarning` carries the reasoning
// for why a multi-cylinder dive usually gets a claim about the dive and not about a
// tank - and for the one set that doesn't, a parallel pair holding a single gas, where
// the sentence is about that gas because there is only one and it was breathed
// throughout. Both readings arrive here as one string either way.
//
// Unlike `MixtureGasHint` above, this deliberately watches the whole `mixtures`
// array: its answer depends on every cylinder, so there is no narrower subscription
// that would still be correct. It re-renders one `<p>` per keystroke, which is why
// the two are separate components - keeping this subscription out of the per-tank
// hint is what stops that breadth reaching the field cards.
function MixtureSetWarning({
  control,
}: {
  control: Control<MixtureFieldsValues>;
}) {
  const mixtures = useWatch({ control, name: "mixtures" });
  const maxDepth = useWatch({ control, name: "max_depth" });
  // A depth again, for the same reason as `MixtureGasHint` above.
  const units = useEntryUnits().entryUnits("depth");

  const warning = diveModWarning(mixtures ?? [], maxDepth, units);

  // Announced from a region that is always mounted and `sr-only` when there is
  // nothing to say. A `role="status"` that mounts together with its text is
  // typically not announced at all - screen readers register the region on
  // insertion and read *subsequent* changes - which is the same trap
  // `dive-file-import.tsx` documents.
  //
  // Settled rather than live, which that file did not have to handle: its note is
  // computed once at import, while this sentence quotes the depth and so changes
  // on every keystroke in that box. Bound directly, a polite region would queue an
  // announcement per digit; the timer lets the diver finish typing "45" first.
  const [announced, setAnnounced] = useState("");
  useEffect(() => {
    const id = setTimeout(
      () => setAnnounced(warning ?? ""),
      ANNOUNCE_SETTLE_MS,
    );
    return () => clearTimeout(id);
  }, [warning]);

  return (
    <>
      {/* `aria-hidden` so the sentence is not also read here, in the copy that
          tracks every keystroke. */}
      {warning && (
        <p
          className="mt-4 flex items-start gap-1.5 text-xs text-warning"
          aria-hidden
        >
          <AlertTriangle className="h-3.5 w-3.5 shrink-0 mt-px" />
          <span>{warning}</span>
        </p>
      )}
      <p role="status" className="sr-only">
        {announced}
      </p>
    </>
  );
}

export interface MixtureFieldsProps<TFieldValues extends MixtureFieldsValues> {
  control: Control<TFieldValues>;
  fieldArray: MixtureFieldArray;
  // Which per-cylinder inputs this form renders. A hidden one is left out of every
  // tank card, and its stored value is untouched by that. Defaults to "all of them"
  // so a caller with no Fields dialog behind it - the render tests - gets the whole
  // card.
  isVisible?: (key: DiveFormFieldKey) => boolean;
  // The dive's start time, which bounds each volume dropdown's recent volumes.
  until?: string;
}

export function MixtureFields<TFieldValues extends MixtureFieldsValues>({
  control,
  fieldArray,
  isVisible = () => true,
  until,
}: MixtureFieldsProps<TFieldValues>) {
  const { fields, append, remove } = fieldArray;
  // Narrowing to `MixtureFieldsValues` is sound for the reason it is in
  // `useMixtureFieldArray`: it is exactly the shape `TFieldValues` is constrained
  // to extend.
  const mixturesControl = control as unknown as Control<MixtureFieldsValues>;

  return (
    <div className="space-y-4">
      {fields.map((field, index) => (
        // Removable on every row, tank 1 included: "this dive records no gas" is
        // a state the API supports outright (`DiveCreate.mixtures` is
        // `default_factory=list`) and `dive-mixtures-card.tsx` describes as "the
        // common case for a dive logged by hand".
        <RepeatableRow
          key={field.id}
          title={
            <span className="flex items-center gap-2">
              <MixtureTitleIcon control={mixturesControl} index={index} />
              Tank {index + 1}
            </span>
          }
          removeLabel={`Remove tank ${index + 1}`}
          onRemove={() => remove(index)}
        >
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            {isVisible("mixture.gear_item_uuid") && (
              <FormField
                control={control}
                name={`mixtures.${index}.gear_item_uuid` as Path<TFieldValues>}
                render={() => (
                  // A row of its own above the boxes, which is what keeps the
                  // pairs below intact: it is a fact about the whole tank, and in
                  // a pair it would push every box after it a column along.
                  <FormItem className="md:col-span-2">
                    <FormLabel>Cylinder</FormLabel>
                    <FormControl
                      render={
                        <MixtureCylinderPicker
                          control={mixturesControl}
                          index={index}
                          count={fields.length}
                          until={until}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            <FormField
              control={control}
              name={`mixtures.${index}.volume` as Path<TFieldValues>}
              render={({ field }) => (
                // Half width, paired with the ppO₂ limit beside it, not
                // `md:col-span-2`: eight boxes divide into four rows either way,
                // and a full-width Volume leaves Usage alone on the last row.
                //
                // Volume | ppO₂, O₂ | He, start | end, Role | Usage: four full
                // rows under the Cylinder row, and each pair is two facts about
                // the same thing - what the cylinder holds and what it was
                // planned to, the mix, the gauge readings, what it was for and
                // how it was carried.
                <FormItem>
                  <FormLabel>Volume (L)</FormLabel>
                  <FormControl
                    render={
                      <MixtureVolumeInput
                        control={mixturesControl}
                        index={index}
                        value={field.value}
                        onChange={field.onChange}
                        until={until}
                      />
                    }
                  />
                  <FormMessage />
                </FormItem>
              )}
            />

            {isVisible("mixture.po2_limit") && (
              <FormField
                control={control}
                name={`mixtures.${index}.po2_limit` as Path<TFieldValues>}
                render={({ field }) => {
                  const choices = ppO2LimitChoices(field.value);

                  return (
                    <FormItem>
                      <FormLabel>ppO₂ limit (bar)</FormLabel>
                      {/* `""` has to reach react-hook-form as the live cleared
                        value rather than `undefined`, which it re-displays the
                        default over.

                        A picker rather than the number box this started as
                        because the field has an actual vocabulary. Every value
                        it could usefully hold is one of seven, while the box
                        accepted any two decimals in a 0.4-2.0 band - so the
                        only things free entry bought were typos and a 422 on
                        save. */}
                      <FormControl
                        render={
                          <NativeSelect
                            {...field}
                            // From the offered list rather than from the raw value,
                            // so the two can't disagree about formatting: `1.0` on
                            // the form has to find the `"1.0"` option, and
                            // `String(1.0)` is `"1"`.
                            value={
                              choices.find(
                                (option) => Number(option) === field.value,
                              ) ?? ""
                            }
                            onChange={(e) => {
                              const raw = e.target.value;
                              field.onChange(raw === "" ? "" : parseFloat(raw));
                            }}
                          />
                        }
                      >
                        {/* The fallback is named rather than pre-selected, so a
                            cylinder with no recorded limit still says what the
                            MOD beneath it was worked out from. Selecting 1.4
                            here would make it claim a limit the diver never
                            chose - see `DEFAULT_MIXTURE`. */}
                        <option value="">
                          Not recorded ({PPO2_WORKING} default)
                        </option>
                        {choices.map((option) => (
                          <option key={option} value={option}>
                            {option}
                          </option>
                        ))}
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  );
                }}
              />
            )}

            <FormField
              control={control}
              name={`mixtures.${index}.oxygen` as Path<TFieldValues>}
              render={({ field: { onChange, value, ...field } }) => (
                <FormItem>
                  <FormLabel>O₂ (%)</FormLabel>
                  {/* `step="any"` (on a mouse's number input), not the API's own
                      two decimals: `oxygen` is a `Float` column, and while the
                      form's prefill rounds to two places, a logbook import keeps
                      whatever precision the file recorded. A `step` finer than
                      the data is a constraint that cancels the save - see
                      DECISIONS.md, "`step` is a claim about the column".
                      `min`/`max` stay: 0-100 is a fact about a percentage.

                      An emptied box is `""`, never `NaN` or `undefined` -
                      the same sentinel the two pressures below use, and for
                      the same reason: an unrecorded mix is a state a cylinder
                      can be in, so clearing this box has to submit "not
                      recorded" rather than draw a "required" message.
                      `undefined` is the one spelling that cannot work,
                      because react-hook-form re-displays a field's default the
                      moment its value resolves to it. */}
                  <FormControl
                    render={
                      <DecimalInput
                        min={0}
                        max={100}
                        {...field}
                        value={value}
                        onValueChange={onChange}
                      />
                    }
                  />
                  <FormMessage />
                </FormItem>
              )}
            />

            {isVisible("mixture.helium") && (
              <FormField
                control={control}
                name={`mixtures.${index}.helium` as Path<TFieldValues>}
                render={({ field: { onChange, value, ...field } }) => (
                  <FormItem>
                    <FormLabel>He (%)</FormLabel>
                    {/* `step="any"` and `""` on an emptied box, for the reasons
                        O₂ above gives. Blank is not 0 % helium here any more
                        than it is on the wire: a file that recorded no analysis
                        said nothing about helium, and writing a 0 for it would
                        be the app inventing the one fact that separates nitrox
                        from trimix.

                        *Hiding* the column does write a 0, and that is not the
                        same act: emptying the box is the diver declining to
                        answer, while hiding it is them saying they dive air and
                        nitrox - which is the claim that made the column
                        hideable. See "A hidden helium is `0`, not blank" in
                        DECISIONS.md. */}
                    <FormControl
                      render={
                        <DecimalInput
                          min={0}
                          max={100}
                          {...field}
                          value={value}
                          onValueChange={onChange}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {isVisible("mixture.start_pressure") && (
              // `emptyValue=""`, unlike every other number box in the dive form:
              // these two pressures are the fields DECISIONS.md names as spelling
              // cleared that way, and the submit path converts the sentinel at the
              // edge.
              <DiveUnitNumberField
                control={control}
                name={`mixtures.${index}.start_pressure` as Path<TFieldValues>}
                label="Start pressure"
                dimension="pressure"
                min={0}
                emptyValue=""
              />
            )}

            {isVisible("mixture.end_pressure") && (
              <DiveUnitNumberField
                control={control}
                name={`mixtures.${index}.end_pressure` as Path<TFieldValues>}
                label="End pressure"
                dimension="pressure"
                min={0}
                emptyValue=""
              />
            )}

            {isVisible("mixture.usage") && (
              <FormField
                control={control}
                name={`mixtures.${index}.usage` as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Usage</FormLabel>
                    {/* Beside Role deliberately: the two are the cylinder's
                      answers to "what for" and "how", and a diver setting one
                      is usually about to consider the other. Per row rather
                      than once for the dive, so a mixed set - a parallel pair
                      plus a staged bottle - stays expressible, which is the
                      shape the API refuses by design and can only refuse if
                      the form can say it. */}
                    <FormControl
                      render={
                        <NativeSelect
                          {...field}
                          value={field.value ?? ""}
                          // `""` straight through, same sentinel and same
                          // react-hook-form trap as Role below.
                          onChange={(e) => field.onChange(e.target.value)}
                        />
                      }
                    >
                      <option value="">Not recorded</option>
                      {TANK_USAGE.map((usage) => (
                        <option key={usage} value={usage}>
                          {TANK_USAGE_OPTION_LABELS[usage]}
                        </option>
                      ))}
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {isVisible("mixture.role") && (
              <FormField
                control={control}
                name={`mixtures.${index}.role` as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Role</FormLabel>
                    <FormControl
                      render={
                        <NativeSelect
                          {...field}
                          value={field.value ?? ""}
                          // `""` straight through, not `|| undefined`: react-hook-form
                          // re-displays a field's default whenever its value resolves to
                          // `undefined`, so mapping the "Not recorded" option to it made
                          // choosing that option snap back to the imported role. Same
                          // sentinel and same reason as `po2_limit` above; converted at
                          // the edge by `normalizeMixtures`.
                          onChange={(e) => field.onChange(e.target.value)}
                        />
                      }
                    >
                      <option value="">Not recorded</option>
                      {GAS_ROLES.map((role) => (
                        <option key={role} value={role}>
                          {GAS_ROLE_LABELS[role]}
                        </option>
                      ))}
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}
          </div>

          <MixtureGasHint
            control={mixturesControl}
            index={index}
            isOnlyMixture={fields.length === 1}
          />
        </RepeatableRow>
      ))}

      {/* Not an error, and worded so it doesn't read as one: a dive with no
          cylinders is a complete record, and most hand-logged dives are exactly
          that. The line exists so the card says something rather than showing a
          heading over nothing. */}
      {fields.length === 0 && (
        <p className="text-sm text-muted-foreground">
          No tanks recorded for this dive.
        </p>
      )}

      {/* One block with the warning under it, which spaces itself: the always-mounted
          `sr-only` status after the button would otherwise be the container's last
          child and leave the button its `space-y-4` margin over the section's end. */}
      <div>
        {/* Under the tank cards rather than in the section header, so it sits where
          the next tank will appear: the button and the card it adds are then in
          reading order, and on a multi-cylinder dive the diver is already
          scrolled to it after filling in the last one. */}
        <AddRowButton
          // `shouldFocus: false`, which is not react-hook-form's default. Its
          // default focuses the first field of the new row that registered a
          // focusable ref - and `VolumeCombobox` registers none, so the focus
          // skipped past Volume and landed on whichever box came next: the ppO₂
          // limit `<select>` where that column is on screen. On iOS, focusing a
          // `<select>` opens its picker wheel, so one tap on this button added a
          // tank *and* opened a dropdown the diver never asked for - which reads
          // as the tap having gone through to the field underneath.
          onClick={() => append({ ...DEFAULT_MIXTURE }, { shouldFocus: false })}
        >
          Add tank
        </AddRowButton>

        <MixtureSetWarning control={mixturesControl} />
      </div>
    </div>
  );
}