Ctrl K
ComunidadGuías…

src/lib/dive-form-fields.ts

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

Archivo original ↗Preguntar a KiBot
// The dive form's hideable fields: the vocabulary, the panel's registry, and the
// value rules that go with them.
//
// The API owns the vocabulary as a `StrEnum` and validates against it, so what is
// below is a hand-kept mirror in the pattern this repo already uses for `TANK_USAGE`
// and the water types (DECISIONS.md, "`TANK_USAGE` is a fourth hand-kept vocabulary
// mirror"). What keeps the mirror honest without a cross-repo test is a two-sided
// guard: the API asserts every one of its values names a non-required field of
// `DiveCreateRequest` (or, prefixed, of `DiveMixtureCreate`), and
// `dive-form-fields.test.ts` here asserts this list equals the optional keys of
// `diveCreateSchema` and `diveMixtureSchema` exactly, minus the named exclusions
// below. API-optional is wider than form-optional, which is why that side is a
// subset check and this one is an equality check.

/**
 * Every field of the dive form a diver may hide, in form order.
 *
 * **Order is load-bearing, and it is this client's.** Every hidden set is put in this
 * order on the way in and on the way out (`canonicalHiddenFields`), so two equal sets
 * are two equal lists and "which preset matches the current state?" is one
 * element-by-element comparison; and it is the order the Fields dialog takes its rows
 * from. The API canonicalizes too, into its own stable order, which need not match.
 *
 * **These are stored data, not labels.** A preset row and a diver's own hidden set
 * name them, so renaming one is a data migration on both sides rather than a rename.
 */
export const DIVE_FORM_FIELDS = [
  "file_import",
  "course_uuid",
  "trip_uuid",
  "contact_uuid",
  "dive_site_uuids",
  "entry_type",
  "boat_name",
  "people",
  "type",
  "max_depth",
  "avg_depth",
  "bottom_temperature",
  "visibility",
  "water_type",
  "altitude",
  "waves",
  "current",
  "weather",
  "air_temperature",
  "mixtures",
  "gear_item_uuids",
  "weight",
  "sightings",
  "rating",
  "tags",
  "notes",
  "mixture.gear_item_uuid",
  "mixture.po2_limit",
  "mixture.helium",
  "mixture.start_pressure",
  "mixture.end_pressure",
  "mixture.usage",
  "mixture.role",
] as const;

export type DiveFormFieldKey = (typeof DIVE_FORM_FIELDS)[number];

/**
 * Keys that hide a part of the form rather than a field of the dive, with no value
 * stored under them - so the value rules (prefill, reveal, empty values) pass them by.
 * The registry guard reads this list rather than restating it, so a key cannot skip
 * the schema check silently.
 */
export const DIVE_FORM_SECTION_FIELDS = [
  "file_import",
] as const satisfies readonly DiveFormFieldKey[];

/** A key naming a field of the dive, which the form holds a value for. */
export type DiveFormValueFieldKey = Exclude<
  DiveFormFieldKey,
  (typeof DIVE_FORM_SECTION_FIELDS)[number]
>;

/** True for a key the form holds a value under. */
export function holdsValue(
  key: DiveFormFieldKey,
): key is DiveFormValueFieldKey {
  return !(DIVE_FORM_SECTION_FIELDS as readonly DiveFormFieldKey[]).includes(
    key,
  );
}

/** The keys naming a field of the dive, in form order. */
export const DIVE_FORM_VALUE_FIELDS = DIVE_FORM_FIELDS.filter(holdsValue);

/**
 * Marks a key as naming a field of *every* cylinder rather than of the dive.
 *
 * Deliberately not react-hook-form's own path (`mixtures.0.po2_limit`): a key names
 * the column, not one row of it, and hiding it hides that input on every tank card.
 */
export const MIXTURE_FIELD_PREFIX = "mixture.";

/** True for a key that governs a column of the cylinder list. */
export function isMixtureField(key: DiveFormFieldKey): boolean {
  return key.startsWith(MIXTURE_FIELD_PREFIX);
}

/** The field name a `mixture.` key governs on one cylinder row. */
export function mixtureFieldName(key: DiveFormFieldKey): string {
  return key.slice(MIXTURE_FIELD_PREFIX.length);
}

/** The per-cylinder keys, in form order - the columns a tank card can lose. */
export const MIXTURE_FORM_FIELDS =
  DIVE_FORM_VALUE_FIELDS.filter(isMixtureField);

