Ctrl K
ComunidadGuías…

src/lib/dive-import.ts

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

Archivo original ↗Preguntar a KiBot
// Turning a parsed dive-computer export into dive-form values, and saying what
// had to be guessed on the way.
//
// Split out of `components/dives/dive-file-import.tsx`, which was doing four
// separable things at 411 lines: this mapping, the note derivation, the note
// prose, and the React component. Living here also puts it inside the
// `src/lib/**` coverage window, which is where the logic worth unit-testing is
// supposed to live - and this is the densest logic in the import path.

import type { ParsedDiveMixture } from "@/lib/api/dives";
import type { DiveMixtureInput } from "@/lib/validations/dive";
import { isVolumeFromVessel, tankVolume } from "@/lib/tank-vessel";

// A mixture field an export may not have recorded, and that the form can therefore
// only fill from the cylinder it already held. `start_pressure`/`end_pressure` are
// absent on purpose: those have always stayed `""` when unknown, and these three now
// join them - what makes the three a set is that a value carried over from the last
// dive is a claim worth reporting, which a pressure never was because it was never
// carried across a gap the file left.
//
// It was `DefaultedMixtureField` while `DEFAULT_MIXTURE` was the third tier. Nothing
// on this path defaults any more, so the name would have outlived the behaviour.
export type CarriedMixtureField = "volume" | "oxygen" | "helium";

/**
 * Where the value now sitting in a mixture field actually came from.
 *
 * `"blank"` is the third answer since the import path stopped defaulting: the file
 * recorded nothing, the form held nothing, and the box is empty - which is what the
 * API stores and what the dive page then shows as an absence. It is deliberately not
 * reported in the note below; an empty box is its own notice, and the note exists for
 * numbers on screen that no dive recorded.
 *
 * `"vessel"` is a tank linked to one of the diver's cylinders keeping its own volume
 * over a different one the file recorded: the size is the vessel's fact, and the
 * diver is told the file's was not taken.
 */
export type MixtureValueSource = "file" | "form" | "blank" | "vessel";

/**
 * What filling the form in from a file left standing that the file did not say, so
 * the import box can say so. The API deliberately sends `null` rather than a
 * plausible number for anything an export didn't record (see `DiveMixtureSchema`);
 * if the form silently turns that back into a number, that care is thrown away at
 * the last step and the diver cannot tell a reading from a placeholder.
 *
 * The form does not turn it into a number *of its own* - `mergeMixture` leaves a gap
 * the file and the form both fail to fill as a blank box, and the API stores NULL
 * for it. What is left to report is a value carried over from the cylinder already
 * on the form, which is a real number on screen that this file never recorded.
 */
export interface MixtureImportNotes {
  /**
   * Fields the file didn't record on at least one cylinder, where the value the
   * diver is now looking at came from the cylinder already on the form. Only
   * `"form"` ever appears: `"file"` is not a gap, and `"blank"` leaves an empty box
   * that says so itself.
   *
   * Reporting the carry-over is the whole message. Importing onto a form prefilled
   * from the previous dive keeps that dive's gases, so the fields are *untouched* -
   * and saying they were "filled in as a starting point" describes something that
   * didn't happen. Note this is not the unanswerable "did the diver type this or did
   * the form seed it": it is which branch of the merge ran, which `mergeMixture`
   * knows exactly.
   *
   * Keyed off the file alone, and not off "no source had a value": a form the
   * prefill filled in, an earlier import wrote to, or a dive with cylinders usually
   * has all three values, so a rule that also consulted the carried-over cylinder
   * would stay silent on exactly the imports the warning exists for. *Usually*,
   * because the columns are nullable: a dive imported from a file that recorded no
   * cylinder size holds a blank, and `pick` reads that as the gap it is rather than
   * as something to carry over.
   */
  guessed: Partial<Record<CarriedMixtureField, MixtureValueSource>>;
  /**
   * Whether tank pressures already on the form were carried onto the new cylinders
   * because the file had none of its own.
   */
  keptPressures: boolean;
  /**
   * Whether pressures that were on the form have been *lost* - the file brought a
   * different number of cylinders, so `existingMixtureFor` refused to pair and there was
   * nowhere to carry them. Worth saying out loud: on the edit form that silently takes a
   * dive's `gas_use` away with it.
   */
  discardedPressures: boolean;
  /**
   * Whether a tank linked to one of the diver's cylinders kept its volume over a
   * different one the file recorded.
   */
  keptLinkedVolume: boolean;
  /**
   * Whether tanks linked to the diver's cylinders were replaced by the file's, which
   * carry no link: the file brought a different number of cylinders, so nothing pairs.
   */
  droppedLinks: boolean;
}

