Admin — V1 Upload (M15 Cutover Flow)¶
The operator-facing UI that wraps the M15 ETL + reconciliation pipeline. The screen Stefan reaches once at V2 cutover (and a handful of times before, in dress-rehearsal mode against the test DB). Owned by Mira. Cross-cuts: process — V1 upload spec (the operator contract this UI implements), v2-scope M15, data-model § Migration shape, ADR-0007 § Order lifecycle, ADR-0008 § Migration from V1, admin-catalogue-moderation (admin-shell precedent), design-tokens.
Source requirement¶
- v2-scope M15 — "Migrate full XLSX history: 329 client orders + 416 self orders + Stefan's rackets list."
- process — V1 upload spec — the canonical operator contract. The CLI form (
python -m migration.etl --xlsx … --reconcile --dry-run) is the technical baseline; this spec wraps that pipeline behind a browser-driven admin UI so Stefan does not need a shell on the production VPS at cutover. - migration/etl.py + migration/report.py — Vera's existing M15 implementation (MR !40, MR !54). The UI calls into this same pipeline; it does not re-implement parsing or reconciliation.
- ADR-0007, ADR-0008 — date-causal relaxation + receipt-number assignment; the UI must show the warning counts these ADRs predict.
Goal¶
Three commitments:
- The dry-run + report is the gate, not the upload itself. The admin UI's hero job is rendering the reconciliation report in a form Stefan can sign off on with one glance at the Summary status line. The actual write-path is mechanical once the report is
OK. - Approve and Cancel are the only two outcomes. No partial commits, no "approve some rows", no override. If the report is
FAIL, Stefan fixes V1 and re-uploads; the UI does not let him bypass. - One operator, one session, one cutover. Stefan runs this flow at most a few times: dress rehearsal against the test DB, then once at the production switch-over. The UI optimises for clarity over throughput — a long, scrollable report is fine; a single primary CTA per stage is the pattern.
Information architecture¶
Admin-only route family at /admin/v1-upload. The flow is a four-stage state machine; each stage corresponds to a route, and the URL is the source of truth so a refresh / accidental tab-close doesn't lose state.
Admin
├── Dashboard (existing)
└── V1 upload (new — admin-only)
├── Stage 1 — Upload GET /admin/v1-upload
│ POST /admin/v1-upload (multipart/form-data)
│ → kicks off dry-run, redirects to ↓
├── Stage 2 — Dry-run report GET /admin/v1-upload/runs/:run_id
│ ├── Summary (OK / FAIL)
│ ├── Differences (when non-clean)
│ ├── Counts table
│ ├── Per-year revenue table
│ ├── Mean turnaround
│ ├── V2 entity totals
│ ├── Per-client top-20
│ ├── Data-quality issues
│ ├── ETL plan (insert / skip / synthesize counts)
│ ├── [Cancel] [Approve and commit]
│ └── (CTAs disabled when status = FAIL)
├── Stage 3 — Commit in flight GET /admin/v1-upload/runs/:run_id/commit
│ ├── Progress indicator (stage-by-stage)
│ └── Auto-redirects on completion → ↓
└── Stage 4 — Final summary GET /admin/v1-upload/runs/:run_id/done
├── Final ETLStats table
├── Receipt-counter seed values
├── Provenance record link
└── [Back to admin dashboard]
The list of past runs (one per upload) lives at /admin/v1-upload/runs — useful when Stefan does the dress rehearsal against test DB then comes back days later for prod cutover.
Stage 1 — Upload¶
sm 375 px¶
┌───────────────────────────────────────┐
│ ← V1 upload │ Header
├───────────────────────────────────────┤
│ │
│ V1 → V2 cutover upload │ H1
│ │
│ Upload your current V1 XLSX. We'll │ text-body slate-700
│ parse it, run reconciliation against │
│ this database in dry-run mode, and │
│ show you a report. Nothing is │
│ written until you approve. │
│ │
│ Database: rbo_test │ text-small slate-600
│ (set by DATABASE_URL) │ text-tiny slate-500
│ │
│ ─── XLSX file ─── │
│ │
│ ┌───────────────────────────────────┐ │
│ │ 📄 Choose file… │ │ Native <input type="file">
│ │ No file selected │ │ styled
│ └───────────────────────────────────┘ │
│ Accepted: .xlsx · max 25 MB │ text-tiny slate-500
│ │
│ ┌───────────────────────────────────┐ │
│ │ Run dry-run │ │ Primary CTA, full width
│ └───────────────────────────────────┘ │
│ │
│ Past runs → │ text-small indigo-700
│ │
└───────────────────────────────────────┘
md 768 px+¶
Same layout, max-width 720 px, centered. The "Past runs" link moves to a side affordance in the page header.
Behaviour¶
- Database name — server reads
DATABASE_URL_DIRECT(or its alias) and surfaces the database name on the page so Stefan never confuses test vs prod. Background colour shifts:slate-50forrbo_test,amber-50forrbo_prod(production gets a visual heads-up). - File picker — native
<input type="file" accept=".xlsx">. The styled label updates to show the picked filename. No drag-and-drop in V2 (the picker is fast enough; drag-and-drop is a polish round). - Client-side guard — if file size exceeds 25 MB, surface inline error before submit ("File too large — V1 XLSX is typically under 5 MB; check you picked the right file."). Server-side limit is canonical; this is a UX shortcut.
- Submit — multipart POST. The server kicks off the dry-run synchronously (V1 XLSX is small; 329 + 416 rows parses in < 1 s on the keystone VPS) and 303-redirects to
/admin/v1-upload/runs/:run_idonce the report is rendered. No background job; no polling. Why synchronous: the parse + dry-run is fast enough that a spinner-on-submit is the right UX, and avoiding a queue / worker keeps the cutover tooling dependency-free. - Loading state — submit button switches to "Running dry-run…" with a 16 px spinner; disabled while in flight. Page does not navigate; HTMX is not used here (regular POST + redirect, since we want the URL to advance to the report on success).
- Past runs link — opens
/admin/v1-upload/runs(a simple list of run records: timestamp, filename, status badge, link to its Stage-2 report). Useful for dress-rehearsal continuity.
Component breakdown¶
| Component | Notes |
|---|---|
| Header | Back arrow → admin dashboard. Title "V1 upload" — text-h1. |
| Database chip | text-tiny font-mono in a chip; background slate-50 (test) or amber-50 (prod). Reads from server. |
| File input | Native <input> with custom label styling. Hit target ≥ 48 px. |
| Hint line | "Accepted: .xlsx · max 25 MB" — text-tiny slate-500. |
| Primary CTA | "Run dry-run" — bg-indigo-700 text-white, full width on sm, fixed-height 48 px. |
| Past runs link | text-small text-indigo-700 underline. |
Error states (Stage 1 → submit)¶
| Error | Inline message | Recovery |
|---|---|---|
| No file picked | "Pick an XLSX file before running the dry-run." | Stays on form; focus jumps to file input. |
| File > 25 MB | "File too large (max 25 MB). V1 XLSX is typically under 5 MB — check you picked the right file." | Stays on form. |
| Wrong file type | "Only .xlsx files are accepted." | Stays on form. |
| Parser fails to open the workbook (corrupt / not a real XLSX) | "Couldn't open the workbook. The file may be corrupt or not a valid XLSX. Try re-exporting from V1." | Stays on form; full server-side ValueError summary shown in a collapsible "Technical details" block underneath. |
Header drift (parser _assert_header raises) |
"V1 column layout has changed — the parser couldn't find expected headers. See details below." | Server returns the parser's exact error message including the Sheet:Row reference and what was expected vs found. Stage 1 shows the error inline; Stefan fixes V1 (renames the column back) and re-uploads. No partial parse. |
| Server / network error (5xx) | Toast "Something went wrong on our side. Try again." | Form values are not preserved (file inputs don't survive POST failures); Stefan re-picks the file. |
Stage 2 — Dry-run report¶
The hero screen of this flow. Renders the same content migration/report.py::render_markdown produces, but as a paginated HTML page with the Summary status line above the fold and the long-tail tables below.
sm 375 px¶
┌───────────────────────────────────────┐
│ ← Dry-run report │ Header (back → Stage 1)
├───────────────────────────────────────┤
│ │
│ ✓ OK │ Status badge — green-700 bg
│ │
│ 329 client orders · 416 self orders │ Summary line — text-body
│ Tolerance: ±0.01 CHF │ text-small slate-600
│ Run on: 2026-05-06 14:22 (8s ago) │ text-tiny slate-500
│ File: stringing_orders.xlsx (4.1 MB) │ text-tiny slate-500
│ Database: rbo_test │ text-tiny slate-500
│ │
│ ─── ETL plan ─── │
│ │
│ Will insert: │ text-small
│ • 329 orders (client) │
│ • 416 orders (self) │
│ • 47 persons / client_profiles │
│ • 31 rackets (Stefan's owned) │
│ • 6 rackets (synthesised — orphans) │ + warn icon
│ • 43 strings (catalogue-shared) │
│ Will skip (already migrated): │
│ • 0 (this is a fresh run) │
│ Receipt counters seeded: │
│ • 2019: last_n=42 → 2026: last_n=89 │
│ │
│ ─── Counts ─── │
│ │
│ Category V1 V2 Δ │ Table
│ Client 329 329 0 ✓ │
│ Self 416 416 0 ✓ │
│ │
│ ─── Per-year revenue (CHF) ─── │
│ │
│ Year V1 V2 Δ │
│ 2019 1234.50 1234.50 0.00 ✓ │
│ 2020 2234.00 2234.00 0.00 ✓ │
│ ⋯ │
│ 2026 1112.00 1112.00 0.00 ✓ │
│ │
│ ─── Mean turnaround ─── │
│ │
│ Sample: 318 client orders │
│ Mean: 4.2 days · Median: 3 days │
│ Min: -1 day (date-causal violation) │
│ Max: 21 days │
│ │
│ ─── Per-client top 20 ─── │
│ │
│ Lukas Müller 47 │
│ Anna Bauer 38 │
│ ⋯ │
│ (self) 416 │
│ │
│ ─── Data-quality issues (18) ─── │ Collapsible
│ ▼ See all warnings │
│ │
│ ┌────────────────────────────────┐ │
│ │ Cancel │ │
│ └────────────────────────────────┘ │
│ ┌────────────────────────────────┐ │
│ │ Approve and commit │ │ Primary CTA — disabled if FAIL
│ └────────────────────────────────┘ │
│ │
│ This will write 329 + 416 orders │ text-tiny slate-500
│ to rbo_test. Idempotent — re-runs │
│ skip already-imported rows. │
│ │
└───────────────────────────────────────┘
md 768 px+¶
Same content, max-width 960 px (the tables earn the wider real estate). Tables become two-column row-by-row blocks on sm; on md+ they render as proper <table> with column alignment. Sticky bottom bar with Cancel + Approve stays visible as Stefan scrolls long-tail tables.
FAIL variant¶
┌───────────────────────────────────────┐
│ ← Dry-run report │
├───────────────────────────────────────┤
│ │
│ ✕ FAIL │ Status badge — red-700 bg
│ │
│ 329 client orders · 416 self orders │
│ Tolerance: ±0.01 CHF │
│ │
│ ─── Differences ─── │ H2 — red-700 prominent
│ │
│ • 2024 revenue: V1 1234.50 vs V2 │ text-body
│ 1230.00 (Δ -4.50 CHF) │
│ • Order count mismatch: │
│ V1 329 vs V2 328 (Δ -1) │
│ │
│ Investigate before committing. │ text-small slate-700
│ Common causes: │
│ • Date-causal violation orphaning │
│ a row from `Strung` aggregation │
│ • Manually-edited price in V1 that │
│ breaks the Strings = sum-of-sides │
│ invariant │
│ Fix in V1 and re-upload. │
│ │
│ ─── Counts ─── │ Same tables follow, for
│ ⋯ │ audit-trail visibility
│ │
│ ┌────────────────────────────────┐ │
│ │ Cancel and re-upload │ │ Only CTA available
│ └────────────────────────────────┘ │
│ │
│ Approve disabled while FAIL. │ text-tiny slate-500
│ │
└───────────────────────────────────────┘
Behaviour¶
- Status badge — top of page; the at-a-glance signal:
✓ OK—bg-green-50 text-green-800 border-green-200. Approve CTA enabled.✕ FAIL—bg-red-50 text-red-700 border-red-200. Approve CTA hidden; only "Cancel and re-upload" shown.- Summary line — V1 row counts + tolerance. Mirrors the
render_markdownSummary section. - Differences section — only renders when status =
FAIL. Bullet list of the specific count + per-year revenue mismatches that caused the failure (verbatim from the report data). Sub-tolerance revenue diffs are NOT echoed here — see process spec § Reconciliation report. - ETL plan — what the commit will do, in concrete numbers. Renders the
migration.etl::ETLStatsprojected from the dry-run: - Insert counts per V2 entity (orders, persons, client_profiles, rackets, strings).
- Skip counts (
skipped_idempotent— non-zero on re-runs). - Synthesised-racket count (orphan racket-IDs in self-orders; clickable to expand the list of synthesized IDs with their
Sheet:Rowreferences — provenance is the contract). - Receipt-counter seed values per year (the
_seed_receipt_countersprojection). - Counts / Per-year revenue / Mean turnaround / V2 entity totals / Per-client top-20 — render the
migration/report.pydata verbatim. Each table cell that crosses the tolerance threshold gets a red Δ; cells within tolerance get a green ✓. - Data-quality issues — collapsed by default (Stefan has 18 known V1 warnings per process spec § Upload checklist; listing them all on first scroll is noise). Expanding shows every warning with its
Sheet:Rowreference and message. Each row istext-smallwith the sheet+row infont-mono. - Approve and commit CTA — primary; only enabled when status =
OK. Tapping does not commit immediately — opens the Stage 3 typed-confirm modal (next section). Why typed-confirm: the prod commit is the one irrevocable step in the flow; the dress-rehearsal-on-test pattern means Stefan can practise this multiple times, but we still want a deliberate moment before writing to prod. - Cancel CTA — discards the run. Returns to Stage 1 with a toast "Dry-run cancelled. Upload again to retry."
- Sticky bottom action bar (md+) —
Cancel | Approve and commitstays visible as Stefan scrolls the long-tail tables. Onsmthe buttons are at the bottom of the page (no sticky bar — the page is short enough at phone widths).
Component breakdown¶
| Component | Notes |
|---|---|
| Status badge | text-h2 size, full-width chip. OK green-700 / FAIL red-700. Icon lucide:check-circle / lucide:x-circle. |
| Summary line | text-body slate-700. Counts + tolerance + run-time + filename. |
| Section H2s | text-h2 slate-900; horizontal bg-slate-200 rule above. |
| ETL plan list | <ul> with bullet markers; text-small. Insert / skip / synthesise counts in font-mono tabular-nums. |
| Reconciliation tables | <table> with font-mono tabular-nums for numbers, alternating-row bg-slate-50. |
| Δ cells | Coloured: green ✓ when within tolerance, red value when outside. |
| Data-quality collapsible | <details> <summary>; summary text "See all warnings (N)". |
| Cancel CTA | bg-white border border-slate-300 text-slate-700. |
| Approve CTA | bg-indigo-700 text-white. Disabled when FAIL: disabled:bg-slate-300 disabled:text-slate-500 cursor-not-allowed. |
| Disabled-CTA hint | text-tiny slate-500 below the button: "Approve disabled while FAIL." |
Stage 3 — Approve and commit (typed-confirm modal + progress)¶
Approve confirmation modal¶
Tapping "Approve and commit" opens a modal. The modal's friction is proportional to the database environment — rbo_test gets a one-tap confirmation; rbo_prod gets a typed-confirm.
╔═══════════════════════════════════╗
║ ║
║ Commit V1 → V2 upload? ║ H2
║ ║
║ Will write to: rbo_prod ║ text-body — bold for prod
║ ║
║ This will insert: ║ text-small
║ • 329 client orders ║
║ • 416 self orders ║
║ • 47 persons + client profiles ║
║ • 37 rackets (incl. 6 synth) ║
║ • 43 strings ║
║ ║
║ The ETL is idempotent — if you ║ text-tiny slate-500
║ re-run later, already-imported ║
║ rows are skipped. ║
║ ║
║ Type the database name to ║ Required for rbo_prod only
║ confirm. ║
║ ┌─────────────────────────────┐ ║
║ │ rbo_prod │ ║ Text input — must match
║ └─────────────────────────────┘ ║
║ ║
║ ┌─────────────────────────┐ ║
║ │ Cancel │ ║
║ └─────────────────────────┘ ║
║ ┌─────────────────────────┐ ║
║ │ Commit │ ║ Disabled until name typed
║ └─────────────────────────┘ ║
║ ║
╚═══════════════════════════════════╝
Behaviour (modal)¶
rbo_testflavour — no typed-confirm; just two buttons (Cancel + Commit). Stefan does dress rehearsals; we don't make him type the name 5 times.rbo_prodflavour — typed-confirm with the literal database name. Commit button disabled until the typed value matches exactly. Why the asymmetry: prod commit is the once-in-V2-cutover step; the friction is proportional. Pattern matches admin-person-merge typed-confirm and admin-dsar-queue erasure typed-confirm.- Submit (Commit) — POSTs to
/admin/v1-upload/runs/:run_id/commit. Server: - Re-reads the stored XLSX from the run record.
- Runs
migration.etl.run()without--dry-run(real commit). - Updates the run record's status to
committedwith the finalETLStatspayload. - Redirects to
/admin/v1-upload/runs/:run_id/done. - In-flight UX — Commit button switches to "Committing…" with a 16 px spinner; modal stays open; backdrop opaque so the page is non-interactive. Server commit takes ~2-5 s on a 5 MB XLSX (no async needed).
Failure during commit¶
The dry-run was clean, so the only realistic commit-time failures are: - DB constraint violation (race: another tenant or operation modified data between dry-run and commit; vanishingly rare for V2 cutover but defensively handled). - Connection drop / network error. - Server crash mid-transaction (the ETL's idempotent design means the V1MigrationLog rows already written stay; the rest get rolled back at the transaction boundary).
| Failure | UX |
|---|---|
| DB constraint violation | Modal closes; Stage 2 page reloads with a red banner: "Commit failed: {error message}. Some rows may have been written — re-run will skip them via the V1MigrationLog. Investigate before retrying." Approve CTA stays enabled (idempotency makes retry safe). |
| Network drop mid-commit | Toast: "Commit may have completed. Reload to check." A reload either lands on Stage 4 (commit succeeded server-side) or stays on Stage 2 with an updated run record showing partial state. |
| Server crash mid-transaction | Same as DB constraint case — Stage 2 reloads; the V1MigrationLog tells the truth on retry. |
| Disk full / OOM | Toast: "Server out of resources. Contact the operator." Run record's status is failed; manual retry from Stage 1 (re-upload) or from /admin/v1-upload/runs archive. |
Stage 4 — Final summary¶
The audit-trail page Stefan signs off on. Mirrors Stage 2 in shape but with the final ETLStats (not the dry-run projection) and a clear "Done" stance.
sm 375 px¶
┌───────────────────────────────────────┐
│ ← V1 upload — done │ Header
├───────────────────────────────────────┤
│ │
│ ✓ Committed │ Status — green-700 bg
│ │
│ 329 client orders · 416 self orders │
│ Database: rbo_prod │
│ Committed at: 2026-05-06 14:24 │
│ Duration: 3.2 s │
│ │
│ ─── Final ETL stats ─── │
│ │
│ Inserted: │
│ • 329 orders (client) │
│ • 416 orders (self) │
│ • 47 persons / client_profiles │
│ • 31 rackets (Stefan's owned) │
│ • 6 rackets (synthesised) │
│ • 43 strings │
│ Skipped (idempotent): 0 │
│ Warnings emitted: 18 │
│ │
│ ─── Receipt counters ─── │
│ │
│ Year last_n │
│ 2019 42 │
│ 2020 58 │
│ ⋯ │
│ 2026 89 │
│ │
│ ─── Provenance ─── │
│ │
│ Every migrated row is logged in │ text-small slate-700
│ v1_migration_log. Run id #41 ↗ │
│ links the per-row trail. │
│ │
│ ┌────────────────────────────────┐ │
│ │ Back to admin dashboard │ │
│ └────────────────────────────────┘ │
│ │
│ You can keep stringing in V1 and │ text-tiny slate-500
│ re-upload later — the ETL is │
│ idempotent. │
│ │
└───────────────────────────────────────┘
Behaviour¶
- No CTA other than "Back to admin dashboard" — Stage 4 is the read-only audit page. The dashboard is where Stefan goes next.
- Provenance link — clickable; opens
/admin/v1-upload/runs/:run_id/provenance(a paged read-only view of the V1MigrationLog rows for this run). Useful for the "wait, where did Order #218 come from?" investigation moment. Out of scope for V2: the provenance view is a basic table; richer JOINs are a polish round. - Re-run hint — the closing tiny-text reminds Stefan the ETL is idempotent. If he stringed any rackets between this commit and actual V1 retirement, he uploads his then-latest XLSX and re-runs; only new rows write.
Past runs index¶
A simple list of historical runs. Each row links back to its Stage-2 (or Stage-4) page.
┌───────────────────────────────────────┐
│ ← V1 upload — past runs │
├───────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────┐│
│ │ #41 ✓ Committed ││
│ │ rbo_prod · 2026-05-06 14:24 ││
│ │ stringing_orders.xlsx (4.1 MB) ││
│ └───────────────────────────────────┘│
│ ┌───────────────────────────────────┐│
│ │ #40 ✓ Committed ││
│ │ rbo_test · 2026-05-04 09:11 ││
│ │ stringing_orders.xlsx (4.1 MB) ││
│ └───────────────────────────────────┘│
│ ┌───────────────────────────────────┐│
│ │ #39 ✕ FAIL (dry-run) ││
│ │ rbo_test · 2026-05-03 18:42 ││
│ │ stringing_orders.xlsx (4.0 MB) ││
│ └───────────────────────────────────┘│
│ │
└───────────────────────────────────────┘
- Sorted newest first.
- Status badges:
Committed(green),FAIL (dry-run)(red),Cancelled(slate),Pending(amber — should be transient). - Each row links to its detail page (Stage 2 if not committed; Stage 4 if committed).
Interaction states¶
| State | What renders |
|---|---|
| Stage 1 — empty form | Upload button disabled until file picked. |
| Stage 1 — file picked, not submitted | Upload button enabled; filename shown. |
| Stage 1 — submit pending | Button reads "Running dry-run…" with spinner; form disabled. |
| Stage 1 — parser error | Form re-renders with inline red-banner error block; technical details collapsible. |
| Stage 2 — OK | Status ✓ OK; Approve enabled; full report visible. |
| Stage 2 — FAIL | Status ✕ FAIL; Differences section prominent; Approve hidden; Cancel-and-re-upload only. |
| Stage 2 — modal open (test DB) | Two-button confirm modal. |
| Stage 2 — modal open (prod DB) | Typed-confirm modal; Commit disabled until name typed. |
| Stage 2 — commit pending | Modal disabled; "Committing…" spinner. |
| Stage 2 — commit failed | Modal closes; banner-error on Stage 2 page. Approve still enabled (idempotent retry). |
| Stage 3 (intermediate) | Strictly speaking, the synchronous commit doesn't render Stage 3 as its own page — the modal-with-spinner IS Stage 3. If the commit takes > 10 s (worst case on a slow VPS), the spinner is the only feedback. |
| Stage 4 — done | Read-only summary; Back-to-dashboard CTA. |
| Past runs — empty | "No previous runs. Upload an XLSX to start." Empty-state shape per admin-catalogue-moderation § Empty state. |
Validation rules (UI surface; canonical server-side)¶
| Rule | Inline message |
|---|---|
| File required (Stage 1) | "Pick an XLSX file before running the dry-run." |
| File too large (> 25 MB) | "File too large (max 25 MB). V1 XLSX is typically under 5 MB — check you picked the right file." |
| Wrong extension | "Only .xlsx files are accepted." |
| Workbook unparseable | "Couldn't open the workbook. The file may be corrupt or not a valid XLSX. Try re-exporting from V1." |
| Header drift (parser raises) | "V1 column layout has changed — the parser couldn't find expected headers. Check Sheet:Row {sheet}:{row} for column '{expected}'; got '{found}'." |
| Typed-confirm name doesn't match (prod) | "Type the database name exactly to confirm." (Inline; submit disabled.) |
| Run already committed (race / re-tap) | "This run was already committed at {timestamp}. View the result." (Toast; redirects to Stage 4.) |
| Run cancelled | "This run was cancelled. Upload again to retry." (Toast; redirects to Stage 1.) |
Accessibility¶
- File input — native
<input type="file">with explicit<label>(the styled label is a<label>wrapping the input). Hit target ≥ 48 px. - Status badge — icon + text + colour:
✓ OKand✕ FAILare redundantly signalled (icon shape + word + colour), so colour-blind users see the difference. - Reconciliation tables —
<table>with<thead>headers;scope="col";<caption>describing the table ("Counts: V1 vs V2 row counts per category"). Numbers intabular-nums. - Δ cells — colour + a textual ✓ / value; never colour-only.
- Approve CTA disabled state —
aria-disabled="true"anddisabledattribute; the hint text below it (text-tiny slate-500) isaria-describedby-linked so screen readers announce the gating reason ("Approve disabled while FAIL"). - Modal (typed-confirm) —
role="dialog" aria-modal="true" aria-labelledby="modal-title". Focus moves to H2 on open; focus trap; ESC dismisses. Typed-confirm input hasaria-describedbylinking to the hint "Type the database name to confirm." - Spinner / in-flight states — buttons gain
aria-busy="true"while the operation runs. - Long-form report — landmark roles:
<main>wraps the report; each H2 anchors a navigable section.<nav aria-label="Report sections">at the top is not added in V2 (the report is short enough that scroll-to-section affordances are over-engineered for the once-per-cutover use case); flagged for polish if Stefan wants it. - Hit targets — every interactive element ≥ 44 × 44 px. CTAs are 48 px tall.
- Contrast — Status badges meet WCAG AA:
green-800ongreen-50= 7.4:1,red-700onred-50= 6.8:1.
HTMX / progressive-enhancement seams¶
The flow's fundamental contract is regular form POSTs + redirects. HTMX is minimal because the synchronous parse + dry-run + commit pattern does not benefit from partial page updates.
- Stage 1 submit — regular
<form method="post" enctype="multipart/form-data">. No HTMX. Server returns 303 redirect to Stage 2 on success. - Stage 2 → Approve modal —
hx-get="/admin/v1-upload/runs/:id/approve-modal" hx-target="#modal-region"opens the modal. Without JS: the Approve button is a regular<a href>to/admin/v1-upload/runs/:id/approve(a full-page version of the modal that submits to the same endpoint). Functionally identical; one extra full-page render without JS. - Modal submit (Commit) — regular
<form method="post" action="/admin/v1-upload/runs/:id/commit">. No HTMX; the server commits and 303-redirects to Stage 4. - Cancel button —
<form method="post" action="/admin/v1-upload/runs/:id/cancel">returning a redirect to Stage 1. - Data-quality collapsible — native
<details><summary>; no HTMX or JS needed.
The only JS-pleasant seam is the typed-confirm modal: with JS, the Commit button enables in real-time as the user types; without JS, the Commit button is always enabled and the server validates the typed name on submit, returning the modal page with a red-700 inline error if it doesn't match. Either way is functional.
i18n affordance¶
EN-only for V2. Per process spec, this UI is operator-facing for Stefan. The DE pass is deferred to a later round (and may stay EN-only forever — Stefan is bilingual and the cutover happens once). Mira drafts EN; if Iris ever asks, the strings below are catalogued for translation:
| String | Catalogue key |
|---|---|
| "V1 upload" (page title) | admin.v1upload.title |
| "V1 → V2 cutover upload" (H1) | admin.v1upload.h1 |
| Body intro paragraph | admin.v1upload.intro |
| "Database: {name}" | admin.v1upload.db_label |
| "Choose file…" | admin.v1upload.file_picker.label |
| "No file selected" | admin.v1upload.file_picker.empty |
| "Accepted: .xlsx · max 25 MB" | admin.v1upload.file_picker.hint |
| "Run dry-run" | admin.v1upload.cta.run |
| "Past runs →" | admin.v1upload.past_runs.link |
| "Dry-run report" (Stage 2 H1) | admin.v1upload.report.h1 |
| Status badges "OK" / "FAIL" / "Committed" / "Cancelled" / "Pending" | admin.v1upload.status.{ok,fail,committed,cancelled,pending} |
| Section H2s "ETL plan" / "Counts" / "Per-year revenue" / "Mean turnaround" / "Per-client top 20" / "Data-quality issues" / "Final ETL stats" / "Receipt counters" / "Provenance" | admin.v1upload.section.{etl_plan,counts,revenue,turnaround,top_clients,issues,final_stats,counters,provenance} |
| "Differences" | admin.v1upload.section.differences |
| "Investigate before committing." + common-causes block | admin.v1upload.differences.{prompt,causes} |
| Table column headers (Year / V1 / V2 / Δ / Category) | admin.v1upload.table.{year,v1,v2,delta,category} |
| "See all warnings (N)" | admin.v1upload.warnings.toggle |
| "Cancel" / "Approve and commit" | admin.v1upload.cta.{cancel,approve} |
| "Approve disabled while FAIL." | admin.v1upload.cta.approve_disabled |
| Approve modal title "Commit V1 → V2 upload?" | admin.v1upload.modal.approve.title |
| "Will write to: {db}" | admin.v1upload.modal.approve.target |
| "The ETL is idempotent…" hint | admin.v1upload.modal.approve.idempotent_hint |
| "Type the database name to confirm." | admin.v1upload.modal.approve.typed_confirm.prompt |
| "Commit" / "Committing…" | admin.v1upload.modal.approve.cta.{commit,committing} |
| "Cancel and re-upload" | admin.v1upload.cta.cancel_and_retry |
| "Back to admin dashboard" | admin.v1upload.cta.back_to_dashboard |
| Stage 4 "V1 upload — done" | admin.v1upload.done.h1 |
| "Committed at: {timestamp}" / "Duration: {n}s" | admin.v1upload.done.{committed_at,duration} |
| "You can keep stringing in V1 and re-upload later — the ETL is idempotent." | admin.v1upload.done.reupload_hint |
| Validation messages | admin.v1upload.validation.{file_required,too_large,wrong_ext,unparseable,header_drift,typed_confirm_mismatch,already_committed,cancelled_run} |
| Toast — commit failure / network drop messages | admin.v1upload.toast.{commit_failed,network_drop,server_error} |
Mobile-friendly note¶
Stefan does V2 cutover from a laptop, by his own description — but the dress-rehearsal practice runs may happen from a tablet (testing the test DB on a Saturday afternoon). The spec assumes:
md768 px (tablet portrait) — fully supported. Tables become proper<table>with column alignment; sticky bottom action bar; max-width 960 px so the report tables don't sprawl edge-to-edge.sm375 px (phone) — supported but not optimised. The reconciliation tables degrade to row-by-row blocks (each cell becomes a "Label: Value" pair), which is readable but not as scannable as the tabular form. Why we don't optimise harder: the cutover-from-phone use case is not real; designing tables that look great at 375 px would distort the tablet/desktop layouts where this UI actually gets used.
If Stefan ever asks for a phone-first variant, the polish-round move is a stacked-card view of each reconciliation section with horizontal swipe to compare V1 / V2 / Δ — but that's not in V2 scope.
Cross-references¶
- Source requirements: v2-scope M15, process — V1 upload spec.
- Backend: migration/etl.py, migration/report.py, migration/parse_v1.py.
- ADRs: ADR-0007 § Order lifecycle (date-causal relaxation + warning surface), ADR-0008 § Migration from V1 (receipt-number assignment + counter seed).
- Design siblings: admin-catalogue-moderation (admin-shell precedent), admin-person-merge (typed-confirm pattern), admin-dsar-queue (admin tooling shell).
- Tokens: design-tokens.
- Issue tracking: racket-book#120.
Open questions for Stefan (with proposed defaults)¶
- Stored XLSX retention. When Stage 1 uploads a file, the server writes it to disk for the dry-run + later commit. Proposed default: keep the XLSX on the run record indefinitely, alongside the JSON snapshot of the report. The size is small (4–5 MB per run; even 100 historical runs = 500 MB, trivial). Provides an audit trail for the cutover. Alternative: delete after commit. Mira leans keep.
- Authentication on the route family. Proposed default: gated on
Stringer.role = 'admin', same pattern as the catalogue and DSAR queues. V2 is one admin (Stefan); the routes 403 for everyone else. - Background-job vs synchronous commit. Proposed default: synchronous commit (the V1 dataset is small enough that even prod commit is < 5 s). Alternative: enqueue the commit, redirect to a polling Stage 3 page. Adds infra cost (a worker process) for negligible UX win at V2's scale. Defer to a future round if Stefan ever needs to migrate a much larger V1 dataset (he won't — V1 stops at cutover).
- Typed-confirm asymmetry test/prod. Proposed default: yes — test gets one-tap, prod gets typed-confirm. Friction proportional to consequence. Alternative: typed-confirm always (Stefan would tire of it during dress rehearsals); typed-confirm never (prod becomes too easy to misclick from a Stage-2 review session). Mira leans the asymmetric default.
- Failure during commit — auto-retry? Proposed default: no auto-retry; show banner + let Stefan re-tap Approve. The ETL's idempotency makes manual retry cheap. Alternative: auto-retry once with a backoff (could mask transient failures Stefan should see). Mira leans manual.
- Past-runs retention policy. Proposed default: keep all run records forever (size is trivial; audit trail is the contract). Alternative: 90-day retention. No real driver for deletion in V2; revisit if anything.
- Per-section drill-down on the dry-run report. Today, each section is a static block. Proposed default: keep static for V2 — Stefan does this once. Alternative: clickable rows that expand to show per-row provenance (e.g. clicking "47 persons" shows the 47 rows with their V1 origin). Polish round; not blocking V2 cutover.
- What happens to the run record when Stefan closes the tab mid-flow. Proposed default: the run record persists in
pendingstatus indefinitely; he can resume from/admin/v1-upload/runsand click the row to land back on Stage 2. Alternative: auto-cancel after 24 h. Adds a cron we don't need; the past-runs list shows pending rows clearly enough that Stefan won't be confused. - Rendering the V1 file's parsed contents inline (preview). Proposed default: no — only the report. The reconciliation report tells Stefan what the import will do; showing him the raw rows is busywork. Alternative: a "preview first 20 rows" panel for sanity. Polish round if he asks.
- Concurrency guard — two admin tabs uploading at once. Proposed default: defensive 409 on Stage-1 POST if another
pendingrun exists for the same admin in the last 5 minutes. Toast: "You have a dry-run already in progress at {url}. Cancel it before starting a new one." Alternative: allow concurrent runs (the run records are isolated). Mira leans defensive — confused-admin-with-stale-tabs is a real failure mode at cutover, single-admin-doing-it-once is the V2 reality. - Receipt-counter seeding visibility. Proposed default: surface the per-year
last_nvalues in both the ETL plan (Stage 2 projection) and Final stats (Stage 4 actuals) so Stefan can sanity-check that the next-issued receipt-number-per-year matches his memory of "I issued 89 receipts in 2026 so far". Alternative: hide as an implementation detail. Mira leans surface — receipt numbers are visible to clients, so getting them right is the customer-facing reason.