Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .cursor/rules/production-data-backup.mdc
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 23 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
23 changes: 23 additions & 0 deletions docs/pages/guides/invoicing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
12 changes: 10 additions & 2 deletions docs/pages/guides/the-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions docs/pages/reference/cli/invoice.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/pages/reference/keyboard-shortcuts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down Expand Up @@ -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 |
Expand All @@ -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 |
9 changes: 9 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions src/ttd/cli/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/ttd/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
7 changes: 4 additions & 3 deletions src/ttd/invoicing/templates/invoice.md.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}

| | |
Expand Down
Loading
Loading