Ctrl K
ComunidadGuías…

src/components/dives/dive-form-fields.tsx

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

Archivo original ↗Preguntar a KiBot
"use client";

import { useId, type ReactNode } from "react";
import { Control, FieldValues, Path, useWatch } from "react-hook-form";
import {
  ArrowDownToLine,
  ChevronsDownUp,
  Clock,
  CloudSun,
  Eye,
  Hash,
  Mountain,
  Shapes,
  Ship,
  Thermometer,
  ThermometerSun,
  Waves,
  WavesArrowDown,
  WavesArrowUp,
  Weight,
  Wind,
} from "lucide-react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { DiveStartTimeField } from "@/components/dives/dive-start-time-field";
import {
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";
import {
  MixtureFieldArray,
  MixtureFields,
} from "@/components/dives/mixture-fields";
import { TripCombobox } from "@/components/trips/trip-combobox";
import { CourseCombobox } from "@/components/courses/course-combobox";
import { ContactCombobox } from "@/components/contacts/contact-combobox";
import type { ContactRole } from "@/lib/api/contacts";
import { PeopleMultiSelect } from "@/components/people/people-multi-select";
import type { PersonReference } from "@/lib/api/people";
import { DiveSitesField } from "@/components/sites/dive-sites-field";
import { useDiveRoster } from "@/hooks/useDiveRoster";
import { DiveGearField } from "@/components/gear/dive-gear-field";
import {
  SpeciesMultiSelect,
  type SightingErrors,
} from "@/components/dives/species-multi-select";
import {
  DiveUnitNumberField,
  DiveUnitNumberItem,
} from "@/components/dives/dive-unit-number-field";
import { DiveVocabularyField } from "@/components/dives/dive-vocabulary-field";
import { RatingInput } from "@/components/dives/rating-input";
import { TagsMultiSelect } from "@/components/dives/tags-multi-select";
import { DiveMixtureInput, type SightingInput } from "@/lib/validations/dive";
import {
  CURRENT_LABELS,
  CURRENTS,
  DIVE_TYPE_LABELS,
  DIVE_TYPES,
  DiveSiteSummary,
  ENTRY_TYPE_LABELS,
  ENTRY_TYPES,
  WATER_TYPES,
  WATER_TYPE_LABELS,
  WAVES,
  WAVES_LABELS,
  WEATHER,
  WEATHER_LABELS,
  type DiveCurrent,
  type DiveType,
  type DiveWaves,
  type DiveWeather,
  type EntryType,
  type WaterType,
} from "@/lib/api/dives";
import { GearItemSummary } from "@/lib/api/gear";
import type { EntryFix } from "@/lib/dive-recordings";
import { SpeciesSummary } from "@/lib/api/species";
import type {
  DiveFormFieldGroup,
  DiveFormFieldKey,
} from "@/lib/dive-form-fields";
import type { DiveFormVisibility } from "@/hooks/useDiveFormVisibility";
import { DiveFormSection } from "@/components/dives/dive-form-section";
import { FetchedSource } from "@/components/dives/autofilled-marks";

// Freediving and snorkeling stay in the vocabulary, which a stored dive can hold,
// but the form does not offer them yet.
const OFFERED_DIVE_TYPES = DIVE_TYPES.filter(
  (type) => type !== "freedive" && type !== "snorkel",
);

// What a contact created from the dive form starts as.
const DIVE_CENTER: readonly ContactRole[] = ["dive_center"];

// What each sighting's inputs were refused for, by row, out of the field's error -
// which for a list is an array of per-row errors with no message of its own.
function sightingErrors(
  error: unknown,
): (SightingErrors | undefined)[] | undefined {
  if (!Array.isArray(error)) return undefined;
  return error.map(
    (row) =>
      row && {
        count: row.count?.message,
        abundance: row.abundance?.message,
        notes: row.notes?.message,
      },
  );
}

// The field shape shared by both `DiveCreateInput` and `DiveUpdateInput`
// (see `lib/validations/dive.ts`): the create schema's fields, all optional
// (the update schema optionalizes every field, since a PATCH only needs to
// send what changed). Both concrete form input types are structurally
// assignable to this, so `DiveFormFields`/`MixtureFields` can stay generic
// over `TFieldValues` instead of falling back to `Control<any, any, any>`,
// which erased type safety between the create/update form shapes entirely.
export interface DiveFormValues extends FieldValues {
  dive_number?: number;
  // ISO 8601, e.g. "2021-04-04T10:04:47+02:00" - the dive's own original
  // timezone, not the viewer's browser, and on an imported dive possibly no
  // timezone at all ("2026-04-17T11:49:23"). Edited via `DiveStartTimeField`,
  // which is the only place that splits/recombines it into the wall-clock +
  // offset pair its two underlying inputs actually edit - see
  // `lib/date-time.ts`.
  start_time?: string;
  submerged_time?: string;
  max_depth?: number | null;
  avg_depth?: number | null;
  bottom_temperature?: number | null;
  visibility?: number | null;
  // `""` is the "Not recorded" option, and the live cleared state - never
  // `undefined`, which react-hook-form re-displays the field's default for.
  // `null` is what the submit path converts it to; both are in the union
  // because `diveToFormValues` seeds one and `buildDiveUpdate` reads the other.
  water_type?: WaterType | "" | null;
  altitude?: number | null;
  // The same three states for every other select, and `""` for a boat name
  // nobody typed.
  type?: DiveType | "" | null;
  current?: DiveCurrent | "" | null;
  waves?: DiveWaves | "" | null;
  weather?: DiveWeather | "" | null;
  entry_type?: EntryType | "" | null;
  boat_name?: string;
  air_temperature?: number | null;
  // `null` is unrated, which the control's clear writes.
  rating?: number | null;
  weight?: number | null;
  // `null` means "no trip", and is distinct from `undefined` ("field not
  // touched") on the edit form - see `DiveUpdate.trip_uuid`.
  trip_uuid?: string | null;
  // Same three states, same reason, for the training course this dive was on.
  course_uuid?: string | null;
  // And for the dive center that ran it.
  contact_uuid?: string | null;
  // Who the diver was with, each with their role on this dive.
  people?: PersonReference[];
  dive_site_uuids?: string[];
  gear_item_uuids?: string[];
  sightings?: SightingInput[];
  // By name, in the diver's order.
  tags?: string[];
  notes?: string;
  mixtures?: DiveMixtureInput[];
}

export interface DiveFormFieldsProps<TFieldValues extends DiveFormValues> {
  control: Control<TFieldValues>;
  // In "create" mode, dive number/start time/dive time are required by the
  // schema and marked with a "*" in the UI. In "edit" mode these fields are
  // optional at the schema level (a PATCH only needs to send what changed),
  // so no asterisks are shown and a cleared value resolves to `undefined`
  // rather than falling back to a default.
  mode: "create" | "edit";
  // Which of the optional fields this form renders at all. A hidden field's
  // `FormField` is not rendered, so its input is out of the DOM, the tab order and
  // the accessibility tree - and its *value* is untouched by that, which is
  // react-hook-form's `shouldUnregister: false` default doing the work.
  visibility: DiveFormVisibility;
  // Created once by the page (alongside `form`/`control`) and also passed to
  // `DiveFileImport` - see `MixtureFieldArray`'s own doc comment for why this
  // can't just be created internally by `MixtureFields`.
  mixtureFieldArray: MixtureFieldArray;
  // The dive's existing sites, when editing. The site picker doesn't load the
  // user's whole catalogue, so it can't look a selected uuid's name up
  // locally - passing the ones already on the record saves it a request each.
  knownDiveSites?: DiveSiteSummary[];
  // The dive's GPS fix as the save would leave it - the site finder opens on it
  // when no chosen site has a pin.
  diveFix?: EntryFix | null;
  // Same idea for gear: `Dive.gear_items` already carries what a picked row
  // renders, so the picker needn't fetch each item back by uuid.
  knownGearItems?: GearItemSummary[];
  // And for species: `Dive.sightings` carries the names the picker's rows need,
  // so an edit form starts out labelled without a lookup per row.
  knownSpecies?: SpeciesSummary[];
  // Raised by the species picker while a pick is still being resolved into a
  // catalog row - see `SpeciesMultiSelect.onPendingChange`. Owned by
  // `DiveForm`, which is where the submit button that must wait for it is.
  onSpeciesPendingChange?: (isPending: boolean) => void;
  // A note shown under the dive number, but only while the field still holds
  // `forValue`. Carried as a value rather than a ready-made string so the
  // "still showing it?" check can happen inside the field's own render, where
  // the current value is already reactive - the alternative, a `form.watch` in
  // the page, opts that whole component out of compiler memoization.
  //
  // Currently only the create form sets it, to say the suggested number is
  // already in use (see `useSuggestedDiveNumber`). A note rather than a
  // validation error on purpose: duplicates are a normal state while
  // back-filling a log, reconciled later with Renumber, so this must not block
  // a save.
  diveNumberNotice?: { forValue: number; message: string } | null;
  // Owned by `DiveForm`, whose failed-submit path opens the sections an error
  // landed in.
  collapsedGroups: ReadonlySet<DiveFormFieldGroup>;
  onGroupOpenChange: (group: DiveFormFieldGroup, open: boolean) => void;
  // That one section's fields alone, with no card around them: what a dive page
  // card's edit dialog holds.
  only?: DiveFormFieldGroup;
}

export function DiveFormFields<TFieldValues extends DiveFormValues>({
  control,
  mode,
  visibility,
  mixtureFieldArray,
  knownDiveSites,
  diveFix,
  knownGearItems,
  knownSpecies,
  onSpeciesPendingChange,
  diveNumberNotice,
  collapsedGroups,
  onGroupOpenChange,
  only,
}: DiveFormFieldsProps<TFieldValues>) {
  const required = mode === "create";
  const requiredMark = required ? " *" : "";
  const isVisible = visibility.isVisible;
  // The rating is a group, which a label names by reference rather than by `for`.
  const ratingLabelId = useId();
  // Offered for a boat entry, and kept on screen whenever it holds a name - typed
  // before the entry changed, or stored - so a name is never both kept and out of
  // reach. Submitted like any other field.
  const [entryType, boatName] = useWatch({
    control,
    name: ["entry_type", "boat_name"] as Path<TFieldValues>[],
  });
  const showBoatName =
    isVisible("boat_name") && (entryType === "boat" || Boolean(boatName));
  // The lookup pickers below rank their rows by last use at or before the dive's
  // own start time, sent as the field holds it; an empty field sends no bound.
  // The species picker takes no bound - a site's species belong to the place, not
  // to the date - and lists the species logged at the dive's sites first.
  const [startTime, tripUuid, courseUuid, diveSiteUuids] = useWatch({
    control,
    name: [
      "start_time",
      "trip_uuid",
      "course_uuid",
      "dive_site_uuids",
    ] as Path<TFieldValues>[],
  });
  const until: string | undefined = startTime || undefined;
  const roster = useDiveRoster(tripUuid, courseUuid);
  const section = (group: DiveFormFieldGroup, children: ReactNode) => {
    if (only) {
      return group === only && <div className="space-y-6">{children}</div>;
    }
    return (
      <DiveFormSection
        title={group}
        open={!collapsedGroups.has(group)}
        onOpenChange={(open) => onGroupOpenChange(group, open)}
      >
        {children}
      </DiveFormSection>
    );
  };

  return (
    // A fragment, so each section is a card in the form's own stack.
    <>
      {isVisible("course_uuid") &&
        section(
          "Training",
          <FormField
            control={control}
            name={"course_uuid" as Path<TFieldValues>}
            render={({ field }) => (
              <FormItem>
                <FormLabel>Course</FormLabel>
                <FormControl
                  render={
                    <CourseCombobox
                      value={field.value}
                      onChange={field.onChange}
                      until={until}
                    />
                  }
                />
                <FormMessage />
              </FormItem>
            )}
          />,
        )}

      {(isVisible("trip_uuid") ||
        isVisible("contact_uuid") ||
        isVisible("dive_site_uuids") ||
        isVisible("entry_type") ||
        showBoatName) &&
        section(
          "Location",
          <>
            {isVisible("trip_uuid") && (
              <FormField
                control={control}
                name={"trip_uuid" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Trip</FormLabel>
                    <FormControl
                      render={
                        <TripCombobox
                          value={field.value}
                          onChange={field.onChange}
                          until={until}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {isVisible("contact_uuid") && (
              <FormField
                control={control}
                name={"contact_uuid" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Dive center</FormLabel>
                    <FormControl
                      render={
                        <ContactCombobox
                          value={field.value}
                          onChange={field.onChange}
                          until={until}
                          pinnedUuids={roster.contacts}
                          initialRoles={DIVE_CENTER}
                          placeholder="Select a dive center..."
                          addNewLabel="Add dive center..."
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {/* Dive Site(s) */}
            {isVisible("dive_site_uuids") && (
              <FormField
                control={control}
                name={"dive_site_uuids" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Dive site(s)</FormLabel>
                    <FormControl
                      render={
                        <DiveSitesField
                          value={field.value ?? []}
                          knownSites={knownDiveSites}
                          onChange={field.onChange}
                          until={until}
                          diveFix={diveFix}
                          tripUuid={tripUuid}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {/* How the diver got in, under where: a property of the site as much as
                of the dive. The boat name beside it - see `showBoatName`. */}
            {(isVisible("entry_type") || showBoatName) && (
              <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                {isVisible("entry_type") && (
                  <DiveVocabularyField
                    control={control}
                    name={"entry_type" as Path<TFieldValues>}
                    label="Entry type"
                    icon={WavesArrowDown}
                    values={ENTRY_TYPES}
                    labels={ENTRY_TYPE_LABELS}
                  />
                )}
                {showBoatName && (
                  <FormField
                    control={control}
                    name={"boat_name" as Path<TFieldValues>}
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Boat name</FormLabel>
                        <div className="relative">
                          <Ship className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
                          {/* `""` is not recorded, and the submit paths trim it into
                              the API's `null` - see `boatNameOrNull`. */}
                          <FormControl
                            render={
                              <Input
                                type="text"
                                placeholder="e.g. Legend"
                                className="pl-9"
                                {...field}
                                value={field.value ?? ""}
                              />
                            }
                          />
                        </div>
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                )}
              </div>
            )}
          </>,
        )}

      {/* A person picked here is a buddy until the diver says otherwise. */}
      {isVisible("people") &&
        section(
          "People",
          <FormField
            control={control}
            name={"people" as Path<TFieldValues>}
            render={({ field }) => (
              <FormItem>
                <FormLabel>People</FormLabel>
                <FormControl
                  render={
                    <PeopleMultiSelect
                      value={field.value ?? []}
                      onChange={field.onChange}
                      defaultRole="buddy"
                      until={until}
                      pinnedUuids={roster.people}
                    />
                  }
                />
                <FormMessage />
              </FormItem>
            )}
          />,
        )}

      {section(
        "Dive info",
        <>
          {/* What kind of dive it was, first: it frames every fact below it. Half
          width in a grid of its own, for the reason Weight is: a select as wide as
          the form is wider than anything it offers. */}
          {isVisible("type") && (
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              <DiveVocabularyField
                control={control}
                name={"type" as Path<TFieldValues>}
                label="Dive type"
                icon={Shapes}
                values={OFFERED_DIVE_TYPES}
                labels={DIVE_TYPE_LABELS}
              />
            </div>
          )}

          {/* Date and Time */}
          <FormField
            control={control}
            name={"start_time" as Path<TFieldValues>}
            render={({ field }) => (
              <FormItem>
                <FormLabel>Start time{requiredMark}</FormLabel>
                <FormControl
                  render={
                    <DiveStartTimeField
                      value={field.value}
                      onChange={field.onChange}
                    />
                  }
                />
                <FormMessage />
              </FormItem>
            )}
          />

          {/* Dive number & Dive time. Paired because both are always rendered -
          neither is in the Fields dialog - so this row is the one pair no
          visibility choice can break, and neither field has to sit half-width
          beside a hole. Dive number was alone in a grid of its own until then,
          for the column width: full width would make the form's one
          always-present field its widest, and a number box is the last thing
          that should be. The pair keeps that width without the empty column.

          Unequal heights are expected here: `diveNumberNotice` adds a line
          under the number when the suggestion is already taken, and Dive time
          has nothing to match it with. */}
          <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
            <FormField
              control={control}
              name={"dive_number" as Path<TFieldValues>}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Dive number{requiredMark}</FormLabel>
                  <div className="relative">
                    <Hash className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
                    <FormControl
                      render={
                        <Input
                          type="number"
                          inputMode="numeric"
                          min="1"
                          className="pl-9"
                          {...field}
                          value={field.value ?? ""}
                          onChange={(e) => {
                            // `parseInt(...) || 1` looked equivalent and wasn't: `||`
                            // treats an emptied box (NaN) and a typed 0 alike, so
                            // clearing the field instantly rewrote it to 1. That write
                            // also marked the field dirty, and `useSuggestedDiveNumber`
                            // reads `isDirty` as its permanent "the diver chose a
                            // number" latch - so one accidental clear stopped the
                            // number following the date for the rest of the form's
                            // life, including after a file import changed the date.
                            // An emptied box must stay empty and let the schema speak.
                            const parsed = parseInt(e.target.value, 10);
                            field.onChange(
                              Number.isNaN(parsed) ? undefined : parsed,
                            );
                          }}
                        />
                      }
                    />
                  </div>
                  {diveNumberNotice &&
                  field.value === diveNumberNotice.forValue ? (
                    <FormDescription>
                      {diveNumberNotice.message}
                    </FormDescription>
                  ) : null}
                  <FormMessage />
                </FormItem>
              )}
            />

            <FormField
              control={control}
              name={"submerged_time" as Path<TFieldValues>}
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Dive time{requiredMark}</FormLabel>
                  <div className="relative">
                    <Clock className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2" />
                    <FormControl
                      render={
                        <Input
                          type="text"
                          placeholder="e.g. 45 or 67:30"
                          className="pl-9"
                          {...field}
                        />
                      }
                    />
                  </div>
                  <FormMessage />
                </FormItem>
              )}
            />
          </div>

          {/* The depths, the pair that closes the dive's own facts. A lone survivor
          sits half-width rather than spanning: see the readings grid below. */}
          {(isVisible("max_depth") || isVisible("avg_depth")) && (
            <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
              {isVisible("max_depth") && (
                <DiveUnitNumberField
                  control={control}
                  name={"max_depth" as Path<TFieldValues>}
                  label="Maximum depth"
                  dimension="depth"
                  icon={ArrowDownToLine}
                  min={0}
                  placeholderValue={30.52}
                />
              )}

              {isVisible("avg_depth") && (
                <DiveUnitNumberField
                  control={control}
                  name={"avg_depth" as Path<TFieldValues>}
                  label="Average depth"
                  dimension="depth"
                  icon={ChevronsDownUp}
                  min={0}
                  placeholderValue={18.24}
                />
              )}
            </div>
          )}
        </>,
      )}

      {(isVisible("bottom_temperature") ||
        isVisible("air_temperature") ||
        isVisible("visibility") ||
        isVisible("water_type") ||
        isVisible("altitude") ||
        isVisible("current") ||
        isVisible("waves") ||
        isVisible("weather")) &&
        section(
          "Environment",
          <>
            {/* The readings. One grid rather than fixed pairs, because every one of
          them hides on its own. Paired up, hiding half of a pair left the other
          half in its column with an empty one beside it, which reads as a field
          that failed to load; a single grid lets auto-flow pack whatever
          survives from the left, so a hidden field costs a slot and not a hole.
          The fields follow the Fields dialog's order, two to a row.

          An odd number visible leaves one field half-width on the last row.
          That is accepted and deliberately not spanned: a ragged bottom edge
          reads as the end of a list, a gap in the middle reads as breakage.

          `gap-y-6` rather than `gap-4`'s 1rem, because the row gap is the
          section's own `space-y-6` rhythm, not a pair's: a full row sits exactly
          where a pair of its own would. The column gap is still 1rem. On a
          phone the fields stack at 1.5rem - one grid has one row gap. */}
            <div className="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-6">
              {isVisible("bottom_temperature") && (
                <DiveUnitNumberField
                  control={control}
                  name={"bottom_temperature" as Path<TFieldValues>}
                  label="Bottom temperature"
                  dimension="temperature"
                  icon={Thermometer}
                  min={-50}
                  max={50}
                  placeholderValue={22.5}
                />
              )}

              {isVisible("visibility") && (
                // An `Integer` column, so feet commit whole metres: 50 ft is stored
                // as 15 m and reads back as 49 ft. Accepted - see DECISIONS.md -
                // because visibility is an estimate and whole-metre resolution is
                // finer than anyone judges it to.
                <DiveUnitNumberField
                  control={control}
                  name={"visibility" as Path<TFieldValues>}
                  label="Visibility"
                  dimension="visibility"
                  icon={Eye}
                  min={0}
                  placeholderValue={15}
                />
              )}
              {/* Water and altitude - what the water was and where it was, which the
                  computer treats as calibration settings and the log treats as facts
                  about the dive. */}
              {isVisible("water_type") && (
                <DiveVocabularyField
                  control={control}
                  name={"water_type" as Path<TFieldValues>}
                  label="Water type"
                  icon={Waves}
                  values={WATER_TYPES}
                  labels={WATER_TYPE_LABELS}
                />
              )}
              {isVisible("altitude") && (
                // Bounds in metres, which the Zod schema and the DB `CHECK` behind it
                // are written in; the box converts them for the spinner. Not
                // Visibility's `min={0}`: the Dead Sea is below sea level, which is
                // the whole reason the API's bound is -450 rather than 0.
                <DiveUnitNumberField
                  control={control}
                  name={"altitude" as Path<TFieldValues>}
                  label="Altitude"
                  dimension="altitude"
                  icon={Mountain}
                  min={-450}
                  max={6500}
                  placeholderValue={372}
                />
              )}
              {/* Then up from the water: the surface, and the air above it. */}
              {isVisible("waves") && (
                <DiveVocabularyField
                  control={control}
                  name={"waves" as Path<TFieldValues>}
                  label="Waves"
                  icon={WavesArrowUp}
                  values={WAVES}
                  labels={WAVES_LABELS}
                />
              )}
              {isVisible("current") && (
                <DiveVocabularyField
                  control={control}
                  name={"current" as Path<TFieldValues>}
                  label="Current"
                  icon={Wind}
                  values={CURRENTS}
                  labels={CURRENT_LABELS}
                />
              )}
              {isVisible("weather") && (
                <DiveVocabularyField
                  control={control}
                  name={"weather" as Path<TFieldValues>}
                  label="Weather"
                  icon={CloudSun}
                  values={WEATHER}
                  labels={WEATHER_LABELS}
                />
              )}

              {isVisible("air_temperature") && (
                <DiveUnitNumberField
                  control={control}
                  name={"air_temperature" as Path<TFieldValues>}
                  label="Air temperature"
                  dimension="temperature"
                  icon={ThermometerSun}
                  min={-60}
                  max={60}
                  placeholderValue={28}
                />
              )}
            </div>
            <FetchedSource control={control} />
          </>,
        )}

      {/* Tanks. Hidden, the whole section goes - the heading, every tank card,
          "Add tank" and the empty-state line - while the cylinders themselves stay
          in form state and are submitted, exactly as a hidden scalar is. */}
      {isVisible("mixtures") &&
        section(
          "Tanks",
          <MixtureFields<TFieldValues>
            control={control}
            fieldArray={mixtureFieldArray}
            isVisible={isVisible}
            until={until}
          />,
        )}

      {/* Gear, weight included - grouped as "how the diver was configured for this
          dive", as opposed to the environment readings above. Weight is a plain
          per-dive number rather than one of the gear items (see DECISIONS.md),
          but it belongs next to them here.

          The two are nested rather than rendered side by side because loading a
          gear set fills in *both*: `DiveGearField` needs the weight field's
          value and setter, and nesting is what puts them in scope without
          registering `weight` twice.

          They still hide independently, and the nesting is what makes that cheap:
          the outer `FormField` is a render prop rather than an input, so each half
          is gated on its own key inside it and `weight` is registered exactly once
          however many of the two are on screen. The pair is skipped altogether when
          neither is - `weight`'s value survives that, which is
          `shouldUnregister: false`. */}
      {(isVisible("gear_item_uuids") || isVisible("weight")) &&
        section(
          "Gear",
          <FormField
            control={control}
            name={"weight" as Path<TFieldValues>}
            render={({ field: weightField }) => (
              <div className="space-y-6">
                {isVisible("gear_item_uuids") && (
                  <FormField
                    control={control}
                    name={"gear_item_uuids" as Path<TFieldValues>}
                    render={({ field }) => (
                      <FormItem>
                        <FormLabel>Gear</FormLabel>
                        <FormControl
                          render={
                            <DiveGearField
                              value={field.value ?? []}
                              knownItems={knownGearItems}
                              onChange={field.onChange}
                              until={until}
                              weight={weightField.value ?? null}
                              onWeightChange={weightField.onChange}
                              // One of the moments a value arrives from outside the
                              // diver's typing: a set that carries a weight fills the
                              // box, so the box has to be on screen to be seen and changed.
                              onSetApplied={(set) => {
                                const revealed: DiveFormFieldKey[] = [];
                                if (set.gear_items.length > 0) {
                                  revealed.push("gear_item_uuids");
                                }
                                if (set.weight != null) revealed.push("weight");
                                visibility.reveal(revealed);
                              }}
                            />
                          }
                        />
                        <FormMessage />
                      </FormItem>
                    )}
                  />
                )}

                {isVisible("weight") && (
                  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
                    {/* Weight is the one dimension with a second toggle elsewhere:
                    the gear-set dialog opens from inside this form and enters a
                    weight of its own. Both read the same store, so the two never
                    disagree, and the modal keeps only one of
                    them exposed at a time. The outer `FormField` holds `weight`, so
                    this is the field's body alone. */}
                    <DiveUnitNumberItem
                      field={weightField}
                      label="Weight"
                      dimension="weight"
                      icon={Weight}
                      min={0}
                      placeholderValue={6}
                    />
                  </div>
                )}
              </div>
            )}
          />,
        )}

      {/* Species spotted - after the kit and before the notes, which is where
          the dive page's own card sits: what was seen is an observation about
          the dive, and the notes underneath are where anything this picker
          can't name ends up. */}
      {isVisible("sightings") &&
        section(
          "Marine life",
          <FormField
            control={control}
            name={"sightings" as Path<TFieldValues>}
            render={({ field, fieldState }) => (
              // No `FormMessage`: it would print that array's missing message as
              // "undefined". Each row shows its own.
              <FormItem>
                <FormLabel>Species spotted</FormLabel>
                <FormControl
                  render={
                    <SpeciesMultiSelect
                      value={field.value ?? []}
                      knownSpecies={knownSpecies}
                      diveSiteUuids={diveSiteUuids}
                      onChange={field.onChange}
                      errors={sightingErrors(fieldState.error)}
                      onPendingChange={onSpeciesPendingChange}
                    />
                  }
                />
              </FormItem>
            )}
          />,
        )}

      {/* The diver's own word on the dive: how it rated, what it is filed
          under, and the notes - the order the dive page reads them in. */}
      {(isVisible("rating") || isVisible("tags") || isVisible("notes")) &&
        section(
          "Notes",
          <>
            {isVisible("rating") && (
              <FormField
                control={control}
                name={"rating" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel id={ratingLabelId}>Rating</FormLabel>
                    <FormControl
                      render={
                        <RatingInput
                          aria-labelledby={ratingLabelId}
                          value={field.value ?? null}
                          onChange={field.onChange}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {isVisible("tags") && (
              <FormField
                control={control}
                name={"tags" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Tags</FormLabel>
                    <FormControl
                      render={
                        <TagsMultiSelect
                          value={field.value ?? []}
                          onChange={field.onChange}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}

            {isVisible("notes") && (
              <FormField
                control={control}
                name={"notes" as Path<TFieldValues>}
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Notes</FormLabel>
                    <FormControl
                      render={
                        <Textarea
                          placeholder="Enter any additional notes about your dive..."
                          className="min-h-[100px]"
                          {...field}
                        />
                      }
                    />
                    <FormMessage />
                  </FormItem>
                )}
              />
            )}
          </>,
        )}
    </>
  );
}