/** What an apply that touched no cylinder has to say about them: nothing. */
export const NO_MIXTURE_NOTES: MixtureImportNotes = {
  guessed: {},
  keptPressures: false,
  discardedPressures: false,
  keptLinkedVolume: false,
  droppedLinks: false,
};

/** One cylinder merged onto the form, and where each carryable field came from. */
export interface MergedMixture {
  value: DiveMixtureInput;
  sources: Record<CarriedMixtureField, MixtureValueSource>;
}

/**
 * Converts a parsed mixture (nullable fields, no `id`) into the shape the
 * mixture form fields expect.
 *
 * Two sources, in order: what the file recorded, then whatever the form already held
 * for the cylinder in this position. A field neither of them has is left blank.
 *
 * The second one matters because the two Suunto Ocean exports are complementary - its
 * FIT carries the gas mixes and no transmitter data, its JSON the pressures and no gas
 * mix - so importing both for one dive is the documented way to log a multi-gas dive
 * (see the API's DECISIONS.md). Without it the second import would silently erase
 * whatever the first one contributed, and a dive that had a `gas_use` before would
 * quietly lose it.
 *
 * **There is no third tier, and its absence is the point.** Filling the rest from
 * `DEFAULT_MIXTURE` would have every FIT import claim an 11.1 L cylinder of air - one
 * cylinder, an average depth and both pressures being exactly what `compute_gas_use`
 * needs, so a diver on a 15 L is handed an RMV about 26 % low. The column is nullable,
 * so blank survives the save: absence is data, and a file that recorded no vessel says
 * so all the way to the dive page. A cylinder added by hand starts from
 * `DEFAULT_MIXTURE`, where the numbers are in front of the diver and can be changed,
 * rather than having them type 21 and 0 for every air dive.
 */