/**
 * Keys of `diveMixtureSchema` that accept `undefined` and are still not hideable,
 * with the reason each is exempt. The registry guard reads this list rather than
 * restating it, so an exemption cannot be made silently.
 *
 * `id` and `gas_number` have no input at all - the first is stripped on submit, the
 * second round-trips untouched from an imported file. `gear_item` has none either: it
 * is the linked vessel as the read embeds it, carried to show the link, while the link
 * itself is `gear_item_uuid`, which is hideable.
 *
 * `volume` and `oxygen` became blank-able when a cylinder was allowed to record a mix
 * with no vessel, and they are exempt on the substance rather than on the type: they
 * are what a cylinder *is*. A tank card that can lose both records a row with nothing
 * in it, and "Add tank" would propose `DEFAULT_MIXTURE` where the diver could
 * neither see nor change it - which is the half of that change the split was chosen to
 * protect.
 *
 * `helium` was exempt with them and is not any more. It is the one of the three a diver
 * can be certain about without measuring: air and nitrox have none, so a logbook that
 * never records a trimix fill is asking a question whose answer is always the same. A
 * cylinder recording no helium is a cylinder of air; one recording no volume or oxygen
 * says nothing at all. The API grew a `mixture.helium` member to match, since a hidden
 * set is validated there.
 */
export const NON_HIDEABLE_MIXTURE_SCHEMA_KEYS = [
  "id",
  "gas_number",
  "gear_item",
  "volume",
  "oxygen",
] as const;

/**
 * The form's sections, in the order the form renders them, which the Fields dialog
 * groups its rows by.
 *
 * **The dialog lists fields in the order the form renders them**, and that order is the
 * invariant to preserve when either side moves: it is what makes a diver looking for a
 * field in the dialog find it where they would look for it on the form. Each group is
 * one section of the form and one run of `DIVE_FORM_FIELDS`, except that the
 * per-cylinder `mixture.` keys trail the list rather than following `mixtures`.
 *
 * Coarser than the form's rows, deliberately: a heading per row would offer more
 * choices than there are decisions to make. Groups carried only by always-on rows are
 * listed anyway - a gap where Start time should be reads as a field that went missing.
 */
export const DIVE_FORM_FIELD_GROUPS = [
  "Import",
  "Training",
  "Location",
  "People",
  "Dive info",
  "Environment",
  "Tanks",
  "Gear",
  "Marine life",
  "Notes",
] as const;

export type DiveFormFieldGroup = (typeof DIVE_FORM_FIELD_GROUPS)[number];

export interface DiveFormFieldEntry {
  key: DiveFormFieldKey;
  /** The field's own visible label on the form, so the panel names what the diver sees. */
  label: string;
  group: DiveFormFieldGroup;
}

/**
 * Every hideable field with the label and group the panel lists it under, in form
 * order. Its completeness against the form schemas is a test, not a convention - a
 * new optional input fails the suite until it is registered here.
 */
export const DIVE_FORM_FIELD_REGISTRY: readonly DiveFormFieldEntry[] = [
  { key: "file_import", label: "Upload dive files", group: "Import" },
  { key: "course_uuid", label: "Course", group: "Training" },
  { key: "trip_uuid", label: "Trip", group: "Location" },
  { key: "contact_uuid", label: "Dive center", group: "Location" },
  {
    key: "dive_site_uuids",
    label: "Dive site(s)",
    group: "Location",
  },
  { key: "entry_type", label: "Entry type", group: "Location" },
  { key: "boat_name", label: "Boat name", group: "Location" },
  { key: "people", label: "People", group: "People" },
  { key: "type", label: "Dive type", group: "Dive info" },
  { key: "max_depth", label: "Maximum depth", group: "Dive info" },
  { key: "avg_depth", label: "Average depth", group: "Dive info" },
  {
    key: "bottom_temperature",
    label: "Bottom temperature",
    group: "Environment",
  },
  { key: "visibility", label: "Visibility", group: "Environment" },
  { key: "water_type", label: "Water type", group: "Environment" },
  { key: "altitude", label: "Altitude", group: "Environment" },
  { key: "waves", label: "Waves", group: "Environment" },
  { key: "current", label: "Current", group: "Environment" },
  { key: "weather", label: "Weather", group: "Environment" },
  { key: "air_temperature", label: "Air temperature", group: "Environment" },
  { key: "mixtures", label: "Tanks", group: "Tanks" },
  { key: "gear_item_uuids", label: "Gear", group: "Gear" },
  { key: "weight", label: "Weight", group: "Gear" },
  { key: "sightings", label: "Species spotted", group: "Marine life" },
  { key: "rating", label: "Rating", group: "Notes" },
  { key: "tags", label: "Tags", group: "Notes" },
  { key: "notes", label: "Notes", group: "Notes" },
  { key: "mixture.gear_item_uuid", label: "Cylinder", group: "Tanks" },
  { key: "mixture.po2_limit", label: "ppO₂ limit", group: "Tanks" },
  { key: "mixture.helium", label: "He", group: "Tanks" },
  {
    key: "mixture.start_pressure",
    label: "Start pressure",
    group: "Tanks",
  },
  { key: "mixture.end_pressure", label: "End pressure", group: "Tanks" },
  { key: "mixture.usage", label: "Usage", group: "Tanks" },
  { key: "mixture.role", label: "Role", group: "Tanks" },
];

