"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` - 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( control: Control, ): 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({ control: control as unknown as Control, 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; 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

{parts.join(" · ")}

; } // 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; 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 ( ); } // 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; index: number; count: number; until?: string; } & FormControlA11yProps) { const { getValues, setValue } = useFormContext(); 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 ( ); } // 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; 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 ( ); } // 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 // `