diff-panel.tsx192 lines · main
1'use client';
2
3import { useState } from 'react';
4
5/**
6 * "What changed since this snapshot" — a friendly, plain-language compare view
7 * for non-coders. Calls a server action that fetches the diff from the API,
8 * then renders it in everyday words ("3 rows added, 1 changed in Customers")
9 * instead of database jargon. Collapsed until the user clicks "compare".
10 */
11
12interface ColumnDiff {
13 name: string;
14 dataType: string;
15}
16
17interface TableRowDiff {
18 added: number;
19 removed: number;
20 changed: number;
21 liveRowCount: number;
22 snapshotRowCount: number;
23 truncated: boolean;
24}
25
26interface TableDiff {
27 name: string;
28 columnsAdded: ColumnDiff[];
29 columnsRemoved: ColumnDiff[];
30 rows: TableRowDiff | null;
31 noPrimaryKey: boolean;
32}
33
34export interface SnapshotDiff {
35 snapshotId: string;
36 snapshotName: string;
37 snapshotCreatedAt: string;
38 tablesAdded: string[];
39 tablesRemoved: string[];
40 tables: TableDiff[];
41 rowCap: number;
42}
43
44function plural(n: number, word: string): string {
45 return `${n} ${word}${n === 1 ? '' : 's'}`;
46}
47
48/** True when this table has nothing different to report. */
49function tableUnchanged(t: TableDiff): boolean {
50 if (t.columnsAdded.length > 0 || t.columnsRemoved.length > 0) return false;
51 if (!t.rows) return false; // can't tell (no primary key) — show it
52 return t.rows.added === 0 && t.rows.removed === 0 && t.rows.changed === 0;
53}
54
55export function DiffPanel({
56 snapshotName,
57 loadDiff,
58}: {
59 snapshotName: string;
60 loadDiff: () => Promise<SnapshotDiff>;
61}) {
62 const [open, setOpen] = useState(false);
63 const [loading, setLoading] = useState(false);
64 const [error, setError] = useState<string | null>(null);
65 const [diff, setDiff] = useState<SnapshotDiff | null>(null);
66
67 async function toggle() {
68 if (open) {
69 setOpen(false);
70 return;
71 }
72 setOpen(true);
73 if (diff || loading) return;
74 setLoading(true);
75 setError(null);
76 try {
77 const result = await loadDiff();
78 setDiff(result);
79 } catch (e) {
80 setError(e instanceof Error ? e.message : 'could not compare');
81 } finally {
82 setLoading(false);
83 }
84 }
85
86 const changedTables = diff?.tables.filter((t) => !tableUnchanged(t)) ?? [];
87 const nothingChanged =
88 diff !== null &&
89 diff.tablesAdded.length === 0 &&
90 diff.tablesRemoved.length === 0 &&
91 changedTables.length === 0;
92
93 return (
94 <div className="flex flex-col gap-2">
95 <button
96 type="button"
97 onClick={toggle}
98 className="self-start rounded-md border border-[var(--color-border-subtle)] px-3 py-1.5 font-mono text-xs text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-text)]"
99 >
100 {open ? 'hide changes' : 'compare'}
101 </button>
102
103 {open && (
104 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg)] p-4 font-mono text-xs">
105 <p className="text-[var(--color-text-muted)]">
106 what changed in your data since &ldquo;{snapshotName}&rdquo; was saved:
107 </p>
108
109 {loading && (
110 <p className="mt-2 text-[var(--color-text-subtle)]">comparing&hellip;</p>
111 )}
112
113 {error && (
114 <p className="mt-2 text-[var(--color-error)]">
115 couldn&apos;t compare: {error}
116 </p>
117 )}
118
119 {diff && !loading && !error && (
120 <div className="mt-3 flex flex-col gap-3">
121 {nothingChanged && (
122 <p className="text-[var(--color-text)]">
123 nothing has changed — your data is exactly as it was in this snapshot.
124 </p>
125 )}
126
127 {diff.tablesAdded.length > 0 && (
128 <p className="text-[var(--color-text)]">
129 new {diff.tablesAdded.length === 1 ? 'table' : 'tables'} added since:{' '}
130 <span className="text-[var(--color-text-muted)]">
131 {diff.tablesAdded.join(', ')}
132 </span>
133 </p>
134 )}
135
136 {diff.tablesRemoved.length > 0 && (
137 <p className="text-[var(--color-text)]">
138 {diff.tablesRemoved.length === 1 ? 'table' : 'tables'} removed since:{' '}
139 <span className="text-[var(--color-text-muted)]">
140 {diff.tablesRemoved.join(', ')}
141 </span>
142 </p>
143 )}
144
145 {changedTables.map((t) => (
146 <div
147 key={t.name}
148 className="rounded-md border border-[var(--color-border-subtle)] px-3 py-2"
149 >
150 <p className="text-[var(--color-text)]">{t.name}</p>
151 <ul className="mt-1 flex flex-col gap-0.5 text-[var(--color-text-muted)]">
152 {t.rows && t.rows.added > 0 && (
153 <li>{plural(t.rows.added, 'row')} added</li>
154 )}
155 {t.rows && t.rows.changed > 0 && (
156 <li>{plural(t.rows.changed, 'row')} changed</li>
157 )}
158 {t.rows && t.rows.removed > 0 && (
159 <li>{plural(t.rows.removed, 'row')} removed</li>
160 )}
161 {t.columnsAdded.length > 0 && (
162 <li>
163 new {t.columnsAdded.length === 1 ? 'field' : 'fields'}:{' '}
164 {t.columnsAdded.map((c) => c.name).join(', ')}
165 </li>
166 )}
167 {t.columnsRemoved.length > 0 && (
168 <li>
169 {t.columnsRemoved.length === 1 ? 'field' : 'fields'} removed:{' '}
170 {t.columnsRemoved.map((c) => c.name).join(', ')}
171 </li>
172 )}
173 {t.noPrimaryKey && (
174 <li className="text-[var(--color-text-subtle)]">
175 can&apos;t compare rows in this table (it has no unique id column)
176 </li>
177 )}
178 {t.rows?.truncated && (
179 <li className="text-[var(--color-text-subtle)]">
180 (only the first {diff.rowCap.toLocaleString()} rows were compared)
181 </li>
182 )}
183 </ul>
184 </div>
185 ))}
186 </div>
187 )}
188 </div>
189 )}
190 </div>
191 );
192}