export function mergeMixture(
  mixture: ParsedDiveMixture,
  existing?: DiveMixtureInput,
): MergedMixture {
  // The pressures move as a pair. Falling back field-by-field would let a start
  // from the file meet an end from whatever was on the form - a fill that never
  // existed, which either trips `diveMixtureSchema`'s `end <= start` refine on a
  // field the diver never touched, or passes and feeds `compute_gas_use` a
  // consumption figure spanning two different fills.
  const carriedPressures =
    mixture.start_pressure == null && mixture.end_pressure == null
      ? existing
      : undefined;

  // Reported rather than reconstructed afterwards: this is the only place that
  // knows which of the three sources supplied a field, and every attempt to work
  // it out from the outside has been wrong in a different way.
  const pick = (
    mixtureField: CarriedMixtureField,
  ): { value: number | ""; source: MixtureValueSource } => {
    const fromFile = mixture[mixtureField];
    if (fromFile != null) return { value: fromFile, source: "file" };
    // `""` and not just null-ish: the form's cleared state is a real value on this
    // field, so a form row that is genuinely blank must not read as one that holds
    // something. Carrying a `""` over as if it were a number is how a note would come
    // to say "that value was already on this form" about an empty box.
    const fromForm = existing?.[mixtureField];
    if (typeof fromForm === "number")
      return { value: fromForm, source: "form" };
    return { value: "", source: "blank" };
  };

  // A tank linked to a vessel that records its capacity keeps the volume it shows -
  // its own, else the vessel's - over the file's: the size is the vessel's fact, and
  // the box showing it is read-only. A vessel recording none leaves the box editable,
  // and the file's figure is taken as on any tank.
  const keptVolume =
    existing && isVolumeFromVessel(existing) ? tankVolume(existing) : "";
  const volume: { value: number | ""; source: MixtureValueSource } =
    typeof keptVolume === "number"
      ? {
          value: keptVolume,
          source:
            mixture.volume == null
              ? "form"
              : mixture.volume === keptVolume
                ? "file"
                : "vessel",
        }
      : pick("volume");
  const oxygen = pick("oxygen");
  const helium = pick("helium");

  return {
    value: {
      volume: volume.value,
      start_pressure:
        mixture.start_pressure ?? carriedPressures?.start_pressure ?? "",
      end_pressure:
        mixture.end_pressure ?? carriedPressures?.end_pressure ?? "",
      oxygen: oxygen.value,
      helium: helium.value,
      // File, then form, then nothing - the same two tiers `pick` applies above,
      // and these three are deliberately absent from `CarriedMixtureField`, so a
      // carry-over here is never reported. There is nothing to warn about: a
      // missing ppO₂ limit falls back to `PPO2_WORKING` at the point a MOD is
      // computed, and a missing role or gas number simply isn't displayed. Compare
      // `volume`, where a hand-added cylinder's 11.1 L carried onto a cylinder the
      // file never described is a claim worth flagging - the tiers are the same, and
      // what separates the two sets is whether the value carried says something the
      // diver could mistake for a reading.
      po2_limit: mixture.po2_limit ?? existing?.po2_limit ?? "",
      gas_number: mixture.gas_number ?? existing?.gas_number,
      // `""` rather than `undefined` for the same reason as `po2_limit` above: it
      // is the cleared state the `<select>` and `normalizeMixtures` agree on, so
      // the row this replaces reads the same whether it came from a file or a form.
      role: mixture.role ?? existing?.role ?? "",
      // Form, then nothing - there is no file tier at all, because no format this
      // app parses carries the flag and `ParsedDiveMixture` therefore has no
      // `usage` to read. Listed anyway rather than left out: this object is
      // constructed field by field, so a field missing from it is silently
      // *dropped* rather than preserved, which is the exact failure the note above
      // records. A diver who flagged a pair Parallel and then imported the dive's
      // computer file would have watched both flags disappear.
      usage: existing?.usage ?? "",
      // Form, then nothing, for `usage`'s reason: no file names the diver's own gear.
      // Carried only where the row pairs, so a list the file reshaped links nothing.
      gear_item_uuid: existing?.gear_item_uuid ?? "",
      gear_item: existing?.gear_item ?? null,
    },
    sources: {
      volume: volume.source,
      oxygen: oxygen.source,
      helium: helium.source,
    },
  };
}

/**
 * One cylinder under the fill-only rule: everything the form already holds
 * stays, and the file supplies only what is blank.
 *
 * The mirror image of `mergeMixture` above, which is file-first because it is
 * the *first* file of a recording and that file is the best thing the form has.
 * A second file of the same recording is not — the diver has seen the form by
 * then, and may have corrected it — so precedence flips. The Suunto app's JSON
 * and the same computer's FIT of one dive are the case this exists for: the FIT
 * lands its oxygen fraction on the cylinder the JSON left blank, and touches
 * nothing else.
 *
 * The pressures still move as a pair, for `mergeMixture`'s reason: a start from
 * the file meeting an end from the form is a fill that never existed, and it
 * either trips `diveMixtureSchema`'s `end <= start` refine or feeds a
 * consumption figure spanning two different fills. Here that means the file's
 * pair is taken only when the form carries **neither** side.
 *
 * No `MixtureImportNotes` come back, and the absence is deliberate: those
 * sentences exist to flag a value the import *guessed* and the diver should
 * check, and a fill that writes only into blanks has guessed nothing.
 *
 * Never a `gas_number`: the label is the API's to write when the file is
 * attached, and a second computer's own numbering can repeat one the first file
 * gave another row.
 */
function fillMixture(
  mixture: ParsedDiveMixture,
  existing: DiveMixtureInput,
): DiveMixtureInput {
  // `""` is the form's cleared state and a real value on these fields, so
  // "blank" has to mean both it and `undefined` - a `typeof` check rather than
  // `??`, which would read `""` as present and fill nothing.
  const keep = <TValue>(
    current: TValue | "" | undefined,
    fromFile: number | null | undefined,
  ): TValue | number | "" =>
    typeof current === "number" ||
    (typeof current === "string" && current !== "")
      ? (current as TValue)
      : (fromFile ?? "");

  const formHasPressures = hasPressure(existing);

  return {
    ...existing,
    volume: keep(existing.volume, mixture.volume),
    oxygen: keep(existing.oxygen, mixture.oxygen),
    helium: keep(existing.helium, mixture.helium),
    start_pressure: formHasPressures
      ? existing.start_pressure
      : (mixture.start_pressure ?? ""),
    end_pressure: formHasPressures
      ? existing.end_pressure
      : (mixture.end_pressure ?? ""),
    po2_limit: keep(existing.po2_limit, mixture.po2_limit),
    role: existing.role ? existing.role : (mixture.role ?? ""),
  };
}

/**
 * A later file's cylinders filled onto the form's, or `null` where none is.
 *
 * The form fills only where the API would pair the same rows when the file is
 * attached: whatever the form writes is saved first, and the API's fill then
 * finds it set. The API pairs by mix, then by order, so position is taken as
 * the pairing only where it gives the same pairs (`pairedByMix`). Past that, a
 * pair made by position is a guess a later file naming the mixes could undo,
 * so it fills only where the row records nothing the cylinder does not, or
 * where it is the one row left meeting the one cylinder left. The API pairs
 * the rows as saved, so the filled rows have to line up too: a mix written
 * into one row can make it another cylinder's match. What this declines, the
 * API's fill takes at the attach.
 */
export function fillMixtures(
  parsed: ParsedDiveMixture[],
  existing: DiveMixtureInput[],
): DiveMixtureInput[] | null {
  const byMix = pairedByMix(parsed, existing);
  if (!byMix) return null;

  const lastLeft = byMix.filter((paired) => !paired).length === 1;
  const fills = parsed.map(
    (cylinder, index) =>
      byMix[index] || lastLeft || recordsNothingElse(existing[index], cylinder),
  );
  if (!fills.some(Boolean)) return null;
  const filled = parsed.map((cylinder, index) =>
    fills[index] ? fillMixture(cylinder, existing[index]) : existing[index],
  );
  return pairedByMix(parsed, filled) ? filled : null;
}

// The API's pairing run over the form's rows - each cylinder takes the first
// row left with its mix, and the rest pair in order - answering which
// positions it pairs by mix, or `null` where it pairs any cylinder off its own
// position, or pairs two that record different fractions.
function pairedByMix(
  parsed: ParsedDiveMixture[],
  rows: DiveMixtureInput[],
): boolean[] | null {
  if (rows.length !== parsed.length) return null;
  if (parsed.some((cylinder, index) => mixesDisagree(rows[index], cylinder)))
    return null;
  const remaining = rows.map((_, index) => index);
  const byMix: boolean[] = [];
  for (const [index, cylinder] of parsed.entries()) {
    const match = remaining.find((row) => sameMix(rows[row], cylinder));
    if (match !== undefined && match !== index) return null;
    if (match !== undefined) remaining.splice(remaining.indexOf(match), 1);
    byMix.push(match !== undefined);
  }
  return byMix;
}

