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 +3 -0
- ttd/api/__init__.py +0 -0
- ttd/api/app.py +38 -0
- ttd/cli/__init__.py +0 -0
- ttd/cli/client_cmds.py +88 -0
- ttd/cli/console.py +28 -0
- ttd/cli/entries_cmds.py +164 -0
- ttd/cli/errors.py +19 -0
- ttd/cli/log_cmds.py +105 -0
- ttd/cli/main.py +27 -0
- ttd/cli/output.py +105 -0
- ttd/cli/project_cmds.py +145 -0
- ttd/cli/runtime.py +96 -0
- ttd/core/__init__.py +1 -0
- ttd/core/config.py +34 -0
- ttd/core/db.py +40 -0
- ttd/core/domain/__init__.py +24 -0
- ttd/core/domain/aggregates.py +34 -0
- ttd/core/domain/hours.py +32 -0
- ttd/core/domain/rates.py +42 -0
- ttd/core/exceptions.py +15 -0
- ttd/core/models/__init__.py +14 -0
- ttd/core/models/client.py +26 -0
- ttd/core/models/enums.py +29 -0
- ttd/core/models/project.py +42 -0
- ttd/core/models/time_entry.py +44 -0
- ttd/core/schemas.py +90 -0
- ttd/core/seed/__init__.py +15 -0
- ttd/core/seed/__main__.py +52 -0
- ttd/core/seed/demo_data.py +140 -0
- ttd/core/seed/runner.py +151 -0
- ttd/core/services/__init__.py +1 -0
- ttd/core/services/clients.py +57 -0
- ttd/core/services/health.py +14 -0
- ttd/core/services/projects.py +171 -0
- ttd/core/services/time_entries.py +126 -0
- ttd/py.typed +0 -0
- ttd/tui/__init__.py +0 -0
- ttd/tui/app.py +16 -0
- ttd_ledger-0.0.0.dist-info/METADATA +111 -0
- ttd_ledger-0.0.0.dist-info/RECORD +43 -0
- ttd_ledger-0.0.0.dist-info/WHEEL +4 -0
- ttd_ledger-0.0.0.dist-info/entry_points.txt +5 -0
ttd/__init__.py
ADDED
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}")
|
ttd/cli/entries_cmds.py
ADDED
|
@@ -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])
|