Ctrl K
ComunidadGuías…

src/components/dives/dive-merge-action.tsx

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

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