src/components/dives/dive-profile-card.tsx
Rama main · réplica de lectura del repositorio original.
"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<ReadonlyMap<
string,
ProfileResult
> | 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<string | null>(null);
// The title row's slot for the chart's controls - see `menuContainer`.
const [menuSlot, setMenuSlot] = useState<HTMLDivElement | null>(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<ProfileResult> => {
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 (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<CardTitle as="h2" className="flex items-center gap-2">
<LineChart className="h-5 w-5" />
Dive Profile
</CardTitle>
{/* The chart's 36px buttons - its channel menu and full screen -
overhanging the title's line as the other cards' pencils do; see
`DiveCardHeader`. */}
<div
ref={setMenuSlot}
className="-my-(--card-title-lift) -mr-2 flex touch:-mr-3"
/>
</div>
</CardHeader>
<CardContent className={CARD_SECTION_CONTENT}>
{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.
<div
role="group"
aria-label="Which recording to chart"
data-testid="profile-recording-switcher"
className="mb-4 flex flex-wrap gap-2"
>
{charted.map((recording) => {
const isShown = recording.uuid === shown.uuid;
return (
<Button
key={recording.uuid}
type="button"
variant={isShown ? "secondary" : "outline"}
size="sm"
aria-pressed={isShown}
className={cn(isShown && "font-semibold")}
onClick={() => setShownUuid(recording.uuid)}
>
{recordingDeviceLabel(recording.device) ??
`${UNNAMED_DEVICE_LABEL} · ${recordingLabel(recording)}`}
</Button>
);
})}
</div>
)}
{result === null ? (
<SectionSpinner />
) : 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.
<DiveProfileChart
profile={result.profile}
mixtures={dive.mixtures}
menuContainer={menuSlot}
scaleWith={peers}
axis={axis}
/>
) : (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">{result.message}</p>
{/* 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" && (
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAttempt((n) => n + 1)}
>
Try again
</Button>
)}
</div>
)}
{/* 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. */}
<p className="mt-4 text-xs text-muted-foreground">
{describeProfileContents(info, units)}
</p>
</CardContent>
</Card>
);
}