/**
 * The inputs the Fields dialog lists as always shown, so a diver looking for "Dive time"
 * finds it rather than a gap where it should be.
 *
 * Three of them are required by the create schema; the two cylinder fields are exempt
 * rather than required - see `NON_HIDEABLE_MIXTURE_SCHEMA_KEYS`. Both reasons end in
 * the same row: a switch that is on and will not move, which is the only claim the
 * dialog makes about either.
 */
export const DIVE_FORM_ALWAYS_ON_FIELDS: readonly {
  label: string;
  group: DiveFormFieldGroup;
}[] = [
  // Form order, which is the invariant above: Start time is its own row under Dive
  // type, and Dive number and Dive time are the pair below it, left to right.
  { label: "Start time", group: "Dive info" },
  { label: "Dive number", group: "Dive info" },
  { label: "Dive time", group: "Dive info" },
  { label: "Volume", group: "Tanks" },
  { label: "O₂", group: "Tanks" },
];

// The always-on inputs by form name, which `DIVE_FORM_ALWAYS_ON_FIELDS` lists by label.
const ALWAYS_ON_FIELD_GROUPS: Readonly<Record<string, DiveFormFieldGroup>> = {
  start_time: "Dive info",
  dive_number: "Dive info",
  submerged_time: "Dive info",
};

/**
 * The section a top-level form field renders in, hideable or not - what a failed submit
 * reads to open the collapsed sections its errors are in.
 */
export function diveFormFieldGroup(
  name: string,
): DiveFormFieldGroup | undefined {
  return (
    ALWAYS_ON_FIELD_GROUPS[name] ??
    DIVE_FORM_FIELD_REGISTRY.find((entry) => entry.key === name)?.group
  );
}

/**
 * Collapses duplicates and imposes `DIVE_FORM_FIELDS` order.
 *
 * Applied before every `PATCH` and to everything read back, so a hidden set is a
 * *set* spelled as a list and comparing two of them is one loop.
 */
export function canonicalHiddenFields(
  values: Iterable<DiveFormFieldKey>,
): DiveFormFieldKey[] {
  const present = new Set(values);
  return DIVE_FORM_FIELDS.filter((field) => present.has(field));
}

/** Whether two hidden sets are the same set, which is what marks the matching preset. */
export function hiddenFieldsEqual(
  first: Iterable<DiveFormFieldKey>,
  second: Iterable<DiveFormFieldKey>,
): boolean {
  const left = canonicalHiddenFields(first);
  const right = canonicalHiddenFields(second);
  return (
    left.length === right.length && left.every((key, i) => key === right[i])
  );
}

/**
 * What each key holds when it is empty.
 *
 * Not one sentinel: every dive scalar clears to `null` and the mixture columns clear
 * to `""`, which is the split `UnitNumberInput.emptyValue` exists to serve. The rule
 * behind both is anti-`undefined` - react-hook-form re-displays a field's default the
 * moment its value resolves to `undefined`, so an "emptied" field would fill itself
 * back in from whatever the prefill left as the default.
 */
export const EMPTY_DIVE_FORM_VALUES: Readonly<
  Record<DiveFormValueFieldKey, unknown>
> = {
  course_uuid: null,
  trip_uuid: null,
  contact_uuid: null,
  dive_site_uuids: [],
  entry_type: "",
  boat_name: "",
  people: [],
  type: "",
  max_depth: null,
  avg_depth: null,
  bottom_temperature: null,
  air_temperature: null,
  visibility: null,
  water_type: "",
  altitude: null,
  current: "",
  waves: "",
  weather: "",
  mixtures: [],
  gear_item_uuids: [],
  weight: null,
  sightings: [],
  rating: null,
  tags: [],
  notes: "",
  "mixture.gear_item_uuid": "",
  "mixture.po2_limit": "",
  // The one column that clears to a *number*, and the only one whose absence means
  // something. Hiding He is the diver saying they dive air and nitrox - which is what
  // made it hideable at all - so the value it clears to has to be the zero that claim
  // implies, matching `DEFAULT_MIXTURE.helium`. `""` here would spell "not recorded",
  // and `gasName`/`isNameableMix` refuse to name a mix whose helium is unknown: every
  // cylinder under Basic or Recreational would lose its "Air"/"EAN32" and its MOD, and
  // a hand-added tank (which takes `DEFAULT_MIXTURE` whole, hidden column or not) would
  // disagree with a carried one on the same dive, in a column neither is showing.
  //
  // Hiding cannot overwrite a recorded helium: a cylinder that holds one is non-empty,
  // so the reveal rule puts the column back on screen and marks it the diver's.
  "mixture.helium": 0,
  "mixture.start_pressure": "",
  "mixture.end_pressure": "",
  "mixture.role": "",
  "mixture.usage": "",
};

