"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; // 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(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); })} { 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} >
{neighbors.map((neighbor) => ( ))}
); }