crowdtime-cli 0.1.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.
- crowdtime_cli/__init__.py +3 -0
- crowdtime_cli/auth.py +69 -0
- crowdtime_cli/client.py +177 -0
- crowdtime_cli/commands/__init__.py +1 -0
- crowdtime_cli/commands/ai_cmd.py +211 -0
- crowdtime_cli/commands/auth_cmd.py +160 -0
- crowdtime_cli/commands/clients_cmd.py +150 -0
- crowdtime_cli/commands/config_cmd.py +91 -0
- crowdtime_cli/commands/favorites_cmd.py +128 -0
- crowdtime_cli/commands/log_cmd.py +298 -0
- crowdtime_cli/commands/org_cmd.py +134 -0
- crowdtime_cli/commands/projects_cmd.py +175 -0
- crowdtime_cli/commands/report_cmd.py +242 -0
- crowdtime_cli/commands/skill_cmd.py +266 -0
- crowdtime_cli/commands/tasks_cmd.py +101 -0
- crowdtime_cli/commands/timer_cmd.py +207 -0
- crowdtime_cli/config.py +125 -0
- crowdtime_cli/formatters.py +395 -0
- crowdtime_cli/main.py +334 -0
- crowdtime_cli/models.py +146 -0
- crowdtime_cli/oauth.py +107 -0
- crowdtime_cli/resolvers.py +80 -0
- crowdtime_cli/skills/crowdtime/SKILL.md +193 -0
- crowdtime_cli/skills/crowdtime/references/commands.md +659 -0
- crowdtime_cli/skills/crowdtime/references/workflows.md +286 -0
- crowdtime_cli/utils.py +166 -0
- crowdtime_cli-0.1.0.dist-info/METADATA +140 -0
- crowdtime_cli-0.1.0.dist-info/RECORD +31 -0
- crowdtime_cli-0.1.0.dist-info/WHEEL +4 -0
- crowdtime_cli-0.1.0.dist-info/entry_points.txt +3 -0
- crowdtime_cli-0.1.0.dist-info/licenses/LICENSE +77 -0
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
"""Rich-based output formatting for CrowdTime CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import date, datetime, timedelta, timezone
|
|
7
|
+
from decimal import Decimal
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.progress import BarColumn, Progress, TextColumn
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from .models import FavoriteEntry, ParseResult, Project, Suggestion, TimeEntry
|
|
17
|
+
from .utils import format_duration, truncate
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def format_error(message: str) -> None:
|
|
23
|
+
"""Print an error message."""
|
|
24
|
+
console.print(f"[bold red]Error:[/bold red] {message}")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def format_success(message: str) -> None:
|
|
28
|
+
"""Print a success message."""
|
|
29
|
+
console.print(f"[bold green]Done:[/bold green] {message}")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_warning(message: str) -> None:
|
|
33
|
+
"""Print a warning message."""
|
|
34
|
+
console.print(f"[bold yellow]Warning:[/bold yellow] {message}")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def format_timer(entry: TimeEntry) -> None:
|
|
38
|
+
"""Display the currently running timer as a Rich panel."""
|
|
39
|
+
if not entry.is_running:
|
|
40
|
+
console.print("[dim]No timer is currently running.[/dim]")
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
elapsed = ""
|
|
44
|
+
if entry.start_time:
|
|
45
|
+
now = datetime.now(timezone.utc)
|
|
46
|
+
start = entry.start_time
|
|
47
|
+
if start.tzinfo is None:
|
|
48
|
+
start = start.replace(tzinfo=timezone.utc)
|
|
49
|
+
delta = now - start
|
|
50
|
+
total_seconds = int(delta.total_seconds())
|
|
51
|
+
hours = total_seconds // 3600
|
|
52
|
+
minutes = (total_seconds % 3600) // 60
|
|
53
|
+
seconds = total_seconds % 60
|
|
54
|
+
elapsed = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
55
|
+
|
|
56
|
+
parts: list[str] = []
|
|
57
|
+
if entry.description:
|
|
58
|
+
parts.append(f"[bold]{entry.description}[/bold]")
|
|
59
|
+
if entry.project_name:
|
|
60
|
+
parts.append(f"[cyan]{entry.project_name}[/cyan]")
|
|
61
|
+
if entry.task_name:
|
|
62
|
+
parts.append(f"[dim]{entry.task_name}[/dim]")
|
|
63
|
+
if entry.is_billable:
|
|
64
|
+
parts.append("[green]$[/green]")
|
|
65
|
+
|
|
66
|
+
title = " | ".join(parts) if parts else "[dim]No description[/dim]"
|
|
67
|
+
|
|
68
|
+
panel = Panel(
|
|
69
|
+
f"[bold green]{elapsed}[/bold green]" if elapsed else "[dim]Running...[/dim]",
|
|
70
|
+
title=title,
|
|
71
|
+
subtitle="[dim]Timer running[/dim]",
|
|
72
|
+
border_style="green",
|
|
73
|
+
padding=(0, 2),
|
|
74
|
+
)
|
|
75
|
+
console.print(panel)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_entry_summary(entry: TimeEntry) -> None:
|
|
79
|
+
"""Display a single time entry summary."""
|
|
80
|
+
parts: list[str] = []
|
|
81
|
+
if entry.description:
|
|
82
|
+
parts.append(f"[bold]{entry.description}[/bold]")
|
|
83
|
+
if entry.project_name:
|
|
84
|
+
parts.append(f"[cyan]{entry.project_name}[/cyan]")
|
|
85
|
+
if entry.task_name:
|
|
86
|
+
parts.append(f"[dim]{entry.task_name}[/dim]")
|
|
87
|
+
if entry.duration is not None:
|
|
88
|
+
parts.append(f"[yellow]{format_duration(entry.duration)}[/yellow]")
|
|
89
|
+
if entry.date:
|
|
90
|
+
parts.append(f"[dim]{entry.date}[/dim]")
|
|
91
|
+
|
|
92
|
+
console.print(" | ".join(parts))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def format_entries_table(entries: list[TimeEntry], show_date: bool = True) -> None:
|
|
96
|
+
"""Display time entries in a Rich table."""
|
|
97
|
+
if not entries:
|
|
98
|
+
console.print("[dim]No entries found.[/dim]")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
table = Table(show_header=True, header_style="bold")
|
|
102
|
+
table.add_column("ID", style="dim")
|
|
103
|
+
if show_date:
|
|
104
|
+
table.add_column("Date", width=12)
|
|
105
|
+
table.add_column("Project", style="cyan", width=15)
|
|
106
|
+
table.add_column("Task", style="dim", width=15)
|
|
107
|
+
table.add_column("Description", width=35)
|
|
108
|
+
table.add_column("Duration", justify="right", width=8)
|
|
109
|
+
table.add_column("$", justify="center", width=3)
|
|
110
|
+
|
|
111
|
+
for entry in entries:
|
|
112
|
+
row: list[str] = [str(entry.id)]
|
|
113
|
+
if show_date:
|
|
114
|
+
row.append(str(entry.date or ""))
|
|
115
|
+
row.extend([
|
|
116
|
+
entry.project_name or "",
|
|
117
|
+
entry.task_name or "",
|
|
118
|
+
truncate(entry.description, 35),
|
|
119
|
+
format_duration(entry.duration) if not entry.is_running else "[green]running[/green]",
|
|
120
|
+
"[green]$[/green]" if entry.is_billable else "",
|
|
121
|
+
])
|
|
122
|
+
table.add_row(*row)
|
|
123
|
+
|
|
124
|
+
# Total
|
|
125
|
+
total_hours = sum(
|
|
126
|
+
(e.duration or Decimal("0")) for e in entries if not e.is_running
|
|
127
|
+
)
|
|
128
|
+
console.print(table)
|
|
129
|
+
console.print(f"\n[bold]Total:[/bold] {format_duration(total_hours)} ({len(entries)} entries)")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def format_status(
|
|
133
|
+
user_name: str,
|
|
134
|
+
org_name: str,
|
|
135
|
+
running_entry: TimeEntry | None,
|
|
136
|
+
today_entries: list[TimeEntry],
|
|
137
|
+
daily_target: str,
|
|
138
|
+
week_entries: list[TimeEntry] | None = None,
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Display the full status dashboard."""
|
|
141
|
+
# Header
|
|
142
|
+
console.print(f"\n[bold]CrowdTime[/bold] | {user_name} @ {org_name}\n")
|
|
143
|
+
|
|
144
|
+
# Running timer
|
|
145
|
+
if running_entry:
|
|
146
|
+
format_timer(running_entry)
|
|
147
|
+
console.print()
|
|
148
|
+
else:
|
|
149
|
+
console.print("[dim]No timer running[/dim]\n")
|
|
150
|
+
|
|
151
|
+
# Today's summary
|
|
152
|
+
from .utils import parse_duration
|
|
153
|
+
try:
|
|
154
|
+
target_hours = parse_duration(daily_target)
|
|
155
|
+
except ValueError:
|
|
156
|
+
target_hours = Decimal("8")
|
|
157
|
+
|
|
158
|
+
today_total = sum(
|
|
159
|
+
(e.duration or Decimal("0")) for e in today_entries if not e.is_running
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
console.print("[bold]Today[/bold]")
|
|
163
|
+
progress_pct = min(float(today_total / target_hours * 100), 100) if target_hours else 0
|
|
164
|
+
|
|
165
|
+
bar_width = 30
|
|
166
|
+
filled = int(bar_width * progress_pct / 100)
|
|
167
|
+
bar = "[green]" + "\u2588" * filled + "[/green]" + "[dim]" + "\u2591" * (bar_width - filled) + "[/dim]"
|
|
168
|
+
console.print(
|
|
169
|
+
f" {bar} {format_duration(today_total)} / {format_duration(target_hours)} "
|
|
170
|
+
f"({progress_pct:.0f}%)"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
if today_entries:
|
|
174
|
+
for entry in today_entries[-5:]:
|
|
175
|
+
prefix = "[green]\u25cf[/green]" if entry.is_running else "[dim]\u25cb[/dim]"
|
|
176
|
+
dur = "running" if entry.is_running else format_duration(entry.duration)
|
|
177
|
+
desc = truncate(entry.description or "(no description)", 40)
|
|
178
|
+
proj = f" [cyan]{entry.project_name}[/cyan]" if entry.project_name else ""
|
|
179
|
+
console.print(f" {prefix} {desc}{proj} [{dur}]")
|
|
180
|
+
|
|
181
|
+
# Week summary
|
|
182
|
+
if week_entries is not None:
|
|
183
|
+
console.print("\n[bold]This Week[/bold]")
|
|
184
|
+
days: dict[str, Decimal] = {}
|
|
185
|
+
day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
|
186
|
+
for entry in week_entries:
|
|
187
|
+
if entry.date:
|
|
188
|
+
day_key = day_names[entry.date.weekday()]
|
|
189
|
+
days[day_key] = days.get(day_key, Decimal("0")) + (entry.duration or Decimal("0"))
|
|
190
|
+
|
|
191
|
+
week_total = sum(days.values(), Decimal("0"))
|
|
192
|
+
for day_name in day_names:
|
|
193
|
+
hrs = days.get(day_name, Decimal("0"))
|
|
194
|
+
day_bar_width = 20
|
|
195
|
+
day_pct = min(float(hrs / target_hours * 100), 100) if target_hours else 0
|
|
196
|
+
day_filled = int(day_bar_width * day_pct / 100)
|
|
197
|
+
day_bar = "\u2588" * day_filled + "\u2591" * (day_bar_width - day_filled)
|
|
198
|
+
style = "green" if day_pct >= 100 else "yellow" if day_pct >= 50 else "dim"
|
|
199
|
+
console.print(f" {day_name} [{style}]{day_bar}[/{style}] {format_duration(hrs)}")
|
|
200
|
+
|
|
201
|
+
console.print(f"\n [bold]Week total:[/bold] {format_duration(week_total)}")
|
|
202
|
+
|
|
203
|
+
console.print()
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def format_weekly_table(entries_by_day: dict[str, list[TimeEntry]]) -> None:
|
|
207
|
+
"""Display entries grouped by day in a weekly view."""
|
|
208
|
+
for day_label, entries in entries_by_day.items():
|
|
209
|
+
day_total = sum((e.duration or Decimal("0")) for e in entries)
|
|
210
|
+
console.print(f"\n[bold]{day_label}[/bold] - {format_duration(day_total)}")
|
|
211
|
+
if entries:
|
|
212
|
+
for entry in entries:
|
|
213
|
+
dur = format_duration(entry.duration)
|
|
214
|
+
desc = truncate(entry.description or "(no description)", 40)
|
|
215
|
+
proj = f" [cyan]{entry.project_name}[/cyan]" if entry.project_name else ""
|
|
216
|
+
console.print(f" \u25cb {desc}{proj} [{dur}]")
|
|
217
|
+
else:
|
|
218
|
+
console.print(" [dim]No entries[/dim]")
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def format_projects_table(projects: list[Project]) -> None:
|
|
222
|
+
"""Display projects in a table."""
|
|
223
|
+
if not projects:
|
|
224
|
+
console.print("[dim]No projects found.[/dim]")
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
table = Table(show_header=True, header_style="bold")
|
|
228
|
+
table.add_column("ID", style="dim")
|
|
229
|
+
table.add_column("Name", style="bold", width=20)
|
|
230
|
+
table.add_column("Code", width=8)
|
|
231
|
+
table.add_column("Client", width=15)
|
|
232
|
+
table.add_column("Status", width=10)
|
|
233
|
+
table.add_column("Billable", justify="center", width=8)
|
|
234
|
+
|
|
235
|
+
for project in projects:
|
|
236
|
+
status_style = "green" if project.status == "active" else "dim"
|
|
237
|
+
table.add_row(
|
|
238
|
+
str(project.id),
|
|
239
|
+
project.name,
|
|
240
|
+
project.code,
|
|
241
|
+
project.client or "",
|
|
242
|
+
f"[{status_style}]{project.status}[/{status_style}]",
|
|
243
|
+
"[green]$[/green]" if project.is_billable else "",
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
console.print(table)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def format_parse_result(result: ParseResult) -> None:
|
|
250
|
+
"""Display AI parse result for confirmation."""
|
|
251
|
+
parsed = result.parsed_result
|
|
252
|
+
|
|
253
|
+
console.print("\n[bold]Parsed Time Entry[/bold]")
|
|
254
|
+
console.print(f" Confidence: [{'green' if result.confidence > 0.8 else 'yellow'}]"
|
|
255
|
+
f"{result.confidence:.0%}[/{'green' if result.confidence > 0.8 else 'yellow'}]")
|
|
256
|
+
|
|
257
|
+
if parsed.get("description"):
|
|
258
|
+
console.print(f" Description: [bold]{parsed['description']}[/bold]")
|
|
259
|
+
if parsed.get("project"):
|
|
260
|
+
console.print(f" Project: [cyan]{parsed['project']}[/cyan]")
|
|
261
|
+
if parsed.get("task"):
|
|
262
|
+
console.print(f" Task: {parsed['task']}")
|
|
263
|
+
if parsed.get("duration"):
|
|
264
|
+
console.print(f" Duration: [yellow]{parsed['duration']}[/yellow]")
|
|
265
|
+
if parsed.get("date"):
|
|
266
|
+
console.print(f" Date: {parsed['date']}")
|
|
267
|
+
if parsed.get("is_billable") is not None:
|
|
268
|
+
console.print(f" Billable: {'Yes' if parsed['is_billable'] else 'No'}")
|
|
269
|
+
|
|
270
|
+
if result.ambiguities:
|
|
271
|
+
console.print("\n [yellow]Ambiguities:[/yellow]")
|
|
272
|
+
for amb in result.ambiguities:
|
|
273
|
+
console.print(f" - {amb}")
|
|
274
|
+
|
|
275
|
+
console.print()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def format_suggestions(suggestions: list[Suggestion]) -> None:
|
|
279
|
+
"""Display AI suggestions as a numbered list."""
|
|
280
|
+
if not suggestions:
|
|
281
|
+
console.print("[dim]No suggestions available.[/dim]")
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
console.print("\n[bold]Suggested entries[/bold]\n")
|
|
285
|
+
for i, s in enumerate(suggestions, 1):
|
|
286
|
+
console.print(f" [bold]{i}.[/bold] {s.description}")
|
|
287
|
+
parts: list[str] = []
|
|
288
|
+
if s.project_name:
|
|
289
|
+
parts.append(f"[cyan]{s.project_name}[/cyan]")
|
|
290
|
+
if s.task_name:
|
|
291
|
+
parts.append(s.task_name)
|
|
292
|
+
if s.estimated_hours:
|
|
293
|
+
parts.append(f"[yellow]{format_duration(s.estimated_hours)}[/yellow]")
|
|
294
|
+
if parts:
|
|
295
|
+
console.print(f" {' | '.join(parts)}")
|
|
296
|
+
if s.reason:
|
|
297
|
+
console.print(f" [dim]{s.reason}[/dim]")
|
|
298
|
+
console.print()
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def format_favorites_table(favorites: list[FavoriteEntry]) -> None:
|
|
302
|
+
"""Display favorites in a table."""
|
|
303
|
+
if not favorites:
|
|
304
|
+
console.print("[dim]No favorites found.[/dim]")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
table = Table(show_header=True, header_style="bold")
|
|
308
|
+
table.add_column("ID", style="dim")
|
|
309
|
+
table.add_column("Project", style="cyan", width=15)
|
|
310
|
+
table.add_column("Task", width=15)
|
|
311
|
+
table.add_column("Description", width=30)
|
|
312
|
+
table.add_column("Uses", justify="right", width=6)
|
|
313
|
+
|
|
314
|
+
for fav in favorites:
|
|
315
|
+
table.add_row(
|
|
316
|
+
str(fav.id),
|
|
317
|
+
fav.project_name or "",
|
|
318
|
+
fav.task_name or "",
|
|
319
|
+
truncate(fav.description, 30),
|
|
320
|
+
str(fav.use_count),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
console.print(table)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def format_report(data: list[dict[str, Any]], group_by: str = "project") -> None:
|
|
327
|
+
"""Display report data in a table."""
|
|
328
|
+
if not data:
|
|
329
|
+
console.print("[dim]No data for this period.[/dim]")
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
table = Table(show_header=True, header_style="bold", title="Time Report")
|
|
333
|
+
table.add_column(group_by.capitalize(), style="bold", width=20)
|
|
334
|
+
table.add_column("Hours", justify="right", width=10)
|
|
335
|
+
table.add_column("Billable", justify="right", width=10)
|
|
336
|
+
table.add_column("Entries", justify="right", width=8)
|
|
337
|
+
|
|
338
|
+
total_hours = Decimal("0")
|
|
339
|
+
total_billable = Decimal("0")
|
|
340
|
+
|
|
341
|
+
for row in data:
|
|
342
|
+
hours = Decimal(str(row.get("hours", 0)))
|
|
343
|
+
billable = Decimal(str(row.get("billable_hours", 0)))
|
|
344
|
+
total_hours += hours
|
|
345
|
+
total_billable += billable
|
|
346
|
+
|
|
347
|
+
table.add_row(
|
|
348
|
+
str(row.get(group_by, row.get("project", ""))),
|
|
349
|
+
format_duration(hours),
|
|
350
|
+
format_duration(billable),
|
|
351
|
+
str(row.get("entries_count", "")),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
table.add_section()
|
|
355
|
+
table.add_row(
|
|
356
|
+
"[bold]Total[/bold]",
|
|
357
|
+
f"[bold]{format_duration(total_hours)}[/bold]",
|
|
358
|
+
f"[bold]{format_duration(total_billable)}[/bold]",
|
|
359
|
+
"",
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
console.print(table)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def format_members_table(members: list[dict[str, Any]]) -> None:
|
|
366
|
+
"""Display organization members in a table."""
|
|
367
|
+
if not members:
|
|
368
|
+
console.print("[dim]No members found.[/dim]")
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
table = Table(show_header=True, header_style="bold")
|
|
372
|
+
table.add_column("Name", width=20)
|
|
373
|
+
table.add_column("Email", width=25)
|
|
374
|
+
table.add_column("Role", width=10)
|
|
375
|
+
table.add_column("Status", width=10)
|
|
376
|
+
|
|
377
|
+
for m in members:
|
|
378
|
+
status = "[green]Active[/green]" if m.get("is_active", True) else "[dim]Inactive[/dim]"
|
|
379
|
+
table.add_row(
|
|
380
|
+
m.get("user_name", ""),
|
|
381
|
+
m.get("user_email", ""),
|
|
382
|
+
m.get("role", "member"),
|
|
383
|
+
status,
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
console.print(table)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def print_json(data: Any) -> None:
|
|
390
|
+
"""Print data as formatted JSON."""
|
|
391
|
+
if hasattr(data, "model_dump"):
|
|
392
|
+
data = data.model_dump(mode="json")
|
|
393
|
+
elif isinstance(data, list) and data and hasattr(data[0], "model_dump"):
|
|
394
|
+
data = [item.model_dump(mode="json") for item in data]
|
|
395
|
+
console.print_json(json.dumps(data, default=str))
|