From f9c658214f41f2a3e23f987f036a474580cb5a9e Mon Sep 17 00:00:00 2001 From: Taylor Date: Thu, 18 Jun 2026 12:25:51 -0400 Subject: [PATCH] feat: invoice refresh, theme picker, and agent backup workflow Add full invoice recalc with TUI/CLI diff preview, bulleted entry notes in line descriptions, a searchable theme picker, and a required just backup step before agents touch production ledger data. Co-authored-by: Cursor --- .cursor/rules/production-data-backup.mdc | 51 ++++ AGENTS.md | 23 ++ docs/pages/guides/invoicing.md | 23 ++ docs/pages/guides/the-tui.md | 12 +- docs/pages/reference/cli/invoice.md | 14 + docs/pages/reference/configuration.md | 2 +- docs/pages/reference/keyboard-shortcuts.md | 4 +- justfile | 9 + src/ttd/cli/invoices.py | 82 ++++++ src/ttd/config/schema.py | 2 +- src/ttd/invoicing/templates/invoice.md.j2 | 7 +- src/ttd/services/invoicing.py | 318 +++++++++++++++++++-- src/ttd/tui/app.py | 39 ++- src/ttd/tui/screens/_base.py | 6 + src/ttd/tui/screens/invoices.py | 139 ++++++++- src/ttd/tui/theme.py | 3 + src/ttd/tui/ttd.tcss | 37 +++ src/ttd/tui/widgets/theme_picker.py | 245 ++++++++++++++++ tests/test_invoicing/test_invoicing.py | 154 ++++++++++ tests/test_tui/test_app.py | 113 ++++++++ 20 files changed, 1249 insertions(+), 34 deletions(-) create mode 100644 .cursor/rules/production-data-backup.mdc create mode 100644 src/ttd/tui/widgets/theme_picker.py diff --git a/.cursor/rules/production-data-backup.mdc b/.cursor/rules/production-data-backup.mdc new file mode 100644 index 0000000..bfc510e --- /dev/null +++ b/.cursor/rules/production-data-backup.mdc @@ -0,0 +1,51 @@ +--- +description: Back up the active TTD ledger before implementing features or other data-touching work +alwaysApply: true +--- + +# Production data backup (required) + +Taylor dogfoods TTD with **real ledger data**. Before **implementing** a feature, fix, refactor that touches persistence/services/CLI data paths, migrations, or anything that could mutate the live database, **create a backup first**. + +## When to back up + +- **Required:** feature/fix/refactor work that changes code under `src/ttd/` (especially `storage/`, `services/`, `cli/`, migrations) +- **Required:** running destructive CLI against the default db (`db seed-demo --reset`, experimental imports, schema experiments) +- **Skip:** read-only Q&A, plan/docs-only edits, work confined to tests with an isolated `TTD_DB_PATH` + +## Command (run from repo root) + +```bash +just backup +``` + +Or explicitly: + +```bash +mkdir -p ~/Backups/ttd +uv run ttd db path # note which ledger you are protecting +uv run ttd db backup ~/Backups/ttd/ +``` + +This writes `~/Backups/ttd/ttd-YYYYMMDD-HHMMSS.db` (full SQLite copy). + +## Agent workflow + +1. Run `just backup` **before** the first code change of the task. +2. If backup prints "No database yet", say so and continue only when the task does not need real data. +3. If backup succeeds, **record the backup path** in your first implementation update (one line is enough). +4. Do **not** run `ttd db seed-demo --reset` (or similar) against the default `db_path` — use `TTD_DB_PATH=/tmp/ttd-dev.db` for experiments. +5. Tests must keep using isolated DB fixtures; never point pytest at the production ledger. + +## Restore (full ledger) + +```bash +# Stop TTD/TUI first, then: +cp ~/Backups/ttd/ttd-YYYYMMDD-HHMMSS.db "$(uv run ttd db path)" +# Remove stale WAL sidecars if present: +rm -f "$(uv run ttd db path)-wal" "$(uv run ttd db path)-shm" +``` + +JSON export (`ttd export ledger.json`) is a useful **secondary** archive for entries but does **not** replace a `.db` backup (invoices and full graph live in SQLite). + +See `docs/pages/guides/data-and-backups.md`. diff --git a/AGENTS.md b/AGENTS.md index 44eab3a..c7825a9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,29 @@ uv run ttd # bare ttd launches the TUI **Done means green:** run `just check` and fix failures before handoff. See `.cursor/rules/quality-gate.mdc`. +## Production ledger backup + +Taylor uses TTD with real data. **Before implementing features or other data-touching work**, run `just backup` and note the backup path. See `.cursor/rules/production-data-backup.mdc`. + +## Branching + +Branch from `main`. Name branches with a conventional-commit prefix and a short kebab-case slug: + +| Prefix | Use for | +|--------|---------| +| `feat/` | New user-facing behavior or capability | +| `fix/` | Bug fixes | +| `refactor/` | Internal restructuring without intended behavior change | +| `docs/` | Documentation-only changes | +| `chore/` | Tooling, CI, dependencies, repo hygiene | +| `test/` | Test-only changes | + +Examples: `feat/tax-set-aside`, `fix/invoice-rounding`, `docs/roadmap-m1-m2-done`. + +- One logical change per branch; open a PR back to `main`. +- Match the prefix to the branch's primary intent (same types as conventional commits / PR titles). +- Do not push feature work directly to `main`. + ## Dependency management (uv) - Use **uv**, not `pip install` / ad-hoc virtualenvs. diff --git a/docs/pages/guides/invoicing.md b/docs/pages/guides/invoicing.md index cf2957a..5c10881 100644 --- a/docs/pages/guides/invoicing.md +++ b/docs/pages/guides/invoicing.md @@ -117,6 +117,28 @@ A project-day totalling **1h07m** bills as **1h00m** with `nearest`, **1h15m** with `up`, and exactly **1h07m** with `none`. What you log is never changed — only what's billed. +## Refreshing an invoice + +Recompute an existing invoice from its **locked entries** using current billing +rules, rates, tax config, and description logic (including entry notes). Preview +shows a before/after diff; nothing changes until you confirm. + +```console +$ ttd invoice refresh 2026-001 # print diff +$ ttd invoice refresh 2026-001 --apply # apply when allowed +``` + +| Status | Preview | Apply | +| --- | --- | --- | +| `void` | Blocked | — | +| `draft`, `sent` | Full recalc diff | Updates lines and invoice totals | +| `paid` | Full recalc diff | **Descriptions only** — apply is blocked when totals or line amounts would change | + +Paid invoices never update `set_aside`, `paid_date`, or header totals. To change +billing on a paid invoice, void it and re-invoice. + +Refresh does **not** pull in new uninvoiced entries or edit locked entry data. + ## Invoices in the TUI Screen `5` lists invoices with status pills. When a tax set-aside rate is @@ -130,6 +152,7 @@ estimates in its summary line. | --- | --- | | `n` | new invoice (pick client, live-preview period) | | `o` | open line-item detail (with est. tax / take-home when configured) | +| `u` | refresh — recompute from locked entries (before/after diff) | | `m` | preview the Markdown render | | `e` | render PDF + Markdown files | | `t` / `p` / `v` | mark sent / paid / void | diff --git a/docs/pages/guides/the-tui.md b/docs/pages/guides/the-tui.md index b01000f..861db08 100644 --- a/docs/pages/guides/the-tui.md +++ b/docs/pages/guides/the-tui.md @@ -56,7 +56,7 @@ Screen `5`. Every invoice with its status pill; create, inspect, render, and advance them without leaving the screen. When `tax.set_aside_rate` is set, the list adds **est. tax** and **take-home** columns (dim until the invoice is paid), and the detail modal shows the same figures. Keys: `n` new, `o` detail, -`m` markdown preview, `e` render files, `t` sent, `p` paid, `v` void. +`u` refresh, `m` markdown preview, `e` render files, `t` sent, `p` paid, `v` void. ![Invoices](../assets/screenshots/invoices-list.svg) @@ -80,7 +80,15 @@ accept `y`; pickers are arrow-key lists. ## Themes -Two built-in themes, switched via config: +Press `t` anywhere in the TUI, or open the command palette (`ctrl+p`) → +**Theme**, to browse Textual's full theme catalog in a two-column picker: +searchable list on the left, a miniature TUI preview on the right. Arrow keys +update the preview live; press ++enter++ to apply or ++escape++ to cancel and +restore your previous theme. You'll be asked whether to save the choice as +your default. + +Saved themes go to your global config file. You can also set the theme via +config (takes effect on next launch): ```console $ ttd config set display.theme ttd-light # or ttd-dark (default) diff --git a/docs/pages/reference/cli/invoice.md b/docs/pages/reference/cli/invoice.md index b418858..41b6d88 100644 --- a/docs/pages/reference/cli/invoice.md +++ b/docs/pages/reference/cli/invoice.md @@ -15,6 +15,7 @@ Create and manage invoices. * [`create`](#invoice-create): Invoice a client's uninvoiced billable work (defaults to last month). * [`list`](#invoice-list): List invoices, newest first. * [`mark`](#invoice-mark): Update invoice status; void releases its entries for re-invoicing. +* [`refresh`](#invoice-refresh): Recompute invoice lines from locked entries and show a before/after diff. * [`render`](#invoice-render): (Re)render an invoice's PDF/Markdown files. * [`show`](#invoice-show): Show one invoice with line items. @@ -74,6 +75,19 @@ ttd invoice render [OPTIONS] NUMBER * `--md, --no-md`: *\[default: False\]* * `--out`: +## invoice refresh + +```console +ttd invoice refresh [OPTIONS] NUMBER +``` + +Recompute invoice lines from locked entries and show a before/after diff. + +**Parameters**: + +* `NUMBER, --number`: **\[required\]** +* `--apply, --no-apply`: Apply changes when allowed *\[default: False\]* + ## invoice mark ```console diff --git a/docs/pages/reference/configuration.md b/docs/pages/reference/configuration.md index 5728ac1..103653e 100644 --- a/docs/pages/reference/configuration.md +++ b/docs/pages/reference/configuration.md @@ -86,7 +86,7 @@ increment_minutes = 15 # Billing increment in minutes [display] time_format = "12h" # 12h | 24h week_start = "monday" # monday | sunday -theme = "ttd-dark" # ttd-dark | ttd-light +theme = "ttd-dark" # any Textual theme name [parsing] workday_start = 7 # Hour (0-23); disambiguates am/pm in parsed times diff --git a/docs/pages/reference/keyboard-shortcuts.md b/docs/pages/reference/keyboard-shortcuts.md index 9e767cd..46f16dc 100644 --- a/docs/pages/reference/keyboard-shortcuts.md +++ b/docs/pages/reference/keyboard-shortcuts.md @@ -17,6 +17,7 @@ Available on every screen: | `6` | Taxes | | `s` | Start / stop the timer | | `l` | Quick log | +| `t` | Theme picker | | `r` | Refresh | | `q` | Quit | @@ -54,6 +55,7 @@ Available on every screen: | --- | --- | | `n` | New invoice | | `o` | Open detail | +| `u` | Refresh (recompute from locked entries) | | `m` | Preview Markdown | | `e` | Render PDF + Markdown | | `t` | Mark sent | @@ -74,4 +76,4 @@ Available on every screen: | `enter` | Submit (quick log, forms, pickers) | | `escape` | Cancel / close | | `y` | Confirm, in confirmation dialogs | -| `ctrl+s` | Create, in the new-invoice form | +| `ctrl+s` | Create, in the new-invoice form; apply refresh, in the refresh diff modal | diff --git a/justfile b/justfile index 99f52e5..33fe70d 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,15 @@ check: uv run ruff format --check . uv run ty check src +# Copy the active ledger to ~/Backups/ttd/ (timestamped). Run before feature work. +backup: + #!/usr/bin/env bash + set -euo pipefail + dest="${HOME}/Backups/ttd" + mkdir -p "$dest" + uv run ttd db path + uv run ttd db backup "$dest" + # Install Python deps (uv) and git hooks (prek via uv). setup: uv sync diff --git a/src/ttd/cli/invoices.py b/src/ttd/cli/invoices.py index 04f9e47..83029c8 100644 --- a/src/ttd/cli/invoices.py +++ b/src/ttd/cli/invoices.py @@ -262,6 +262,88 @@ async def render( _render_files(view, pdf, md, out) +def _print_refresh_diff(preview: svc.RefreshPreview) -> None: + invoice = preview.invoice + currency = invoice.currency + status = enum_value(invoice.status) + console.print(f"\n[bold]Refresh {invoice.number}[/bold] {_status_pill(status)}") + if preview.blocked_reason: + console.print(f"[err]{preview.blocked_reason}[/err]") + elif status == "paid" and preview.can_apply: + console.print("[muted]Paid invoice — only line descriptions will be updated.[/muted]") + elif not preview.has_changes: + console.print("[muted]No changes — invoice lines match current rules.[/muted]") + + changed = [d for d in preview.lines if d.changed] + if changed: + t = table("Date", "Project", "Description", "Hours", "Rate", "Amount") + for diff in changed: + t.add_row( + diff.work_date.strftime("%a %b %-d"), + diff.project_name, + _refresh_diff_cell("description", diff, currency), + _refresh_diff_cell("billed_seconds", diff, currency), + _refresh_diff_cell("rate", diff, currency), + _refresh_diff_cell("amount", diff, currency), + ) + console.print(t) + + sub = format_money(preview.before_subtotal, currency) + sub_after = format_money(preview.after_subtotal, currency) + total = format_money(preview.before_total, currency) + total_after = format_money(preview.after_total, currency) + if preview.totals_changed: + console.print(f"Subtotal: {sub} → [bold]{sub_after}[/bold]") + if preview.before_tax or preview.after_tax: + console.print( + f"Tax: {format_money(preview.before_tax, currency)} → " + f"[bold]{format_money(preview.after_tax, currency)}[/bold]" + ) + console.print(f"Total: {total} → [bold]{total_after}[/bold]") + else: + console.print(f"Subtotal: {sub} · Total: {total}") + + +def _refresh_diff_cell(field: str, diff: svc.LineDiff, currency: str) -> str: + before = diff.before + after = diff.after + if field == "description": + old = svc.flatten_line_description(before.description if before else "") + new = svc.flatten_line_description(after.description) + elif field == "billed_seconds": + old, new = ( + f"{before.billed_seconds / 3600:.2f}" if before else "0.00", + f"{after.billed_seconds / 3600:.2f}", + ) + elif field == "rate": + old = format_money(before.rate, currency) if before else "—" + new = format_money(after.rate, currency) + else: + old = format_money(before.amount, currency) if before else "—" + new = format_money(after.amount, currency) + if field not in diff.changed or old == new: + return new + return f"[muted]{old}[/muted] → {new}" + + +@app.command(name="refresh") +@with_db +async def refresh( + number: str, + *, + apply: Annotated[bool, Parameter(name="--apply", help="Apply changes when allowed")] = False, +) -> None: + """Recompute invoice lines from locked entries and show a before/after diff.""" + settings = get_settings() + preview = await svc.preview_refresh(number, settings) + _print_refresh_diff(preview) + if apply: + if not preview.can_apply: + raise TtdError(preview.blocked_reason or "No changes to apply") + invoice = await svc.apply_refresh(number, preview, settings) + success(f"Updated invoice [accent]{invoice.number}[/accent]") + + @app.command(name="mark") @with_db async def mark( diff --git a/src/ttd/config/schema.py b/src/ttd/config/schema.py index cac7dbf..46919a1 100644 --- a/src/ttd/config/schema.py +++ b/src/ttd/config/schema.py @@ -85,7 +85,7 @@ class DisplayConfig(_Section): week_start: Literal["monday", "sunday"] = "monday" """First day of the week in reports.""" theme: str = "ttd-dark" - """TUI theme name.""" + """TUI theme name (any Textual theme, e.g. ttd-dark, dracula, nord).""" class ParsingConfig(_Section): diff --git a/src/ttd/invoicing/templates/invoice.md.j2 b/src/ttd/invoicing/templates/invoice.md.j2 index 843751d..957330f 100644 --- a/src/ttd/invoicing/templates/invoice.md.j2 +++ b/src/ttd/invoicing/templates/invoice.md.j2 @@ -17,10 +17,11 @@ ## Work -| Date | Description | Hours | Rate | Amount | -|---|---|--:|--:|--:| {% for line in lines -%} -| {{ line.work_date.strftime("%b %-d") }} | {{ line.description }} | {{ "%.2f" | format(line.billed_seconds / 3600) }} | {{ money(line.rate) }} | {{ money(line.amount) }} | +**{{ line.work_date.strftime("%b %-d") }}** · {{ "%.2f" | format(line.billed_seconds / 3600) }} h · {{ money(line.rate) }} · **{{ money(line.amount) }}** + +{{ line.description }} + {% endfor %} | | | diff --git a/src/ttd/services/invoicing.py b/src/ttd/services/invoicing.py index 17c4411..7564a02 100644 --- a/src/ttd/services/invoicing.py +++ b/src/ttd/services/invoicing.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from datetime import date, datetime, timedelta from decimal import Decimal -from uuid import uuid4 +from uuid import UUID, uuid4 from ferro import transaction @@ -27,6 +27,36 @@ pk, ) +PAID_REFRESH_BLOCKED = ( + "Paid invoices can only be updated for description changes. " + "Totals or line amounts would change — void and re-invoice to change billing." +) + +BILLING_FIELDS = frozenset({"billed_seconds", "rate", "amount"}) + + +def _line_description(project_name: str, entry_count: int, notes: list[str]) -> str: + base = f"{project_name} — {entry_count} entr{'y' if entry_count == 1 else 'ies'}" + if not notes: + return base + bullets = "\n".join(f"- {note}" for note in notes) + return f"{base}\n{bullets}" + + +def flatten_line_description(text: str) -> str: + """Single-line form for tables and terminals.""" + if not text: + return "" + lines = text.splitlines() + if len(lines) == 1: + return lines[0] + return f"{lines[0]} · {' · '.join(lines[1:])}" + + +def _entry_sort_key(entry: Entry) -> tuple: + started = entry.started_at or datetime.combine(entry.work_date, datetime.min.time()) + return (entry.work_date, started, entry.created_at) + @dataclass class DraftLine: @@ -59,30 +89,73 @@ class InvoiceView: project_names: dict -async def build_draft(client_slug: str, period: Period, settings: Settings) -> Draft: - client = await get_client(client_slug) - projects = {pk(p): p for p in await Project.where(lambda p: p.client_id == client.id).all()} - if not projects: - raise TtdError(f"Client '{client_slug}' has no projects") +@dataclass +class LineDiff: + project_id: UUID + work_date: date + project_name: str + before: InvoiceLine | None + after: DraftLine + changed: frozenset[str] - entries = [ - e - for e in await Entry.all() - if e.project_id in projects - and e.invoice_id is None - and e.billable - and period.start <= e.work_date <= period.end - ] - if not entries: - raise TtdError(f"No uninvoiced billable entries for '{client_slug}' in {period.label}") +@dataclass +class RefreshPreview: + invoice: Invoice + client: Client + lines: list[LineDiff] + before_subtotal: Decimal + after_subtotal: Decimal + before_tax: Decimal + after_tax: Decimal + before_total: Decimal + after_total: Decimal + totals_changed: bool + billing_fields_changed: bool + has_changes: bool + can_apply: bool + blocked_reason: str | None + + +def _line_key(project_id: UUID, work_date: date) -> tuple[UUID, date]: + return (project_id, work_date) + + +def _draft_totals(lines: list[DraftLine], tax_rate: Decimal) -> tuple[Decimal, Decimal, Decimal]: + subtotal = sum((line.amount for line in lines), Decimal("0")) + tax = to_cents(subtotal * tax_rate) + return subtotal, tax, subtotal + tax + + +def _line_changed(before: InvoiceLine | None, after: DraftLine) -> frozenset[str]: + if before is None: + return frozenset({"description", "billed_seconds", "rate", "amount"}) + changed: set[str] = set() + if before.description != after.description: + changed.add("description") + if before.billed_seconds != after.billed_seconds: + changed.add("billed_seconds") + if before.rate != after.rate: + changed.add("rate") + if before.amount != after.amount: + changed.add("amount") + return frozenset(changed) + + +async def _build_lines_from_entries( + entries: list[Entry], + client: Client, + projects: dict[UUID, Project], + settings: Settings, +) -> list[DraftLine]: + entries = sorted(entries, key=_entry_sort_key) facts = [ - EntryFacts(e.project_id, pk(client), e.work_date, e.seconds, e.billable) for e in entries + EntryFacts(e.project_id, pk(client), e.work_date, e.seconds, e.billable, e.note) + for e in entries ] cells = rollup_days(facts) lines: list[DraftLine] = [] - subtotal = Decimal("0") for cell in cells: project = projects[cell.project_id] rate = await effective_rate(project) @@ -110,21 +183,39 @@ async def build_draft(client_slug: str, period: Period, settings: Settings) -> D billed_seconds=billed, rate=rate, amount=amount, - description=f"{project.name} — {cell.entry_count} " - f"entr{'y' if cell.entry_count == 1 else 'ies'}", + description=_line_description(project.name, cell.entry_count, cell.notes), entry_ids=entry_ids, ) ) - subtotal += amount + return lines + - tax = to_cents(subtotal * settings.invoice.tax_rate) +async def build_draft(client_slug: str, period: Period, settings: Settings) -> Draft: + client = await get_client(client_slug) + projects = {pk(p): p for p in await Project.where(lambda p: p.client_id == client.id).all()} + if not projects: + raise TtdError(f"Client '{client_slug}' has no projects") + + entries = [ + e + for e in await Entry.all() + if e.project_id in projects + and e.invoice_id is None + and e.billable + and period.start <= e.work_date <= period.end + ] + if not entries: + raise TtdError(f"No uninvoiced billable entries for '{client_slug}' in {period.label}") + + lines = await _build_lines_from_entries(entries, client, projects, settings) + subtotal, tax, total = _draft_totals(lines, settings.invoice.tax_rate) return Draft( client=client, period=period, lines=lines, subtotal=subtotal, tax=tax, - total=subtotal + tax, + total=total, ) @@ -243,3 +334,184 @@ def _clear_paid_snapshot(invoice: Invoice) -> None: invoice.paid_date = None invoice.set_aside_rate = None invoice.set_aside = None + + +async def preview_refresh(number: str, settings: Settings) -> RefreshPreview: + view = await get_invoice(number) + invoice, client = view.invoice, view.client + status = enum_value(invoice.status) + if status == "void": + raise ConflictError(f"Invoice {number} is void and can't be refreshed") + + entries = await Entry.where(lambda e: e.invoice_id == invoice.id).all() + if not entries: + raise TtdError(f"Invoice {number} has no linked entries") + + project_ids = {e.project_id for e in entries} + projects = {pk(p): p for p in await Project.all() if pk(p) in project_ids} + after_lines = await _build_lines_from_entries(entries, client, projects, settings) + + before_by_key = {_line_key(li.project_id, li.work_date): li for li in view.lines} + after_by_key = {_line_key(pk(line.project), line.work_date): line for line in after_lines} + + all_keys = sorted(set(before_by_key) | set(after_by_key), key=lambda k: (k[1], str(k[0]))) + + diffs: list[LineDiff] = [] + billing_fields_changed = False + for key in all_keys: + project_id, work_date = key + before = before_by_key.get(key) + after = after_by_key.get(key) + project_name = view.project_names.get(project_id, projects[project_id].name) + + if after is None: + assert before is not None + billing_fields_changed = True + diffs.append( + LineDiff( + project_id=project_id, + work_date=work_date, + project_name=project_name, + before=before, + after=DraftLine( + project=projects[project_id], + work_date=work_date, + raw_seconds=before.billed_seconds, + billed_seconds=0, + rate=before.rate, + amount=Decimal("0"), + description="", + entry_ids=[], + ), + changed=frozenset({"description", "billed_seconds", "rate", "amount"}), + ) + ) + continue + + changed = _line_changed(before, after) + if changed & BILLING_FIELDS: + billing_fields_changed = True + diffs.append( + LineDiff( + project_id=project_id, + work_date=work_date, + project_name=project_name, + before=before, + after=after, + changed=changed, + ) + ) + + before_subtotal = invoice.subtotal + before_tax = invoice.tax + before_total = invoice.total + after_subtotal, after_tax, after_total = _draft_totals(after_lines, settings.invoice.tax_rate) + + totals_changed = ( + before_subtotal != after_subtotal or before_tax != after_tax or before_total != after_total + ) + has_changes = any(d.changed for d in diffs) or totals_changed + + description_only = has_changes and not totals_changed and not billing_fields_changed + can_apply = has_changes and ( + status in ("draft", "sent") or (status == "paid" and description_only) + ) + blocked_reason: str | None = None + if status == "paid" and has_changes and not description_only: + blocked_reason = PAID_REFRESH_BLOCKED + + return RefreshPreview( + invoice=invoice, + client=client, + lines=diffs, + before_subtotal=before_subtotal, + after_subtotal=after_subtotal, + before_tax=before_tax, + after_tax=after_tax, + before_total=before_total, + after_total=after_total, + totals_changed=totals_changed, + billing_fields_changed=billing_fields_changed, + has_changes=has_changes, + can_apply=can_apply, + blocked_reason=blocked_reason, + ) + + +async def apply_refresh(number: str, preview: RefreshPreview, settings: Settings) -> Invoice: + status = enum_value(preview.invoice.status) + if status == "void": + raise ConflictError(f"Invoice {number} is void and can't be refreshed") + if not preview.can_apply: + if preview.blocked_reason: + raise TtdError(preview.blocked_reason) + raise TtdError("No changes to apply") + + fresh = await preview_refresh(number, settings) + if not fresh.can_apply: + if fresh.blocked_reason: + raise TtdError(fresh.blocked_reason) + raise TtdError("Invoice changed since preview — refresh again") + + invoice = fresh.invoice + status = enum_value(invoice.status) + + async with transaction(): + if status == "paid": + before_by_key = { + _line_key(li.project_id, li.work_date): li + for li in await InvoiceLine.where(lambda li: li.invoice_id == invoice.id).all() + } + for diff in fresh.lines: + if "description" not in diff.changed: + continue + stored = before_by_key.get(_line_key(diff.project_id, diff.work_date)) + if stored is None: + continue + stored.description = diff.after.description + await stored.save() + else: + stored_lines = await InvoiceLine.where(lambda li: li.invoice_id == invoice.id).all() + stored_by_key = {_line_key(li.project_id, li.work_date): li for li in stored_lines} + seen_keys: set[tuple[UUID, date]] = set() + + for diff in fresh.lines: + key = _line_key(diff.project_id, diff.work_date) + after = diff.after + if after.billed_seconds == 0 and diff.before is not None: + continue + seen_keys.add(key) + if not diff.changed: + continue + if key in stored_by_key: + line = stored_by_key[key] + line.description = after.description + line.billed_seconds = after.billed_seconds + line.rate = after.rate + line.amount = after.amount + await line.save() + else: + await InvoiceLine( + id=uuid4(), + invoice_id=pk(invoice), + project_id=diff.project_id, + work_date=diff.work_date, + billed_seconds=after.billed_seconds, + rate=after.rate, + amount=after.amount, + description=after.description, + ).save() + + for key, line in stored_by_key.items(): + if key not in seen_keys: + await line.delete() + + invoice.subtotal = fresh.after_subtotal + invoice.tax_rate = settings.invoice.tax_rate + invoice.tax = fresh.after_tax + invoice.total = fresh.after_total + await invoice.save() + + updated = await Invoice.where(lambda i: i.number == number).first() + assert updated is not None + return updated diff --git a/src/ttd/tui/app.py b/src/ttd/tui/app.py index 61d8129..5a8b18e 100644 --- a/src/ttd/tui/app.py +++ b/src/ttd/tui/app.py @@ -4,15 +4,20 @@ from textual.app import App +from ttd.config import writer from ttd.config.loader import get_settings +from ttd.core.errors import ConfigError from ttd.storage.db import close_db, init_db +from ttd.tui.screens._base import TtdScreen from ttd.tui.screens.clients import ClientsScreen from ttd.tui.screens.dashboard import DashboardScreen from ttd.tui.screens.invoices import InvoicesScreen from ttd.tui.screens.reports import ReportsScreen from ttd.tui.screens.taxes import TaxesScreen from ttd.tui.screens.timesheet import TimesheetScreen -from ttd.tui.theme import TTD_DARK, TTD_LIGHT +from ttd.tui.theme import THEME_DARK, TTD_DARK, TTD_LIGHT +from ttd.tui.widgets.modals import ConfirmModal +from ttd.tui.widgets.theme_picker import ThemePickerModal class TtdApp(App): @@ -32,10 +37,40 @@ async def on_mount(self) -> None: self.register_theme(TTD_DARK) self.register_theme(TTD_LIGHT) configured = get_settings().display.theme - self.theme = configured if configured in ("ttd-dark", "ttd-light") else "ttd-dark" + self.theme = configured if configured in self.available_themes else THEME_DARK await init_db() await self.push_screen("dashboard") + def search_themes(self) -> None: + """Command palette → Theme: two-column picker with live preview.""" + calling = self.screen if isinstance(self.screen, TtdScreen) else None + + async def _picked(selected: str | None) -> None: + if selected is None: + if isinstance(calling, TtdScreen): + await calling.refresh_data() + return + + async def _save(save: bool | None) -> None: + if save: + try: + path = writer.set_value("display.theme", selected, local=False) + except ConfigError as exc: + self.notify(str(exc), severity="error") + else: + self.notify(f"saved to {path}", title=f"theme: {selected}") + else: + self.notify("theme changed for this session", title=selected) + if isinstance(calling, TtdScreen): + await calling.refresh_data() + + self.push_screen( + ConfirmModal(f"Save {selected} as your default theme?"), + _save, + ) + + self.push_screen(ThemePickerModal(), _picked) + async def on_unmount(self) -> None: await close_db() diff --git a/src/ttd/tui/screens/_base.py b/src/ttd/tui/screens/_base.py index e997edb..191b398 100644 --- a/src/ttd/tui/screens/_base.py +++ b/src/ttd/tui/screens/_base.py @@ -47,6 +47,7 @@ class TtdScreen(Screen): Binding("6", "goto('taxes')", "taxes", group=SCREEN_GROUP), Binding("s", "toggle_timer", "start/stop"), Binding("l", "quick_log", "log"), + Binding("t", "pick_theme", "theme"), Binding("r", "refresh", "refresh"), Binding("q", "quit_app", "quit"), ] @@ -129,6 +130,11 @@ async def _start(choice: str | None) -> None: self.app.push_screen(PickerModal("start timer on…", options), _start) + def action_pick_theme(self) -> None: + search = getattr(self.app, "search_themes", None) + if search is not None: + search() + async def action_quick_log(self) -> None: options = await project_options() if not options: diff --git a/src/ttd/tui/screens/invoices.py b/src/ttd/tui/screens/invoices.py index 58a0a70..ec411b2 100644 --- a/src/ttd/tui/screens/invoices.py +++ b/src/ttd/tui/screens/invoices.py @@ -197,6 +197,119 @@ def action_create(self) -> None: self.dismiss(self.draft) +def _format_diff_cell( + field: str, diff: svc.LineDiff, currency: str, *, changed_only: bool = False +) -> str: + if changed_only and field not in diff.changed: + return "" + before = diff.before + after = diff.after + if field == "description": + old = svc.flatten_line_description(before.description if before else "") + new = svc.flatten_line_description(after.description) + if old == new: + return new + return f"[dim]{old}[/dim] → {new}" + if field == "billed_seconds": + old = format_hours(before.billed_seconds) if before else "0:00" + new = format_hours(after.billed_seconds) + if old == new: + return new + return f"[dim]{old}[/dim] → {new}" + if field == "rate": + old = format_money(before.rate, currency) if before else "—" + new = format_money(after.rate, currency) + if old == new: + return new + return f"[dim]{old}[/dim] → {new}" + if field == "amount": + old = format_money(before.amount, currency) if before else "—" + new = format_money(after.amount, currency) + if old == new: + return new + return f"[dim]{old}[/dim] → {new}" + return "" + + +class InvoiceRefreshModal(ModalScreen["svc.RefreshPreview | None"]): + """Before/after diff for recomputing invoice lines from locked entries.""" + + BINDINGS: ClassVar = [ + ("escape", "dismiss(None)", "cancel"), + ("ctrl+s", "apply", "apply"), + ] + + def __init__(self, preview: svc.RefreshPreview) -> None: + super().__init__() + self.preview = preview + + def compose(self) -> ComposeResult: + invoice = self.preview.invoice + status = enum_value(invoice.status) + currency = invoice.currency + with Vertical(classes="modal-box wide"): + yield Label( + f"refresh · {invoice.number} · [{PILL.get(status, 'dim')}]{status}[/]", + classes="modal-title", + ) + if self.preview.blocked_reason: + yield Static(f"[#ff5c5c]{self.preview.blocked_reason}[/#ff5c5c]") + elif status == "paid" and self.preview.can_apply: + yield Static("[dim]Paid invoice — only line descriptions will be updated.[/dim]") + elif not self.preview.has_changes: + yield Static("[dim]No changes — invoice lines match current rules.[/dim]") + + table = DataTable(id="refresh-table", cursor_type="none") + table.add_columns("date", "project", "description", "hours", "rate", "amount") + for diff in self.preview.lines: + if not diff.changed: + continue + table.add_row( + diff.work_date.strftime("%a %b %-d"), + diff.project_name, + _format_diff_cell("description", diff, currency), + _format_diff_cell("billed_seconds", diff, currency), + _format_diff_cell("rate", diff, currency), + _format_diff_cell("amount", diff, currency), + ) + yield table + + totals = self.preview + sub = format_money(totals.before_subtotal, currency) + sub_after = format_money(totals.after_subtotal, currency) + total = format_money(totals.before_total, currency) + total_after = format_money(totals.after_total, currency) + if totals.totals_changed: + footer = ( + f"subtotal {sub} → [bold]{sub_after}[/bold] · " + f"total {total} → [bold]{total_after}[/bold]" + ) + else: + footer = f"subtotal {sub} · total {total}" + yield Label(footer) + + with Horizontal(classes="modal-buttons"): + yield Button( + "Apply (ctrl+s)", + variant="primary", + id="apply", + disabled=not self.preview.can_apply, + ) + yield Button("Cancel (esc)", id="cancel") + + @on(Button.Pressed, "#apply") + def _apply_pressed(self) -> None: + self.action_apply() + + @on(Button.Pressed, "#cancel") + def _cancel_pressed(self) -> None: + self.dismiss(None) + + def action_apply(self) -> None: + if self.preview.can_apply: + self.dismiss(self.preview) + + class InvoicesScreen(TtdScreen): nav_id = "invoices" @@ -206,6 +319,7 @@ class InvoicesScreen(TtdScreen): Binding("o", "open_detail", "open"), ("m", "preview_markdown", "preview md"), ("e", "render_files", "render pdf+md"), + ("u", "refresh_invoice", "update"), ("p", "mark('paid')", "paid"), ("t", "mark('sent')", "sent"), ("v", "mark('void')", "void"), @@ -248,7 +362,8 @@ async def render_data(self) -> None: table.add_row(*row, key=invoice.number) self.query_one("#invoice-help", Label).update( f"{len(rows)} invoice{'s' if len(rows) != 1 else ''} " - "[dim]n new · o detail · m preview md · e render · t sent · p paid · v void[/dim]" + "[dim]n new · o detail · u update · m preview md · e render · " + "t sent · p paid · v void[/dim]" ) def _selected_number(self) -> str | None: @@ -283,6 +398,28 @@ async def action_render_files(self) -> None: write_markdown(view, settings, stem.with_suffix(".md")) self.notify(f"wrote {stem}.pdf + .md", title="rendered") + async def action_refresh_invoice(self) -> None: + number = self._selected_number() + if number is None: + return + try: + preview = await svc.preview_refresh(number, get_settings()) + except TtdError as exc: + self.notify(str(exc), severity="error") + return + + async def _done(accepted: svc.RefreshPreview | None) -> None: + if accepted is None or not accepted.can_apply: + return + try: + await svc.apply_refresh(number, accepted, get_settings()) + self.notify(f"updated {number}", title="refresh") + except TtdError as exc: + self.notify(str(exc), severity="error") + await self.refresh_data() + + self.app.push_screen(InvoiceRefreshModal(preview), _done) + async def action_mark(self, status: str) -> None: number = self._selected_number() if number is None: diff --git a/src/ttd/tui/theme.py b/src/ttd/tui/theme.py index 0e5ef6d..7b46f88 100644 --- a/src/ttd/tui/theme.py +++ b/src/ttd/tui/theme.py @@ -5,6 +5,9 @@ from textual.theme import Theme +THEME_DARK = "ttd-dark" +THEME_LIGHT = "ttd-light" + AMBER = "#ffb000" INK = "#0d0f12" diff --git a/src/ttd/tui/ttd.tcss b/src/ttd/tui/ttd.tcss index 00f6be9..e693c5c 100644 --- a/src/ttd/tui/ttd.tcss +++ b/src/ttd/tui/ttd.tcss @@ -88,6 +88,43 @@ Tree { margin-bottom: 1; } +/* ---- theme picker ---- */ + +.modal-box.theme-picker { + width: 110; + max-width: 95%; +} + +.theme-picker-body { + height: 22; + margin-bottom: 1; +} + +.theme-picker-list { + width: 40%; + padding-right: 1; +} + +.theme-picker-list OptionList { + height: 1fr; + max-height: 100%; + margin-bottom: 0; + background: $surface; +} + +.theme-picker-list .option-list--option-disabled { + color: $accent; + text-style: bold; +} + +.theme-picker-list Input { + margin-bottom: 1; +} + +#theme-preview { + width: 1fr; +} + /* ---- modals ---- */ ModalScreen { diff --git a/src/ttd/tui/widgets/theme_picker.py b/src/ttd/tui/widgets/theme_picker.py new file mode 100644 index 0000000..0da74e9 --- /dev/null +++ b/src/ttd/tui/widgets/theme_picker.py @@ -0,0 +1,245 @@ +"""Two-column theme picker: searchable list + miniature TUI preview.""" + +from typing import ClassVar + +from textual import on +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.screen import ModalScreen +from textual.widgets import Button, Input, Label, OptionList, Rule, Static +from textual.widgets.option_list import Option + + +class ThemePreview(Static): + """Representative TUI chrome — reads live theme tokens from the app.""" + + DEFAULT_CSS = """ + ThemePreview { + height: 1fr; + background: $background; + border: round $panel; + padding: 0 1; + } + + ThemePreview #preview-frame { + height: 1fr; + } + + ThemePreview #preview-rail { + width: 14; + padding: 1 0 0 1; + border-right: solid $panel; + } + + ThemePreview #preview-brand { + color: $accent; + text-style: bold; + margin-bottom: 1; + } + + ThemePreview .preview-nav { + color: $secondary; + } + + ThemePreview .preview-nav-active { + color: $accent; + text-style: bold; + } + + ThemePreview #preview-content { + padding: 1; + width: 1fr; + } + + ThemePreview .preview-section { + text-style: bold; + margin-bottom: 1; + } + + ThemePreview .preview-header { + color: $accent; + text-style: bold; + } + + ThemePreview .preview-row { + color: $foreground; + } + + ThemePreview .preview-muted { + color: $secondary; + margin-top: 1; + } + + ThemePreview Rule { + color: $panel; + margin: 0 0 1 0; + } + """ + + def compose(self) -> ComposeResult: + with Horizontal(id="preview-frame"): + with Vertical(id="preview-rail"): + yield Label("ttd", id="preview-brand") + yield Label("1 dashboard", classes="preview-nav-active") + yield Label("2 timesheet", classes="preview-nav") + yield Label("3 clients", classes="preview-nav") + with Vertical(id="preview-content"): + yield Label("today", classes="preview-section") + yield Rule(line_style="dashed") + yield Label("project time hours", classes="preview-header") + yield Label("api rewrite 9:00–11:30 2.50", classes="preview-row") + yield Label("design 1:00–2:00 1.00", classes="preview-row") + yield Rule(line_style="dashed") + yield Label("s start/stop l log t theme", classes="preview-muted") + + +class ThemePickerModal(ModalScreen[str | None]): + """Browse Textual's theme catalog with a side-by-side TUI mockup.""" + + BINDINGS: ClassVar = [ + Binding("escape", "cancel", "Cancel"), + Binding("up", "list_up", "Up", show=False), + Binding("down", "list_down", "Down", show=False), + ] + + def __init__(self) -> None: + super().__init__() + self._base_theme = "" + self._selected: str | None = None + + def compose(self) -> ComposeResult: + with Vertical(classes="modal-box theme-picker"): + yield Label("theme", classes="modal-title") + with Horizontal(classes="theme-picker-body"): + with Vertical(classes="theme-picker-list"): + yield Input(placeholder="search themes…", id="theme-search") + yield OptionList(id="theme-list") + yield ThemePreview(id="theme-preview") + with Horizontal(classes="modal-buttons"): + yield Button("Apply (enter)", variant="primary", id="apply") + yield Button("Cancel (esc)", id="cancel") + + def on_mount(self) -> None: + self._base_theme = self.app.theme + self._rebuild_list(focus_current=True) + + def action_list_up(self) -> None: + self.query_one("#theme-list", OptionList).action_cursor_up() + + def action_list_down(self) -> None: + self.query_one("#theme-list", OptionList).action_cursor_down() + + def _grouped_entries(self) -> list[tuple[str, str | None]]: + """Section headers use theme_id None; selectable rows carry the theme name.""" + query = self.query_one("#theme-search", Input).value.strip().lower() + themes = self.app.available_themes + + def matches(name: str) -> bool: + return not query or query in name.lower() + + dark = sorted(name for name, theme in themes.items() if theme.dark and matches(name)) + light = sorted(name for name, theme in themes.items() if not theme.dark and matches(name)) + + entries: list[tuple[str, str | None]] = [] + if dark: + entries.append(("dark", None)) + entries.extend((name, name) for name in dark) + if light: + entries.append(("light", None)) + entries.extend((name, name) for name in light) + return entries + + def _selectable_theme_ids(self) -> list[str]: + return [theme_id for _, theme_id in self._grouped_entries() if theme_id is not None] + + def _highlight_index( + self, theme_list: OptionList, selectable: list[str], *, focus_current: bool + ) -> int: + if not selectable: + return 0 + if len(selectable) == 1: + target = selectable[0] + elif focus_current and self._base_theme in selectable: + target = self._base_theme + elif self._selected in selectable: + target = self._selected + else: + target = selectable[0] + return next(i for i, option in enumerate(theme_list.options) if option.id == target) + + def _rebuild_list(self, *, focus_current: bool = False) -> None: + theme_list = self.query_one("#theme-list", OptionList) + entries = self._grouped_entries() + theme_list.clear_options() + if not entries: + self._selected = None + return + for label, theme_id in entries: + if theme_id is None: + theme_list.add_option( + Option(label, id=f"__header_{label}__", disabled=True), + ) + else: + theme_list.add_option(Option(label, id=theme_id)) + selectable = self._selectable_theme_ids() + highlight = self._highlight_index(theme_list, selectable, focus_current=focus_current) + theme_list.highlighted = highlight + self._preview_at(highlight) + + def _preview_at(self, index: int | None) -> None: + if index is None: + return + theme_list = self.query_one("#theme-list", OptionList) + if index < 0 or index >= theme_list.option_count: + return + option = theme_list.get_option_at_index(index) + if option.id is None or option.disabled or str(option.id).startswith("__header_"): + return + self._selected = str(option.id) + if self._selected in self.app.available_themes: + self.app.theme = self._selected + + @on(Input.Changed, "#theme-search") + def _search_changed(self, _event: Input.Changed) -> None: + self._rebuild_list() + + @on(OptionList.OptionHighlighted, "#theme-list") + def _highlighted(self, event: OptionList.OptionHighlighted) -> None: + if event.option.disabled or event.option.id is None: + return + theme_name = str(event.option.id) + if theme_name.startswith("__header_"): + return + self._selected = theme_name + if self._selected in self.app.available_themes: + self.app.theme = self._selected + + @on(OptionList.OptionSelected, "#theme-list") + def _selected_option(self, _event: OptionList.OptionSelected) -> None: + self._apply() + + @on(Input.Submitted, "#theme-search") + def _search_submitted(self) -> None: + self._apply() + + @on(Button.Pressed, "#apply") + def _apply_button(self) -> None: + self._apply() + + @on(Button.Pressed, "#cancel") + def _cancel_button(self) -> None: + self.action_cancel() + + def action_cancel(self) -> None: + self.app.theme = self._base_theme + self.dismiss(None) + + def _apply(self) -> None: + selectable = self._selectable_theme_ids() + if self._selected is None or self._selected not in self.app.available_themes: + if not selectable: + return + self._selected = selectable[0] + self.app.theme = self._selected + self.dismiss(self._selected) diff --git a/tests/test_invoicing/test_invoicing.py b/tests/test_invoicing/test_invoicing.py index 422037c..9dc9746 100644 --- a/tests/test_invoicing/test_invoicing.py +++ b/tests/test_invoicing/test_invoicing.py @@ -12,6 +12,8 @@ from ttd.services import entries as entry_svc from ttd.services import invoicing as svc from ttd.services import projects as project_svc +from ttd.services.invoicing import PAID_REFRESH_BLOCKED +from ttd.storage.models import InvoiceLine NOW = datetime(2026, 6, 9, 15, 0) JUNE = month_period(date(2026, 6, 9)) @@ -79,6 +81,27 @@ async def test_draft_empty_period_errors(seeded): await svc.build_draft("acme", month_period(date(2026, 1, 1)), SETTINGS) +async def test_draft_line_includes_entry_notes(db): + await client_svc.create_client("Acme", hourly_rate=Decimal("150")) + await project_svc.create_project("API", "acme") + await entry_svc.log_entry("2026-06-08 9am to 11am", "api", now=NOW, note="API design") + await entry_svc.log_entry("2026-06-08 1pm to 2pm", "api", now=NOW, note="Code review") + draft = await svc.build_draft("acme", JUNE, SETTINGS) + assert len(draft.lines) == 1 + assert draft.lines[0].description == "API — 2 entries\n- API design\n- Code review" + + +async def test_draft_line_without_notes_unchanged(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + assert draft.lines[0].description == "API — 2 entries" + + +def test_flatten_line_description_shows_notes(): + text = "API — 2 entries\n- Design\n- Review" + assert svc.flatten_line_description(text) == "API — 2 entries · - Design · - Review" + assert svc.flatten_line_description("API — 2 entries") == "API — 2 entries" + + # --- persistence ------------------------------------------------------------- @@ -165,6 +188,18 @@ async def test_markdown_snapshot(seeded): assert "Payment due within 30 days" in md +async def test_markdown_renders_multiline_notes(db): + await client_svc.create_client("Acme", hourly_rate=Decimal("150")) + await project_svc.create_project("API", "acme") + await entry_svc.log_entry("2026-06-08 9am to 11am", "api", now=NOW, note="Design") + await entry_svc.log_entry("2026-06-08 1pm to 2pm", "api", now=NOW, note="Review") + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + view = await svc.get_invoice(invoice.number) + md = render_markdown(view, SETTINGS) + assert "API — 2 entries\n- Design\n- Review" in md + + async def test_set_aside_never_reaches_client_renders(seeded, tmp_path): """The tax set-aside is internal — it must not leak onto client invoices.""" from ttd.invoicing.pdf import render_pdf @@ -189,3 +224,122 @@ async def test_pdf_smoke(seeded, tmp_path): assert data.startswith(b"%PDF-") assert len(data) > 1500 assert b"/Page" in data + + +async def test_pdf_with_entry_notes(db, tmp_path): + from ttd.invoicing.pdf import render_pdf + + await client_svc.create_client("Acme", hourly_rate=Decimal("150")) + await project_svc.create_project("API", "acme") + await entry_svc.log_entry("2026-06-08 9am to 11am", "api", now=NOW, note="Design work") + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + view = await svc.get_invoice(invoice.number) + out = render_pdf(view, SETTINGS, tmp_path / "invoice-with-notes.pdf") + assert out.read_bytes().startswith(b"%PDF-") + + +# --- refresh --------------------------------------------------------------- + + +async def _legacy_line_descriptions(invoice_id) -> None: + """Strip notes from stored lines to simulate pre-notes invoices.""" + for line in await InvoiceLine.where(lambda li: li.invoice_id == invoice_id).all(): + line.description = line.description.splitlines()[0] + await line.save() + + +async def test_refresh_updates_descriptions_from_notes(db): + await client_svc.create_client("Acme", hourly_rate=Decimal("150")) + await project_svc.create_project("API", "acme") + await entry_svc.log_entry("2026-06-08 9am to 11am", "api", now=NOW, note="Design") + await entry_svc.log_entry("2026-06-08 1pm to 2pm", "api", now=NOW, note="Review") + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await _legacy_line_descriptions(invoice.id) + + preview = await svc.preview_refresh(invoice.number, SETTINGS) + expected = "API — 2 entries\n- Design\n- Review" + assert preview.has_changes and preview.can_apply + assert preview.lines[0].changed == frozenset({"description"}) + assert preview.lines[0].after.description == expected + + await svc.apply_refresh(invoice.number, preview, SETTINGS) + view = await svc.get_invoice(invoice.number) + assert view.lines[0].description == expected + + +async def test_refresh_recalcs_totals_when_rate_changes(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await client_svc.update_client("acme", hourly_rate=Decimal("200")) + + preview = await svc.preview_refresh(invoice.number, SETTINGS) + assert preview.totals_changed and preview.can_apply + assert preview.after_total > preview.before_total + + await svc.apply_refresh(invoice.number, preview, SETTINGS) + view = await svc.get_invoice(invoice.number) + assert view.invoice.total == preview.after_total + + +async def test_refresh_noop(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + preview = await svc.preview_refresh(invoice.number, SETTINGS) + assert not preview.has_changes + assert not preview.can_apply + + +async def test_refresh_void_rejected(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await svc.mark_invoice(invoice.number, "void") + with pytest.raises(ConflictError, match="void"): + await svc.preview_refresh(invoice.number, SETTINGS) + + +async def test_refresh_paid_description_only(db): + await client_svc.create_client("Acme", hourly_rate=Decimal("150")) + await project_svc.create_project("API", "acme") + await entry_svc.log_entry("2026-06-08 9am to 11am", "api", now=NOW, note="Design") + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await svc.mark_invoice(invoice.number, "paid", set_aside_rate=Decimal("0.32")) + await _legacy_line_descriptions(invoice.id) + before = await svc.get_invoice(invoice.number) + + preview = await svc.preview_refresh(invoice.number, SETTINGS) + assert preview.can_apply and not preview.totals_changed + await svc.apply_refresh(invoice.number, preview, SETTINGS) + + after = await svc.get_invoice(invoice.number) + assert after.invoice.total == before.invoice.total + assert after.invoice.set_aside == before.invoice.set_aside + assert after.lines[0].description == "API — 1 entry\n- Design" + + +async def test_refresh_paid_blocks_billing_changes(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await svc.mark_invoice(invoice.number, "paid") + await client_svc.update_client("acme", hourly_rate=Decimal("200")) + + preview = await svc.preview_refresh(invoice.number, SETTINGS) + assert preview.has_changes + assert not preview.can_apply + assert preview.blocked_reason == PAID_REFRESH_BLOCKED + with pytest.raises(TtdError, match="Paid invoices"): + await svc.apply_refresh(invoice.number, preview, SETTINGS) + + +async def test_refresh_apply_stale_rejected(seeded): + draft = await svc.build_draft("acme", JUNE, SETTINGS) + invoice = await svc.persist_draft(draft, SETTINGS, now=NOW) + await client_svc.update_client("acme", hourly_rate=Decimal("200")) + preview = await svc.preview_refresh(invoice.number, SETTINGS) + assert preview.can_apply + + await svc.mark_invoice(invoice.number, "paid") + with pytest.raises(TtdError, match="Paid invoices"): + await svc.apply_refresh(invoice.number, preview, SETTINGS) diff --git a/tests/test_tui/test_app.py b/tests/test_tui/test_app.py index 2ac3f63..d31b71d 100644 --- a/tests/test_tui/test_app.py +++ b/tests/test_tui/test_app.py @@ -13,6 +13,8 @@ from ttd.services import timer as timer_svc from ttd.storage.db import close_db, init_db from ttd.tui.app import TtdApp +from ttd.tui.theme import THEME_DARK, THEME_LIGHT +from ttd.tui.widgets.modals import ConfirmModal NOW = datetime.now().replace(hour=15, minute=0, second=0, microsecond=0) @@ -595,3 +597,114 @@ async def test_taxes_screen_shows_quarters_and_payment_modal(seeded_app): await pilot.press("escape") await pilot.pause() assert seeded_app.screen.nav_id == "taxes" + + +async def test_t_key_opens_theme_picker(seeded_app): + from ttd.tui.widgets.theme_picker import ThemePickerModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("t") + await pilot.pause() + assert isinstance(seeded_app.screen, ThemePickerModal) + + +async def test_t_key_save_theme(seeded_app): + from ttd.config.loader import get_settings + from ttd.tui.widgets.theme_picker import ThemePickerModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + await pilot.press("t") + await pilot.pause() + assert isinstance(seeded_app.screen, ThemePickerModal) + await pilot.press(*list("ttd-light")) + await pilot.press("enter") + await pilot.pause() + await pilot.press("y") + await pilot.pause() + assert get_settings().display.theme == THEME_LIGHT + assert seeded_app.screen.nav_id == "dashboard" + + +async def test_palette_theme_previews_and_reverts(seeded_app): + from ttd.tui.widgets.theme_picker import ThemePickerModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert seeded_app.theme == THEME_DARK + seeded_app.search_themes() + await pilot.pause() + assert isinstance(seeded_app.screen, ThemePickerModal) + await pilot.press(*list("ttd-light")) + await pilot.pause() + assert seeded_app.theme == THEME_LIGHT + await pilot.press("escape") + await pilot.pause() + assert seeded_app.theme == THEME_DARK + assert seeded_app.screen.nav_id == "dashboard" + + +async def test_palette_theme_arrow_navigation(seeded_app): + from ttd.tui.widgets.theme_picker import ThemePickerModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert seeded_app.theme == THEME_DARK + seeded_app.search_themes() + await pilot.pause() + assert isinstance(seeded_app.screen, ThemePickerModal) + light_names = sorted( + name for name, theme in seeded_app.available_themes.items() if not theme.dark + ) + await pilot.press("down") # ttd-dark is last in dark; next is first light theme + await pilot.pause() + assert seeded_app.theme == light_names[0] + + +async def test_palette_theme_groups_dark_and_light(seeded_app): + from ttd.tui.widgets.theme_picker import ThemePickerModal + + async with seeded_app.run_test(size=(120, 40)) as pilot: + seeded_app.search_themes() + await pilot.pause() + modal = seeded_app.screen + assert isinstance(modal, ThemePickerModal) + theme_list = modal.query_one("#theme-list") + labels = [str(option.prompt) for option in theme_list.options] + assert "dark" in labels + assert "light" in labels + assert labels.index("dark") < labels.index(THEME_DARK) + assert labels.index("light") < labels.index(THEME_LIGHT) + + +async def test_palette_theme_save_non_ttd_theme(seeded_app): + from ttd.config.loader import get_settings + + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + seeded_app.search_themes() + await pilot.pause() + await pilot.press(*list("dracula")) + await pilot.press("enter") + await pilot.pause() + await pilot.press("y") + await pilot.pause() + assert get_settings().display.theme == "dracula" + assert seeded_app.screen.nav_id == "dashboard" + + +async def test_palette_theme_applies_on_select(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + seeded_app.search_themes() + await pilot.pause() + await pilot.press(*list("ttd-light")) + await pilot.press("enter") + await pilot.pause() + assert isinstance(seeded_app.screen, ConfirmModal) + assert seeded_app.theme == THEME_LIGHT + await pilot.press("escape") # session-only + await pilot.pause() + assert seeded_app.theme == THEME_LIGHT + assert seeded_app.screen.nav_id == "dashboard"