"use client"; import { useEffect, useState } from "react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { ButtonSpinner } from "@/components/ui/button-spinner"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { DatePicker } from "@/components/ui/date-picker"; import { useToast } from "@/components/ui/use-toast"; import { divesAPI, DiveRenumberResult } from "@/lib/api/dives"; import { combineStartTime, formatDiveDateTime, getBrowserUtcOffsetMinutes, } from "@/lib/date-time"; import { getApiErrorMessage, getApiErrorStatus } from "@/lib/api/error"; import { POSTGRES_INTEGER_MAX } from "@/lib/validations/fields"; // Long enough that typing "1", "0", "2" into the start-at box is one preview // rather than three. const PREVIEW_DEBOUNCE_MS = 350; // Turns the scope date picker's "YYYY-MM-DD" into the offset-aware instant the // API compares against. // // Midnight in the *browser's* current offset, which is a choice worth naming: // a dive's `start_time` carries its own original offset, so a boundary of // "2023-01-01" is inherently a little fuzzy for a diver who was in another // timezone that week. The alternative - asking for a date *and* a timezone - // buys precision nobody needs to split a log into "the old part" and "the // recent part", which is the only thing this control is for. // // This is one of the two remaining write paths that stamps the browser's offset // on, and it stays that way while the display and edit paths stop: it names an // *instant* to compare dives against, and `DiveRenumberRequest.from_start_time` // is offset-required on the API for that reason. Nothing here is editing a // dive's own recorded zone, so there is no unknown state to preserve. function scopeStartTime(date: string): string | undefined { if (!date) return undefined; return combineStartTime(`${date} 00:00:00`, getBrowserUtcOffsetMinutes()); } // A preview, tagged with the inputs that produced it. Tagged rather than just // stored, so the change list on screen and the button under it can never // describe different renumbers: everything below is gated on the preview // matching what the form currently says. interface TaggedPreview { startAt: number; fromDate: string; // Null when the request failed - distinct from having no preview for these // inputs yet, which is `null` for the whole `TaggedPreview`. result: DiveRenumberResult | null; // Why it failed, when the API said: a renumber that would number the last dive // past what the column holds is a 422 with a sentence of its own. error: string | null; } // What the form opens on, for a caller that knows which part of the log wants // renumbering. Without it the form opens on the whole log from #1. export interface RenumberDefaults { startAt: number; // "YYYY-MM-DD", read at the browser's offset as `scopeStartTime` reads it. fromDate: string; } function RenumberForm({ defaults, onDone, }: { defaults?: RenumberDefaults; onDone: (renumbered: boolean) => void; }) { const { toast } = useToast(); const [startAt, setStartAt] = useState(String(defaults?.startAt ?? 1)); const [fromDate, setFromDate] = useState(defaults?.fromDate ?? ""); const [preview, setPreview] = useState(null); const [isApplying, setIsApplying] = useState(false); // `""` while the box is empty mid-edit; nothing is previewed until it holds a // number, rather than previewing a renumber from 1 nobody asked for. const parsedStartAt = Number.parseInt(startAt, 10); const isStartAtValid = Number.isInteger(parsedStartAt) && parsedStartAt >= 1 && parsedStartAt <= POSTGRES_INTEGER_MAX; const isPreviewCurrent = preview !== null && preview.startAt === parsedStartAt && preview.fromDate === fromDate; useEffect(() => { if (!isStartAtValid) return; let cancelled = false; const timer = setTimeout(async () => { let result: DiveRenumberResult | null = null; let failure: string | null = null; try { result = await divesAPI.renumberDives({ start_at: parsedStartAt, from_start_time: scopeStartTime(fromDate), dry_run: true, }); } catch (error) { // Shown in place of the change list rather than toasted: a preview that // failed to load is information about this dialog, not about the log. console.error("Failed to preview the renumber:", error); failure = getApiErrorStatus(error) === 422 ? getApiErrorMessage(error, "") : null; } // One state update for both outcomes, tagged with the inputs it came // from - a failed preview still has to replace whatever was on screen, // or the diver reads a change list for different settings. if (!cancelled) { setPreview({ startAt: parsedStartAt, fromDate, result, error: failure, }); } }, PREVIEW_DEBOUNCE_MS); return () => { cancelled = true; clearTimeout(timer); }; }, [isStartAtValid, parsedStartAt, fromDate]); const apply = async () => { try { setIsApplying(true); const result = await divesAPI.renumberDives({ start_at: parsedStartAt, from_start_time: scopeStartTime(fromDate), }); toast({ title: "Dives renumbered", description: `${result.changes.length} dive${ result.changes.length === 1 ? "" : "s" } renumbered.`, }); onDone(true); } catch (error) { console.error("Failed to renumber dives:", error); toast({ title: "Error", description: getApiErrorMessage( error, "Failed to renumber dives. Please try again.", ), variant: "destructive", }); setIsApplying(false); } }; const changeCount = isPreviewCurrent ? (preview.result?.changes.length ?? 0) : 0; return ( <> Renumber Dives Numbers your dives consecutively in date order. Nothing else about them changes. Worth doing once your log is complete — if some of your dives are recorded elsewhere, the gaps between your numbers may be deliberate.
setStartAt(e.target.value)} disabled={isApplying} />

The number your earliest dive in scope gets.

Dates are YYYY-MM-DD. Leave empty to renumber the whole log.

{!isStartAtValid ? ( Enter a starting number from 1 to{" "} {POSTGRES_INTEGER_MAX.toLocaleString("en-US")}. ) : !isPreviewCurrent ? ( Working out what would change… ) : preview.result === null ? ( {preview.error || "Couldn't load a preview. Check your connection and try again."} ) : changeCount === 0 ? ( {preview.result.dives_in_scope === 0 ? "No dives fall in this range." : "Your dives are already numbered this way — nothing would change."} ) : ( <>

{changeCount} of {preview.result.dives_in_scope} dive {preview.result.dives_in_scope === 1 ? "" : "s"} would be renumbered.

{/* Every change, scrollable - a preview that says "and 180 more" hides exactly the rows a diver would want to check against a paper logbook. */}
    {preview.result.changes.map((change) => (
  • #{change.dive_number} → #{change.new_dive_number} {formatDiveDateTime(change.start_time)}
  • ))}
)}
); } export interface RenumberDivesDialogProps { open: boolean; onOpenChange: (open: boolean) => void; // Called after numbers have actually been written, so the caller can refetch // the list and the numbering summary this just invalidated. onRenumbered: () => void; defaults?: RenumberDefaults; // For a caller whose Renumber button leaves the page once the log is // renumbered, so focus has somewhere to go when the dialog closes. finalFocus?: React.ComponentProps["finalFocus"]; } // Renumbers a log consecutively, in date order. The only thing in the app that // rewrites numbers a diver typed - which is why it's a dialog they open, shows // them every change before making it, and is never triggered by anything else. // // The form is a child mounted only while open, so each visit starts from clean // defaults rather than inheriting half-remembered inputs from the last one. export function RenumberDivesDialog({ open, onOpenChange, onRenumbered, defaults, finalFocus, }: RenumberDivesDialogProps) { return ( {open ? ( { if (renumbered) onRenumbered(); onOpenChange(false); }} /> ) : null} ); }