From ad02dd6c0ad068ca2a073f5953c5d2125e4676a6 Mon Sep 17 00:00:00 2001 From: Taylor Date: Thu, 18 Jun 2026 16:53:03 -0400 Subject: [PATCH] feat: reports entry drill-down and CLI parity fixes Add TUI expand/collapse and CLI --entries for project-level entry detail, plus project rm, invoice --period and --format md, and week/unbilled on ttd status via shared summary helpers. Co-authored-by: Cursor --- docs/pages/guides/clients-and-projects.md | 5 ++ docs/pages/guides/invoicing.md | 9 +++ docs/pages/guides/reports.md | 15 +++++ docs/pages/guides/the-tui.md | 4 +- docs/pages/reference/cli/invoice.md | 8 ++- docs/pages/reference/cli/project.md | 15 +++++ docs/pages/reference/cli/report.md | 3 + docs/pages/reference/cli/timer.md | 2 +- src/ttd/cli/invoices.py | 41 ++++++++++--- src/ttd/cli/projects.py | 20 ++++++- src/ttd/cli/reports.py | 71 ++++++++++++++++++++--- src/ttd/cli/timer.py | 13 ++++- src/ttd/reporting/render.py | 62 ++++++++++++++++++++ src/ttd/services/summary.py | 36 ++++++++++++ src/ttd/tui/_data.py | 33 +++-------- src/ttd/tui/screens/reports.py | 66 ++++++++++++++++++--- tests/test_cli/test_client_project_cli.py | 41 +++++++++++++ tests/test_cli/test_invoice_cli.py | 39 +++++++++++++ tests/test_cli/test_report_cli.py | 16 +++++ tests/test_tui/test_app.py | 21 +++++++ 20 files changed, 460 insertions(+), 60 deletions(-) create mode 100644 src/ttd/services/summary.py diff --git a/docs/pages/guides/clients-and-projects.md b/docs/pages/guides/clients-and-projects.md index 0c89667..7488010 100644 --- a/docs/pages/guides/clients-and-projects.md +++ b/docs/pages/guides/clients-and-projects.md @@ -53,8 +53,13 @@ $ ttd project list # hours logged + unbilled work per project $ ttd project list --client acme-corp $ ttd project edit api-rewrite --rate 175 $ ttd project archive api-rewrite +$ ttd project rm api-rewrite --client acme-corp # empty project only +$ ttd project rm api-rewrite --client acme-corp --force # deletes entries too ``` +Deleting a project with logged entries requires `--force`. Invoiced entries +block deletion even with `--force` — void the invoice first. + ## How hourly rates resolve When an invoice prices an hour of work, the rate comes from the first level diff --git a/docs/pages/guides/invoicing.md b/docs/pages/guides/invoicing.md index 5c10881..867922a 100644 --- a/docs/pages/guides/invoicing.md +++ b/docs/pages/guides/invoicing.md @@ -18,9 +18,14 @@ rate is frozen onto the invoice so later rate changes never rewrite history. $ ttd invoice create --client acme-corp # last calendar month $ ttd invoice create --client acme-corp --month 2026-05 $ ttd invoice create --client acme-corp --from 2026-05-15 --to 2026-05-31 +$ ttd invoice create --client acme-corp --period "last month" +$ ttd invoice create --client acme-corp --period "2026-05-01 to 2026-05-31" $ ttd invoice create --client acme-corp --number 2026-CUSTOM-1 ``` +`--period` accepts the same strings as the TUI invoice wizard: `last month`, +`this month`, `YYYY-MM`, or `YYYY-MM-DD to YYYY-MM-DD`. + `-i` opens a form with a live total preview instead. ### Preview with --dry-run @@ -74,8 +79,12 @@ are never reused. ```console $ ttd invoice list # newest first: number, client, period, total, status $ ttd invoice show 2026-001 # line items, dates, subtotal/tax/total, set-aside +$ ttd invoice show 2026-001 --format md # rendered Markdown to stdout (preview) ``` +`invoice show --format md` prints the same Markdown the TUI preview (`m`) shows, +without writing a file — use `invoice render --md` when you want files on disk. + When `tax.set_aside_rate` is greater than zero, `invoice list` also shows **Est. Tax** and **Take-Home** columns. Unpaid invoices preview at the current rate (shown muted); paid invoices use the frozen snapshot from when they were diff --git a/docs/pages/guides/reports.md b/docs/pages/guides/reports.md index 210391d..b2990b4 100644 --- a/docs/pages/guides/reports.md +++ b/docs/pages/guides/reports.md @@ -40,6 +40,18 @@ $ ttd report week --by day Project and client groupings include days-active counts and an activity heat strip — one colored cell per day, brighter meaning more hours. +## Entry drill-down + +See the individual entries behind each project rollup: + +```console +$ ttd report week --entries +$ ttd report month --entries -p api-rewrite +``` + +Requires the default `--by project` grouping. Entry lines appear indented +under each project row with date, time, hours, note, and per-entry value. + ## Reports in the TUI Screen `4` shows the same rollups with a bar chart of the period's days: @@ -50,6 +62,9 @@ Screen `4` shows the same rollups with a bar chart of the period's days: | --- | --- | | `w` / `m` | week / month mode | | `[` / `]` | older / newer period | +| `Enter` | expand / collapse entries under the focused project | + +Entry sub-rows are read-only — edit on Timesheet (`2`) or with `ttd entry edit`. Rows without a configured rate show `—` in the value column — set one at the project, client, or `business.default_hourly_rate` level. diff --git a/docs/pages/guides/the-tui.md b/docs/pages/guides/the-tui.md index 861db08..2b9fbb7 100644 --- a/docs/pages/guides/the-tui.md +++ b/docs/pages/guides/the-tui.md @@ -25,6 +25,8 @@ narrow terminals it wraps onto extra rows rather than hiding shortcuts. Screen `1`. The big timer (idle or running), today's entries, the week's hours and unbilled value, and a 12-week activity heatmap: +The footer summary matches `ttd status` on the CLI (week hours and unbilled value). + ![Dashboard](../assets/screenshots/dashboard.svg) ## Timesheet @@ -46,7 +48,7 @@ unbilled hours. Keys: `a` add client, `p` add project, `e` edit, `x` archive, ## Reports Screen `4`. Weekly or monthly rollups with per-day bar chart and activity -heat strips. Keys: `w`/`m` mode, `[` `]` paging. +heat strips. Keys: `w`/`m` mode, `[` `]` paging, `Enter` expand entries. ![Reports](../assets/screenshots/reports-month.svg) diff --git a/docs/pages/reference/cli/invoice.md b/docs/pages/reference/cli/invoice.md index 41b6d88..89d8efe 100644 --- a/docs/pages/reference/cli/invoice.md +++ b/docs/pages/reference/cli/invoice.md @@ -17,7 +17,7 @@ Create and manage invoices. * [`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. +* [`show`](#invoice-show): Show one invoice with line items, or rendered Markdown with --format md. ## invoice create @@ -31,6 +31,7 @@ Invoice a client's uninvoiced billable work (defaults to last month). * `--client`: Client slug * `--month`: YYYY-MM +* `--period`: Period spec: 'last month', 'this month', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD * `--from`: * `--to`: * `--number`: Override the number @@ -51,14 +52,15 @@ List invoices, newest first. ## invoice show ```console -ttd invoice show NUMBER +ttd invoice show [OPTIONS] NUMBER ``` -Show one invoice with line items. +Show one invoice with line items, or rendered Markdown with --format md. **Parameters**: * `NUMBER, --number`: **\[required\]** +* `--format`: Output format: table (default) or md *\[default: table\]* ## invoice render diff --git a/docs/pages/reference/cli/project.md b/docs/pages/reference/cli/project.md index d1814d5..7d04049 100644 --- a/docs/pages/reference/cli/project.md +++ b/docs/pages/reference/cli/project.md @@ -16,6 +16,7 @@ Manage projects. * [`archive`](#project-archive): Archive a project. * [`edit`](#project-edit): Edit a project. * [`list`](#project-list): List projects with effective rates and logged hours. +* [`rm`](#project-rm): Delete a project. Refuses if it has entries unless --force. ## project add @@ -74,3 +75,17 @@ Archive a project. * `SLUG, --slug`: **\[required\]** * `--client`: Client slug + +## project rm + +```console +ttd project rm [OPTIONS] SLUG +``` + +Delete a project. Refuses if it has entries unless --force. + +**Parameters**: + +* `SLUG, --slug`: **\[required\]** +* `--client`: Client slug +* `--force, --no-force`: Also delete the project's entries *\[default: False\]* diff --git a/docs/pages/reference/cli/report.md b/docs/pages/reference/cli/report.md index f62f4a8..b9007ea 100644 --- a/docs/pages/reference/cli/report.md +++ b/docs/pages/reference/cli/report.md @@ -45,6 +45,7 @@ This (or last) week. * `--project, -p`: * `--client`: * `--by`: Group rows by: day|project|client *\[default: project\]* +* `--entries, --no-entries`: Show individual entries under each project *\[default: False\]* ## report month @@ -61,6 +62,7 @@ A calendar month. * `--project, -p`: * `--client`: * `--by`: Group rows by: day|project|client *\[default: project\]* +* `--entries, --no-entries`: Show individual entries under each project *\[default: False\]* ## report range @@ -77,3 +79,4 @@ An arbitrary date range. * `--project, -p`: * `--client`: * `--by`: Group rows by: day|project|client *\[default: project\]* +* `--entries, --no-entries`: Show individual entries under each project *\[default: False\]* diff --git a/docs/pages/reference/cli/timer.md b/docs/pages/reference/cli/timer.md index 4ef459d..ef018a2 100644 --- a/docs/pages/reference/cli/timer.md +++ b/docs/pages/reference/cli/timer.md @@ -39,7 +39,7 @@ Stop the running timer and log the entry. ttd status ``` -Show the running timer and today's total. +Running timer, today's total, this week, and unbilled billable value. ## cancel diff --git a/src/ttd/cli/invoices.py b/src/ttd/cli/invoices.py index 83029c8..ffaa6a5 100644 --- a/src/ttd/cli/invoices.py +++ b/src/ttd/cli/invoices.py @@ -15,7 +15,7 @@ from ttd.core.errors import TtdError from ttd.core.money import format_hours, format_money from ttd.core.taxes import compute_set_aside, format_rate -from ttd.invoicing.markdown import write_markdown +from ttd.invoicing.markdown import render_markdown, write_markdown from ttd.invoicing.pdf import render_pdf from ttd.reporting import periods from ttd.services import invoicing as svc @@ -52,15 +52,21 @@ def _parse_date(raw: str, what: str) -> date: def _resolve_period( - month: str | None, date_from: str | None, date_to: str | None + month: str | None, + date_from: str | None, + date_to: str | None, + period: str | None = None, ) -> periods.Period: + if period is not None: + if month is not None or date_from is not None or date_to is not None: + raise TtdError("Pass --period alone, not with --month or --from/--to") + return periods.parse_period(period, datetime.now().date()) if month is not None: return periods.month_period(datetime.now().date(), ym=month) if date_from is not None and date_to is not None: return periods.range_period(_parse_date(date_from, "--from"), _parse_date(date_to, "--to")) if date_from or date_to: - raise TtdError("Pass both --from and --to (or use --month)") - # default: last calendar month — the usual "invoice my last month" flow + raise TtdError("Pass both --from and --to (or use --month or --period)") return periods.month_period(datetime.now().date(), last=True) @@ -138,6 +144,12 @@ async def create( *, client: Annotated[str | None, Parameter(help="Client slug")] = None, month: Annotated[str | None, Parameter(help="YYYY-MM")] = None, + period: Annotated[ + str | None, + Parameter( + help="Period spec: 'last month', 'this month', YYYY-MM, or YYYY-MM-DD to YYYY-MM-DD" + ), + ] = None, date_from: Annotated[str | None, Parameter(name="--from")] = None, date_to: Annotated[str | None, Parameter(name="--to")] = None, number: Annotated[str | None, Parameter(help="Override the number")] = None, @@ -159,15 +171,15 @@ async def create( client, month, pdf, md = data.client, data.month, data.pdf, data.md if client is None: raise TtdError("--client is required (or use -i for the interactive form)") - period = _resolve_period(month, date_from, date_to) + period_obj = _resolve_period(month, date_from, date_to, period) - draft = await svc.build_draft(client, period, settings) + draft = await svc.build_draft(client, period_obj, settings) view = None if not dry_run: invoice = await svc.persist_draft(draft, settings, number=number) view = await svc.get_invoice(invoice.number) - console.print(f"\n[bold]{draft.client.name}[/bold] — {period.label}") + console.print(f"\n[bold]{draft.client.name}[/bold] — {period_obj.label}") _print_draft(draft) if dry_run: console.print("[muted]Dry run — nothing created.[/muted]") @@ -207,9 +219,20 @@ async def list_() -> None: @app.command(name="show") @with_db -async def show(number: str) -> None: - """Show one invoice with line items.""" +async def show( + number: str, + *, + format: Annotated[ + str, Parameter(name="--format", help="Output format: table (default) or md") + ] = "table", +) -> None: + """Show one invoice with line items, or rendered Markdown with --format md.""" + if format not in ("table", "md"): + raise TtdError("--format must be table or md") view = await svc.get_invoice(number) + if format == "md": + console.print(render_markdown(view, get_settings())) + return invoice, client = view.invoice, view.client console.print( f"\n[bold]Invoice {invoice.number}[/bold] {_status_pill(enum_value(invoice.status))}" diff --git a/src/ttd/cli/projects.py b/src/ttd/cli/projects.py index 87ce843..4b09614 100644 --- a/src/ttd/cli/projects.py +++ b/src/ttd/cli/projects.py @@ -4,11 +4,12 @@ from cyclopts import Parameter from pydantic import BaseModel, Field +from rich.prompt import Confirm from ttd.cli._interactive import interactive_fill from ttd.cli._output import console, success, table from ttd.cli._pickers import client_choices, validate_money -from ttd.cli._run import TtdApp, with_db +from ttd.cli._run import TtdApp, abort, with_db from ttd.config.loader import get_settings from ttd.core.errors import TtdError from ttd.core.money import format_hours, format_money, parse_money @@ -142,3 +143,20 @@ async def archive( """Archive a project.""" project = await svc.archive_project(slug, client) success(f"Archived project {project.slug}") + + +@app.command(name="rm") +@with_db +async def rm( + slug: str, + *, + client: ClientOpt = None, + force: Annotated[bool, Parameter(help="Also delete the project's entries")] = False, +) -> None: + """Delete a project. Refuses if it has entries unless --force.""" + client_slug = _default_client(client) + label = f"{client_slug}/{slug}" + if force and not Confirm.ask(f"Delete project '{label}' and ALL its entries?"): + abort() + await svc.delete_project(slug, client_slug, force=force) + success(f"Deleted project {label}") diff --git a/src/ttd/cli/reports.py b/src/ttd/cli/reports.py index 69492ed..4200853 100644 --- a/src/ttd/cli/reports.py +++ b/src/ttd/cli/reports.py @@ -14,7 +14,17 @@ from ttd.core.rollup import EntryFacts, amount, rollup_days, seconds_by_date from ttd.core.taxes import compute_set_aside from ttd.reporting import periods -from ttd.reporting.render import day_series, hours_cell, money_cell, sparkline +from ttd.reporting.render import ( + day_series, + entry_amount, + entry_detail_label, + entry_flags_markup, + group_entries_by_project, + hours_cell, + money_cell, + sparkline, + truncate_note, +) from ttd.services import entries as entry_svc from ttd.services import projects as project_svc from ttd.storage.models import pk @@ -24,6 +34,9 @@ ProjectOpt = Annotated[str | None, Parameter(name=["--project", "-p"])] ClientOpt = Annotated[str | None, Parameter(name="--client")] ByOpt = Annotated[str, Parameter(name="--by", help="Group rows by: day|project|client")] +EntriesOpt = Annotated[ + bool, Parameter(name="--entries", help="Show individual entries under each project") +] def _parse_date(raw: str, what: str) -> date: @@ -58,16 +71,28 @@ async def _gather(period: periods.Period, project: str | None, client: str | Non if r.project.id not in rates: rates[r.project.id] = await project_svc.effective_rate(r.project) meta[r.project.id] = (r.project, r.client) - return facts, rates, meta + return rows, facts, rates, meta -def _render(period: periods.Period, facts, rates, meta, by: str) -> None: +def _render( + period: periods.Period, + rows: list[entry_svc.EntryRow], + facts, + rates, + meta, + by: str, + *, + entries: bool = False, +) -> None: settings = get_settings() console.print(f"\n[bold]{period.label}[/bold]") if not facts: console.print("[muted]No entries in this period.[/muted]") return + if entries and by != "project": + raise TtdError("--entries requires --by project") + cells = rollup_days(facts) days = period.days() total_seconds = sum(f.seconds for f in facts) @@ -75,6 +100,7 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: total_tax = Decimal("0") set_aside_rate = settings.tax.set_aside_rate # 0 hides the tax columns any_rate = False + entries_by_project = group_entries_by_project(rows) if entries else {} if by == "day": t = table("Date", "Project", "Entries", "Hours", "Amount") @@ -139,6 +165,23 @@ def _render(period: periods.Period, facts, rates, meta, by: str) -> None: money_cell(value - tax if has_rate else None, client.currency), ] t.add_row(*row) + if entries and by == "project": + rate = rates[project.id] + currency = client.currency + for r in entries_by_project.get(pk(project), []): + entry = r.entry + entry_value = entry_amount(entry, rate, settings.billing) + hours = format_hours(entry.seconds) + entry_flags_markup(entry) + entry_row = [ + f" [dim]{entry_detail_label(entry)}[/dim]", + "", + hours, + truncate_note(entry.note), + money_cell(entry_value, currency), + ] + if set_aside_rate > 0: + entry_row += ["[muted]—[/muted]", "[muted]—[/muted]"] + t.add_row(*entry_row) console.print(t) summary = f"Total: [bold]{format_hours(total_seconds)}[/bold]" @@ -158,11 +201,18 @@ def key_of_fact(f: EntryFacts, by: str, meta: dict) -> str: return client.slug if by == "client" else f"{client.slug}/{project.slug}" -async def _run(period: periods.Period, project: str | None, client: str | None, by: str) -> None: +async def _run( + period: periods.Period, + project: str | None, + client: str | None, + by: str, + *, + entries: bool = False, +) -> None: if by not in ("day", "project", "client"): raise TtdError(f"--by must be day, project, or client (got '{by}')") - facts, rates, meta = await _gather(period, project, client) - _render(period, facts, rates, meta, by) + rows, facts, rates, meta = await _gather(period, project, client) + _render(period, rows, facts, rates, meta, by, entries=entries) @app.command(name="day") @@ -186,10 +236,11 @@ async def week( project: ProjectOpt = None, client: ClientOpt = None, by: ByOpt = "project", + entries: EntriesOpt = False, ) -> None: """This (or last) week.""" period = periods.week_period(datetime.now().date(), get_settings().display.week_start, last) - await _run(period, project, client, by) + await _run(period, project, client, by, entries=entries) @app.command(name="month") @@ -201,10 +252,11 @@ async def month( project: ProjectOpt = None, client: ClientOpt = None, by: ByOpt = "project", + entries: EntriesOpt = False, ) -> None: """A calendar month.""" period = periods.month_period(datetime.now().date(), last=last, ym=ym) - await _run(period, project, client, by) + await _run(period, project, client, by, entries=entries) @app.command(name="range") @@ -216,7 +268,8 @@ async def range_( project: ProjectOpt = None, client: ClientOpt = None, by: ByOpt = "project", + entries: EntriesOpt = False, ) -> None: """An arbitrary date range.""" period = periods.range_period(_parse_date(date_from, "--from"), _parse_date(date_to, "--to")) - await _run(period, project, client, by) + await _run(period, project, client, by, entries=entries) diff --git a/src/ttd/cli/timer.py b/src/ttd/cli/timer.py index 8c100d3..a294a70 100644 --- a/src/ttd/cli/timer.py +++ b/src/ttd/cli/timer.py @@ -12,8 +12,9 @@ from ttd.cli._run import TtdApp, with_db from ttd.config.loader import get_settings from ttd.core.errors import TtdError -from ttd.core.money import format_hours +from ttd.core.money import format_hours, format_money from ttd.parsing.resolve import resolve_point +from ttd.services import summary as summary_svc from ttd.services import timer as svc AtOpt = Annotated[str | None, Parameter(help="Clock time like '9am' or 'today 8:30'")] @@ -87,8 +88,9 @@ async def stop( @app.command(name="status") @with_db async def status() -> None: - """Show the running timer and today's total.""" + """Running timer, today's total, this week, and unbilled billable value.""" now = datetime.now() + settings = get_settings() st = await svc.timer_status(now=now) if st.timer is None: console.print("[muted]No timer running.[/muted]") @@ -100,6 +102,13 @@ async def status() -> None: + (f" [muted]{st.timer.note}[/muted]" if st.timer.note else "") ) console.print(f"Today: [bold]{format_hours(st.today_seconds)}[/bold]") + week_secs = await summary_svc.week_total(now.date(), settings.display.week_start) + unbilled_secs, unbilled_money = await summary_svc.unbilled_totals() + money = format_money(unbilled_money, "USD") if unbilled_money is not None else "—" + console.print( + f"This week: [bold]{format_hours(week_secs)}[/bold] · " + f"Unbilled: [bold]{format_hours(unbilled_secs)}[/bold] ({money})" + ) @app.command(name="cancel") @with_db diff --git a/src/ttd/reporting/render.py b/src/ttd/reporting/render.py index 8206bdc..54f1e3f 100644 --- a/src/ttd/reporting/render.py +++ b/src/ttd/reporting/render.py @@ -2,8 +2,14 @@ from datetime import date from decimal import Decimal +from uuid import UUID +from ttd.config.schema import BillingConfig from ttd.core.money import format_hours, format_money +from ttd.core.rollup import amount +from ttd.core.rounding import round_seconds +from ttd.services import entries as entry_svc +from ttd.storage.models import Entry, pk BLOCKS = " ▁▂▃▄▅▆▇█" @@ -35,3 +41,59 @@ def money_cell(value: Decimal | None, currency: str) -> str: if value is None: return "[muted]—[/muted]" return format_money(value, currency) + + +def entry_time_label(entry: Entry) -> str: + """Interval times for an entry, or em dash for duration-only.""" + if entry.started_at and entry.ended_at: + return f"{entry.started_at:%-I:%M%p}–{entry.ended_at:%-I:%M%p}".lower() + return "—" + + +def entry_flags_markup(entry: Entry) -> str: + """Rich suffix for non-billable / invoiced entries.""" + parts = [] + if not entry.billable: + parts.append(" [muted](nb)[/muted]") + if entry.invoice_id is not None: + parts.append(" [accent]·inv[/accent]") + return "".join(parts) + + +def entry_detail_label(entry: Entry) -> str: + """One-line date + time label for report drill-down rows.""" + day = entry.work_date.strftime("%a %b %-d") + when = entry_time_label(entry) + return f"{day} · {when}" if when != "—" else day + + +def truncate_note(note: str, limit: int = 40) -> str: + text = note.strip() + if not text: + return "—" + if len(text) <= limit: + return text + return text[: limit - 1] + "…" + + +def entry_billed_seconds(entry: Entry, billing: BillingConfig) -> int: + if not entry.billable: + return 0 + return round_seconds(entry.seconds, billing) + + +def entry_amount(entry: Entry, rate: Decimal | None, billing: BillingConfig) -> Decimal | None: + return amount(entry_billed_seconds(entry, billing), rate) + + +def group_entries_by_project( + rows: list[entry_svc.EntryRow], +) -> dict[UUID, list[entry_svc.EntryRow]]: + out: dict[UUID, list[entry_svc.EntryRow]] = {} + for r in rows: + pid = pk(r.project) + bucket = out.get(pid) + if bucket is None: + bucket = out[pid] = [] + bucket.append(r) + return out diff --git a/src/ttd/services/summary.py b/src/ttd/services/summary.py new file mode 100644 index 0000000..041c4f3 --- /dev/null +++ b/src/ttd/services/summary.py @@ -0,0 +1,36 @@ +"""Dashboard-style ledger summaries (week totals, unbilled value).""" + +from datetime import date, timedelta +from decimal import Decimal + +from ttd.config.loader import get_settings +from ttd.core.rollup import amount as rollup_amount +from ttd.services import entries as entry_svc +from ttd.services import projects as project_svc + + +async def week_total(today: date, week_start: str = "monday") -> int: + """Billable and non-billable seconds logged from week start through today.""" + offset = today.weekday() if week_start == "monday" else (today.weekday() + 1) % 7 + start = today - timedelta(days=offset) + rows = await entry_svc.list_entries(date_from=start, date_to=today) + return sum(r.entry.seconds for r in rows) + + +async def unbilled_totals() -> tuple[int, Decimal | None]: + """Uninvoiced billable seconds and summed billable value (USD), or None if unrated.""" + rows = await entry_svc.list_entries() + settings = get_settings() + seconds = 0 + total: Decimal | None = None + for r in rows: + if r.entry.invoice_id is not None or not r.entry.billable: + continue + seconds += r.entry.seconds + rate = await project_svc.effective_rate(r.project) + if rate is None: + rate = settings.business.default_hourly_rate + value = rollup_amount(r.entry.seconds, rate) + if value is not None: + total = (total or Decimal("0")) + value + return seconds, total diff --git a/src/ttd/tui/_data.py b/src/ttd/tui/_data.py index 0cc54eb..4090881 100644 --- a/src/ttd/tui/_data.py +++ b/src/ttd/tui/_data.py @@ -5,7 +5,7 @@ from ttd.config.loader import get_settings from ttd.core.errors import TtdError from ttd.core.money import format_hours, format_money -from ttd.core.rollup import amount as rollup_amount +from ttd.reporting.render import entry_time_label from ttd.services import entries as entry_svc from ttd.services import projects as project_svc from ttd.storage.models import Client, Entry, Project, pk @@ -59,28 +59,16 @@ async def day_rows(day: date) -> list[entry_svc.EntryRow]: async def week_seconds(today: date, week_start: str = "monday") -> int: - offset = today.weekday() if week_start == "monday" else (today.weekday() + 1) % 7 - start = today - timedelta(days=offset) - rows = await entry_svc.list_entries(date_from=start, date_to=today) - return sum(r.entry.seconds for r in rows) + from ttd.services import summary as summary_svc + + return await summary_svc.week_total(today, week_start) async def unbilled_value() -> tuple[int, str]: """(total unbilled billable seconds, formatted money across clients).""" - rows = await entry_svc.list_entries() - settings = get_settings() - seconds = 0 - total = None - for r in rows: - if r.entry.invoice_id is not None or not r.entry.billable: - continue - seconds += r.entry.seconds - rate = await project_svc.effective_rate(r.project) - if rate is None: - rate = settings.business.default_hourly_rate - value = rollup_amount(r.entry.seconds, rate) - if value is not None: - total = (total or 0) + value + from ttd.services import summary as summary_svc + + seconds, total = await summary_svc.unbilled_totals() money = format_money(total, "USD") if total is not None else "—" return seconds, money @@ -123,12 +111,7 @@ async def delete_entry_by_id(entry_id) -> None: def hours_for_row(entry: Entry) -> str: - when = ( - f"{entry.started_at:%-I:%M%p}–{entry.ended_at:%-I:%M%p}".lower() - if entry.started_at and entry.ended_at - else "—" - ) - return when + return entry_time_label(entry) __all__ = [ diff --git a/src/ttd/tui/screens/reports.py b/src/ttd/tui/screens/reports.py index d08c6c6..d087225 100644 --- a/src/ttd/tui/screens/reports.py +++ b/src/ttd/tui/screens/reports.py @@ -3,10 +3,12 @@ from datetime import date, timedelta from decimal import Decimal from typing import ClassVar +from uuid import UUID from textual.app import ComposeResult from textual.binding import Binding from textual.containers import Vertical +from textual.coordinate import Coordinate from textual.widgets import DataTable, Label from ttd.config.loader import get_settings @@ -14,6 +16,13 @@ from ttd.core.rollup import EntryFacts, amount, rollup_days from ttd.core.taxes import compute_set_aside from ttd.reporting import periods +from ttd.reporting.render import ( + entry_amount, + entry_detail_label, + entry_flags_markup, + group_entries_by_project, + truncate_note, +) from ttd.services import entries as entry_svc from ttd.services import projects as project_svc from ttd.tui._data import pk @@ -46,12 +55,14 @@ class ReportsScreen(TtdScreen): Binding("m", "mode('month')", "month", group=MODE_GROUP), Binding("left_square_bracket", "shift(-1)", "prev", group=PREV_NEXT_GROUP), Binding("right_square_bracket", "shift(1)", "next", group=PREV_NEXT_GROUP), + Binding("enter", "toggle_expand", "expand", priority=True), ] def __init__(self) -> None: super().__init__() self.report_mode = "week" self.period_offset = 0 # periods back from current + self._expanded_projects: set[UUID] = set() def compose_content(self) -> ComposeResult: with Vertical(id="reports"): @@ -77,6 +88,7 @@ async def render_data(self) -> None: period = self._period() settings = get_settings() rows = await entry_svc.list_entries(date_from=period.start, date_to=period.end) + entries_by_project = group_entries_by_project(rows) facts, rates, labels, currencies = [], {}, {}, {} for r in rows: @@ -91,18 +103,17 @@ async def render_data(self) -> None: r.entry.invoice_id is not None, ) ) - if pk(r.project) not in rates: - rates[pk(r.project)] = await project_svc.effective_rate(r.project) - labels[pk(r.project)] = f"{r.client.slug}/{r.project.slug}" - currencies[pk(r.project)] = r.client.currency + pid = pk(r.project) + if pid not in rates: + rates[pid] = await project_svc.effective_rate(r.project) + labels[pid] = f"{r.client.slug}/{r.project.slug}" + currencies[pid] = r.client.currency cells = rollup_days(facts) groups: dict = {} for cell in cells: groups.setdefault(cell.project_id, []).append(cell) - # The set-aside rate is config, reloaded each render — rebuild the - # column set when it crosses zero (0 disables the tax columns). set_aside_rate = settings.tax.set_aside_rate columns = ("project", "days", "hours", "activity", "value") if set_aside_rate > 0: @@ -137,8 +148,10 @@ async def render_data(self) -> None: value += v has_rate = True tax = compute_set_aside(value, set_aside_rate) + expanded = project_id in self._expanded_projects + prefix = "▾ " if expanded else "▸ " row = [ - labels[project_id], + prefix + labels[project_id], str(len({c.work_date for c in group})), format_hours(seconds), _heat_strip([by_date.get(d, 0) for d in days]), @@ -157,7 +170,25 @@ async def render_data(self) -> None: total_value += value total_tax += tax any_rate = True - table.add_row(*row) + table.add_row(*row, key=f"p:{project_id}") + if expanded: + rate = rates[project_id] + currency = currencies[project_id] + for r in entries_by_project.get(project_id, []): + entry = r.entry + entry_value = entry_amount(entry, rate, settings.billing) + hours = format_hours(entry.seconds) + entry_flags_markup(entry) + sub = [ + f" [dim]{entry_detail_label(entry)}[/dim]", + "—", + hours, + truncate_note(entry.note), + format_money(entry_value, currency) if entry_value is not None else "—", + ] + if set_aside_rate > 0: + sub += ["—", "—"] + table.add_row(*sub, key=f"e:{entry.id}") + self.query_one("#report-title", Label).update(f"{period.label}") total = f"total {format_hours(total_seconds)}" if any_rate: @@ -168,14 +199,31 @@ async def render_data(self) -> None: f" · {format_money(total_value - total_tax, 'USD')} take-home" ) self.query_one("#report-total", Label).update( - f"{total} [dim]w/m switch period · \\[ ] older/newer[/dim]" + f"{total} [dim]Enter expand · w/m switch period · \\[ ] older/newer[/dim]" ) + async def action_toggle_expand(self) -> None: + table = self.query_one("#report-table", DataTable) + if table.row_count == 0 or table.cursor_row is None: + return + raw_key = table.coordinate_to_cell_key(Coordinate(table.cursor_row, 0)).row_key.value + key = str(raw_key) if raw_key is not None else "" + if not key.startswith("p:"): + return + project_id = UUID(key.removeprefix("p:")) + if project_id in self._expanded_projects: + self._expanded_projects.discard(project_id) + else: + self._expanded_projects.add(project_id) + await self.refresh_data() + async def action_mode(self, mode: str) -> None: self.report_mode = mode self.period_offset = 0 + self._expanded_projects.clear() await self.refresh_data() async def action_shift(self, delta: int) -> None: self.period_offset = max(0, self.period_offset - delta) + self._expanded_projects.clear() await self.refresh_data() diff --git a/tests/test_cli/test_client_project_cli.py b/tests/test_cli/test_client_project_cli.py index 03be9e5..1cc6fe9 100644 --- a/tests/test_cli/test_client_project_cli.py +++ b/tests/test_cli/test_client_project_cli.py @@ -126,3 +126,44 @@ def test_seed_demo_reset_wipes_existing_data(isolated_config): listing = runner.invoke(app, ["client", "list"]).output assert "Existing Client" not in listing assert "Acme Corp" in listing + + +def test_project_rm_empty(isolated_config): + runner.invoke(app, ["client", "add", "Acme"]) + runner.invoke(app, ["project", "add", "API", "--client", "acme"]) + result = runner.invoke(app, ["project", "rm", "api", "--client", "acme"]) + assert result.exit_code == 0, result.output + assert "Deleted project" in result.output + assert "api" not in runner.invoke(app, ["project", "list"]).output + + +def test_project_rm_refuses_entries_without_force(isolated_config): + runner.invoke(app, ["client", "add", "Acme", "--rate", "150"]) + runner.invoke(app, ["project", "add", "API", "--client", "acme"]) + runner.invoke(app, ["log", "today 1h", "-p", "api"]) + result = runner.invoke(app, ["project", "rm", "api", "--client", "acme"]) + assert result.exit_code == 1 + assert "entr" in result.output.lower() + + +def test_project_rm_force_deletes_entries(isolated_config, monkeypatch): + runner.invoke(app, ["client", "add", "Acme", "--rate", "150"]) + runner.invoke(app, ["project", "add", "API", "--client", "acme"]) + runner.invoke(app, ["log", "today 1h", "-p", "api"]) + monkeypatch.setattr("ttd.cli.projects.Confirm.ask", lambda _msg: True) + result = runner.invoke(app, ["project", "rm", "api", "--client", "acme", "--force"]) + assert result.exit_code == 0, result.output + listing = runner.invoke(app, ["entry", "list"]) + assert "No entries" in listing.output or listing.output.count("1:00") == 0 + + +def test_status_shows_week_and_unbilled(isolated_config): + runner.invoke(app, ["client", "add", "Acme", "--rate", "150"]) + runner.invoke(app, ["project", "add", "API", "--client", "acme"]) + runner.invoke(app, ["log", "today 2h", "-p", "api"]) + result = runner.invoke(app, ["status"]) + assert result.exit_code == 0, result.output + assert "This week:" in result.output + assert "Unbilled:" in result.output + assert "2:00" in result.output + assert "300" in result.output # 2h * $150 diff --git a/tests/test_cli/test_invoice_cli.py b/tests/test_cli/test_invoice_cli.py index d7bcd9b..d9fdd1c 100644 --- a/tests/test_cli/test_invoice_cli.py +++ b/tests/test_cli/test_invoice_cli.py @@ -99,3 +99,42 @@ def test_invoiced_entries_locked_via_cli(isolated_config): # void releases them runner.invoke(app, ["invoice", "mark", "2026-001", "void"]) assert runner.invoke(app, ["entry", "rm", uid]).exit_code == 0 + + +def test_invoice_create_with_period_spec(isolated_config): + _seed(isolated_config) + result = runner.invoke( + app, ["invoice", "create", "--client", "acme", "--period", "this month", "--dry-run"] + ) + assert result.exit_code == 0, result.output + assert "Dry run" in result.output + assert "600.00" in result.output + + +def test_invoice_create_period_conflicts(isolated_config): + _seed(isolated_config) + result = runner.invoke( + app, + [ + "invoice", + "create", + "--client", + "acme", + "--period", + "this month", + "--month", + "2026-06", + "--dry-run", + ], + ) + assert result.exit_code == 1 + assert "--period alone" in result.output + + +def test_invoice_show_markdown_format(isolated_config): + _seed(isolated_config) + runner.invoke(app, ["invoice", "create", "--client", "acme", "--month", "2026-06"]) + result = runner.invoke(app, ["invoice", "show", "2026-001", "--format", "md"]) + assert result.exit_code == 0, result.output + assert "# Invoice 2026-001" in result.output + assert "API" in result.output diff --git a/tests/test_cli/test_report_cli.py b/tests/test_cli/test_report_cli.py index 9385cca..d0826a8 100644 --- a/tests/test_cli/test_report_cli.py +++ b/tests/test_cli/test_report_cli.py @@ -85,3 +85,19 @@ def test_report_by_rejects_unknown(isolated_config): _seed() result = runner.invoke(app, ["report", "week", "--by", "banana"]) assert result.exit_code == 1 + + +def test_report_week_with_entries(isolated_config): + _seed() + result = runner.invoke(app, ["report", "week", "--entries"]) + assert result.exit_code == 0, result.output + assert "acme/api" in result.output + assert "sync" in result.output + assert "Total" in result.output + + +def test_report_entries_requires_by_project(isolated_config): + _seed() + result = runner.invoke(app, ["report", "week", "--entries", "--by", "client"]) + assert result.exit_code == 1 + assert "--entries requires --by project" in result.output diff --git a/tests/test_tui/test_app.py b/tests/test_tui/test_app.py index d31b71d..4088fcc 100644 --- a/tests/test_tui/test_app.py +++ b/tests/test_tui/test_app.py @@ -179,6 +179,27 @@ async def test_reports_screen_modes(seeded_app): ) +async def test_reports_project_expand_collapse(seeded_app): + async with seeded_app.run_test(size=(120, 40)) as pilot: + await pilot.press("4") + await pilot.pause() + screen = seeded_app.screen + table = screen.query_one("#report-table") + table.focus() + initial = table.row_count + assert initial >= 1 + await screen.action_toggle_expand() + await pilot.pause() + expanded = table.row_count + assert expanded > initial + await screen.action_toggle_expand() + await pilot.pause() + assert table.row_count == initial + await pilot.press("m") + await pilot.pause() + assert table.row_count <= initial + + async def test_reports_screen_tax_columns(seeded_app, monkeypatch): monkeypatch.setenv("TTD_TAX__SET_ASIDE_RATE", "0.32") async with seeded_app.run_test(size=(140, 40)) as pilot: