Ctrl K
ComunidadGuías…

src/components/dives/dive-profile-card.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"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>
  );
}