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
5 changes: 5 additions & 0 deletions docs/pages/guides/clients-and-projects.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions docs/pages/guides/invoicing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions docs/pages/guides/reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion docs/pages/guides/the-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

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

Expand All @@ -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
Expand All @@ -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

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

Expand Down Expand Up @@ -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\]*
3 changes: 3 additions & 0 deletions docs/pages/reference/cli/report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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\]*
2 changes: 1 addition & 1 deletion docs/pages/reference/cli/timer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
41 changes: 32 additions & 9 deletions src/ttd/cli/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand Down Expand Up @@ -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,
Expand All @@ -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]")
Expand Down Expand Up @@ -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))}"
Expand Down
20 changes: 19 additions & 1 deletion src/ttd/cli/projects.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")
Loading
Loading