// A form member as the API would read it: `""`, `undefined` and a cleared
// number input's `NaN` are all nothing recorded.
function recorded<TValue>(
  value: TValue | "" | null | undefined,
): TValue | null {
  if (value == null || value === "") return null;
  if (typeof value === "number" && Number.isNaN(value)) return null;
  return value as TValue;
}

// The API's mix match: a recorded oxygen on both sides, and the same oxygen and
// helium, an unrecorded helium matching an unrecorded one.
function sameMix(row: DiveMixtureInput, cylinder: ParsedDiveMixture): boolean {
  const oxygen = recorded(row.oxygen);
  return (
    oxygen != null &&
    cylinder.oxygen != null &&
    oxygen === cylinder.oxygen &&
    recorded(row.helium) === cylinder.helium
  );
}

function mixesDisagree(
  row: DiveMixtureInput,
  cylinder: ParsedDiveMixture,
): boolean {
  return (["oxygen", "helium"] as const).some((member) => {
    const onForm = recorded(row[member]);
    return (
      onForm != null && cylinder[member] != null && onForm !== cylinder[member]
    );
  });
}

// Every member the row records, the label aside, recorded by the cylinder with
// the same value. `usage` is among them, and no file records it.
function recordsNothingElse(
  row: DiveMixtureInput,
  cylinder: ParsedDiveMixture,
): boolean {
  const members = [
    "oxygen",
    "helium",
    "volume",
    "start_pressure",
    "end_pressure",
    "po2_limit",
    "role",
  ] as const;
  return (
    members.every((member) => {
      const onForm = recorded(row[member]);
      return onForm == null || onForm === cylinder[member];
    }) && recorded(row.usage) == null
  );
}

/**
 * Pairs incoming cylinders with the ones already on the form, but only when the
 * counts match exactly. Carrying a stage bottle's pressures onto a back gas
 * would produce a confidently wrong SAC/RMV, which is worse than an empty field
 * the diver fills in.
 */
export function existingMixtureFor(
  existing: DiveMixtureInput[],
  parsedCount: number,
  index: number,
): DiveMixtureInput | undefined {
  return existing.length === parsedCount ? existing[index] : undefined;
}

function hasPressure(cylinder: DiveMixtureInput): boolean {
  return (
    typeof cylinder.start_pressure === "number" ||
    typeof cylinder.end_pressure === "number"
  );
}

/** What the file left for the form to supply, per field. */
export function mixtureImportNotes(
  parsed: ParsedDiveMixture[],
  merged: MergedMixture[],
  existing: DiveMixtureInput[],
): MixtureImportNotes {
  const fields: CarriedMixtureField[] = ["volume", "oxygen", "helium"];
  const guessed: Partial<Record<CarriedMixtureField, MixtureValueSource>> = {};
  for (const mixtureField of fields) {
    // `"form"` only. A `"blank"` field is a gap the file left and nothing filled,
    // which the empty box in front of the diver states more plainly than a sentence
    // 2 200 px above it could - and which the dive page will go on stating as an
    // absence rather than as an invented number. What still needs saying is a figure
    // that came from the last dive and could be mistaken for this one's.
    if (merged.some((cylinder) => cylinder.sources[mixtureField] === "form")) {
      guessed[mixtureField] = "form";
    }
  }

  return {
    guessed,
    keptPressures: parsed.some((mixture, index) => {
      const cylinder = merged[index].value;
      return (
        mixture.start_pressure == null &&
        mixture.end_pressure == null &&
        (cylinder.start_pressure !== "" || cylinder.end_pressure !== "")
      );
    }),
    // Three conditions, and the third is the one that keeps it honest. The counts
    // differing is what makes `existingMixtureFor` refuse to pair, and the form having
    // had pressures is what makes that a loss - but if the file brought pressures of its
    // own, nothing was lost and saying so sends the diver looking for data that is
    // sitting right there.
    discardedPressures:
      existing.length !== parsed.length &&
      existing.some(hasPressure) &&
      merged.every((cylinder) => !hasPressure(cylinder.value)),
    keptLinkedVolume: merged.some(
      (cylinder) => cylinder.sources.volume === "vessel",
    ),
    droppedLinks:
      existing.length !== parsed.length &&
      existing.some((cylinder) => !!cylinder.gear_item_uuid),
  };
}

