src/lib/dive-recordings.ts
Rama main · réplica de lectura del repositorio original.
import type {
Dive,
DiveFileInfo,
Recording,
RecordingDecoModel,
RecordingDevice,
} from "./api/dives";
import { importSourceLabel } from "./api/logbook-import";
/**
* What to call a recording whose source named no computer at all.
*
* A dive imported from a document that recorded samples and nothing about the
* machine is an ordinary case, not a broken row, so it gets a word rather than a
* blank cell — the `importSourceLabel` stance applied to a device.
*/
export const UNNAMED_DEVICE_LABEL = "Dive computer";
/**
* One device on one line: brand and model, then the serial, then the name its
* owner gave it.
*
* `null` when the device carries nothing worth putting on screen — a row with
* only a firmware string has nothing to identify, and the caller substitutes
* `UNNAMED_DEVICE_LABEL`.
*
* Two joins are doing real work here. **A model already carrying its brand does
* not get it twice:** a FIT decodes its maker to the lowercase `suunto` beside
* the model `Suunto Ocean`, and the API stores both exactly as the file wrote
* them (it compares them case-folded and normalizes neither), so the naive join
* renders `suunto Suunto Ocean`. And **a name equal to the model is dropped**,
* because a Shearwater UDDF writes `Perdix 3` in both places and `Perdix 3
* (Perdix 3)` says nothing twice.
*/
export function recordingDeviceLabel(
device: RecordingDevice | null | undefined,
): string | null {
if (!device) return null;
const brand = device.brand?.trim() || null;
const model = device.model?.trim() || null;
const serial = device.serial?.trim() || null;
const name = device.name?.trim() || null;
const modelCarriesBrand =
brand != null &&
model != null &&
model.toLowerCase().startsWith(brand.toLowerCase());
const machine = [modelCarriesBrand ? null : brand, model]
.filter((part): part is string => part != null)
.join(" ");
const parts: string[] = [];
if (machine) parts.push(machine);
if (serial) parts.push(serial);
let label = parts.join(" · ");
if (name && name.toLowerCase() !== model?.toLowerCase()) {
label = label ? `${label} (${name})` : name;
}
return label || null;
}
// Trimmed and lower-cased, or null: a FIT's `suunto` and its JSON's `Suunto` are
// one maker.
function foldedDeviceMember(value: string | null | undefined): string | null {
return value?.trim().toLowerCase() || null;
}
// Both sides carry the member and the two differ.
function deviceMembersDisagree(
left: string | null | undefined,
right: string | null | undefined,
): boolean {
const a = foldedDeviceMember(left);
const b = foldedDeviceMember(right);
return a != null && b != null && a !== b;
}
/**
* Whether two files came off the same computer, by the API's own test
* (`same_device`), so the form and the server never disagree about a pair.
*
* A member absent on either side never makes two devices differ: the brands may
* not disagree, and then equal serials settle it where both carry one, or else
* the models may not disagree. So a Suunto Ocean's FIT (`suunto`, model, no
* serial) and its JSON (`Suunto`, serial, no model) are one computer.
*/
export function sameDevice(
left: RecordingDevice | null | undefined,
right: RecordingDevice | null | undefined,
): boolean {
if (deviceMembersDisagree(left?.brand, right?.brand)) return false;
const leftSerial = foldedDeviceMember(left?.serial);
const rightSerial = foldedDeviceMember(right?.serial);
if (leftSerial != null && rightSerial != null) {
return leftSerial === rightSerial;
}
return !deviceMembersDisagree(left?.model, right?.model);
}
/**
* What the API's `DiveMode` values are called in words.
*
* A lookup with no fallback entry on purpose. The vocabulary is closed in the
* API's *current* build and the two repos deploy independently, so a mode this
* bundle has never heard of is a real arrival — and the honest answer to it is
* to say nothing rather than to print a raw `semi_closed_rebreather` or, worse,
* to default to open circuit. **An absent mode is never open circuit**: UDDF
* documents an absent `<divemode>` as meaning one, and neither repo reads that,
* because it is the source format's claim about its own default rather than the
* device's about the dive.
*/
export const MODE_LABELS: Record<string, string> = {
open_circuit: "Open circuit",
closed_circuit: "Closed circuit",
semi_closed: "Semi-closed",
gauge: "Gauge",
freedive: "Freedive",
};
/** The same, for the model families the API's `DecoAlgorithm` names. */
const ALGORITHM_LABELS: Record<string, string> = {
buhlmann: "Bühlmann",
rgbm: "RGBM",
};
/**
* The same, for the API's `Salinity`: the density a device was set to, named as
* the computer's own menu names it rather than as the dive's water types are.
* `EN13319` is the European depth-gauge calibration, not a kind of water, and
* keeps the standard's spelling because it has no other name.
*/
export const SALINITY_LABELS: Record<string, string> = {
fresh: "Fresh",
en13319: "EN13319",
salt: "Salt",
};
/**
* The decompression model one device ran, in words, or `null` where nothing
* worth a line was recorded.
*
* **The device's own name wins over the family**, which is the same call
* `recordingDeviceLabel` makes between a model string and a brand: a Suunto
* names its model `Suunto Fused RGBM 2` and that says more than `RGBM` does,
* while `RGBM Suunto Fused RGBM 2` says one of them twice. The family is what
* fills in where a source named a model without naming a product — a UDDF's
* `<buehlmann>` element, a FIT's `zhl_16c`.
*
* The gradient factors attach to it (`Bühlmann GF 30/85`) because they are that
* model's settings, and both halves are required together or not at all. The
* conservatism is a separate clause: it is on the device's own scale and means
* nothing without the model and the computer beside it, which is exactly where
* this line is rendered.
*/
export function decoModelLabel(model: RecordingDecoModel | null | undefined) {
if (!model) return null;
const named =
model.name?.trim() ||
(model.algorithm ? (ALGORITHM_LABELS[model.algorithm] ?? null) : null);
const gradientFactors =
model.gf_low != null && model.gf_high != null
? `GF ${model.gf_low}/${model.gf_high}`
: null;
const described = [named, gradientFactors]
.filter((part): part is string => part != null)
.join(" ");
// `0` is a setting - the middle of Suunto's P-2 to P2 - so this tests for null
// rather than for falsiness, and a positive value keeps its sign because the
// scale runs both ways.
const conservatism =
model.conservatism == null
? null
: `conservatism ${model.conservatism > 0 ? `+${model.conservatism}` : model.conservatism}`;
return (
[described || null, conservatism]
.filter((part): part is string => part != null)
.join(" · ") || null
);
}
/**
* What this device was set to when it recorded the dive: the mode it ran in, the
* decompression model it ran and the salinity it divided pressure by, or `null`
* where the source recorded none of them.
*
* One line rather than three because they are one fact about one machine — `Open
* circuit · Bühlmann GF 30/85 · salinity EN13319`, or `Freedive` where a
* freediving computer has no model to name, or a model alone where a file
* recorded the algorithm and not the mode. The salinity is a clause like the
* conservatism, named, because a bare `Salt` beside a mode says nothing.
*
* **Per recording, never per dive.** A backup computer run in gauge mode beside
* a primary on open circuit is ordinary practice and the dive was not a gauge
* dive; two computers give two answers, and the recording is the row that can
* hold both.
*/
export function recordingSettingsLabel(recording: Recording): string | null {
const mode = recording.mode ? (MODE_LABELS[recording.mode] ?? null) : null;
const salinity = recording.salinity
? (SALINITY_LABELS[recording.salinity] ?? null)
: null;
return (
[
mode,
decoModelLabel(recording.deco_model),
salinity ? `salinity ${salinity}` : null,
]
.filter((part): part is string => part != null)
.join(" · ") || null
);
}
/**
* What this device reported about the dive as a whole, on one line, or `null`
* where it reported none of it: `CNS 0% → 9% · OTU 0 → 22 · Surface pressure
* 1.012 bar`.
*
* The recording's own readouts, which is the point of showing them per
* recording at all: two computers on one dive give two answers to every one of
* these, and the exposure card shows only the primary's. Displayed exactly as
* stored and a missing half as an em dash, on `DiveExposureCard`'s terms, so the
* two never read the same recording differently.
*/
export function recordingReadoutsLabel(recording: Recording): string | null {
const pair = (
label: string,
start: number | null | undefined,
end: number | null | undefined,
unit = "",
) =>
start == null && end == null
? null
: `${label} ${start != null ? `${start}${unit}` : "—"} → ${end != null ? `${end}${unit}` : "—"}`;
return (
[
pair("CNS", recording.cns_start, recording.cns_end, "%"),
pair("OTU", recording.otu_start, recording.otu_end),
recording.surface_pressure_bar != null
? `Surface pressure ${recording.surface_pressure_bar} bar`
: null,
]
.filter((part): part is string => part != null)
.join(" · ") || null
);
}
/**
* The dive's recordings in ordinal order, defaulted and never undefined.
*
* Sorted here rather than trusted: the API documents the order but every card
* reading `recordings[0]` as "primary" would otherwise be one response shape
* away from drawing the wrong device's figures. `Dive.recordings` is also
* absent — not `[]` — on a list row and on any detail payload cached before
* recordings existed, which is the other half of what this normalizes.
*/
export function diveRecordings(dive: Dive): Recording[] {
return [...(dive.recordings ?? [])].sort((a, b) => a.ordinal - b.ordinal);
}
/**
* How many computers recorded the dive, one recording each: the list row's
* count, or the detail response's recordings.
*/
export function recordingCount(dive: Dive): number {
return dive.recording_count ?? dive.recordings?.length ?? 0;
}
/**
* The recording whose readouts the exposure card shows and whose profile a
* single-profile consumer takes — ordinal 0 — or `null` for a dive logged by
* hand.
*/
export function primaryRecording(dive: Dive): Recording | null {
return diveRecordings(dive)[0] ?? null;
}
/**
* Whether the dive owed decompression: the list row's flag, or a ceiling on
* the profile the dive page charts first - the first recording with one, which
* is the API's own choice for that flag.
*/
export function owedDeco(dive: Dive): boolean {
if (dive.deco != null) return dive.deco;
const charted = diveRecordings(dive).find(
(recording) => recording.profile != null,
);
return charted?.profile?.max_ceiling != null;
}
/**
* A positional handle for a recording, for the one place the device is not
* enough: two recordings of one dive that both name no computer.
*/
export function recordingLabel(recording: Recording): string {
return `Recording ${recording.ordinal + 1}`;
}
/**
* Why a recording holds no downloadable file, or `null` when it holds one.
*
* A recording with samples and no files is first-class rather than degenerate —
* it is what an import builds from a logbook of several dives, whose file
* belongs to no one dive, and what a merge of two such recordings leaves — so
* the list says so in words instead of rendering an empty row that reads as
* a loss.
*
* **Which of the two it was comes from the profile's `provenance`**, the closed
* `file` / `divejson_import` / `merge` vocabulary the dive read publishes on
* every recording's profile summary. The recording's own shape cannot answer it:
* `files` is empty either way, and a merge keeps whatever files either half had,
* so file-lessness says only that neither half had one.
*
* The fallback covers the case with no provenance to read — a recording the
* import wrote from a device and nothing else, which carries no profile at all,
* and any payload that reaches a browser without the member. It claims nothing
* about samples, because that recording has none.
*/
export function noFileKeptSentence(recording: Recording): string | null {
if (recording.files.length > 0) return null;
switch (recording.profile?.provenance) {
case "divejson_import":
return "No file kept — these samples were imported through the converter.";
case "merge":
return "No file kept — these samples were merged from two recordings.";
default:
return "No file kept — nothing was stored here to re-read this recording from.";
}
}
/**
* The title and the description of a confirmation before one destructive action
* on a recording or one of its files.
*
* Both, because on this route the *title* is where the material difference
* lands: deleting a file the recording survives and deleting the one that takes
* the recording with it are different actions, and a dialog headed "Delete this
* file?" for both asks about the smaller one.
*/
export interface RecordingConfirmation {
title: string;
description: string;
}
// The dive row itself is never touched by any of this - it is the recordings
// that are hard-deleted - and a diver being told their recording disappears is
// exactly who needs to hear it. Offered only where something did disappear; on a
// file among others it would be reassurance against a fear nobody has.
const DIVE_IS_UNTOUCHED =
"The dive itself stays, with everything you typed on it.";
/**
* The confirmation before deleting a whole dive, shared by the list page and the
* detail page — the case `DIVE_IS_UNTOUCHED` exists to rule out.
*
* **It names the files because they are the only part of this that is genuinely
* gone.** The dive row is soft-deleted, but `delete_files_for_dive` hard-deletes
* every recording of it, and the cascade takes their profiles and their files
* with them and unlinks the stored blobs (`erase_dive` in the API's
* `api/v1/dives.py`). So "This action cannot be undone", which is all this used
* to say, was attached to the reversible half of the action while the
* irreversible half went unmentioned.
*
* **It carries no counts, and that is what lets both pages say it.** The detail
* page holds `dive.recordings` and could say how many recordings and files go;
* `GET /dives` deliberately omits them, each costing a query, so the list page
* could not. One prompt that is true of every dive beats a file-aware one on the
* detail page and a vaguer one two clicks away on the list — the same
* conclusion the certification delete reached, which names the card images
* stored with it without counting them either.
*
* "Any" rather than "its" for the files, so the sentence stays true of a dive
* logged by hand, which has neither a recording nor a file.
*/
export const DELETE_DIVE_CONFIRMATION =
"Are you sure you want to delete this dive? Its recordings and any dive-computer files you imported are permanently deleted too — download anything you want to keep first.";
/**
* Why this recording would survive losing its last file, as a clause, or `null`
* when it would not survive it.
*
* The `provenance` question `noFileKeptSentence` asks, in the vocabulary that
* one uses - this is describing the row that sentence is about to occupy. The
* API keeps a recording whose samples no file could yield again, a merge's or a
* converted document's, and deletes one whose profile was only ever read off the
* file being removed (`delete_dive_file` in the API's `services/dive_files.py`).
*
* A recording carrying no profile at all falls to the `null` here and is
* deleted, which is right: it has no samples to be unable to reproduce.
*/
function unreproducibleSamples(recording: Recording): string | null {
switch (recording.profile?.provenance) {
case "merge":
return "they were merged from two recordings";
case "divejson_import":
return "they came in through the converter";
default:
return null;
}
}
/** What is left of the recording once the action goes through. */
type RecordingOutcome = "keeps files" | "keeps samples only" | "removed";
// Everything `refresh_tech_scalars` rewrites, as one noun phrase - the API's
// whole `DiveTechScalars` mixin, the entry and exit positions the dive page
// draws on its map. Named as one thing rather than enumerated because they move
// as one: the write is outright and covers the lot. A recording's own readouts
// are not among them; they go and come with that recording's files, which the
// file sentences say.
const COMPUTER_FIGURES = "the positions the dive computer recorded";
/**
* What the positions the dive computer recorded do, as a sentence.
*
* **One rule sits under every branch**: the figures move only where the deletion
* reached the recording shown by default, and then they become whatever that
* slot holds files for once it is through. A deletion elsewhere on the dive is a
* no-op - the API's `refresh_tech_scalars` takes a `touched_primary` and returns
* without writing when it is false, precisely so that emptying a second
* computer's recording cannot clear figures a converted logbook import wrote
* onto the dive beside a file-less primary. Where the deletion *does* reach
* ordinal 0, that rewrite is outright: every figure it does not find on the
* primary's files is written null, so an empty ordinal 0 clears them however it
* came to be empty - this recording survived its own last file, the recording
* promoted in its place holds none, or there is nothing left to promote.
* `renumber_ordinals` promotes in ordinal order without skipping a file-less
* recording, so "is another recording there" is not the question; "does whoever
* is primary afterwards hold a file" is.
*
* **No count of the branches here, deliberately.** One was stated and went wrong
* twice in three commits, each time because the function grew a case; the rule
* above is what a reader needs to check a branch against, and unlike a total it
* cannot be made stale by adding one.
*/
function figuresSentence(
recording: Recording,
recordings: Recording[],
outcome: RecordingOutcome,
): string {
if (recording.ordinal !== 0) {
return `Another recording is the one shown by default, so ${COMPUTER_FIGURES} are left alone.`;
}
if (outcome === "keeps files") {
return `This recording also writes ${COMPUTER_FIGURES}, so those are re-read along with it.`;
}
if (outcome === "keeps samples only") {
// The recording stays, but with no file behind it there is nothing to read
// the figures off - which is what "nothing here can re-derive them" means.
return `This recording's files wrote ${COMPUTER_FIGURES}, and they are cleared with the last of those files.`;
}
// Which recording takes over: the lowest ordinal among the rest. By ordinal
// and not by position, because the edit form hands its list over unsorted.
const successor = recordings.reduce<Recording | undefined>(
(next, other) =>
other.uuid === recording.uuid ||
(next !== undefined && next.ordinal <= other.ordinal)
? next
: other,
undefined,
);
if (successor === undefined) {
return `It is the dive's only recording, so ${COMPUTER_FIGURES} are cleared — nothing is left to read them from.`;
}
return successor.files.length > 0
? `It is the recording shown by default, so the next one takes over and ${COMPUTER_FIGURES} are re-read from that instead.`
: `It is the recording shown by default, so the next one takes over — and it holds no file, so ${COMPUTER_FIGURES} are cleared.`;
}
function sentences(...parts: (string | null)[]): string {
return parts.filter((part): part is string => part !== null).join(" ");
}
/**
* What deleting one stored file will actually do, for the dialog that asks.
*
* Three outcomes behind one control, and the diver cannot tell them apart from
* the row: the recording keeps its other files and re-reads its profile from
* them; the recording's last file goes and **the recording goes with it**, its
* profile and samples included; or the recording survives file-less because its
* samples came from a merge or a converted document and no file could produce
* them again. Each also moves the dive's recorded positions differently - see
* `figuresSentence`.
*
* Takes the dive's whole recording list rather than the one recording, so that
* "is this the last file", "is this the recording shown by default" and "is
* there another one to take over" are all derived here instead of being answered
* three times at two call sites. A `fileUuid` no recording holds gets a
* conservative sentence rather than a confident wrong one; the dialog can
* outlive a re-read that removed the row behind it.
*/
export function deleteFileConfirmation(
recordings: Recording[],
fileUuid: string,
): RecordingConfirmation {
const recording = recordings.find((candidate) =>
candidate.files.some((file) => file.uuid === fileUuid),
);
if (!recording) {
return {
title: "Delete this file?",
description:
"The file is permanently deleted, and what this dive shows may change with it.",
};
}
const isLast = recording.files.length === 1;
const kept = isLast ? unreproducibleSamples(recording) : null;
const outcome: RecordingOutcome = !isLast
? "keeps files"
: kept !== null
? "keeps samples only"
: "removed";
const fileSentence =
outcome === "keeps files"
? "The file is permanently deleted, and this recording's profile and readouts are re-read from the files it keeps."
: outcome === "keeps samples only"
? `The file is permanently deleted, leaving this recording with nothing to download. Its samples and readouts stay — ${kept}, and no file can produce them again.`
: "This is the recording's last file, so the whole recording goes with it: the file, its profile, its readouts and its samples, permanently.";
return {
title:
outcome === "removed"
? "Delete this file and its recording?"
: "Delete this file?",
description: sentences(
fileSentence,
figuresSentence(recording, recordings, outcome),
outcome === "removed" ? DIVE_IS_UNTOUCHED : null,
),
};
}
/**
* What deleting a whole recording will do, for the dialog that asks.
*
* Offered only for a recording holding no files - nothing else can remove one,
* and where there are files deleting them one at a time is the smaller action -
* but the sentence covers files anyway rather than assuming the caller's rule,
* which is a claim about a button and not about this route.
*/
export function deleteRecordingConfirmation(
recordings: Recording[],
recordingUuid: string,
): RecordingConfirmation {
const recording = recordings.find(
(candidate) => candidate.uuid === recordingUuid,
);
if (!recording) {
return {
title: "Delete this recording?",
description:
"The recording and its samples are permanently deleted, and what this dive shows may change with it.",
};
}
return {
title: "Delete this recording?",
description: sentences(
recording.files.length > 0
? "The recording, its profile, its readouts, its samples and every file it holds are permanently deleted."
: "The recording, its profile, its readouts and its samples are permanently deleted.",
figuresSentence(recording, recordings, "removed"),
DIVE_IS_UNTOUCHED,
),
};
}
/** One line of the file list on a dive form or in the recordings card. */
export type DiveFileRow =
| {
kind: "file";
/** Stable across re-renders and unique within the list. */
key: string;
recordingUuid: string;
/** `null` where the recording's source named no computer. */
deviceLabel: string | null;
/** Only set when the dive has more than one recording. */
recordingName: string | null;
file: DiveFileInfo;
/** The file's format, already labelled. */
formatLabel: string;
}
| {
kind: "empty";
key: string;
recordingUuid: string;
deviceLabel: string | null;
recordingName: string | null;
/** Why there is no file — see `noFileKeptSentence`. */
reason: string;
};
/**
* The stored half of a form's file list: a row per file, plus a row per
* recording that kept none.
*
* A file-less recording earns a row rather than being skipped because the list
* is a statement about what the account holds, and a device silently missing
* from it is the thing a diver would read as data loss.
*
* `recordingName` is filled only on a dive with more than one recording, which
* is decision-shaped rather than cosmetic: on a single-recording dive every row
* belongs to the same device and a per-row "Recording 1" is noise.
*/
export function diveFileRows(recordings: Recording[]): DiveFileRow[] {
const namesRecordings = recordings.length > 1;
return recordings.flatMap((recording): DiveFileRow[] => {
const deviceLabel = recordingDeviceLabel(recording.device);
const recordingName = namesRecordings ? recordingLabel(recording) : null;
if (recording.files.length === 0) {
return [
{
kind: "empty" as const,
key: `recording:${recording.uuid}`,
recordingUuid: recording.uuid,
deviceLabel,
recordingName,
reason: noFileKeptSentence(recording) ?? "",
},
];
}
return recording.files.map((file) => ({
kind: "file" as const,
key: `file:${file.uuid}`,
recordingUuid: recording.uuid,
deviceLabel,
recordingName,
file,
formatLabel: importSourceLabel(file.parser_key),
}));
});
}
/**
* Whether the merge action has anything to offer for this dive.
*
* The API refuses a merge where either side is hand-entered — Subsurface's own
* rule, and this app's route enforces it — so offering the action on a dive with
* no recording would be offering a 422.
*/
export function canMergeDive(dive: Dive): boolean {
return diveRecordings(dive).length > 0;
}
/** A dive's entry fix: where its first fixed file places the descent. */
export interface EntryFix {
latitude: number;
longitude: number;
}
/**
* The dive's entry fix once a form's files are saved: the first file that has one,
* in attach order across every recording - the stored files left after `removed`
* by `attach_order`, then the `pending` ones in pick order, which the save attaches
* last whichever recording each joins. The API's rule, recalculated on every
* attach and delete. With no file attached or deleted the save recalculates
* nothing, and the dive keeps `stored`, which a merge or a logbook document may
* have given it though no file has it.
*/
export function entryFixAfterSave({
recordings = [],
removed = [],
pending = [],
stored = null,
}: {
recordings?: readonly Recording[];
removed?: readonly string[];
pending?: readonly { entry: EntryFix | null }[];
stored?: EntryFix | null;
}): EntryFix | null {
if (removed.length === 0 && pending.length === 0) return stored;
const storedFiles = recordings
.flatMap((recording) => recording.files)
.sort((first, second) => first.attach_order - second.attach_order);
const struck = new Set(removed);
for (const file of storedFiles) {
if (struck.has(file.uuid)) continue;
if (file.entry_latitude != null && file.entry_longitude != null) {
return { latitude: file.entry_latitude, longitude: file.entry_longitude };
}
}
return pending.find((file) => file.entry)?.entry ?? null;
}