diff-panel.tsx192 lines · main
| 1 | 'use client'; |
| 2 | |
| 3 | import { 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 | |
| 12 | interface ColumnDiff { |
| 13 | name: string; |
| 14 | dataType: string; |
| 15 | } |
| 16 | |
| 17 | interface TableRowDiff { |
| 18 | added: number; |
| 19 | removed: number; |
| 20 | changed: number; |
| 21 | liveRowCount: number; |
| 22 | snapshotRowCount: number; |
| 23 | truncated: boolean; |
| 24 | } |
| 25 | |
| 26 | interface TableDiff { |
| 27 | name: string; |
| 28 | columnsAdded: ColumnDiff[]; |
| 29 | columnsRemoved: ColumnDiff[]; |
| 30 | rows: TableRowDiff | null; |
| 31 | noPrimaryKey: boolean; |
| 32 | } |
| 33 | |
| 34 | export 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 | |
| 44 | function 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. */ |
| 49 | function 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 | |
| 55 | export 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 “{snapshotName}” was saved: |
| 107 | </p> |
| 108 | |
| 109 | {loading && ( |
| 110 | <p className="mt-2 text-[var(--color-text-subtle)]">comparing…</p> |
| 111 | )} |
| 112 | |
| 113 | {error && ( |
| 114 | <p className="mt-2 text-[var(--color-error)]"> |
| 115 | couldn'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'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 | } |