"use client"; import { useEffect, useState } from "react"; import { LineChart } from "lucide-react"; import { CARD_SECTION_CONTENT, Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { SectionSpinner } from "@/components/ui/section-spinner"; import { DiveProfileChart } from "@/components/dives/dive-profile-chart"; import { type Dive, type DiveProfile, type DiveProfileInfo, divesAPI, } from "@/lib/api/dives"; import { diveRecordings, recordingDeviceLabel, recordingLabel, UNNAMED_DEVICE_LABEL, } from "@/lib/dive-recordings"; import { cn } from "@/lib/utils"; import { getApiErrorMessage, getApiErrorStatus } from "@/lib/api/error"; import { useUnits } from "@/hooks/useUnits"; import { formatDepth, type UnitSystem } from "@/lib/units"; import { countNoun } from "@/lib/format"; import { type DiveAxis, diveAxisEnd } from "@/lib/dive-profile"; // What the card knows about the profile right now. `null` is "in flight", derived // rather than stored - the same shape `useAuthedBlobUrl` uses, so nothing has to be // set synchronously in an effect body just to mark the fetch as started. // // A single settled value rather than a `profile` + `hasFailed` pair, because two // independent states can disagree: a failure flag not reset when the dive or the // profile version changes is permanent for the life of the page, and a `profile` not // cleared renders the *previous* version's chart under a re-import's sample count. type ProfileResult = | { status: "ready"; profile: DiveProfile } // The API says this dive has no profile. Nothing to retry - re-asking returns the // same 404, and offering a retry button implies otherwise. | { status: "missing"; message: string } // A 401, a 5xx, or the network. Worth another go. | { status: "failed"; message: string }; interface DiveProfileCardProps { dive: Dive; } // What the dive computer recorded, from the summary the dive detail response // already carries - so it is on screen before the series themselves have // finished loading, and stays put when they fail. // // The marker count comes from `event_count` rather than from the loaded // profile's `events.length` for that reason, and reads `> 0` rather than // `!= null`: null is a profile extracted before the API recorded events at all // and 0 is one that looked and found none, and neither is worth a sentence. // // So this count can exceed the ticks on the chart, and that is intended rather // than a drift to fix. The chart's axis ends where the dive does, so a marker the // computer logged at the surface afterwards - a low-pressure alarm, or a FIT // `user_marker` pressed after the recording's last sample, which the API leaves // where the file put it as the DiveJSON spec allows (§6.4) - is clipped from the // chart and still counted here. **This line describes what the dive computer // recorded; the chart describes what fits on the axis.** Reconciling them would // mean either the card waiting on the series it deliberately doesn't wait for, or // the chart drawing past its plot. function describeProfileContents( info: DiveProfileInfo, units: UnitSystem, ): string { const parts = [ `${info.depth_sample_count.toLocaleString()} depth samples recorded by the dive computer`, ]; if (info.event_count && info.event_count > 0) { parts.push(countNoun(info.event_count, "marker")); } // Stated here as well as drawn, because a deco obligation is the one thing in // this card worth knowing without reading a chart. if (info.max_ceiling != null) { parts.push( `a deco ceiling to ${formatDepth(info.max_ceiling, units, { decimals: 1 })}`, ); } // The two figures a diver asks of a heart-rate trace, which the chart answers // only on hover. The average is the API's, taken as it stored the series, which // the capped points the chart draws cannot reproduce. const heartRate = [ info.max_heart_rate != null && `peaking at ${info.max_heart_rate} bpm`, info.average_heart_rate != null && `averaging ${info.average_heart_rate} bpm`, ].filter(Boolean); if (heartRate.length > 0) { parts.push(`a heart rate ${heartRate.join(" and ")}`); } return `${parts.join(", ")}.`; } // The dive's recorded curves - depth, temperature, tank pressure and heart rate, // the deco ceiling, and the six readouts its computer worked out for itself - on // the dive detail page. // // Renders nothing when the dive has no profile - the same call as // `DiveRecordingsCard`, and the opposite of `GasUseCard`: a dive logged by hand // has no samples and never could, so there is nothing for the diver to act on // and nothing worth an empty state. A dive that *does* have a recording always // shows this if that recording carried samples. // // Needs no `onChanged` callback. Deleting a recording's last file deletes the // recording with it (see the API's `delete_dive_file`), and the page's // `refreshDive` already drops it from `dive.recordings` - which re-renders this // card against what is left. // // **One chart, and a switcher rather than an overlay**, on a dive that two // computers recorded. Subsurface's arrow keys switch too: two depth traces on // one axis with two pressure families is a legend problem this app does not have // to solve, and the question a diver actually asks is "what did *this* computer // see", not "where do they disagree by a pixel". What they share is the grid: // the scales, and the time axis wherever the API could place both on it by // their clocks, so that answer reads the same way off either computer's - and // switching moves the curve and nothing else. export function DiveProfileCard({ dive }: DiveProfileCardProps) { const units = useUnits(); // Every charted recording's samples by recording uuid, all fetched together: // the shown one is drawn, and the others set its scales alongside it - see // `scaleWith`. Null while in flight, for the reasons `ProfileResult` gives. const [results, setResults] = useState | null>(null); // Bumped by the retry button to re-run the effect below. const [attempt, setAttempt] = useState(0); // Which recording's samples are on screen, by uuid rather than by index: a // deletion or a promotion reorders the list, and an index would quietly point // at a different device's curves afterwards. const [shownUuid, setShownUuid] = useState(null); // The title row's slot for the chart's controls - see `menuContainer`. const [menuSlot, setMenuSlot] = useState(null); // Only recordings that actually carry samples: a recording whose files held // none has nothing to draw, and a switcher button leading to a permanent 404 // is worse than not offering it. const charted = diveRecordings(dive).filter( (recording) => recording.profile != null, ); // Falls back to the first charted recording - which is the primary wherever // the primary has a profile - whenever the chosen one is gone. const shown = charted.find((recording) => recording.uuid === shownUuid) ?? charted[0]; const info = shown?.profile ?? null; const diveUuid = dive.uuid; // Each profile's identity as far as the browser cache is concerned - the same // `uuid:updated_at` pair `DiveRecordingsCard` uses, and for the same reason. // `uuid` alone would not be enough if a row were ever updated in place, and // `updated_at` alone would not be enough here at all: re-extraction is a // delete-then-insert, so a re-extracted profile is a *new row* with a fresh // `uuid` and a null `updated_at`. const versions = charted.map((recording) => ({ recordingUuid: recording.uuid, version: `${recording.profile?.uuid ?? ""}:${recording.profile?.updated_at ?? ""}`, })); const fetchKey = versions .map(({ recordingUuid, version }) => `${recordingUuid}=${version}`) .join(","); useEffect(() => { if (versions.length === 0) return; let isCurrent = true; const fetchProfile = async ( recordingUuid: string, version: string, ): Promise => { try { const data = await divesAPI.getRecordingProfile( diveUuid, recordingUuid, version, ); return { status: "ready", profile: data }; } catch (error) { console.error("Failed to fetch dive profile:", error); // A muted line in the card rather than a toast: nothing the diver did // caused this and there is nothing for them to do about it, so it // belongs where the chart would have been, not over the whole page. // // The API's own wording where it has one - "This dive has no profile" - // now that the response interceptor unwraps blob-wrapped error bodies. const status = getApiErrorStatus(error) === 404 ? "missing" : "failed"; return { status, message: getApiErrorMessage( error, "This recording's profile couldn't be loaded.", ), }; } }; Promise.all( versions.map( async ({ recordingUuid, version }) => [recordingUuid, await fetchProfile(recordingUuid, version)] as const, ), ).then((entries) => { if (isCurrent) setResults(new Map(entries)); }); return () => { isCurrent = false; // Back to "in flight" for whatever comes next. This is what stops a stale // chart or a stale failure surviving a dive change or a re-import. setResults(null); }; // `versions` is a fresh array on every render; `fetchKey` is what actually // identifies the payloads. // eslint-disable-next-line react-hooks/exhaustive-deps }, [diveUuid, fetchKey, attempt]); const result = (shown && results?.get(shown.uuid)) ?? null; // A computer whose samples failed to load only stops widening the scales; // the one on screen still draws. const peers = [...(results ?? [])].flatMap(([recordingUuid, peer]) => recordingUuid !== shown?.uuid && peer.status === "ready" ? [peer.profile] : [], ); // The shown recording's place on the dive's shared time axis, which runs to // the latest end over every placed recording whose samples loaded - the same // extent whichever of them is shown. Null where the API placed it nowhere, or // sent no placement at all: it is then charted on its own axis. const placed = charted.flatMap((recording) => { const loaded = results?.get(recording.uuid); return recording.start_on_dive_axis != null && loaded?.status === "ready" ? [{ start: recording.start_on_dive_axis, profile: loaded.profile }] : []; }); const axis: DiveAxis | null = shown?.start_on_dive_axis != null ? { start: shown.start_on_dive_axis, end: diveAxisEnd(placed) } : null; if (!info || !shown) return null; return (
Dive Profile {/* The chart's 36px buttons - its channel menu and full screen - overhanging the title's line as the other cards' pencils do; see `DiveCardHeader`. */}
{charted.length > 1 && ( // A named group rather than loose buttons: these are one control with // several settings, and a screen reader meeting them in the middle of // a card otherwise hears a row of device names with nothing saying // what choosing one does.
{charted.map((recording) => { const isShown = recording.uuid === shown.uuid; return ( ); })}
)} {result === null ? ( ) : result.status === "ready" ? ( // Every recording's labels join the dive's own cylinders - the API // maps a second computer's onto them - so one list names them all. ) : (

{result.message}

{/* Only for the transient case. A 404 means this dive genuinely has no profile, and a retry button there would just fail again. */} {result.status === "failed" && ( )}
)} {/* At the foot, as the Gas Consumption card puts its own note: it describes the recording, and the chart is what the card is for. */}

{describeProfileContents(info, units)}

); }