ttd-ledger 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ttd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """TTD — terminal-native billable ledger."""
2
+
3
+ __version__ = "0.1.0"
ttd/api/__init__.py ADDED
File without changes
ttd/api/app.py ADDED
@@ -0,0 +1,38 @@
1
+ from collections.abc import AsyncIterator
2
+ from contextlib import asynccontextmanager
3
+
4
+ from litestar import Litestar, get
5
+
6
+ from ttd.core.db import close_db, init_db
7
+ from ttd.core.services import health
8
+
9
+
10
+ @get("/health")
11
+ async def health_route() -> dict[str, str]:
12
+ result = await health.ping()
13
+ return {"status": result["status"], "db_path": result["db_path"]}
14
+
15
+
16
+ @asynccontextmanager
17
+ async def lifespan(_app: Litestar) -> AsyncIterator[None]:
18
+ await init_db()
19
+ try:
20
+ yield
21
+ finally:
22
+ await close_db()
23
+
24
+
25
+ def create_app() -> Litestar:
26
+ return Litestar(route_handlers=[health_route], lifespan=[lifespan])
27
+
28
+
29
+ def run() -> None:
30
+ import uvicorn
31
+
32
+ uvicorn.run(
33
+ "ttd.api.app:create_app",
34
+ factory=True,
35
+ host="127.0.0.1",
36
+ port=8000,
37
+ log_level="info",
38
+ )
ttd/cli/__init__.py ADDED
File without changes
ttd/cli/client_cmds.py ADDED
@@ -0,0 +1,88 @@
1
+ """`ttd client` commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+ from uuid import UUID
7
+
8
+ from cyclopts import App, Parameter
9
+
10
+ from ttd.cli.console import success
11
+ from ttd.cli.errors import cli_exit
12
+ from ttd.cli.output import print_clients
13
+ from ttd.cli.runtime import ensure_db, parse_decimal
14
+ from ttd.core.schemas import CreateClient, UpdateClient
15
+ from ttd.core.services import clients as client_service
16
+
17
+ app = App(name="client", help="Manage clients.")
18
+
19
+
20
+ @app.command
21
+ async def add(
22
+ name: str,
23
+ *,
24
+ rate: Annotated[str, Parameter(name="--rate", help="Default hourly rate.")],
25
+ currency: Annotated[str, Parameter(name="--currency")] = "USD",
26
+ ) -> None:
27
+ """Add a client."""
28
+ try:
29
+ await ensure_db()
30
+ client = await client_service.create_client(
31
+ CreateClient(
32
+ name=name,
33
+ default_hourly_rate=parse_decimal(rate),
34
+ currency=currency,
35
+ )
36
+ )
37
+ success(f"Created client {_short(client.id)} ({client.name})")
38
+ except BaseException as exc:
39
+ cli_exit(exc)
40
+
41
+
42
+ @app.command(name="list")
43
+ async def list_clients() -> None:
44
+ """List clients."""
45
+ try:
46
+ await ensure_db()
47
+ print_clients(await client_service.list_clients())
48
+ except BaseException as exc:
49
+ cli_exit(exc)
50
+
51
+
52
+ @app.command
53
+ async def update(
54
+ client_id: UUID,
55
+ *,
56
+ name: str | None = None,
57
+ rate: Annotated[str | None, Parameter(name="--rate")] = None,
58
+ currency: Annotated[str | None, Parameter(name="--currency")] = None,
59
+ ) -> None:
60
+ """Update a client."""
61
+ try:
62
+ await ensure_db()
63
+ client = await client_service.update_client(
64
+ client_id,
65
+ UpdateClient(
66
+ name=name,
67
+ default_hourly_rate=parse_decimal(rate) if rate is not None else None,
68
+ currency=currency,
69
+ ),
70
+ )
71
+ success(f"Updated client {_short(client.id)} ({client.name})")
72
+ except BaseException as exc:
73
+ cli_exit(exc)
74
+
75
+
76
+ @app.command
77
+ async def delete(client_id: UUID) -> None:
78
+ """Delete a client (only when it has no projects)."""
79
+ try:
80
+ await ensure_db()
81
+ await client_service.delete_client(client_id)
82
+ success(f"Deleted client {_short(client_id)}")
83
+ except BaseException as exc:
84
+ cli_exit(exc)
85
+
86
+
87
+ def _short(client_id: UUID | None) -> str:
88
+ return str(client_id)[:8] if client_id is not None else "--------"
ttd/cli/console.py ADDED
@@ -0,0 +1,28 @@
1
+ """Shared Rich console for CLI output."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ stdout = Console()
8
+ stderr = Console(stderr=True)
9
+
10
+
11
+ def success(message: str) -> None:
12
+ """Print a success line to stdout."""
13
+ stdout.print(f"[green]✓[/green] {message}")
14
+
15
+
16
+ def info(message: str) -> None:
17
+ """Print a neutral informational line to stdout."""
18
+ stdout.print(message)
19
+
20
+
21
+ def muted(message: str) -> None:
22
+ """Print dim placeholder text (e.g. empty lists)."""
23
+ stdout.print(f"[dim]{message}[/dim]")
24
+
25
+
26
+ def error(message: str) -> None:
27
+ """Print an error line to stderr."""
28
+ stderr.print(f"[bold red]Error:[/bold red] {message}")
@@ -0,0 +1,164 @@
1
+ """`ttd entries` — list and correct time entries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Annotated
7
+ from uuid import UUID
8
+
9
+ from cyclopts import App, Parameter
10
+
11
+ from ttd.cli.console import info, success
12
+ from ttd.cli.errors import cli_exit
13
+ from ttd.cli.output import print_entries, print_entry
14
+ from ttd.cli.runtime import (
15
+ ensure_db,
16
+ parse_clock_on_date,
17
+ parse_date,
18
+ parse_decimal,
19
+ require_id,
20
+ resolve_client,
21
+ resolve_project,
22
+ )
23
+ from ttd.core.models.enums import EntryMode
24
+ from ttd.core.models.time_entry import TimeEntry
25
+ from ttd.core.schemas import UpdateDurationEntry, UpdateIntervalEntry
26
+ from ttd.core.services import time_entries as entry_service
27
+
28
+ app = App(name="entries", help="List and edit time entries.")
29
+
30
+
31
+ def _filter_by_period(
32
+ entries: list[TimeEntry],
33
+ *,
34
+ from_date: date | None,
35
+ to_date: date | None,
36
+ ) -> list[TimeEntry]:
37
+ filtered = entries
38
+ if from_date is not None:
39
+ filtered = [e for e in filtered if e.work_date >= from_date]
40
+ if to_date is not None:
41
+ filtered = [e for e in filtered if e.work_date <= to_date]
42
+ return filtered
43
+
44
+
45
+ @app.command(name="list")
46
+ async def list_entries(
47
+ *,
48
+ client: Annotated[str | None, Parameter(name="--client")] = None,
49
+ project: Annotated[str | None, Parameter(name="--project")] = None,
50
+ project_id: Annotated[UUID | None, Parameter(name="--project-id")] = None,
51
+ from_date: Annotated[
52
+ str | None, Parameter(name="--from", help="Start date (YYYY-MM-DD).")
53
+ ] = None,
54
+ to_date: Annotated[
55
+ str | None, Parameter(name="--to", help="End date (YYYY-MM-DD).")
56
+ ] = None,
57
+ ) -> None:
58
+ """List entries for a project, optionally filtered by work date."""
59
+ try:
60
+ await ensure_db()
61
+ owner = (
62
+ await resolve_client(client_id=None, client_name=client)
63
+ if client is not None
64
+ else None
65
+ )
66
+ resolved = await resolve_project(
67
+ project_id=project_id,
68
+ client=owner,
69
+ project_name=project,
70
+ )
71
+ entries = await entry_service.list_time_entries_for_project(
72
+ require_id(resolved.id, "project")
73
+ )
74
+ period_start = parse_date(from_date) if from_date is not None else None
75
+ period_end = parse_date(to_date) if to_date is not None else None
76
+ print_entries(
77
+ _filter_by_period(entries, from_date=period_start, to_date=period_end)
78
+ )
79
+ except BaseException as exc:
80
+ cli_exit(exc)
81
+
82
+
83
+ @app.command
84
+ async def edit(
85
+ entry_id: UUID,
86
+ *,
87
+ work_date: Annotated[str | None, Parameter(name="--date")] = None,
88
+ hours: Annotated[str | None, Parameter(name="--hours")] = None,
89
+ time_from: Annotated[str | None, Parameter(name="--from")] = None,
90
+ time_to: Annotated[str | None, Parameter(name="--to")] = None,
91
+ note: str | None = None,
92
+ non_billable: Annotated[bool | None, Parameter(name="--no-billable")] = None,
93
+ billable: Annotated[bool | None, Parameter(name="--billable")] = None,
94
+ ) -> None:
95
+ """Edit a time entry (fields depend on duration vs interval mode)."""
96
+ try:
97
+ await ensure_db()
98
+ entry = await entry_service.get_time_entry(entry_id)
99
+ if non_billable is True and billable is not None:
100
+ from ttd.core.exceptions import ValidationError
101
+
102
+ raise ValidationError("Use only one of --billable or --no-billable")
103
+ billable_flag: bool | None = None
104
+ if non_billable is True:
105
+ billable_flag = False
106
+ elif billable is not None:
107
+ billable_flag = billable
108
+
109
+ day = parse_date(work_date) if work_date is not None else None
110
+
111
+ if entry.entry_mode == EntryMode.DURATION:
112
+ if time_from is not None or time_to is not None:
113
+ from ttd.core.exceptions import ValidationError
114
+
115
+ raise ValidationError("Duration entries use --hours, not --from/--to")
116
+ updated = await entry_service.update_duration_entry(
117
+ entry_id,
118
+ UpdateDurationEntry(
119
+ work_date=day,
120
+ billable_hours=parse_decimal(hours) if hours else None,
121
+ billable=billable_flag,
122
+ note=note,
123
+ ),
124
+ )
125
+ else:
126
+ if hours is not None:
127
+ from ttd.core.exceptions import ValidationError
128
+
129
+ raise ValidationError("Interval entries use --from/--to, not --hours")
130
+ started = (
131
+ parse_clock_on_date(day or entry.work_date, time_from)
132
+ if time_from
133
+ else None
134
+ )
135
+ ended = (
136
+ parse_clock_on_date(day or entry.work_date, time_to)
137
+ if time_to
138
+ else None
139
+ )
140
+ updated = await entry_service.update_interval_entry(
141
+ entry_id,
142
+ UpdateIntervalEntry(
143
+ work_date=day,
144
+ started_at=started,
145
+ ended_at=ended,
146
+ billable=billable_flag,
147
+ note=note,
148
+ ),
149
+ )
150
+ info("Updated entry:")
151
+ print_entry(updated)
152
+ except BaseException as exc:
153
+ cli_exit(exc)
154
+
155
+
156
+ @app.command
157
+ async def delete(entry_id: UUID) -> None:
158
+ """Delete a time entry."""
159
+ try:
160
+ await ensure_db()
161
+ await entry_service.delete_time_entry(entry_id)
162
+ success(f"Deleted entry {str(entry_id)[:8]}")
163
+ except BaseException as exc:
164
+ cli_exit(exc)
ttd/cli/errors.py ADDED
@@ -0,0 +1,19 @@
1
+ """Map core exceptions to CLI exit codes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ttd.cli.console import error as print_error
6
+ from ttd.core.exceptions import NotFoundError, TTDError, ValidationError
7
+
8
+
9
+ def cli_exit(exc: BaseException) -> None:
10
+ if isinstance(exc, NotFoundError):
11
+ print_error(str(exc))
12
+ raise SystemExit(1) from exc
13
+ if isinstance(exc, ValidationError):
14
+ print_error(str(exc))
15
+ raise SystemExit(2) from exc
16
+ if isinstance(exc, TTDError):
17
+ print_error(str(exc))
18
+ raise SystemExit(1) from exc
19
+ raise exc
ttd/cli/log_cmds.py ADDED
@@ -0,0 +1,105 @@
1
+ """`ttd log` — fast retroactive time capture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from typing import Annotated
7
+ from uuid import UUID
8
+
9
+ from cyclopts import App, Parameter
10
+
11
+ from ttd.cli.console import info
12
+ from ttd.cli.errors import cli_exit
13
+ from ttd.cli.output import print_entry
14
+ from ttd.cli.runtime import (
15
+ ensure_db,
16
+ parse_clock_on_date,
17
+ parse_date,
18
+ parse_decimal,
19
+ require_id,
20
+ resolve_client,
21
+ resolve_project,
22
+ )
23
+ from ttd.core.schemas import CreateDurationEntry, CreateIntervalEntry
24
+ from ttd.core.services import time_entries as entry_service
25
+
26
+ app = App(name="log", help="Log retroactive time on a project.")
27
+
28
+
29
+ @app.default
30
+ async def log_entry(
31
+ *,
32
+ client: Annotated[str | None, Parameter(name="--client")] = None,
33
+ project: Annotated[str | None, Parameter(name="--project")] = None,
34
+ project_id: Annotated[
35
+ str | None, Parameter(name="--project-id", help="Project UUID.")
36
+ ] = None,
37
+ work_date: Annotated[
38
+ str | None,
39
+ Parameter(name="--date", help="Work date (YYYY-MM-DD). Defaults to today."),
40
+ ] = None,
41
+ hours: Annotated[
42
+ str | None, Parameter(name="--hours", help="Duration in hours.")
43
+ ] = None,
44
+ time_from: Annotated[
45
+ str | None, Parameter(name="--from", help="Interval start (HH:MM UTC).")
46
+ ] = None,
47
+ time_to: Annotated[
48
+ str | None, Parameter(name="--to", help="Interval end (HH:MM UTC).")
49
+ ] = None,
50
+ note: str | None = None,
51
+ non_billable: Annotated[bool, Parameter(name="--no-billable")] = False,
52
+ ) -> None:
53
+ """Log time by duration (--hours) or interval (--from/--to)."""
54
+ try:
55
+ await ensure_db()
56
+ pid = UUID(project_id) if project_id is not None else None
57
+ owner = (
58
+ await resolve_client(client_id=None, client_name=client)
59
+ if client is not None
60
+ else None
61
+ )
62
+ resolved = await resolve_project(
63
+ project_id=pid,
64
+ client=owner,
65
+ project_name=project,
66
+ )
67
+ day = parse_date(work_date) if work_date is not None else date.today()
68
+ billable = not non_billable
69
+
70
+ if hours is not None:
71
+ if time_from is not None or time_to is not None:
72
+ from ttd.core.exceptions import ValidationError
73
+
74
+ raise ValidationError("Use --hours or --from/--to, not both")
75
+ entry = await entry_service.create_duration_entry(
76
+ CreateDurationEntry(
77
+ project_id=require_id(resolved.id, "project"),
78
+ work_date=day,
79
+ billable_hours=parse_decimal(hours),
80
+ billable=billable,
81
+ note=note,
82
+ )
83
+ )
84
+ elif time_from is not None and time_to is not None:
85
+ started = parse_clock_on_date(day, time_from)
86
+ ended = parse_clock_on_date(day, time_to)
87
+ entry = await entry_service.create_interval_entry(
88
+ CreateIntervalEntry(
89
+ project_id=require_id(resolved.id, "project"),
90
+ work_date=day,
91
+ started_at=started,
92
+ ended_at=ended,
93
+ billable=billable,
94
+ note=note,
95
+ )
96
+ )
97
+ else:
98
+ from ttd.core.exceptions import ValidationError
99
+
100
+ raise ValidationError("Provide --hours or both --from and --to")
101
+
102
+ info("Logged entry:")
103
+ print_entry(entry)
104
+ except BaseException as exc:
105
+ cli_exit(exc)
ttd/cli/main.py ADDED
@@ -0,0 +1,27 @@
1
+ from cyclopts import App
2
+ from rich.table import Table
3
+
4
+ from ttd.cli import client_cmds, entries_cmds, log_cmds, project_cmds
5
+ from ttd.cli.console import stdout
6
+ from ttd.core.services import health
7
+
8
+ app = App(name="ttd", help="Terminal-native billable ledger.")
9
+
10
+ app.command(client_cmds.app)
11
+ app.command(project_cmds.app)
12
+ app.command(log_cmds.app)
13
+ app.command(entries_cmds.app)
14
+
15
+
16
+ @app.default
17
+ async def health_cmd() -> None:
18
+ """Check service and database connectivity."""
19
+ result = await health.ping()
20
+ table = Table(show_header=False, box=None, padding=(0, 2))
21
+ table.add_row("[bold]status[/bold]", f"[green]{result['status']}[/green]")
22
+ table.add_row("[bold]db[/bold]", str(result["db_path"]))
23
+ stdout.print(table)
24
+
25
+
26
+ def main() -> None:
27
+ app()
ttd/cli/output.py ADDED
@@ -0,0 +1,105 @@
1
+ """Rich formatters for CLI list and detail views."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from decimal import Decimal
6
+ from uuid import UUID
7
+
8
+ from rich.table import Table
9
+
10
+ from ttd.cli.console import muted, stdout
11
+ from ttd.core.models.client import Client
12
+ from ttd.core.models.enums import BillingMode, enum_value
13
+ from ttd.core.models.project import Project
14
+ from ttd.core.models.time_entry import TimeEntry
15
+
16
+
17
+ def _short_id(value: UUID | None) -> str:
18
+ return str(value)[:8] if value is not None else "--------"
19
+
20
+
21
+ def format_hours(hours: Decimal) -> str:
22
+ return f"{hours.quantize(Decimal('0.01'))}h"
23
+
24
+
25
+ def print_clients(clients: list[Client]) -> None:
26
+ if not clients:
27
+ muted("No clients.")
28
+ return
29
+ table = Table(title="Clients", show_header=True, header_style="bold")
30
+ table.add_column("ID", style="cyan", no_wrap=True)
31
+ table.add_column("Name")
32
+ table.add_column("Rate", justify="right")
33
+ table.add_column("Currency", justify="center")
34
+ for client in clients:
35
+ table.add_row(
36
+ _short_id(client.id),
37
+ client.name,
38
+ str(client.default_hourly_rate),
39
+ client.currency,
40
+ )
41
+ stdout.print(table)
42
+
43
+
44
+ def print_projects(projects: list[Project]) -> None:
45
+ if not projects:
46
+ muted("No projects.")
47
+ return
48
+ table = Table(title="Projects", show_header=True, header_style="bold")
49
+ table.add_column("ID", style="cyan", no_wrap=True)
50
+ table.add_column("Client", style="cyan", no_wrap=True)
51
+ table.add_column("Name")
52
+ table.add_column("Mode")
53
+ table.add_column("Billing", overflow="fold")
54
+ for project in projects:
55
+ mode = enum_value(project.billing_mode)
56
+ if mode == BillingMode.HOURLY:
57
+ billing = "hourly (inherits client rate unless overridden)"
58
+ else:
59
+ billing = f"fixed {project.contract_total} {project.currency}"
60
+ table.add_row(
61
+ _short_id(project.id),
62
+ _short_id(project.client_id),
63
+ project.name,
64
+ mode,
65
+ billing,
66
+ )
67
+ stdout.print(table)
68
+
69
+
70
+ def print_entries(entries: list[TimeEntry]) -> None:
71
+ if not entries:
72
+ muted("No entries.")
73
+ return
74
+ table = Table(title="Time entries", show_header=True, header_style="bold")
75
+ table.add_column("ID", style="cyan", no_wrap=True)
76
+ table.add_column("Project", style="cyan", no_wrap=True)
77
+ table.add_column("Date", no_wrap=True)
78
+ table.add_column("Hours", justify="right", no_wrap=True)
79
+ table.add_column("Capture")
80
+ table.add_column("Billable", no_wrap=True)
81
+ table.add_column("Note", overflow="fold")
82
+ for entry in sorted(entries, key=lambda e: (e.work_date, e.id)):
83
+ mode = enum_value(entry.entry_mode)
84
+ if mode == "interval" and entry.started_at and entry.ended_at:
85
+ span = (
86
+ f"{entry.started_at.strftime('%H:%M')}-"
87
+ f"{entry.ended_at.strftime('%H:%M')} UTC"
88
+ )
89
+ else:
90
+ span = mode
91
+ billable = "[green]yes[/green]" if entry.billable else "[dim]no[/dim]"
92
+ table.add_row(
93
+ _short_id(entry.id),
94
+ _short_id(entry.project_id),
95
+ str(entry.work_date),
96
+ format_hours(entry.billable_hours),
97
+ span,
98
+ billable,
99
+ entry.note or "",
100
+ )
101
+ stdout.print(table)
102
+
103
+
104
+ def print_entry(entry: TimeEntry) -> None:
105
+ print_entries([entry])