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.
@@ -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))