"use client"; import { useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { FieldPathValue, Path, UseFormReturn } from "react-hook-form"; import { Button } from "@/components/ui/button"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { useToast } from "@/components/ui/use-toast"; import { divesAPI, MAX_DIVE_FILE_SIZE, ParsedDive, ParsedDiveInferredField, ParsedDiveMatch, Recording, RecordingDevice, } from "@/lib/api/dives"; import { formatDiveStartTime, formatDurationForForm, isDateOnlyStartTime, normalizeParsedStartTime, } from "@/lib/date-time"; import { getApiErrorMessage } from "@/lib/api/error"; import { DIVE_COMPUTER_FILE_ACCEPT } from "@/lib/api/logbook-import"; import { DiveFormValues } from "@/components/dives/dive-form-fields"; import { DiveRecordingFiles, type PendingDiveFile, } from "@/components/dives/dive-recording-files"; import { DiveMixtureInput } from "@/lib/validations/dive"; import { describeMixtureImport, existingMixtureFor, fillMixtures, mergeMixture, mixtureImportNotes, NO_MIXTURE_NOTES, type MixtureImportNotes, } from "@/lib/dive-import"; import { isNonEmptyFieldValue } from "@/lib/dive-form-fields"; import { diveFileRows, recordingDeviceLabel, sameDevice, } from "@/lib/dive-recordings"; import { DiveFormSection } from "@/components/dives/dive-form-section"; import { withReturnTo } from "@/lib/return-to"; import { Info, Upload } from "lucide-react"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { IconTooltip } from "@/components/ui/tooltip"; // Applies the fields parsed from a dive-computer export file onto a dive // form. Shared between the "new dive" and "edit dive" forms since both // expose the same importable field set. Generic over `TFieldValues` (rather // than `UseFormReturn`) so the concrete create/edit form type is // checked at the call site; field name literals below are cast to // `Path` since react-hook-form can't verify a literal string // against a still-generic `TFieldValues`. // Sets a single named field on the form. `name`/`value` are checked against // `DiveFormValues` (a known, closed set of fields/types) rather than the // still-generic `TFieldValues`, so callers get real type safety on both the // field name and the value they pass in; only the final `form.setValue` call // needs a narrow cast, since react-hook-form can't verify a `DiveFormValues` // key/value pair against a still-unresolved `TFieldValues` type parameter. function setDiveFormValue< TFieldValues extends DiveFormValues, TName extends keyof DiveFormValues & string, >( form: UseFormReturn, name: TName, value: DiveFormValues[TName], ) { form.setValue( name as unknown as Path, value as unknown as FieldPathValue>, { shouldValidate: true, shouldDirty: true }, ); } // The mixtures currently on the form. Same cast reasoning as `setDiveFormValue` // above: the field name is known against `DiveFormValues` but not against a // still-generic `TFieldValues`. function getDiveFormMixtures( form: UseFormReturn, ): DiveMixtureInput[] { const mixtures = form.getValues( "mixtures" as unknown as Path, ) as unknown as DiveMixtureInput[] | undefined; return mixtures ?? []; } // Whether a form field is still empty, and so free for a fill-only import to // write. `0` is a recorded reading and not an absence - a freedive to the // surface logs `max_depth` 0 - so this is deliberately not falsiness. function isDiveFormFieldEmpty< TFieldValues extends DiveFormValues, TName extends keyof DiveFormValues & string, >(form: UseFormReturn, name: TName): boolean { const value = form.getValues(name as unknown as Path); if (value == null) return true; if (typeof value === "string") return value.trim() === ""; // A number input the diver cleared reads back as `NaN` rather than as // `undefined`, which is the one non-obvious empty state on this form. if (typeof value === "number") return Number.isNaN(value); if (Array.isArray(value)) return value.length === 0; return false; } // The dive's members a file states beyond its readings, each under the same name // on `ParsedDive` and on the form. const PARSED_DIVE_MEMBERS = [ "notes", "visibility", "weight", "water_type", "altitude", "type", "rating", "air_temperature", "current", "waves", "weather", "entry_type", "boat_name", "tags", ] as const satisfies readonly (keyof ParsedDive & keyof DiveFormValues)[]; /** * How a parsed file is written onto the form. * * `"prefill"` is the first file of a recording: it writes every field the file * carries, which is what makes importing a dive computer's export worth doing. * * `"fill-only"` is every later file — one the API reported as a second export of * a recording the dive already has, or one picked while another is already * pending or stored — and it writes only fields the form has left empty. That is * how "a second file of one recording fills, never overwrites" reaches the * dive's own fields - its figures, its notes and the rest of what a file states: * no attach path on the server writes them, so if the rule did not hold here it * would not hold anywhere. A diver who has just corrected a depth does not * lose the correction to the same computer's second spelling of the same dive. * The one exception is a figure an earlier file derived - see `InferredFigures`. */ export type ParsedDiveApplyMode = "prefill" | "fill-only"; /** * The figures on the form a file derived rather than read, this form session * only: the value it wrote and the computer it came off. Under `"fill-only"` a * later file that *states* one of these, off the same computer, replaces it - * the one exception to fills-never-overwrites. A form loaded from a saved dive * starts with none, since nothing stored says how its figures were made. * * Mutated by `applyParsedDiveToForm`; hold one per form, e.g. in a ref. */ export type InferredFigures = Partial< Record< ParsedDiveInferredField, { value: unknown; device: RecordingDevice | null | undefined } > >; export function applyParsedDiveToForm( form: UseFormReturn, parsed: ParsedDive, // Replaces the `mixtures` field array wholesale. Must come from the *same* // `useFieldArray({ name: "mixtures" })` instance `MixtureFields` renders // with (passed down from the page - see `DiveFileImportProps.replaceMixtures`) // rather than a separate one created here: react-hook-form doesn't reliably // keep multiple separate `useFieldArray` instances on the same `control`/ // `name` in sync (e.g. `replace()` on one instance doesn't shrink another // instance's `fields` when the new array is shorter - see DECISIONS.md), // and plain `form.setValue("mixtures", ...)` has the same problem. replaceMixtures: (mixtures: DiveMixtureInput[]) => void, mode: ParsedDiveApplyMode = "prefill", inferredFigures: InferredFigures = {}, // Told of every write before it lands, with the value it replaces - what the dive // form marks the fields an import filled in by. noteWrite?: (path: string, before: unknown, after: unknown) => void, ): MixtureImportNotes { const setValue = ( name: TName, value: DiveFormValues[TName], ) => { noteWrite?.( name, form.getValues(name as unknown as Path), value, ); setDiveFormValue(form, name, value); }; const replace = (mixtures: DiveMixtureInput[]) => { noteWrite?.("mixtures", getDiveFormMixtures(form), mixtures); replaceMixtures(mixtures); }; // One gate for every scalar field below, so "fill-only" cannot be honoured by // some of them and forgotten by one. The dive's water type is written only as // the file states the dive's own: a file's salinity is a setting of the device, // never a kind of water, and the recording keeps it on attach. const writes = (name: TName) => mode === "prefill" || isDiveFormFieldEmpty(form, name); const inferred = new Set(parsed.inferred ?? []); // A figure also passes where an earlier file of this computer derived what the // form holds and this one states it. "What the form holds" is checked against // the value written, so a figure the diver has since changed is theirs. const writesFigure = (name: ParsedDiveInferredField) => { if (writes(name)) return true; const mark = inferredFigures[name]; return ( mark != null && !inferred.has(name) && Object.is( form.getValues(name as unknown as Path), mark.value, ) && sameDevice(mark.device, parsed.device) ); }; const setFigure = ( name: TName, value: DiveFormValues[TName], ) => { setValue(name, value); if (inferred.has(name)) { inferredFigures[name] = { value, device: parsed.device }; } else { delete inferredFigures[name]; } }; if (parsed.dive_number != null && writes("dive_number")) { setValue("dive_number", parsed.dive_number); } // A file that states only a day, applied to a dive that has only a day, keeps // it a day: the midnight `normalizeParsedStartTime` supplies for a new dive // would end the state with a time neither of them recorded. const keepsDateOnly = isDateOnlyStartTime(parsed.start_time) && isDateOnlyStartTime( form.getValues("start_time" as unknown as Path) as string | undefined, ); const normalizedStartTime = keepsDateOnly ? parsed.start_time : parsed.start_time ? normalizeParsedStartTime(parsed.start_time) : undefined; if (normalizedStartTime && writes("start_time")) { setValue("start_time", normalizedStartTime); } if (parsed.submerged_time != null && writesFigure("submerged_time")) { setFigure("submerged_time", formatDurationForForm(parsed.submerged_time)); } if (parsed.max_depth != null && writesFigure("max_depth")) { setFigure("max_depth", parsed.max_depth); } if (parsed.avg_depth != null && writesFigure("avg_depth")) { setFigure("avg_depth", parsed.avg_depth); } if (parsed.bottom_temperature != null && writes("bottom_temperature")) { setValue("bottom_temperature", parsed.bottom_temperature); } // An empty string or list is a file stating nothing, never one clearing the form. for (const name of PARSED_DIVE_MEMBERS) { const value = parsed[name]; if (value != null && isNonEmptyFieldValue(value) && writes(name)) { setValue(name, value); } } if (parsed.mixtures.length === 0) { return NO_MIXTURE_NOTES; } const existing = getDiveFormMixtures(form); if (mode === "fill-only") { // A later file may fill a cylinder the form already has; it may not // reshape the list, and it fills only the rows the API would pair it with // at the attach (`fillMixtures`), since what the form writes is saved // first. A form with no cylinders at all takes the file's, which fills // rather than overwrites by definition - without the file's labels, which // the attach writes. if (existing.length === 0) { replace( parsed.mixtures.map((mixture) => ({ ...mergeMixture(mixture).value, gas_number: undefined, })), ); } else { const filled = fillMixtures(parsed.mixtures, existing); if (filled) replace(filled); } return NO_MIXTURE_NOTES; } const merged = parsed.mixtures.map((mixture, index) => mergeMixture( mixture, existingMixtureFor(existing, parsed.mixtures.length, index), ), ); replace(merged.map((cylinder) => cylinder.value)); return mixtureImportNotes(parsed.mixtures, merged, existing); } export interface DiveFileImportProps { form: UseFormReturn; // Must come from the same `useFieldArray` instance passed to `DiveFormFields` // as `mixtureFieldArray` (i.e. `replace` destructured from it) - see the // `applyParsedDiveToForm` doc comment above for why a separate instance // created here wouldn't work. replaceMixtures: (mixtures: DiveMixtureInput[]) => void; // Called only after a *successful* parse, with a file the page should hold // until the dive has been saved - see `divesAPI.attachRecordingFile`. A failed // parse applied nothing to the form, so there is nothing to attach and this // isn't called. // // More than one file can be waiting: a diver logging one dive off two // computers, or the same computer's JSON beside its FIT, picks each in turn // and every one of them is attached on save. onFileAdded?: (pending: PendingDiveFile) => void; // Raised the moment a parsed file has been written onto the form, before the // toast. The dive form uses it to put every field the file filled in back on // screen when the diver has it hidden - an imported cylinder volume the parser // guessed must not sit behind an unchecked box. `applyParsedDiveToForm` itself is // deliberately visibility-blind: it sets whatever the file carries, which is the // owner's import rule for free. onValuesApplied?: () => void; // Told of each value the import writes, before it lands - see // `applyParsedDiveToForm`'s `noteWrite`. onWrite?: (path: string, before: unknown, after: unknown) => void; // The files this dive already holds, on the edit form. Empty on the create // form, where nothing is stored until the dive exists. recordings?: Recording[]; // Files parsed on this form and not yet attached, held by the page. pending?: PendingDiveFile[]; onRemovePending?: (id: string) => void; // Stored files struck off the list, and the two controls over that. Edit form // only, and deferred: the deletions go out with the save, beside the attaches, // so that Cancel leaves the dive's files exactly as it found them. removedStored?: string[]; onRemoveStored?: (fileUuid: string) => void; onRestoreStored?: (fileUuid: string) => void; // The dive being edited, so a match against *this* dive can be told from a // match against another one. Absent on the create form, where every match is // another dive by definition. diveUuid?: string; // Where the form goes back to, which the dive a file is attached to instead // leads back to as well, as a saved dive does. returnTo?: string; // The section's content with no card around it: what the Recordings card's // edit dialog holds. bare?: boolean; } export function DiveFileImport({ form, replaceMixtures, onFileAdded, onValuesApplied, onWrite, recordings = [], pending = [], onRemovePending, removedStored, onRemoveStored, onRestoreStored, diveUuid, returnTo, bare = false, }: DiveFileImportProps) { const { toast } = useToast(); const router = useRouter(); const [isParsingFile, setIsParsingFile] = useState(false); // What the import had to guess, shown next to the file list rather than in the // toast: the toast is gone in seconds, and this is exactly the thing the diver // has to still be able to see while fixing it. // // The rendered sentence, not the structured notes - it is a statement about the // file and never changes after import, so there is nothing to re-derive. See // `describeMixtureImport`. const [importNote, setImportNote] = useState(null); // A parsed file whose bytes look like they belong to a dive that already // exists, waiting for the diver to say which. **This path never attaches by // itself**, whatever the match says: a wrong match on a form is a dive the // diver did not ask for with nothing on screen to refuse it. Logbook import // is the opposite case and does attach automatically, because its preview is // already the confirmation step and its test is far stricter than this one. const [offer, setOffer] = useState(null); const [isAttaching, setIsAttaching] = useState(false); const fileInputRef = useRef(null); const inferredFigures = useRef({}); // Whether a newly parsed file is allowed to overwrite what is on the form. // // The first file of a dive is the form's best information and writes // everything it carries. Every later one - a second computer, or this // computer's other export - fills blanks only (and figures an earlier file of // the same computer derived, `InferredFigures`), which is how first-file-wins // reaches the dive's own fields: no server-side attach writes them, so if the // rule does not hold here it holds nowhere. `DECISIONS.md`, *"A second file of // one recording fills the form, and never overwrites it"*, has the whole // argument. const hasFileAlready = pending.length > 0 || recordings.length > 0; // Applies a parsed file to the form and hands it to the page to attach on // save. Shared by the ordinary path and by "Log as a new dive", which is the // same thing after the diver has refused a match. // // `alreadyHasFile` is passed rather than read off `hasFileAlready`, because // within one pick this is called several times before React has re-rendered // with the files it added: the prop still says "none" while the second file // of the batch is being applied, and first-file-wins would collapse into // last-file-wins. const acceptParsedFile = ( file: File, parsed: ParsedDive, alreadyHasFile: boolean, ) => { const sameRecording = (parsed.matches ?? []).some( (match) => match.same_recording && match.dive_uuid === diveUuid, ); const notes = applyParsedDiveToForm( form, parsed, replaceMixtures, alreadyHasFile || sameRecording ? "fill-only" : "prefill", inferredFigures.current, onWrite, ); onValuesApplied?.(); onFileAdded?.({ id: pendingFileId(), file, token: parsed.file_token, deviceLabel: recordingDeviceLabel(parsed.device), entry: parsed.entry_latitude != null && parsed.entry_longitude != null ? { latitude: parsed.entry_latitude, longitude: parsed.entry_longitude, } : null, }); // Kept rather than replaced. Only a prefill has anything to report, so // every file after the first answers `null` - and assigning that would wipe // the first file's note off the screen mid-batch. The pick clears it once, // in `handleFilesSelected`. const note = describeMixtureImport(notes); if (note) setImportNote(note); }; // One toast for the pick rather than one per file: a diver emptying a // computer's card picks several at once, and a stack of identical toasts is // the same sentence four times over. const announceImported = (count: number) => { if (count === 0) return; toast({ title: count === 1 ? "Dive file parsed" : `${count} dive files parsed`, description: count === 1 ? "Form fields have been filled in from the uploaded file. Please review before saving." : "Form fields have been filled in from the uploaded files. Please review before saving.", }); }; // Parses the picked files one at a time and writes each onto the form, in // pick order. // // **Serially, and never in parallel.** The mode each file is applied in // depends on what the ones before it left behind, so a `Promise.all` here // would make first-file-wins depend on which response came back first. // // One bad file doesn't end the batch: an oversized or unparseable file says // so and the rest carry on, since the diver picked them together and has no // way to re-pick "the other three". A *match* does pause it - that is a // question only they can answer - and the remainder rides along on the offer // until they have. const importFiles = async ( files: File[], seed: { hasFile: boolean; accepted: number }, ) => { let hasFile = seed.hasFile; let accepted = seed.accepted; setIsParsingFile(true); try { for (const [index, file] of files.entries()) { // Checked here as well as by the API so a diver on a slow connection // isn't made to upload an oversized file before being told no. The API // re-checks regardless, and its check is the one that counts. if (file.size > MAX_DIVE_FILE_SIZE) { toast({ title: "File too large", description: `${file.name} is larger than 5 MB, so it wasn't read.`, variant: "destructive", }); continue; } let parsed: ParsedDive; try { parsed = await divesAPI.parseDiveFile(file); } catch (error) { console.error("Failed to parse dive file:", error); toast({ title: "Error", description: getApiErrorMessage( error, `Failed to parse ${file.name}. Please check the file and try again.`, ), variant: "destructive", }); continue; } // A match on a dive the diver is not looking at is the one case that // stops and asks. A match on *this* dive is not a question - the file // belongs here, which is what the form is already doing - it only // decides that the write fills rather than overwrites, above. const elsewhere = (parsed.matches ?? []).filter( (match) => match.dive_uuid !== diveUuid, ); if (elsewhere.length > 0) { setOffer({ file, parsed, match: elsewhere[0], rest: files.slice(index + 1), accepted, }); return; } acceptParsedFile(file, parsed, hasFile); hasFile = true; accepted += 1; } announceImported(accepted); } finally { setIsParsingFile(false); } }; const handleFilesSelected = async ( e: React.ChangeEvent, ) => { const files = Array.from(e.target.files ?? []); // Cleared before the first parse rather than after the last. The input is // what has to fire `change` again if the diver re-picks the same file, and // holding the old selection across an upload that takes seconds is how that // gets missed. e.target.value = ""; if (files.length === 0) return; setImportNote(null); await importFiles(files, { hasFile: hasFileAlready, accepted: 0, }); }; // "Attach there": the file joins the dive it matched, right now, and the diver // goes to look at it. Nothing on this form is saved - the dive they were // filling in is the one this file turned out not to be. const attachToMatch = async () => { if (!offer) return; try { setIsAttaching(true); await divesAPI.attachRecordingFile( offer.match.dive_uuid, offer.file, offer.parsed.file_token, ); toast({ title: "File attached", description: `Added to dive #${offer.match.dive_number}.`, }); setOffer(null); router.push( withReturnTo(`/dives/${offer.match.dive_uuid}`, returnTo ?? null), ); } catch (error) { console.error("Failed to attach the dive file:", error); toast({ title: "Error", description: getApiErrorMessage( error, "Failed to attach the file to that dive. Please try again.", ), variant: "destructive", }); } finally { setIsAttaching(false); } }; const uploadButton = ( <> ); const content = ( <> {offer && ( !open && setOffer(null)} title="This file may already have a dive" description={describeMatch(offer.match)} confirmText="Attach there" variant="default" isLoading={isAttaching} secondaryAction={{ label: "Log as a new dive", onClick: () => { const { file, parsed, rest, accepted } = offer; setOffer(null); acceptParsedFile(file, parsed, hasFileAlready); // Picks the batch back up where the question interrupted it, and // does the announcing even when nothing is left: `rest` empty is // the ordinary single-file case, and the toast belongs to the // pick rather than to the loop. void importFiles(rest, { hasFile: true, accepted: accepted + 1 }); }, }} onConfirm={attachToMatch} /> )} {/* Rendered unconditionally and `sr-only` until there is something to say: a `role="status"` region that mounts together with its text is typically not announced at all, since screen readers register it on insertion and read *subsequent* changes. The text is computed once at import and never changes afterwards, so this announces exactly once - see `describeMixtureImport`. */}

{importNote}

onRemovePending?.(id)} removedStored={removedStored} onRemoveStored={onRemoveStored} onRestoreStored={onRestoreStored} /> ); if (bare) { return (
{uploadButton}
{content}
); } return ( } action={uploadButton} empty={ !importNote && pending.length === 0 && diveFileRows(recordings).length === 0 } > {content} ); } // What the import does, beside its section's title. // // A popover rather than a hover hint: a paragraph is more than a hint carries, and a // tap has to open it on a phone. function DiveFileImportInfo() { return ( } > Upload this dive's file, in any format logbook import reads, to fill in the fields below. Pick as many as you like at once: one per computer that recorded this dive, or one computer's second export alongside its first. A file holding several dives goes through logbook import instead. ); } // A parsed file the API matched against an existing dive, and the match it is // being offered against. Only the nearest is offered: the list is ordered by // start, and a second-best candidate is a question nobody can answer better // than the first. interface MatchOffer { file: File; parsed: ParsedDive; match: ParsedDiveMatch; /** * The files picked after this one, waiting on the answer. Held here rather * than in state of their own so that dismissing the dialog drops them with * the question - refusing to answer is not an instruction to carry on. */ rest: File[]; /** How many of this pick's files reached the form before the question. */ accepted: number; } // What the match dialog says. Names the dive and the device, because those are // the two things that make the offer checkable - "a dive nearby" is not // something a diver can agree or disagree with. function describeMatch(match: ParsedDiveMatch): string { const device = recordingDeviceLabel(match.device); const when = match.started_at ? formatDiveStartTime(match.started_at) : "an unrecorded time"; return match.same_recording ? `Dive #${match.dive_number} already has a recording from ${device ?? "the same computer"}, starting ${when}. Attaching adds this file to that recording and fills in whatever it left blank — nothing already recorded is overwritten.` : `Dive #${match.dive_number} started around the same time (${when}${device ? `, recorded by ${device}` : ""}). Attaching adds this file to that dive as a second recording.`; } // `crypto.randomUUID` is available in every browser this app supports and in // jsdom, but not over plain HTTP on a LAN address - a self-hoster's first // look at the app from another machine. The counter is the fallback, and is // only ever a key within one form's lifetime. let pendingFileCounter = 0; function pendingFileId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); } pendingFileCounter += 1; return `pending-${pendingFileCounter}`; }