const FIELD_LABELS: Record<CarriedMixtureField, string> = {
  volume: "cylinder size",
  oxygen: "gas mix",
  helium: "helium fraction",
};

/**
 * One or two sentences naming what the file did not record and the form supplied from
 * the cylinder it already held, or `null` when there is nothing of the sort to say.
 *
 * **A statement about the file, not about the form.** "This export doesn't record
 * cylinder size" is true when the note appears and stays true; nothing the diver types
 * afterwards bears on it. Two earlier rounds pulled the other way - quote the live value
 * so the figure in the field is recognisable, then track provenance so a corrected field
 * stops being called a default - and both were right *given* a note that talks about
 * form state. It shouldn't.
 *
 * The reason the whole idea collapses is layout: this sits in the import card and the gas
 * fields are ~2 200 px below it, so the two are never on screen together. A quoted number
 * cannot be compared with the one in the box, and a sentence that rewrites itself while
 * the diver edits a field two viewports away is a change nobody sees. Which left only the
 * cost - a snapshot of the imported cylinders, a `form.watch` subscription, an
 * `addressed` check, and a live region re-announcing itself per keystroke.
 *
 * So: no numbers, no tracking, computed once. If pointing at the specific value ever
 * matters, the place for it is a hint on the field itself, where the two *are* visible
 * together - not a sentence 2 200 px away trying to describe it.
 */
export function describeMixtureImport(
  notes: MixtureImportNotes,
): string | null {
  const fields = (
    ["volume", "oxygen", "helium"] as CarriedMixtureField[]
  ).filter((mixtureField) => notes.guessed[mixtureField] !== undefined);
  // Helium rides along with oxygen: a file that recorded neither has no gas mix at all,
  // and "0% helium" adds nothing to that. On its own it is named, since "gas mix" would
  // then be describing an oxygen the file *did* record.
  const named = fields.filter(
    (mixtureField) =>
      !(mixtureField === "helium" && notes.guessed.oxygen !== undefined),
  );

  const sentences: string[] = [];
  if (named.length > 0) {
    const labels = named.map((mixtureField) => FIELD_LABELS[mixtureField]);
    const one = named.length === 1;
    // One provenance, because there is one left. `notes.guessed` records `"form"` and
    // nothing else now that the merge has no default tier: a field neither source had
    // is blank, and an empty box is not a claim to warn about. The earlier version
    // chose between this sentence and "Those are defaults" - and had to judge that
    // over `fields` rather than `named`, so a defaulted helium could not hide behind a
    // form-sourced oxygen under the shared "gas mix" label. Nothing hides now; there
    // is one thing to say.
    const provenance = one
      ? "That value was already on this form"
      : "Those values were already on this form";
    sentences.push(
      `This file doesn't record ${labels.join(" or ")}. ${provenance}, not read from the file — check the tanks below before saving.`,
    );
  }
  if (notes.keptPressures) {
    sentences.push("Tank pressures already on this dive were kept.");
  }
  if (notes.discardedPressures) {
    sentences.push(
      "The tank pressures on this dive were cleared — the file has a different number of cylinders.",
    );
  }
  if (notes.keptLinkedVolume) {
    sentences.push(
      "A tank linked to a cylinder in your gear kept that cylinder's size; the file's different size was not taken.",
    );
  }
  if (notes.droppedLinks) {
    sentences.push(
      "The tanks were replaced with the file's, which has a different number of cylinders, so they are no longer linked to your gear.",
    );
  }
  return sentences.length > 0 ? sentences.join(" ") : null;
}