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,150 @@
1
+ """Client commands: list, show, create, archive."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..client import APIError, CrowdTimeClient
12
+ from ..formatters import format_error, format_success, print_json
13
+
14
+ app = typer.Typer(name="clients", help="Manage clients.")
15
+ console = Console()
16
+
17
+
18
+ @app.command("list")
19
+ def list_clients(
20
+ archived: bool = typer.Option(False, "--archived", "-a", help="Include archived clients."),
21
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
22
+ ) -> None:
23
+ """List all clients in the current organization."""
24
+ client = CrowdTimeClient(require_auth=True, require_org=True)
25
+
26
+ params: dict = {}
27
+ if archived:
28
+ params["is_archived"] = "true"
29
+
30
+ try:
31
+ data = client.get("/clients/", params=params)
32
+ clients_list = data if isinstance(data, list) else data.get("results", [])
33
+
34
+ if output_json:
35
+ print_json(clients_list)
36
+ else:
37
+ if not clients_list:
38
+ console.print("[dim]No clients found.[/dim]")
39
+ return
40
+
41
+ table = Table(show_header=True, header_style="bold")
42
+ table.add_column("ID", style="dim")
43
+ table.add_column("Name")
44
+ table.add_column("Contact")
45
+ table.add_column("Projects", justify="right")
46
+ table.add_column("Status")
47
+
48
+ for c in clients_list:
49
+ client_id = str(c.get("id", ""))
50
+ name = c.get("name", "")
51
+ contact = c.get("contact_name", "") or "-"
52
+ project_count = str(c.get("project_count", 0))
53
+ is_archived = c.get("is_archived", False)
54
+ status = "[red]Archived[/red]" if is_archived else "[green]Active[/green]"
55
+ table.add_row(client_id, name, contact, project_count, status)
56
+
57
+ console.print(table)
58
+ except APIError as e:
59
+ format_error(e.message)
60
+ raise typer.Exit(1)
61
+
62
+
63
+ @app.command()
64
+ def show(
65
+ client_id: str = typer.Argument(..., help="Client ID."),
66
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
67
+ ) -> None:
68
+ """Show details for a specific client."""
69
+ api_client = CrowdTimeClient(require_auth=True, require_org=True)
70
+
71
+ try:
72
+ data = api_client.get(f"/clients/{client_id}/")
73
+
74
+ if output_json:
75
+ print_json(data)
76
+ else:
77
+ console.print(f"\n[bold]{data.get('name')}[/bold]")
78
+ console.print(f" ID: {data.get('id')}")
79
+ console.print(f" Currency: {data.get('currency', '-')}")
80
+ console.print(f" Contact: {data.get('contact_name', '-')}")
81
+ if data.get("contact_email"):
82
+ console.print(f" Email: {data['contact_email']}")
83
+ if data.get("contact_phone"):
84
+ console.print(f" Phone: {data['contact_phone']}")
85
+ if data.get("address"):
86
+ console.print(f" Address: {data['address']}")
87
+ status = "Archived" if data.get("is_archived") else "Active"
88
+ console.print(f" Status: {status}")
89
+ console.print()
90
+ except APIError as e:
91
+ if e.status_code == 404:
92
+ format_error(f"Client '{client_id}' not found.")
93
+ else:
94
+ format_error(e.message)
95
+ raise typer.Exit(1)
96
+
97
+
98
+ @app.command()
99
+ def create(
100
+ name: str = typer.Argument(..., help="Client name."),
101
+ currency: Optional[str] = typer.Option(None, "--currency", help="Currency code (e.g. USD)."),
102
+ contact_name: Optional[str] = typer.Option(None, "--contact", help="Contact person name."),
103
+ contact_email: Optional[str] = typer.Option(None, "--email", help="Contact email."),
104
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
105
+ ) -> None:
106
+ """Create a new client."""
107
+ api_client = CrowdTimeClient(require_auth=True, require_org=True)
108
+
109
+ payload: dict = {"name": name}
110
+ if currency:
111
+ payload["currency"] = currency
112
+ if contact_name:
113
+ payload["contact_name"] = contact_name
114
+ if contact_email:
115
+ payload["contact_email"] = contact_email
116
+
117
+ try:
118
+ data = api_client.post("/clients/", data=payload)
119
+
120
+ if output_json:
121
+ print_json(data)
122
+ else:
123
+ format_success(f"Client '{data.get('name')}' created (ID: {data.get('id')})")
124
+ except APIError as e:
125
+ format_error(e.message)
126
+ raise typer.Exit(1)
127
+
128
+
129
+ @app.command()
130
+ def archive(
131
+ client_id: str = typer.Argument(..., help="Client ID to archive."),
132
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
133
+ ) -> None:
134
+ """Archive a client."""
135
+ if not force:
136
+ if not typer.confirm(f"Archive client '{client_id}'?"):
137
+ console.print("[dim]Cancelled.[/dim]")
138
+ return
139
+
140
+ api_client = CrowdTimeClient(require_auth=True, require_org=True)
141
+
142
+ try:
143
+ api_client.delete(f"/clients/{client_id}/")
144
+ format_success(f"Client '{client_id}' archived.")
145
+ except APIError as e:
146
+ if e.status_code == 404:
147
+ format_error(f"Client '{client_id}' not found.")
148
+ else:
149
+ format_error(e.message)
150
+ raise typer.Exit(1)
@@ -0,0 +1,91 @@
1
+ """Config commands: set, get, list, edit."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import subprocess
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from ..config import get_config
12
+ from ..formatters import format_error, format_success, print_json
13
+
14
+ app = typer.Typer(name="config", help="Manage CLI configuration.")
15
+ console = Console()
16
+
17
+
18
+ @app.command("set")
19
+ def config_set(
20
+ key: str = typer.Argument(..., help="Config key (e.g. server.url, defaults.project)."),
21
+ value: str = typer.Argument(..., help="Value to set."),
22
+ ) -> None:
23
+ """Set a configuration value.
24
+
25
+ Examples:
26
+ ct config set server.url https://api.crowdtime.io
27
+ ct config set defaults.project myproject
28
+ ct config set defaults.daily_target 7h
29
+ """
30
+ config = get_config()
31
+ # Convert boolean strings
32
+ if value.lower() in ("true", "yes", "1"):
33
+ config.set(key, True)
34
+ elif value.lower() in ("false", "no", "0"):
35
+ config.set(key, False)
36
+ else:
37
+ config.set(key, value)
38
+ format_success(f"{key} = {value}")
39
+
40
+
41
+ @app.command("get")
42
+ def config_get(
43
+ key: str = typer.Argument(..., help="Config key to read."),
44
+ ) -> None:
45
+ """Get a configuration value."""
46
+ config = get_config()
47
+ value = config.get(key)
48
+ if value is None:
49
+ format_error(f"Key '{key}' not found.")
50
+ raise typer.Exit(1)
51
+ console.print(f"{key} = {value}")
52
+
53
+
54
+ @app.command("list")
55
+ def config_list(
56
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
57
+ ) -> None:
58
+ """Show all configuration values."""
59
+ config = get_config()
60
+ all_config = config.all()
61
+
62
+ if output_json:
63
+ print_json(dict(all_config))
64
+ return
65
+
66
+ console.print(f"\n[dim]Config file: {config.config_path}[/dim]\n")
67
+ for section, values in all_config.items():
68
+ console.print(f"[bold][{section}][/bold]")
69
+ if isinstance(values, dict):
70
+ for key, value in values.items():
71
+ console.print(f" {key} = {value}")
72
+ else:
73
+ console.print(f" {values}")
74
+ console.print()
75
+
76
+
77
+ @app.command("edit")
78
+ def config_edit() -> None:
79
+ """Open the config file in your default editor."""
80
+ config = get_config()
81
+ editor = os.environ.get("EDITOR", os.environ.get("VISUAL", "nano"))
82
+
83
+ try:
84
+ subprocess.run([editor, str(config.config_path)], check=True)
85
+ format_success(f"Config saved at {config.config_path}")
86
+ except FileNotFoundError:
87
+ format_error(f"Editor '{editor}' not found. Set $EDITOR to your preferred editor.")
88
+ raise typer.Exit(1)
89
+ except subprocess.CalledProcessError:
90
+ format_error("Editor exited with an error.")
91
+ raise typer.Exit(1)
@@ -0,0 +1,128 @@
1
+ """Favorites commands: list, create, delete, start."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ..client import APIError, CrowdTimeClient
11
+ from ..formatters import (
12
+ format_error,
13
+ format_favorites_table,
14
+ format_success,
15
+ format_timer,
16
+ print_json,
17
+ )
18
+ from ..models import FavoriteEntry, TimeEntry
19
+
20
+ app = typer.Typer(name="favorites", help="Manage favorite time entry templates.")
21
+ console = Console()
22
+
23
+
24
+ @app.command("list")
25
+ def list_favorites(
26
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
27
+ ) -> None:
28
+ """List all saved favorites."""
29
+ client = CrowdTimeClient(require_auth=True, require_org=True)
30
+
31
+ try:
32
+ data = client.get("/favorites/")
33
+ favs_list = data if isinstance(data, list) else data.get("results", [])
34
+ favorites = [FavoriteEntry(**item) for item in favs_list]
35
+
36
+ if output_json:
37
+ print_json(favorites)
38
+ else:
39
+ format_favorites_table(favorites)
40
+ except APIError as e:
41
+ format_error(e.message)
42
+ raise typer.Exit(1)
43
+
44
+
45
+ @app.command()
46
+ def create(
47
+ description: str = typer.Argument("", help="Description for the favorite."),
48
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project name or ID."),
49
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
50
+ notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Additional notes."),
51
+ billable: bool = typer.Option(True, "--billable/--no-billable", "-b/-B",
52
+ help="Mark as billable."),
53
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
54
+ ) -> None:
55
+ """Create a new favorite entry template."""
56
+ client = CrowdTimeClient(require_auth=True, require_org=True)
57
+
58
+ payload: dict = {
59
+ "description": description,
60
+ "is_billable": billable,
61
+ }
62
+ if project:
63
+ payload["project"] = project
64
+ if task:
65
+ payload["task"] = task
66
+ if notes:
67
+ payload["notes"] = notes
68
+
69
+ try:
70
+ data = client.post("/favorites/", data=payload)
71
+ fav = FavoriteEntry(**data)
72
+
73
+ if output_json:
74
+ print_json(fav)
75
+ else:
76
+ format_success(f"Favorite created (ID: {fav.id})")
77
+ except APIError as e:
78
+ format_error(e.message)
79
+ raise typer.Exit(1)
80
+
81
+
82
+ @app.command()
83
+ def delete(
84
+ favorite_id: str = typer.Argument(..., help="Favorite ID to delete."),
85
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
86
+ ) -> None:
87
+ """Delete a favorite."""
88
+ if not force:
89
+ if not typer.confirm(f"Delete favorite {favorite_id}?"):
90
+ console.print("[dim]Cancelled.[/dim]")
91
+ return
92
+
93
+ client = CrowdTimeClient(require_auth=True, require_org=True)
94
+
95
+ try:
96
+ client.delete(f"/favorites/{favorite_id}/")
97
+ format_success(f"Favorite {favorite_id} deleted.")
98
+ except APIError as e:
99
+ if e.status_code == 404:
100
+ format_error(f"Favorite {favorite_id} not found.")
101
+ else:
102
+ format_error(e.message)
103
+ raise typer.Exit(1)
104
+
105
+
106
+ @app.command()
107
+ def start(
108
+ favorite_id: str = typer.Argument(..., help="Favorite ID to start timer from."),
109
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
110
+ ) -> None:
111
+ """Start a timer from a favorite template."""
112
+ client = CrowdTimeClient(require_auth=True, require_org=True)
113
+
114
+ try:
115
+ data = client.post(f"/favorites/{favorite_id}/start/")
116
+ entry = TimeEntry(**data)
117
+
118
+ if output_json:
119
+ print_json(entry)
120
+ else:
121
+ format_success("Timer started from favorite")
122
+ format_timer(entry)
123
+ except APIError as e:
124
+ if e.status_code == 404:
125
+ format_error(f"Favorite {favorite_id} not found.")
126
+ else:
127
+ format_error(e.message)
128
+ raise typer.Exit(1)
@@ -0,0 +1,298 @@
1
+ """Log commands: create, edit, delete, list."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ from ..client import APIError, CrowdTimeClient
11
+ from ..formatters import (
12
+ format_entries_table,
13
+ format_entry_summary,
14
+ format_error,
15
+ format_success,
16
+ print_json,
17
+ )
18
+ from ..models import TimeEntry
19
+ from ..resolvers import resolve_task
20
+ from ..utils import format_date, parse_date, parse_duration
21
+
22
+ app = typer.Typer(name="log", help="Log and manage time entries.")
23
+ console = Console()
24
+
25
+
26
+ @app.callback(invoke_without_command=True)
27
+ def log_default(
28
+ ctx: typer.Context,
29
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
30
+ ) -> None:
31
+ """Log and manage time entries. Run without arguments to list today's entries."""
32
+ if ctx.invoked_subcommand is None:
33
+ _list_entries(date_str="today", output_json=output_json)
34
+
35
+
36
+ @app.command()
37
+ def create(
38
+ duration: str = typer.Argument(..., help="Duration (e.g. 2h, 2h30m, 2:30, 150m)."),
39
+ description: Optional[str] = typer.Argument(None, help="What did you work on?"),
40
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Project slug or ID."),
41
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
42
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Date (default: today)."),
43
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
44
+ help="Mark as billable/non-billable."),
45
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
46
+ ) -> None:
47
+ """Log a completed time entry.
48
+
49
+ Examples:
50
+ ct log create 2h "Code review"
51
+ ct log create -p myproject 2h30m "Feature work"
52
+ ct l -p myproject -d yesterday 1:30 "Standup"
53
+ """
54
+ try:
55
+ hours = parse_duration(duration)
56
+ except ValueError as e:
57
+ format_error(str(e))
58
+ raise typer.Exit(1)
59
+
60
+ client = CrowdTimeClient(require_auth=True, require_org=True)
61
+
62
+ payload: dict = {
63
+ "hours": str(hours),
64
+ }
65
+ if description:
66
+ payload["notes"] = description
67
+
68
+ project_id = project or client.config.default_project
69
+ if project_id:
70
+ payload["project"] = project_id
71
+ if task:
72
+ try:
73
+ payload["task"] = resolve_task(client, task, project_id=project_id)
74
+ except APIError as e:
75
+ format_error(f"Failed to resolve task '{task}': {e.message}")
76
+ raise typer.Exit(1)
77
+ if date:
78
+ try:
79
+ parsed = parse_date(date)
80
+ payload["date"] = format_date(parsed)
81
+ except ValueError as e:
82
+ format_error(str(e))
83
+ raise typer.Exit(1)
84
+ if billable is not None:
85
+ payload["is_billable"] = billable
86
+
87
+ try:
88
+ data = client.post("/time/", data=payload)
89
+ entry = TimeEntry(**data)
90
+
91
+ if output_json:
92
+ print_json(entry)
93
+ else:
94
+ format_success("Time entry logged")
95
+ format_entry_summary(entry)
96
+ except APIError as e:
97
+ format_error(e.message)
98
+ raise typer.Exit(1)
99
+
100
+
101
+ @app.command()
102
+ def edit(
103
+ entry_id: str = typer.Argument(..., help="Time entry ID to edit."),
104
+ duration: Optional[str] = typer.Option(None, "--duration", help="New duration."),
105
+ description: Optional[str] = typer.Option(None, "--description", help="New description."),
106
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="New project."),
107
+ task: Optional[str] = typer.Option(None, "--task", "-t", help="New task."),
108
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="New date."),
109
+ billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
110
+ help="Mark as billable/non-billable."),
111
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
112
+ ) -> None:
113
+ """Edit an existing time entry."""
114
+ client = CrowdTimeClient(require_auth=True, require_org=True)
115
+
116
+ payload: dict = {}
117
+ if duration:
118
+ try:
119
+ payload["hours"] = str(parse_duration(duration))
120
+ except ValueError as e:
121
+ format_error(str(e))
122
+ raise typer.Exit(1)
123
+ if description is not None:
124
+ payload["notes"] = description
125
+ if project:
126
+ payload["project"] = project
127
+ if task:
128
+ try:
129
+ payload["task"] = resolve_task(client, task, project_id=project)
130
+ except APIError as e:
131
+ format_error(f"Failed to resolve task '{task}': {e.message}")
132
+ raise typer.Exit(1)
133
+ if date:
134
+ try:
135
+ payload["date"] = format_date(parse_date(date))
136
+ except ValueError as e:
137
+ format_error(str(e))
138
+ raise typer.Exit(1)
139
+ if billable is not None:
140
+ payload["is_billable"] = billable
141
+
142
+ if not payload:
143
+ format_error("No changes specified. Use --duration, --description, --project, etc.")
144
+ raise typer.Exit(1)
145
+
146
+ try:
147
+ data = client.patch(f"/time/{entry_id}/", data=payload)
148
+ entry = TimeEntry(**data)
149
+
150
+ if output_json:
151
+ print_json(entry)
152
+ else:
153
+ format_success(f"Entry {entry_id} updated")
154
+ format_entry_summary(entry)
155
+ except APIError as e:
156
+ if e.status_code == 404:
157
+ format_error(f"Entry {entry_id} not found.")
158
+ else:
159
+ format_error(e.message)
160
+ raise typer.Exit(1)
161
+
162
+
163
+ @app.command()
164
+ def delete(
165
+ entry_id: str = typer.Argument(..., help="Time entry ID to delete."),
166
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
167
+ ) -> None:
168
+ """Delete a time entry."""
169
+ client = CrowdTimeClient(require_auth=True, require_org=True)
170
+
171
+ if not force:
172
+ # Fetch entry details for confirmation
173
+ try:
174
+ data = client.get(f"/time/{entry_id}/")
175
+ entry = TimeEntry(**data)
176
+ desc = entry.description or "(no description)"
177
+ if not typer.confirm(f"Delete entry '{desc}'?"):
178
+ console.print("[dim]Cancelled.[/dim]")
179
+ return
180
+ except APIError as e:
181
+ if e.status_code == 404:
182
+ format_error(f"Entry {entry_id} not found.")
183
+ else:
184
+ format_error(e.message)
185
+ raise typer.Exit(1)
186
+
187
+ try:
188
+ client.delete(f"/time/{entry_id}/")
189
+ format_success(f"Entry {entry_id} deleted.")
190
+ except APIError as e:
191
+ if e.status_code == 404:
192
+ format_error(f"Entry {entry_id} not found.")
193
+ else:
194
+ format_error(e.message)
195
+ raise typer.Exit(1)
196
+
197
+
198
+ @app.command("list")
199
+ def list_entries(
200
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Filter by date."),
201
+ week: bool = typer.Option(False, "--week", "-w", help="Show this week's entries."),
202
+ month: bool = typer.Option(False, "--month", "-m", help="Show this month's entries."),
203
+ from_date: Optional[str] = typer.Option(None, "--from", help="Start date for range filter."),
204
+ to_date: Optional[str] = typer.Option(None, "--to", help="End date for range filter."),
205
+ project: Optional[str] = typer.Option(None, "--project", "-p", help="Filter by project."),
206
+ format_output: str = typer.Option("table", "--format", help="Output format: table, json, csv."),
207
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
208
+ ) -> None:
209
+ """List time entries with optional filters."""
210
+ _list_entries(
211
+ date_str=date,
212
+ week=week,
213
+ month=month,
214
+ from_date=from_date,
215
+ to_date=to_date,
216
+ project=project,
217
+ format_output=format_output,
218
+ output_json=output_json,
219
+ )
220
+
221
+
222
+ def _list_entries(
223
+ date_str: str | None = None,
224
+ week: bool = False,
225
+ month: bool = False,
226
+ from_date: str | None = None,
227
+ to_date: str | None = None,
228
+ project: str | None = None,
229
+ format_output: str = "table",
230
+ output_json: bool = False,
231
+ ) -> None:
232
+ """Internal function to list entries."""
233
+ from datetime import date as date_type, timedelta
234
+
235
+ client = CrowdTimeClient(require_auth=True, require_org=True)
236
+
237
+ params: dict = {}
238
+
239
+ if date_str:
240
+ try:
241
+ parsed = parse_date(date_str)
242
+ params["date"] = format_date(parsed)
243
+ except ValueError as e:
244
+ format_error(str(e))
245
+ raise typer.Exit(1)
246
+ elif week:
247
+ today = date_type.today()
248
+ start = today - timedelta(days=today.weekday())
249
+ params["from"] = format_date(start)
250
+ params["to"] = format_date(today)
251
+ elif month:
252
+ today = date_type.today()
253
+ start = today.replace(day=1)
254
+ params["from"] = format_date(start)
255
+ params["to"] = format_date(today)
256
+
257
+ if from_date:
258
+ try:
259
+ params["from"] = format_date(parse_date(from_date))
260
+ except ValueError as e:
261
+ format_error(str(e))
262
+ raise typer.Exit(1)
263
+ if to_date:
264
+ try:
265
+ params["to"] = format_date(parse_date(to_date))
266
+ except ValueError as e:
267
+ format_error(str(e))
268
+ raise typer.Exit(1)
269
+
270
+ if project:
271
+ params["project"] = project
272
+
273
+ try:
274
+ data = client.get("/time/", params=params)
275
+ entries = [TimeEntry(**item) for item in (data if isinstance(data, list) else data.get("results", []))]
276
+
277
+ if format_output == "json" or output_json:
278
+ print_json(entries)
279
+ elif format_output == "csv":
280
+ _print_csv(entries)
281
+ else:
282
+ format_entries_table(entries, show_date=True)
283
+ except APIError as e:
284
+ format_error(e.message)
285
+ raise typer.Exit(1)
286
+
287
+
288
+ def _print_csv(entries: list[TimeEntry]) -> None:
289
+ """Print entries as CSV."""
290
+ console.print("id,date,project,task,description,duration,billable")
291
+ for e in entries:
292
+ desc = (e.description or "").replace('"', '""')
293
+ console.print(
294
+ f'{e.id},{e.date or ""},'
295
+ f'{e.project_name or ""},{e.task_name or ""},'
296
+ f'"{desc}",{e.duration or 0},'
297
+ f'{"yes" if e.is_billable else "no"}'
298
+ )