"use client"; import Link from "next/link"; import { useState } from "react"; import type { PlanDelta, PlanDiff } from "@/lib/plandiff"; // Which notes actually differ between two arrangements. // // Fetched on demand rather than rendered with the page: two plans is two file // reads and an alignment over up to 880 notes each, and the question is only // asked when a song has more than one plan and somebody wants to know. // // Type-only import above -- lib/plandiff.ts reads the filesystem, and a value // import would drag node:fs into the client bundle (see lib/note-types.ts). type Result = PlanDiff & { summary: string; deltaCount: number; truncated: number }; const KIND_TONE: Record = { swapped: "text-[var(--color-dirty)]", moved: "text-[var(--color-sel)]", added: "text-[var(--color-good)]", removed: "text-[var(--color-bad)]", same: "text-[var(--color-dim)]", }; /** `same` is the bulk of any diff and is not why the tool was opened. */ const INTERESTING: PlanDelta["kind"][] = ["swapped", "moved", "added", "removed"]; export default function PlanDiffPanel({ song, plans, current, }: { song: string; plans: string[]; current: string | null; }) { const [a, setA] = useState(current ?? plans[0] ?? ""); const [b, setB] = useState(plans.find((p) => p !== (current ?? plans[0])) ?? ""); const [diff, setDiff] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [showSame, setShowSame] = useState(false); if (plans.length < 2) return null; const compare = async () => { setBusy(true); setError(null); setDiff(null); try { const res = await fetch( `/api/browse/plandiff?song=${encodeURIComponent(song)}&a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`, { cache: "no-store" }, ); const j = (await res.json()) as Result & { error?: string }; if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`); setDiff(j); } catch (e) { setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } }; const rows = diff ? diff.deltas.filter((d) => showSame || INTERESTING.includes(d.kind)) : []; return (
compare plans vs {a === b && pick two different plans}
{error &&

{error}

} {diff && (

{diff.summary}

{diff.voices.map((v) => ( ))}
voice same re-sung moved added dropped
{v.voice} {v.only && ( only in {v.only === "a" ? diff.a.name : diff.b.name} )} {v.same} {v.swapped} {v.moved} {v.added} {v.removed}
{(diff.videosOnlyA.length > 0 || diff.videosOnlyB.length > 0) && (

{diff.videosOnlyA.length > 0 && ( <> left the palette: {diff.videosOnlyA.join(" ")}.{" "} )} {diff.videosOnlyB.length > 0 && ( <> entered: {diff.videosOnlyB.join(" ")}. )}

)} {diff.truncated > 0 && (

showing the first {diff.deltas.length} of {diff.deltaCount} deltas — the counts above are complete

)}
{rows.map((d, i) => ( ))}
at voice what {diff.a.name} {diff.b.name}
{d.songTime.toFixed(2)} {d.voice} {d.kind === "swapped" ? "re-sung" : d.kind} {d.dSlot ? ` ${d.dSlot > 0 ? "+" : ""}${d.dSlot}s` : ""}
)}
); } function Picker({ value, onChange, plans, name, }: { value: string; onChange: (v: string) => void; plans: string[]; name: string; }) { return ( ); } function Side({ row }: { row: PlanDelta["a"] }) { if (!row) return —; return ( {row.link ? ( {row.clipId} ) : ( {row.clipId} )} {row.word && "{row.word}"} {row.shift.toFixed(2)} deck ); }