// 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>; /** * 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; } /** * 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 `