src/components/dives/dive-merge-action.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { ReactNode, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useToast } from "@/components/ui/use-toast";
import { Dive, DiveNeighbor, divesAPI } from "@/lib/api/dives";
import { getApiErrorMessage } from "@/lib/api/error";
import { canMergeDive } from "@/lib/dive-recordings";
import { formatDiveDateTime } from "@/lib/date-time";
import { cn } from "@/lib/utils";
import { withReturnTo } from "@/lib/return-to";
interface DiveMergeActionProps {
dive: Dive;
// Re-reads the dive after a merge that left this one standing, **and bumps
// `reloadToken`**. Both halves are the page's to do, because both consumers
// of `GET /dive/{uuid}/neighbors` on this page - this and `DiveNeighborNav` -
// go stale on the same event and there is no reason for each to discover it
// separately.
onMerged: () => void | Promise<void>;
// The page's reload signal, shared with `DiveNeighborNav` - see that
// component's prop for why the dive's uuid cannot carry it.
reloadToken?: number;
// Renders the trigger - on the dive page, a menu item. It lives in a menu that
// unmounts on close, so the state and the dialog stay here and only the
// opener is handed out: `null` while there is nothing to merge with.
children: (openMerge: (() => void) | null) => ReactNode;
}
// How a neighbour is named in the dialog: the diver's own number and the day.
// `#212` alone is ambiguous in a log with duplicate numbers, which the numbering
// summary exists because logs have.
function neighborName(neighbor: DiveNeighbor): string {
const date = formatDiveDateTime(neighbor.start_time, {
year: "numeric",
month: "short",
day: "numeric",
});
return `#${neighbor.dive_number}, ${date}`;
}
/**
* Fold this dive together with the one before or after it.
*
* **The two neighbours, and nothing else.** A dive computer that surfaced for a
* few minutes logs one dive as two consecutive ones, and a second computer's
* record of one dive sits in the same place - so the candidates are exactly the
* dives a chopped recording is adjacent to. A free-form dive picker would be a
* search for something that, by construction, is never more than one step away.
*
* Which dive survives is the server's answer, not the diver's: the earlier of
* the two, by the same clock rule the match gates use. So this navigates to
* whichever came back rather than assuming it stayed put.
*
* Offers no opener for a dive with no recording. The API refuses to merge a
* hand-entered dive - Subsurface's own rule - and an action that can only 422 is
* not an action.
*/
export function DiveMergeAction({
dive,
onMerged,
reloadToken = 0,
children,
}: DiveMergeActionProps) {
const router = useRouter();
// The dive a merge keeps leads back where the one on screen did.
const from = useSearchParams()?.get("from") ?? null;
const { toast } = useToast();
const [isOpen, setIsOpen] = useState(false);
const [isMerging, setIsMerging] = useState(false);
const [chosen, setChosen] = useState<DiveNeighbor | null>(null);
// Keyed by the uuid they were fetched for, the same shape `DiveNeighborNav`
// uses: this component survives a step of the pager, and plain state would
// spend that render offering the previous dive's neighbours.
const [loaded, setLoaded] = useState<{
diveUuid: string;
neighbors: DiveNeighbor[];
} | null>(null);
const diveUuid = dive.uuid;
const mergeable = canMergeDive(dive);
useEffect(() => {
if (!mergeable) return;
let cancelled = false;
const load = async () => {
try {
const neighbors = await divesAPI.getDiveNeighbors(diveUuid);
if (cancelled) return;
setLoaded({
diveUuid,
neighbors: [neighbors.previous, neighbors.next].filter(
(neighbor): neighbor is DiveNeighbor => neighbor != null,
),
});
} catch (error) {
// Silent, and the menu simply offers no Merge. This is a shortcut for
// an occasional repair, not part of the dive the diver came to read.
console.error("Failed to load neighbouring dives:", error);
}
};
load();
return () => {
cancelled = true;
};
}, [diveUuid, mergeable, reloadToken]);
const neighbors = loaded?.diveUuid === diveUuid ? loaded.neighbors : [];
if (!mergeable || neighbors.length === 0) return children(null);
const handleMerge = async () => {
if (!chosen) return;
try {
setIsMerging(true);
const result = await divesAPI.mergeDives([dive.uuid, chosen.uuid]);
setIsOpen(false);
setChosen(null);
toast({
title: "Dives merged",
description: result.folded
? "One computer's two records of the dive were folded into a single recording."
: "The two recordings are now side by side on one dive.",
});
// The survivor is the earlier dive, which is often the *other* one - and
// the uuid that lost is soft-deleted and no longer resolves, so staying
// here would be a 404 on the next reload.
if (result.dive.uuid === dive.uuid) {
// Cleared before the page's `onMerged` bumps `reloadToken` and the
// effect refetches, so the offer goes away for the one round trip
// rather than offering a list that is now wrong. The same
// dead-until-known stance `DiveNeighborNav` takes, and for the same
// reason: a control pointing somewhere that no longer exists is worse
// than a control that is briefly absent.
setLoaded(null);
await onMerged();
} else {
router.push(withReturnTo(`/dives/${result.dive.uuid}`, from));
}
} catch (error) {
console.error("Failed to merge dives:", error);
toast({
title: "Error",
description: getApiErrorMessage(
error,
"Failed to merge these dives. Please try again.",
),
variant: "destructive",
});
} finally {
setIsMerging(false);
}
};
return (
<>
{children(() => {
setChosen(neighbors.length === 1 ? neighbors[0] : null);
setIsOpen(true);
})}
<ConfirmDialog
open={isOpen}
onOpenChange={(open) => {
setIsOpen(open);
if (!open) setChosen(null);
}}
title="Merge with a neighbouring dive"
// Says plainly what does *not* come along, because the natural reading
// is that everything does. The oxygen-exposure readings are each
// device's own running accounting, not a per-dive quantity that can be
// added up: every recording keeps its own, and a fold of one computer's
// two records only fills the blanks the surviving one had.
description="Their recordings, cylinders, sites, gear, species, people, tags and notes all end up on one dive — the earlier of the two — and the other is deleted for good. Each recording keeps its own oxygen-exposure readings rather than having them combined."
confirmText="Merge"
variant="default"
isLoading={isMerging}
confirmDisabled={chosen === null}
onConfirm={handleMerge}
>
<div
role="group"
aria-label="Which dive to merge with"
className="flex flex-col gap-2"
>
{neighbors.map((neighbor) => (
<Button
key={neighbor.uuid}
type="button"
variant={chosen?.uuid === neighbor.uuid ? "secondary" : "outline"}
aria-pressed={chosen?.uuid === neighbor.uuid}
className={cn(
"justify-start",
chosen?.uuid === neighbor.uuid && "font-semibold",
)}
onClick={() => setChosen(neighbor)}
>
{neighborName(neighbor)}
</Button>
))}
</div>
</ConfirmDialog>
</>
);
}