/**
 * The keys whose empty value is a real value rather than a blank, and what each is.
 *
 * Only `mixture.helium`, whose `0` is the claim hiding the column makes - see "A hidden
 * helium is `0`, not blank" in DECISIONS.md. Every other key clears to `null`, `""` or
 * `[]`, and that is the rule: an empty value the reveal rule would call non-empty is
 * normally a mistake, since the key would put itself back on screen the moment a stored
 * dive held one. The registry guard reads this list rather than restating it, so a second
 * exception cannot be made silently, and it pins the value as well as the name.
 */
export const NON_BLANK_EMPTY_FIELD_VALUES: Partial<
  Record<DiveFormValueFieldKey, unknown>
> = {
  "mixture.helium": 0,
};

/**
 * "Non-empty" as the reveal rule means it: not `undefined`, not `null`, not `""` and
 * not `[]`. `0` is a value - an end pressure of 0 is a drained cylinder, not a
 * missing reading.
 */
export function isNonEmptyFieldValue(value: unknown): boolean {
  if (value === undefined || value === null || value === "") return false;
  if (Array.isArray(value)) return value.length > 0;
  return true;
}

/** The shape the reveal rule reads: a form's values, as loosely as it needs them. */
export type DiveFormFieldValues = {
  readonly [key: string]: unknown;
} & { readonly mixtures?: readonly Record<string, unknown>[] };

/**
 * Whether a value in `key` is one the reveal rule should put back on screen.
 *
 * Non-empty, *and* not simply what hiding the key would have written there. The second
 * half exists for `mixture.helium` and reads as a no-op everywhere else: every other
 * key clears to `null`, `""` or `[]`, which the first half already rejects. Helium
 * clears to `0`, and `0` is the value nearly every stored cylinder holds - so without
 * this, loading any air or nitrox dive would reveal the column the diver hid, on every
 * dive they own, and hiding He would work until the first time they opened a dive.
 *
 * A key does not reveal itself by holding what hiding it writes. `mixture.end_pressure`
 * still reveals on a stored `0`, because its empty value is `""`: a drained cylinder is
 * a reading, and that distinction is the whole reason the empty values are per-key.
 */
function revealsField(key: DiveFormValueFieldKey, value: unknown): boolean {
  return isNonEmptyFieldValue(value) && value !== EMPTY_DIVE_FORM_VALUES[key];
}

/**
 * Every key these values hold something in - what the "a value arrived from outside
 * the diver's typing" moments reveal.
 *
 * A per-cylinder key counts as non-empty when *any* cylinder holds a value for it, so
 * an edit load or an import that brings the gas section back brings every column some
 * tank recorded with it: an imported start pressure lands on screen rather than
 * behind an unchecked box.
 */
export function nonEmptyDiveFormFields(
  values: DiveFormFieldValues,
): DiveFormFieldKey[] {
  const mixtures = Array.isArray(values.mixtures) ? values.mixtures : [];

  return DIVE_FORM_VALUE_FIELDS.filter((key) => {
    if (!isMixtureField(key)) return revealsField(key, values[key]);
    const name = mixtureFieldName(key);
    return mixtures.some((row) => revealsField(key, row?.[name]));
  });
}

/**
 * The keys a failed submit has to put back on screen.
 *
 * The resolver validates hidden fields too (react-hook-form's default
 * `shouldUnregister: false` keeps their values in form state), so a hidden
 * field carrying an error would otherwise block the save with no message
 * anywhere on the page - the exact shape of "The API sends `null` and the form
 * schema wants `""`, so `toDiveMixtureInput` converts at the boundary". A
 * per-cylinder error also reveals `mixtures`, since its message has nowhere to
 * render while the section is off screen.
 */
export function diveFormFieldsWithErrors(
  errors: Record<string, unknown>,
): DiveFormFieldKey[] {
  const revealed = new Set<DiveFormFieldKey>();

  for (const key of DIVE_FORM_VALUE_FIELDS) {
    if (!isMixtureField(key) && errors[key] !== undefined) revealed.add(key);
  }

  const mixtureErrors = errors.mixtures;
  if (mixtureErrors !== undefined) revealed.add("mixtures");
  if (Array.isArray(mixtureErrors)) {
    for (const row of mixtureErrors) {
      if (!row) continue;
      for (const key of MIXTURE_FORM_FIELDS) {
        if ((row as Record<string, unknown>)[mixtureFieldName(key)]) {
          revealed.add(key);
        }
      }
    }
  }

  return canonicalHiddenFields(revealed);
}