diff --git a/src/rowbridge/config.py b/src/rowbridge/config.py index 58955df..74b9666 100644 --- a/src/rowbridge/config.py +++ b/src/rowbridge/config.py @@ -10,6 +10,7 @@ class Settings: data_dir: Path stage_ttl_seconds: int = 24 * 60 * 60 results_page_size: int = 100 + runs_page_size: int = 25 manual_link_select_limit: int = 200 @property diff --git a/src/rowbridge/static/app.css b/src/rowbridge/static/app.css index 80aafd4..ceec39c 100644 --- a/src/rowbridge/static/app.css +++ b/src/rowbridge/static/app.css @@ -318,7 +318,22 @@ select:focus, input[type="number"]:focus { outline: 2px solid #bfd0e9; outline-o .evidence-list em { margin-left: 5px; color: #8a938e; font-size: 11px; font-style: normal; } .row-actions { display: flex; gap: 7px; } .row-actions form { margin: 0; } -.mini-button { min-height: 34px; padding: 0 10px; border: 1px solid var(--line-strong); border-radius: 5px; background: #fff; font-size: 12px; font-weight: 750; cursor: pointer; } +.mini-button { + min-height: 34px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border: 1px solid var(--line-strong); + border-radius: 5px; + background: #fff; + color: var(--ink); + font-size: 12px; + font-weight: 750; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} .mini-primary { border-color: var(--navy); background: var(--navy); color: #fff; } .mini-danger { color: var(--red); } .muted { color: #8a938e; } @@ -377,6 +392,169 @@ select:focus, input[type="number"]:focus { outline: 2px solid #bfd0e9; outline-o .review-panel section + section { border-left: 0; border-top: 1px solid var(--line); } } +/* Run history */ +.runs-page { padding: 30px 0 56px; } +.runs-heading { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 28px; + padding-bottom: 22px; + border-bottom: 1px solid var(--line); +} +.runs-heading h1 { margin-top: 5px; font-size: 34px; line-height: 1.13; letter-spacing: -.025em; } +.runs-heading .lead { max-width: 720px; margin-top: 8px; font-size: 15px; } +.runs-card { + margin-top: 22px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + overflow: hidden; +} +.runs-card-head { + min-height: 52px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 15px; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-size: 13px; +} +.runs-card-head strong { color: var(--ink); } +.runs-table { min-width: 980px; } +.runs-table td { vertical-align: middle; } +.run-date { display: block; font-size: 13px; white-space: nowrap; } +.run-time { display: block; margin-top: 4px; color: var(--muted-2); font-size: 11px; white-space: nowrap; } +.run-files { min-width: 250px; } +.run-files strong { display: inline; overflow-wrap: anywhere; font-size: 13px; } +.run-files span { padding: 0 5px; color: var(--muted-2); } +.run-row-count { color: var(--muted); font-size: 13px; white-space: nowrap; } +.run-count { + min-width: 28px; + display: inline-flex; + justify-content: center; + padding: 4px 7px; + border-radius: 999px; + background: #eef1ef; + color: #59635d; + font-size: 12px; + font-weight: 750; +} +.run-count-good { background: #edf6f1; color: var(--green); } +.run-count-review { background: #fff7e8; color: var(--amber); } +.run-count-unmatched { background: #fbeff0; color: var(--red); } +.run-open-cell { text-align: right; } +.runs-empty { + margin-top: 22px; + padding: 42px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); + text-align: center; +} +.runs-empty h2 { margin-top: 7px; font-size: 22px; } +.runs-empty > p:not(.eyebrow) { + max-width: 580px; + margin: 9px auto 0; + color: var(--muted); + line-height: 1.5; +} +.runs-empty .button { margin-top: 22px; } + +@media (max-width: 760px) { + .runs-page { padding-top: 22px; } + .runs-heading { align-items: stretch; flex-direction: column; } + .runs-heading h1 { font-size: 29px; } + .runs-heading .button { width: 100%; } + .runs-card-head { align-items: flex-start; flex-direction: column; padding: 12px 15px; } + .runs-empty { padding: 30px 20px; } +} + +/* Delete run */ +.delete-run-page { + min-height: calc(100vh - 64px); + display: grid; + place-items: center; + padding: 48px 0; +} +.delete-run-panel { + width: min(100%, 680px); + padding: 30px; + border: 1px solid var(--line-strong); + border-radius: 8px; + background: var(--surface); +} +.delete-run-panel h1 { + margin-top: 8px; + font-size: 34px; + line-height: 1.15; + letter-spacing: -.02em; +} +.delete-run-warning { + margin-top: 12px; + color: var(--muted); + line-height: 1.55; +} +.delete-run-details { + margin-top: 24px; + border-top: 1px solid var(--line); +} +.delete-run-details > div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: 16px; + padding: 13px 0; + border-bottom: 1px solid var(--line); +} +.delete-run-details dt { + color: var(--muted); + font-size: 13px; + font-weight: 700; +} +.delete-run-details dd { + min-width: 0; + overflow-wrap: anywhere; + font-size: 14px; +} +.delete-run-details dd span { + padding: 0 5px; + color: var(--muted-2); +} +.delete-run-actions { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 10px; +} +.delete-run-actions form { margin: 0; } +.button-danger { + border-color: var(--red); + background: var(--red); + color: #fff; +} +.button-danger:hover { + border-color: #8e343b; + background: #8e343b; +} +.run-actions { + display: flex; + justify-content: flex-end; + gap: 7px; +} + +@media (max-width: 760px) { + .delete-run-page { padding: 24px 0; } + .delete-run-panel { padding: 24px 20px; } + .delete-run-panel h1 { font-size: 29px; } + .delete-run-details > div { grid-template-columns: 1fr; gap: 5px; } + .delete-run-actions { align-items: stretch; flex-direction: column-reverse; } + .delete-run-actions .button, + .delete-run-actions form, + .delete-run-actions button { width: 100%; } +} + /* Pagination and error states */ .pagination { display: flex; diff --git a/src/rowbridge/storage.py b/src/rowbridge/storage.py index bd02065..82728df 100644 --- a/src/rowbridge/storage.py +++ b/src/rowbridge/storage.py @@ -40,6 +40,20 @@ class RunSummary: unmatched: int +@dataclass(frozen=True, slots=True) +class RunListItem: + id: str + created_at: str + filename_a: str + filename_b: str + total_a: int + total_b: int + auto_matched: int + human_matched: int + review: int + unmatched: int + + @dataclass(frozen=True, slots=True) class StoredMatch: id: int @@ -266,6 +280,75 @@ def get_summary(self, run_id: str) -> RunSummary: unmatched=counts.get(MatchStatus.UNMATCHED.value, 0), ) + def count_runs(self) -> int: + with self._connect() as connection: + row = connection.execute("SELECT COUNT(*) AS count FROM runs").fetchone() + return 0 if row is None else int(row["count"]) + + def list_runs(self, *, limit: int, offset: int = 0) -> tuple[RunListItem, ...]: + if limit <= 0: + raise ValueError("limit must be positive") + if offset < 0: + raise ValueError("offset cannot be negative") + + with self._connect() as connection: + rows = connection.execute( + """ + SELECT + r.id, + r.created_at, + r.filename_a, + r.filename_b, + r.total_a, + r.total_b, + SUM(CASE WHEN m.status = ? THEN 1 ELSE 0 END) AS auto_matched, + SUM(CASE WHEN m.status IN (?, ?) THEN 1 ELSE 0 END) AS human_matched, + SUM(CASE WHEN m.status = ? THEN 1 ELSE 0 END) AS review, + SUM(CASE WHEN m.status = ? THEN 1 ELSE 0 END) AS unmatched + FROM runs r + LEFT JOIN matches m ON m.run_id = r.id + GROUP BY + r.id, + r.created_at, + r.filename_a, + r.filename_b, + r.total_a, + r.total_b + ORDER BY r.created_at DESC, r.id DESC + LIMIT ? OFFSET ? + """, + ( + MatchStatus.AUTO_MATCHED.value, + MatchStatus.CONFIRMED.value, + MatchStatus.MANUAL_MATCHED.value, + MatchStatus.REVIEW.value, + MatchStatus.UNMATCHED.value, + limit, + offset, + ), + ).fetchall() + + return tuple( + RunListItem( + id=str(row["id"]), + created_at=str(row["created_at"]), + filename_a=str(row["filename_a"]), + filename_b=str(row["filename_b"]), + total_a=int(row["total_a"]), + total_b=int(row["total_b"]), + auto_matched=int(row["auto_matched"]), + human_matched=int(row["human_matched"]), + review=int(row["review"]), + unmatched=int(row["unmatched"]), + ) + for row in rows + ) + + def delete_run(self, run_id: str) -> bool: + with self._connect() as connection: + cursor = connection.execute("DELETE FROM runs WHERE id = ?", (run_id,)) + return cursor.rowcount == 1 + @staticmethod def _matches_from_rows(rows: list[sqlite3.Row]) -> tuple[StoredMatch, ...]: result: list[StoredMatch] = [] diff --git a/src/rowbridge/templates/base.html b/src/rowbridge/templates/base.html index 147da8c..1d60a4d 100644 --- a/src/rowbridge/templates/base.html +++ b/src/rowbridge/templates/base.html @@ -17,6 +17,7 @@ diff --git a/src/rowbridge/templates/delete_run.html b/src/rowbridge/templates/delete_run.html new file mode 100644 index 0000000..5fdf45b --- /dev/null +++ b/src/rowbridge/templates/delete_run.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% block title %}Delete run | RowBridge{% endblock %} +{% block content %} +
+
+

Delete saved run

+

Delete this reconciliation run?

+

+ This permanently removes the saved result, source rows, matches, and review history from this local RowBridge data directory. +

+ +
+
+
Files
+
{{ run.filename_a }} → {{ run.filename_b }}
+
+
+
Created
+
{{ run.created_at[:10] }} {{ run.created_at[11:19] }} UTC
+
+
+
Source rows
+
{{ run.total_a }} + {{ run.total_b }}
+
+
+ +
+ Cancel +
+ +
+
+
+
+{% endblock %} diff --git a/src/rowbridge/templates/runs.html b/src/rowbridge/templates/runs.html new file mode 100644 index 0000000..8d29c32 --- /dev/null +++ b/src/rowbridge/templates/runs.html @@ -0,0 +1,88 @@ +{% extends "base.html" %} +{% block title %}Runs | RowBridge{% endblock %} +{% block content %} +
+
+
+

Saved locally

+

Reconciliation runs

+

Open previous results stored in this RowBridge data directory.

+
+ New reconciliation +
+ + {% if runs %} +
+
+

{{ total_runs }} saved run{% if total_runs != 1 %}s{% endif %}

+

Newest first

+
+
+ + + + + + + + + + + + + + + {% for run in runs %} + + + + + + + + + + + {% endfor %} + +
CreatedFilesRowsAutoHumanReviewUnmatched
+ {{ run.created_at[:10] }} + {{ run.created_at[11:19] }} UTC + + {{ run.filename_a }} + → + {{ run.filename_b }} + {{ run.total_a }} + {{ run.total_b }}{{ run.auto_matched }}{{ run.human_matched }}{{ run.review }}{{ run.unmatched }} +
+ Open + Delete +
+
+
+ + {% if total_pages > 1 %} + + {% endif %} +
+ {% else %} +
+

No saved runs

+

Your reconciliation history will appear here.

+

Start a reconciliation and RowBridge will keep the result in the local data directory.

+ Start a reconciliation +
+ {% endif %} +
+{% endblock %} diff --git a/src/rowbridge/web.py b/src/rowbridge/web.py index 2f25459..2d0b2bd 100644 --- a/src/rowbridge/web.py +++ b/src/rowbridge/web.py @@ -291,6 +291,50 @@ def create_run( discard_staged_upload(stage_dir) return RedirectResponse(url=f"/runs/{run_id}", status_code=303) + @app.get("/runs", response_class=HTMLResponse) + def run_history(request: Request, page: int = 1) -> HTMLResponse: + if page < 1: + raise HTTPException(status_code=400, detail="Page number must be positive") + + total_runs = repository.count_runs() + page_size = app_settings.runs_page_size + total_pages = max(1, math.ceil(total_runs / page_size)) + if page > total_pages: + raise HTTPException(status_code=404, detail="Run history page not found") + + runs = repository.list_runs( + limit=page_size, + offset=(page - 1) * page_size, + ) + return templates.TemplateResponse( + request=request, + name="runs.html", + context={ + "runs": runs, + "page": page, + "total_pages": total_pages, + "total_runs": total_runs, + "pagination_pages": _pagination_window(page, total_pages), + }, + ) + + @app.get("/runs/{run_id}/delete", response_class=HTMLResponse) + def confirm_delete_run(request: Request, run_id: str) -> HTMLResponse: + run = repository.get_run(run_id) + if run is None: + raise HTTPException(status_code=404, detail="Run not found") + return templates.TemplateResponse( + request=request, + name="delete_run.html", + context={"run": run}, + ) + + @app.post("/runs/{run_id}/delete") + def delete_run(run_id: str) -> RedirectResponse: + if not repository.delete_run(run_id): + raise HTTPException(status_code=404, detail="Run not found") + return RedirectResponse(url="/runs", status_code=303) + @app.get("/runs/{run_id}", response_class=HTMLResponse) def results( request: Request, diff --git a/tests/test_storage.py b/tests/test_storage.py index b1bec8e..cf74c98 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -1,5 +1,6 @@ from __future__ import annotations +import sqlite3 from datetime import UTC, datetime from pathlib import Path @@ -80,3 +81,133 @@ def test_unmatched_lookup_by_source_row_supports_large_manual_link_workflows( assert b_match is not None assert a_match.a_payload == {"id": "A-001"} assert b_match.b_payload == {"id": "B-000"} + + +def test_run_history_is_newest_first_and_uses_current_status_counts( + tmp_path: Path, +) -> None: + repository = make_repository(tmp_path) + + repository.save_run( + run_id="c" * 32, + created_at="2026-10-04T09:00:00+00:00", + filename_a="orders.csv", + filename_b="payments.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-1"}, {"id": "A-2"}), + rows_b=({"id": "B-1"}, {"id": "B-2"}), + decisions=( + MatchDecision(0, 0, 1.0, MatchStatus.AUTO_MATCHED, ()), + MatchDecision(1, None, 0.0, MatchStatus.UNMATCHED, ()), + MatchDecision(None, 1, 0.0, MatchStatus.UNMATCHED, ()), + ), + ) + repository.save_run( + run_id="d" * 32, + created_at="2026-10-04T10:00:00+00:00", + filename_a="ledger.csv", + filename_b="bank.xlsx", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-9"},), + rows_b=({"id": "B-9"},), + decisions=(MatchDecision(0, 0, 0.8, MatchStatus.REVIEW, ()),), + ) + + review_match = repository.list_matches( + "d" * 32, + statuses=(MatchStatus.REVIEW,), + limit=1, + )[0] + repository.accept_review("d" * 32, review_match.id) + + assert repository.count_runs() == 2 + + first_page = repository.list_runs(limit=1) + second_page = repository.list_runs(limit=1, offset=1) + + assert [item.id for item in first_page] == ["d" * 32] + assert [item.id for item in second_page] == ["c" * 32] + + newest = first_page[0] + assert newest.filename_a == "ledger.csv" + assert newest.filename_b == "bank.xlsx" + assert newest.total_a == 1 + assert newest.total_b == 1 + assert newest.auto_matched == 0 + assert newest.human_matched == 1 + assert newest.review == 0 + assert newest.unmatched == 0 + + older = second_page[0] + assert older.auto_matched == 1 + assert older.human_matched == 0 + assert older.review == 0 + assert older.unmatched == 2 + + +def test_delete_run_cascades_children_without_touching_other_runs( + tmp_path: Path, +) -> None: + repository = make_repository(tmp_path) + deleted_run_id = "e" * 32 + kept_run_id = "f" * 32 + + repository.save_run( + run_id=deleted_run_id, + created_at="2026-10-04T11:00:00+00:00", + filename_a="delete-a.csv", + filename_b="delete-b.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-1"},), + rows_b=({"id": "B-1"},), + decisions=(MatchDecision(0, 0, 0.8, MatchStatus.REVIEW, ()),), + ) + review_match = repository.list_matches( + deleted_run_id, + statuses=(MatchStatus.REVIEW,), + limit=1, + )[0] + repository.accept_review(deleted_run_id, review_match.id) + + repository.save_run( + run_id=kept_run_id, + created_at="2026-10-04T12:00:00+00:00", + filename_a="keep-a.csv", + filename_b="keep-b.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": "A-2"},), + rows_b=({"id": "B-2"},), + decisions=(MatchDecision(0, 0, 1.0, MatchStatus.AUTO_MATCHED, ()),), + ) + + assert repository.delete_run(deleted_run_id) is True + assert repository.delete_run(deleted_run_id) is False + assert repository.get_run(deleted_run_id) is None + assert repository.get_run(kept_run_id) is not None + + with sqlite3.connect(repository.database_path) as connection: + for table in ("source_rows", "matches", "review_events"): + deleted_count = connection.execute( + f"SELECT COUNT(*) FROM {table} WHERE run_id = ?", + (deleted_run_id,), + ).fetchone() + assert deleted_count is not None + assert deleted_count[0] == 0 + + kept_source_rows = connection.execute( + "SELECT COUNT(*) FROM source_rows WHERE run_id = ?", + (kept_run_id,), + ).fetchone() + kept_matches = connection.execute( + "SELECT COUNT(*) FROM matches WHERE run_id = ?", + (kept_run_id,), + ).fetchone() + + assert kept_source_rows is not None + assert kept_matches is not None + assert kept_source_rows[0] == 2 + assert kept_matches[0] == 1 diff --git a/tests/test_web.py b/tests/test_web.py index 607572d..376d8c7 100644 --- a/tests/test_web.py +++ b/tests/test_web.py @@ -66,6 +66,137 @@ def review_match_id(html: str) -> int: return int(match.group(1)) +def test_runs_page_shows_empty_state_and_header_link(tmp_path: Path) -> None: + client = make_client(tmp_path) + + home = client.get("/") + history = client.get("/runs") + + assert home.status_code == 200 + assert 'href="/runs">Runs' in home.text + assert history.status_code == 200 + assert "Reconciliation runs" in history.text + assert "No saved runs" in history.text + assert "Start a reconciliation" in history.text + + missing = client.get("/runs?page=2") + assert missing.status_code == 404 + assert "Run history page not found" in missing.text + + +def test_runs_page_lists_saved_run_with_current_summary(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + results = client.get(location) + match_id = review_match_id(results.text) + accepted = client.post( + f"{location}/matches/{match_id}/accept", + follow_redirects=False, + ) + assert accepted.status_code == 303 + + history = client.get("/runs") + + assert history.status_code == 200 + assert "orders.csv" in history.text + assert "payments.csv" in history.text + assert f'href="{location}">Open' in history.text + assert '1' in history.text + assert '0' in history.text + + +def test_runs_page_paginates_and_rejects_out_of_range_pages(tmp_path: Path) -> None: + from datetime import UTC, datetime, timedelta + + from rowbridge.models import FieldMapping, MatchDecision, MatchSettings, MatchStatus + from rowbridge.storage import Repository + + settings = Settings(data_dir=tmp_path / "data", runs_page_size=2) + client = TestClient(create_app(settings)) + repository = Repository(settings.database_path) + + for index in range(3): + repository.save_run( + run_id=str(index) * 32, + created_at=(datetime(2026, 10, 4, 9, tzinfo=UTC) + timedelta(hours=index)).isoformat( + timespec="seconds" + ), + filename_a=f"a-{index}.csv", + filename_b=f"b-{index}.csv", + mapping=FieldMapping(primary_a="id", primary_b="id"), + settings=MatchSettings(), + rows_a=({"id": f"A-{index}"},), + rows_b=({"id": f"B-{index}"},), + decisions=(MatchDecision(0, 0, 1.0, MatchStatus.AUTO_MATCHED, ()),), + ) + + first = client.get("/runs") + second = client.get("/runs?page=2") + missing = client.get("/runs?page=3") + + assert first.status_code == 200 + assert "a-2.csv" in first.text + assert "a-1.csv" in first.text + assert "a-0.csv" not in first.text + assert 'href="/runs?page=2">Next' in first.text + + assert second.status_code == 200 + assert "a-0.csv" in second.text + assert "a-2.csv" not in second.text + assert 'href="/runs?page=1">Previous' in second.text + + assert missing.status_code == 404 + assert "Run history page not found" in missing.text + + +def test_delete_run_confirmation_shows_run_and_cancel_link(tmp_path: Path) -> None: + client = make_client(tmp_path) + location = create_sample_run(client) + + history = client.get("/runs") + confirmation = client.get(f"{location}/delete") + + assert history.status_code == 200 + assert f'href="{location}/delete">Delete' in history.text + assert confirmation.status_code == 200 + assert "Delete this reconciliation run?" in confirmation.text + assert "orders.csv" in confirmation.text + assert "payments.csv" in confirmation.text + assert f'href="{location}">Cancel' in confirmation.text + assert f'action="{location}/delete"' in confirmation.text + + +def test_delete_run_post_removes_run_and_redirects_to_history(tmp_path: Path) -> None: + client = make_client(tmp_path) + deleted_location = create_sample_run(client) + kept_location = create_sample_run(client) + + deleted = client.post(f"{deleted_location}/delete", follow_redirects=False) + + assert deleted.status_code == 303 + assert deleted.headers["location"] == "/runs" + assert client.get(deleted_location).status_code == 404 + assert client.get(kept_location).status_code == 200 + + history = client.get("/runs") + assert history.status_code == 200 + assert history.text.count("orders.csv") == 1 + assert history.text.count("payments.csv") == 1 + + +def test_delete_missing_run_returns_not_found(tmp_path: Path) -> None: + client = make_client(tmp_path) + missing = "f" * 32 + + confirmation = client.get(f"/runs/{missing}/delete") + deleted = client.post(f"/runs/{missing}/delete", follow_redirects=False) + + assert confirmation.status_code == 404 + assert deleted.status_code == 404 + assert "Run not found" in confirmation.text + assert "Run not found" in deleted.text + + def test_vertical_slice_upload_map_persist_render_and_export(tmp_path: Path) -> None: client = make_client(tmp_path) location = create_sample_run(client)