crowdtime-cli 0.15.0__tar.gz → 0.17.1__tar.gz

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.
Files changed (43) hide show
  1. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/ai_cmd.py +17 -1
  5. crowdtime_cli-0.17.1/src/crowdtime_cli/commands/calendar_cmd.py +136 -0
  6. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/log_cmd.py +4 -2
  7. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/projects_cmd.py +46 -0
  8. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/pto_cmd.py +23 -11
  9. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/formatters.py +9 -1
  10. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/main.py +26 -12
  11. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/models.py +20 -0
  12. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/skills/crowdtime/SKILL.md +75 -12
  13. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/skills/crowdtime/references/commands.md +105 -13
  14. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/skills/crowdtime/references/pto.md +42 -4
  15. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +6 -4
  16. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/.gitignore +0 -0
  17. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/LICENSE +0 -0
  18. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/README.md +0 -0
  19. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/auth.py +0 -0
  20. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/client.py +0 -0
  21. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/__init__.py +0 -0
  22. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  23. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  24. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  25. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  26. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  27. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  28. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  29. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  30. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  31. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  32. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  33. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  34. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  35. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  36. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  37. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  38. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  39. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/config.py +0 -0
  40. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.1}/src/crowdtime_cli/version_check.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: crowdtime-cli
3
- Version: 0.15.0
3
+ Version: 0.17.1
4
4
  Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
5
5
  Project-URL: Homepage, https://crowdtime.lat
6
6
  Project-URL: Documentation, https://crowdtime.lat/docs
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "crowdtime-cli"
3
- version = "0.15.0"
3
+ version = "0.17.1"
4
4
  description = "AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest"
5
5
  readme = "README.md"
6
6
  license = {text = "Proprietary"}
@@ -1,3 +1,3 @@
1
1
  """CrowdTime CLI - AI-powered time tracking from the command line."""
2
2
 
3
- __version__ = "0.15.0"
3
+ __version__ = "0.17.1"
@@ -37,6 +37,7 @@ def parse(
37
37
  ct ai parse "2 hours on project alpha doing code review"
38
38
  ct ai parse "spent yesterday afternoon on bug fixes for client X"
39
39
  ct ai parse "30min standup" --force
40
+ ct ai parse "daily 15 min standup on argo all week" # creates 5 entries
40
41
  """
41
42
  client = CrowdTimeClient(require_auth=True, require_org=True)
42
43
 
@@ -54,8 +55,13 @@ def parse(
54
55
  console.print("[dim]Dry run - not saving.[/dim]")
55
56
  return
56
57
 
58
+ occurrence_count = len(result.occurrence_dates)
57
59
  if not force:
58
- if not typer.confirm("Create this entry?"):
60
+ prompt = (
61
+ f"Create {occurrence_count} entries?" if occurrence_count > 1
62
+ else "Create this entry?"
63
+ )
64
+ if not typer.confirm(prompt):
59
65
  console.print("[dim]Cancelled.[/dim]")
60
66
  return
61
67
 
@@ -65,6 +71,16 @@ def parse(
65
71
  **result.parsed_fields,
66
72
  })
67
73
 
74
+ # A recurring parse comes back as {count, entries}; a single date keeps
75
+ # the plain TimeEntry shape.
76
+ if "entries" in confirm_data:
77
+ entries = [TimeEntry(**e) for e in confirm_data["entries"]]
78
+ format_success(f"{confirm_data['count']} entries created from AI parse")
79
+ from ..formatters import format_entry_summary
80
+ for entry in entries:
81
+ format_entry_summary(entry)
82
+ return
83
+
68
84
  # Response may be a full TimeEntry or a minimal {detail, time_entry_id}
69
85
  if "id" in confirm_data and "project_name" in confirm_data:
70
86
  entry = TimeEntry(**confirm_data)
@@ -0,0 +1,136 @@
1
+ """Calendar commands: status, events, disconnect (CRO-74).
2
+
3
+ Connecting requires a browser for the Google consent screen, so that step
4
+ lives in the web app (Settings → Integrations). Once connected, the CLI can
5
+ read the day's meetings and turn one into a time entry.
6
+
7
+ Events are fetched live from Google on every call and are never stored by
8
+ CrowdTime — only the entry you create from one is.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from ..client import APIError, CrowdTimeClient
20
+ from ..formatters import format_error, format_success, print_json
21
+ from ..utils import format_date, parse_date
22
+
23
+ app = typer.Typer(help="Google Calendar integration.")
24
+ console = Console()
25
+
26
+
27
+ @app.command("status")
28
+ def status(
29
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
30
+ ) -> None:
31
+ """Show whether a calendar is connected.
32
+
33
+ Examples:
34
+ ct calendar status
35
+ """
36
+ client = CrowdTimeClient(require_auth=True, require_org=True)
37
+
38
+ try:
39
+ data = client.get("/calendar/")
40
+ except APIError as e:
41
+ format_error(e.message)
42
+ raise typer.Exit(1)
43
+
44
+ if output_json:
45
+ print_json(data)
46
+ return
47
+
48
+ if data.get("available") is False:
49
+ console.print("[dim]Google Calendar is not configured for this deployment.[/dim]")
50
+ return
51
+
52
+ if not data.get("connected"):
53
+ console.print("[dim]No calendar connected.[/dim]")
54
+ console.print(
55
+ "[dim]Connect one from the web app: Settings → Integrations.[/dim]"
56
+ )
57
+ return
58
+
59
+ account = data.get("account_email") or "(unknown account)"
60
+ console.print(f"Connected: [green]{account}[/green]")
61
+ if data.get("last_used_at"):
62
+ console.print(f" Last read: {data['last_used_at']}")
63
+ if data.get("last_error"):
64
+ console.print(f" [yellow]Last error:[/yellow] {data['last_error']}")
65
+
66
+
67
+ @app.command("events")
68
+ def events(
69
+ date: Optional[str] = typer.Option(None, "--date", "-d", help="Day to read (YYYY-MM-DD). Defaults to today."),
70
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
71
+ ) -> None:
72
+ """List your calendar events for a day.
73
+
74
+ Fetched live from Google. Use the hours and title to create an entry:
75
+
76
+ ct calendar events
77
+ ct log 0.25 --project argo --task BE --notes "Daily standup"
78
+ """
79
+ client = CrowdTimeClient(require_auth=True, require_org=True)
80
+
81
+ params = {}
82
+ if date:
83
+ params["date"] = format_date(parse_date(date))
84
+
85
+ try:
86
+ data = client.get("/calendar/events/", params=params)
87
+ except APIError as e:
88
+ format_error(e.message)
89
+ raise typer.Exit(1)
90
+
91
+ if output_json:
92
+ print_json(data)
93
+ return
94
+
95
+ items = data.get("events", [])
96
+ if not items:
97
+ console.print(f"[dim]No events on {data.get('date', 'that day')}.[/dim]")
98
+ return
99
+
100
+ table = Table(title=f"Calendar — {data.get('date', '')}")
101
+ table.add_column("Title")
102
+ table.add_column("Hours", justify="right")
103
+ table.add_column("Start", style="dim")
104
+ for event in items:
105
+ title = event.get("title", "")
106
+ if event.get("declined"):
107
+ title = f"[dim]{title} (declined)[/dim]"
108
+ table.add_row(
109
+ title,
110
+ "—" if event.get("all_day") else f"{event.get('hours', 0)}",
111
+ "all day" if event.get("all_day") else (event.get("start", "") or "")[11:16],
112
+ )
113
+ console.print(table)
114
+
115
+
116
+ @app.command("disconnect")
117
+ def disconnect(
118
+ force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
119
+ ) -> None:
120
+ """Disconnect your calendar and revoke CrowdTime's access.
121
+
122
+ Examples:
123
+ ct calendar disconnect
124
+ """
125
+ client = CrowdTimeClient(require_auth=True, require_org=True)
126
+
127
+ if not force and not typer.confirm("Disconnect your Google Calendar?"):
128
+ console.print("[dim]Cancelled.[/dim]")
129
+ return
130
+
131
+ try:
132
+ client.delete("/calendar/")
133
+ format_success("Calendar disconnected.")
134
+ except APIError as e:
135
+ format_error(e.message)
136
+ raise typer.Exit(1)
@@ -43,7 +43,8 @@ def create(
43
43
  task: Optional[str] = typer.Option(None, "--task", "-t", help="Task name or ID."),
44
44
  date: Optional[str] = typer.Option(None, "--date", "-d", help="Date in YYYY-MM-DD (default: today)."),
45
45
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
46
- help="Mark as billable/non-billable."),
46
+ help="Override billable status. Account managers only — "
47
+ "otherwise the project/task setting decides."),
47
48
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
48
49
  ) -> None:
49
50
  """Log a completed time entry.
@@ -137,7 +138,8 @@ def edit(
137
138
  task: Optional[str] = typer.Option(None, "--task", "-t", help="New task."),
138
139
  date: Optional[str] = typer.Option(None, "--date", "-d", help="New date (YYYY-MM-DD)."),
139
140
  billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", "-b/-B",
140
- help="Mark as billable/non-billable."),
141
+ help="Override billable status. Account managers only — "
142
+ "otherwise the project/task setting decides."),
141
143
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
142
144
  ) -> None:
143
145
  """Edit an existing time entry."""
@@ -755,6 +755,52 @@ def update_member(
755
755
  raise typer.Exit(1)
756
756
 
757
757
 
758
+ @app.command("assignable-users")
759
+ def assignable_users(
760
+ project_id: str = typer.Argument(..., help="Project ID."),
761
+ output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
762
+ ) -> None:
763
+ """List org members who can still be added to a project.
764
+
765
+ Everyone active in the organization who is not already on the project.
766
+ Use this to find user IDs for `ct projects add-members`.
767
+
768
+ Examples:
769
+ ct projects assignable-users <project-id>
770
+ """
771
+ client = CrowdTimeClient(require_auth=True, require_org=True)
772
+
773
+ try:
774
+ project_uuid = resolve_project(client, project_id)
775
+ data = client.get(f"/projects/{project_uuid}/assignable-users/")
776
+ users = extract_results(data) or data
777
+
778
+ if output_json:
779
+ print_json(users)
780
+ return
781
+
782
+ if not users:
783
+ console.print("[dim]Everyone in the organization is already on this project.[/dim]")
784
+ return
785
+
786
+ table = Table(title="Assignable users")
787
+ table.add_column("ID", style="dim")
788
+ table.add_column("Name")
789
+ table.add_column("Email")
790
+ table.add_column("Org role")
791
+ for u in users:
792
+ table.add_row(
793
+ u.get("id", ""),
794
+ u.get("full_name", ""),
795
+ u.get("email", ""),
796
+ u.get("org_role", ""),
797
+ )
798
+ console.print(table)
799
+ except APIError as e:
800
+ format_error(e.message)
801
+ raise typer.Exit(1)
802
+
803
+
758
804
  @app.command("add-members")
759
805
  def add_members_bulk(
760
806
  project_id: str = typer.Argument(..., help="Project ID."),
@@ -5,13 +5,13 @@ Mirrors the PTO API at `/organizations/<slug>/time-off/` — see
5
5
 
6
6
  Subcommands:
7
7
  request – ask for time off (status=pending)
8
- add – manager-on-behalf creation (auto-approved by backend)
8
+ add – on-behalf creation by project manager+ (auto-approved)
9
9
  list – list / filter entries (own or team)
10
10
  show – detail panel
11
11
  edit – patch fields
12
- cancel – self/manager+ cancel
13
- approve – manager+ approve
14
- reject – manager+ reject (notes required)
12
+ cancel – self/project manager+ cancel
13
+ approve – project manager+ approve
14
+ reject – project manager+ reject (notes required)
15
15
  calendar – render-ready month grid
16
16
  summary – aggregate hours/days for a window
17
17
  """
@@ -215,7 +215,7 @@ def request_cmd(
215
215
  notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional free-text notes."),
216
216
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
217
217
  ) -> None:
218
- """Request time off for yourself (status=pending until a manager approves)."""
218
+ """Request time off for yourself (status=pending until an approver decides)."""
219
219
  _validate_kind(kind)
220
220
  start, end = _parse_range_or_exit(dates)
221
221
  hpd = _hours_payload(hours_per_day)
@@ -258,9 +258,15 @@ def add_cmd(
258
258
  help="Decimal hours per day. Omit for full-day from membership.",
259
259
  ),
260
260
  notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional free-text notes."),
261
+ paid: Optional[bool] = typer.Option(
262
+ None, "--paid/--unpaid",
263
+ help="Whether these hours are paid. Omit to follow the organization's "
264
+ "policy for this kind. Account managers and above only — the API "
265
+ "rejects the request from anyone below, it is not ignored.",
266
+ ),
261
267
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
262
268
  ) -> None:
263
- """Manager-on-behalf PTO entry. Backend auto-approves when caller is manager+."""
269
+ """On-behalf PTO entry, auto-approved. Project manager+, within their scope."""
264
270
  _validate_kind(kind)
265
271
  start, end = _parse_range_or_exit(dates)
266
272
  hpd = _hours_payload(hours_per_day)
@@ -283,12 +289,18 @@ def add_cmd(
283
289
  payload["hours_per_day"] = str(hpd)
284
290
  if notes is not None:
285
291
  payload["notes"] = notes
292
+ # Declared since the flag was added but never sent, so --unpaid silently
293
+ # booked paid leave. The backend defaults every kind to paid, so the
294
+ # omission was not neutral.
295
+ if paid is not None:
296
+ payload["is_paid"] = paid
286
297
 
287
298
  try:
288
299
  data = client.post("/time-off/", data=payload)
289
300
  except APIError as e:
290
- # Surface 403 as a friendly error — backend rejects non-managers
291
- # who try to set `user` to anyone but themselves.
301
+ # Surface the rejection as a friendly error — the backend refuses
302
+ # `user` values outside the caller's visible set, refuses it entirely
303
+ # below project manager, and refuses `is_paid` below account manager.
292
304
  format_error(e.message)
293
305
  raise typer.Exit(1)
294
306
 
@@ -516,7 +528,7 @@ def cancel_cmd(
516
528
  force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation."),
517
529
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
518
530
  ) -> None:
519
- """Cancel a PTO entry (self if pending; manager+ for any state except canceled)."""
531
+ """Cancel a PTO entry (self if pending; project manager+ for any non-canceled state)."""
520
532
  if not force:
521
533
  if not typer.confirm(f"Cancel PTO entry {pto_id}?"):
522
534
  console.print("[dim]Cancelled.[/dim]")
@@ -544,7 +556,7 @@ def approve_cmd(
544
556
  notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional decision notes."),
545
557
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
546
558
  ) -> None:
547
- """Approve a pending PTO entry (manager+ only)."""
559
+ """Approve a pending PTO entry (project manager+, within their scope)."""
548
560
  payload: dict = {}
549
561
  if notes is not None:
550
562
  payload["notes"] = notes
@@ -571,7 +583,7 @@ def reject_cmd(
571
583
  notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Decision notes (REQUIRED)."),
572
584
  output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
573
585
  ) -> None:
574
- """Reject a pending PTO entry (manager+ only). --notes is required."""
586
+ """Reject a pending PTO entry (project manager+, within scope). --notes is required."""
575
587
  if not notes or not notes.strip():
576
588
  format_error("--notes is required when rejecting a PTO entry.")
577
589
  raise typer.Exit(1)
@@ -378,7 +378,15 @@ def format_parse_result(result: ParseResult) -> None:
378
378
  console.print(f" Task: {result.task_name}")
379
379
  if result.hours is not None:
380
380
  console.print(f" Duration: [yellow]{format_duration(result.hours)}[/yellow]")
381
- if result.date:
381
+ occurrences = result.occurrence_dates
382
+ if len(occurrences) > 1:
383
+ console.print(
384
+ f" Dates: [yellow]{len(occurrences)} entries[/yellow] "
385
+ f"({occurrences[0]} to {occurrences[-1]})"
386
+ )
387
+ for d in occurrences:
388
+ console.print(f" - {d:%a %d %b}")
389
+ elif result.date:
382
390
  console.print(f" Date: {result.date}")
383
391
  console.print(f" Billable: {'Yes' if result.is_billable else 'No'}")
384
392
 
@@ -23,6 +23,7 @@ from .commands import (
23
23
  ai_cmd,
24
24
  auth_cmd,
25
25
  billing_cmd,
26
+ calendar_cmd,
26
27
  clients_cmd,
27
28
  config_cmd,
28
29
  expense_cmd,
@@ -74,6 +75,7 @@ app.add_typer(expense_cmd.app, name="expense")
74
75
  app.add_typer(insights_cmd.app, name="insights")
75
76
  app.add_typer(team_cmd.app, name="team")
76
77
  app.add_typer(pto_cmd.app, name="pto", help="Manage PTO (paid time off) requests")
78
+ app.add_typer(calendar_cmd.app, name="calendar", help="Google Calendar integration")
77
79
  app.add_typer(version_cmd.app, name="version")
78
80
 
79
81
 
@@ -158,18 +160,30 @@ def _show_status(output_json: bool = False) -> None:
158
160
  params={"status": "pending", "page_size": 50},
159
161
  )
160
162
  pending_items = extract_results(pending) if pending is not None else []
161
- # If the caller is a plain member, the backend already scoped to
162
- # `own`. If they're manager+, they get team-wide pending which is
163
- # exactly what we want for the "requests pending review" surface.
164
- pto_pending_count = len(pending_items)
165
- # If we know the caller's id and *every* pending row belongs to
166
- # them, label as "yours pending"; otherwise as "to review".
167
- if pto_pending_count and self_user_id:
168
- user_ids = {row.get("user") for row in pending_items}
169
- if user_ids == {self_user_id}:
170
- pto_pending_label = "of your PTO requests pending"
171
- else:
172
- pto_pending_label = "PTO requests pending review"
163
+ own_pending = [
164
+ row for row in pending_items if row.get("user") == self_user_id
165
+ ] if self_user_id else []
166
+
167
+ # Approvers get the server's routed count — it excludes requests that
168
+ # were routed to someone else, which listing and filtering here cannot
169
+ # know. Falls back to the local count when the caller isn't an approver
170
+ # (the endpoint is project manager+ and 403s for everyone below).
171
+ routed_count = None
172
+ try:
173
+ routed = client.get("/time-off/pending-count/")
174
+ if isinstance(routed, dict):
175
+ routed_count = routed.get("count")
176
+ except APIError:
177
+ routed_count = None
178
+
179
+ if routed_count:
180
+ pto_pending_count = routed_count
181
+ pto_pending_label = "PTO requests pending review"
182
+ elif own_pending:
183
+ pto_pending_count = len(own_pending)
184
+ pto_pending_label = "of your PTO requests pending"
185
+ else:
186
+ pto_pending_count = 0
173
187
  except APIError:
174
188
  pto_pending_count = None
175
189
  except Exception:
@@ -136,11 +136,22 @@ class ParseResult(BaseModel):
136
136
  task_id: str | None = None
137
137
  task_name: str | None = None
138
138
  date: Optional[dt.date] = None
139
+ # Every date the description covers. A one-off entry holds a single date
140
+ # equal to ``date``; recurring input ("daily standup all week") expands to
141
+ # one date per occurrence.
142
+ dates: list[dt.date] = Field(default_factory=list)
139
143
  is_billable: bool = True
140
144
  confidence: float = 0.0
141
145
  ambiguities: list[str] = Field(default_factory=list)
142
146
  parse_log_id: str | None = None
143
147
 
148
+ @property
149
+ def occurrence_dates(self) -> list[dt.date]:
150
+ """The dates to create entries for, never empty when a date parsed."""
151
+ if self.dates:
152
+ return self.dates
153
+ return [self.date] if self.date else []
154
+
144
155
  @property
145
156
  def parsed_fields(self) -> dict[str, Any]:
146
157
  """Return the parsed entry fields as a dict (for confirm endpoint)."""
@@ -160,6 +171,8 @@ class ParseResult(BaseModel):
160
171
  fields["task_name"] = self.task_name
161
172
  if self.date:
162
173
  fields["date"] = str(self.date)
174
+ if self.occurrence_dates:
175
+ fields["dates"] = [str(d) for d in self.occurrence_dates]
163
176
  fields["is_billable"] = self.is_billable
164
177
  return fields
165
178
 
@@ -269,6 +282,11 @@ class TimeOffEntryListItem(BaseModel):
269
282
  days_count: int = 0
270
283
  kind: str = "vacation"
271
284
  kind_display: str = ""
285
+ # Explicit override; None follows the org policy for the kind.
286
+ is_paid: bool | None = None
287
+ # Effective paid/unpaid after resolution. Unpaid hours explain the absence
288
+ # but do not count towards the hours the person is paid for.
289
+ resolved_is_paid: bool = True
272
290
  status: str = "pending"
273
291
  status_display: str = ""
274
292
  resolved_hours_per_day: Decimal | None = None
@@ -294,6 +312,8 @@ class TimeOffEntry(BaseModel):
294
312
  total_hours: Decimal | None = None
295
313
  kind: str = "vacation"
296
314
  kind_display: str = ""
315
+ is_paid: bool | None = None
316
+ resolved_is_paid: bool = True
297
317
  status: str = "pending"
298
318
  status_display: str = ""
299
319
  notes: str = ""
@@ -34,6 +34,19 @@ ct s # Short alias
34
34
  ct s --json # Machine-readable output
35
35
  ```
36
36
 
37
+ ### Recurring Entries
38
+
39
+ `ct ai parse` expands a repeating description into one entry per day, so a
40
+ whole week goes in with a single command:
41
+
42
+ ```bash
43
+ ct ai parse "daily 15 min standup on argo all week" # → 5 entries, one per weekday
44
+ ct ai parse "1h code review on acme monday and wednesday"
45
+ ```
46
+
47
+ It shows every date before creating anything. Prefer this over looping `ct log`
48
+ when the user describes repeating work; use `ct log` for one-off entries.
49
+
37
50
  ### Timers
38
51
  ```bash
39
52
  ct timer start "description" -p project-slug -t "task name" # Start a timer
@@ -52,6 +65,20 @@ ct t # timer status
52
65
  ct c # timer continue
53
66
  ```
54
67
 
68
+ ### Billable Status
69
+
70
+ Billability comes from the **project and task configuration**, not from whoever
71
+ logs the time:
72
+
73
+ 1. A non-billable project always wins — nothing on it is ever billable.
74
+ 2. Otherwise the task's billable setting decides, when a task is set.
75
+ 3. Otherwise the project's setting.
76
+
77
+ `--billable/--no-billable` is an override that only account managers and above
78
+ can use, and never on a non-billable project. For everyone else the flag is
79
+ ignored, so do not pass it and do not tell the user an entry is billable
80
+ because they asked for it — check the project/task setup instead.
81
+
55
82
  ### Logging Time
56
83
 
57
84
  **Options MUST come BEFORE positional arguments (duration, description).**
@@ -72,7 +99,7 @@ Other log commands:
72
99
  ct log list --week # This week's entries
73
100
  ct log list --task "Design" --billable # Filter by task + billable
74
101
  ct log list --user <user-id> # See team member's entries (manager+)
75
- ct log list --format csv # CSV with financial columns
102
+ ct log list --format csv # CSV (rate/amount columns are empty below manager)
76
103
  ct log edit <entry-id> --duration 3h # Edit an entry
77
104
  ct log edit <entry-id> --task "Research" # Change task
78
105
  ct log delete <entry-id> # Delete an entry
@@ -137,10 +164,21 @@ ct p -a # include archived
137
164
 
138
165
  #### Project Members
139
166
  ```bash
140
- ct projects update-member <project-id> <user-id> --role project_manager --rate 150 # Set per-project role & rate
141
- ct projects add-members <project-id> --user <uid1> --user <uid2> --role member --rate 120 # Bulk add
167
+ ct projects assignable-users <project-id> # Who can still be added (gives you the user IDs)
168
+ ct projects update-member <project-id> <user-id> --role project_manager --rate 150 # Set per-project role & rate (--rate: manager+)
169
+ ct projects add-members <project-id> --user <uid1> --user <uid2> --role member --rate 120 # Bulk add (--rate: manager+)
142
170
  ```
143
171
 
172
+ **Adding someone to a project:** run `assignable-users` first to get the user
173
+ IDs — it lists every active org member not already on the project. Do not use
174
+ `ct team list` for this; it is scoped to who you can review time for, which is
175
+ a different and narrower set.
176
+
177
+ **Per-project roles:** setting `--role project_manager` on a project member
178
+ grants them management of *that project only* (adding members, editing it,
179
+ managing its tasks), whatever their organization role is. That is the way to
180
+ let a member run one project without making them an org-wide project manager.
181
+
144
182
  #### Bulk Task Assignment
145
183
  ```bash
146
184
  ct projects add-tasks <project-id> --task <tid1> --task <tid2> # Bulk assign tasks to project
@@ -148,6 +186,8 @@ ct projects add-tasks <project-id> --task <tid1> --task <tid2> # Bulk assign ta
148
186
 
149
187
  **Note:** `--client` on `projects create` accepts a client name. If the client doesn't exist yet, it will be auto-created.
150
188
 
189
+ **Rates and money are manager+ only.** Hourly rates, budgets, fixed fees, billing types/modes, billable amounts, invoices and retainers are visible and editable only by account managers, admins and owners. For viewers, members and project managers the API returns these fields as `null`, silently ignores them on writes, and returns 403 on invoice/retainer endpoints. Don't offer rate or money operations to those roles, and never try to infer rates from other data.
190
+
151
191
  **Billing types** (*what* is charged): `time_and_materials` (default) or `fixed_fee` (an agreed price via `--fixed-fee`, billed instead of logged hours).
152
192
 
153
193
  **Billing modes** (*how* an hourly rate resolves, T&M only): `custom` (default), `per_task` (rate per task), `per_person` (rate per person).
@@ -343,22 +383,44 @@ ct billing events -n 50 # Show last 50 events
343
383
  ct b # billing status
344
384
  ```
345
385
 
386
+ ### Calendar (Google)
387
+
388
+ ```bash
389
+ ct calendar status # Is a calendar connected?
390
+ ct calendar events # Today's meetings, fetched live from Google
391
+ ct calendar events --date 2026-06-01 # A specific day
392
+ ct calendar disconnect # Revoke access
393
+ ```
394
+
395
+ Connecting requires the Google consent screen, so it happens in the web app
396
+ (Settings → Integrations); the CLI can only read and disconnect. Events are
397
+ fetched live on every call and are never stored by CrowdTime.
398
+
399
+ **Turning a meeting into an entry:** read the events, then log the time
400
+ explicitly — there is no automatic conversion:
401
+
402
+ ```bash
403
+ ct calendar events # e.g. "Daily standup", 0.25h
404
+ ct log 0.25 --project argo --task BE --notes "Daily standup"
405
+ ```
406
+
346
407
  ### Time Off (PTO)
347
408
 
348
- Per-user time-off entries with a kind (vacation, sick, holiday, personal, other) and a status lifecycle (pending → approved/rejected; pending → canceled). Members submit requests that await manager approval; managers can also add PTO directly on behalf of someone (auto-approved).
409
+ Per-user time-off entries with a kind (vacation, sick, holiday, personal, other) and a status lifecycle (pending → approved/rejected; pending → canceled). Members submit requests that await approval, and their approvers are emailed automatically; project managers and above can also add PTO directly on behalf of someone in their scope (auto-approved).
349
410
 
350
411
  > **IMPORTANT**: Never use magic routing (`ct "..."`) or `ct ai parse` for PTO operations. Always construct explicit `ct pto ...` commands.
351
412
  > Full reference: `references/pto.md` and `references/commands.md` § PTO.
352
413
 
353
414
  ```bash
354
415
  ct pto request <dates> # Submit your own PTO request (status=pending)
355
- ct pto add <user> <dates> # Manager adds PTO on behalf of user (auto-approved)
416
+ ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved (project manager+, in scope)
417
+ ct pto add <user> <dates> --unpaid # ... marked unpaid (account manager+; 400 for anyone below, not ignored)
356
418
  ct pto list [--mine|--team] # List PTO entries with optional filters
357
419
  ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
358
420
  ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day (pending entries)
359
- ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or manager+ any)
360
- ct pto approve <id> [--notes "..."] # Approve a pending entry (manager+)
361
- ct pto reject <id> --notes "..." # Reject a pending entry — notes required (manager+)
421
+ ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+ any)
422
+ ct pto approve <id> [--notes "..."] # Approve a pending entry (project manager+)
423
+ ct pto reject <id> --notes "..." # Reject a pending entry — notes required (project manager+)
362
424
  ct pto calendar [--from --to --user --status] # PTO slice for a date window (approved only unless --status)
363
425
  ct pto summary [--from --to --user] # Aggregate approved PTO hours/days, by kind and by user
364
426
  ```
@@ -481,8 +543,8 @@ Read `references/workflows.md` for detailed multi-step workflow patterns includi
481
543
  - Checking project budgets
482
544
  - Retainer management and withdrawals
483
545
  - **Requesting your own PTO** (member submits, awaits approval)
484
- - **Reviewing and approving team PTO** (manager+ reviews pending requests)
485
- - **Adding PTO on behalf of someone** (manager logs pre-agreed leave, auto-approved)
546
+ - **Reviewing and approving team PTO** (project manager+ reviews pending requests)
547
+ - **Adding PTO on behalf of someone** (approver logs pre-agreed leave, auto-approved)
486
548
  - **Reconciling missing hours with PTO** (diagnose low-hour weeks: check PTO coverage, then anomaly report)
487
549
 
488
550
  ## Complete Command Reference
@@ -507,5 +569,6 @@ Read `references/commands.md` for the full reference of every command, subcomman
507
569
  14. **Expenses** — when the user mentions expenses, receipts, or purchases, use the `ct expense` commands. Always suggest a category and ask if the expense is billable. If recording a billable expense for a client project, always set `--project` and `--billable` so it gets included automatically when the client is invoiced
508
570
  15. **Invoices include expenses** — when creating invoices with `ct invoice create --period`, billable expenses from the same period are automatically included as line items. Mention this to users so they know their expenses will appear on the invoice. Invoices can be grouped by `project`, `task`, `user`, `date`, or `none` via `--group-by`. Line items support discounts (`--discount-type fixed|percent|hours --discount-value N`)
509
571
  16. **Client contacts** — use `ct clients contacts` and `ct clients add-contact` to manage multiple contacts per client. Set `--primary` for the main point of contact. Use `--receives-invoices` to flag contacts who should receive invoice emails
510
- 17. **Payroll** — payroll data is strictly confidential. Only HR managers, admins, and owners can access it. When the user asks about payroll, always use the `ct payroll` commands. Compensation types are `hourly` (rate × hours) or `salary` (fixed monthly). Rate changes are always effective from the 1st of a month. The payroll workflow is: configure compensation → run liquidation → approve → mark paid
511
- 18. **PTO / Time Off** — always construct explicit `ct pto ...` commands. Never use magic routing or `ct ai parse` for PTO. Before requesting PTO, always confirm the exact date range with the user — date ambiguity is the most common source of mistakes. Use `YYYY-MM-DD` for a single day or `YYYY-MM-DD:YYYY-MM-DD` for a range. `ct pto request` creates a pending entry (requires manager approval); `ct pto add <user>` is manager-only and creates an auto-approved entry. When reconciling missing hours for a user, always check `ct pto summary --user <email> --from X --to Y` before assuming the hours are missing. See `references/pto.md` for the full status lifecycle, kind definitions, and LLM guidance.
572
+ 17. **Rate confidentiality** — rates, budgets, amounts, invoices and retainers are manager+ only (see Projects). If a member, viewer or project manager asks about rates or billed amounts, tell them it's restricted to account managers instead of trying workarounds
573
+ 18. **Payroll** — payroll data is strictly confidential. Only HR managers, admins, and owners can access it. When the user asks about payroll, always use the `ct payroll` commands. Compensation types are `hourly` (rate × hours) or `salary` (fixed monthly). Rate changes are always effective from the 1st of a month. The payroll workflow is: configure compensation → run liquidation → approve → mark paid
574
+ 19. **PTO / Time Off** — always construct explicit `ct pto ...` commands. Never use magic routing or `ct ai parse` for PTO. Before requesting PTO, always confirm the exact date range with the user — date ambiguity is the most common source of mistakes. Use `YYYY-MM-DD` for a single day or `YYYY-MM-DD:YYYY-MM-DD` for a range. `ct pto request` creates a pending entry (the requester's approvers are notified by email); `ct pto add <user>` requires project manager+ with the subject in their scope, and creates an auto-approved entry. When reconciling missing hours for a user, always check `ct pto summary --user <email> --from X --to Y` before assuming the hours are missing. See `references/pto.md` for the full status lifecycle, kind definitions, and LLM guidance.
@@ -383,7 +383,7 @@ ct log list [options]
383
383
  | `--format` | string | Output: `table`, `json`, `csv` |
384
384
  | `--json` | flag | JSON output |
385
385
 
386
- CSV output includes financial columns: `hourly_rate`, `billable_amount`.
386
+ CSV output includes financial columns: `hourly_rate`, `billable_amount`. They are empty for viewers, members and project managers (rates and amounts are manager+ only).
387
387
 
388
388
  Endpoint: `GET /time/`
389
389
 
@@ -969,6 +969,8 @@ ct projects create NAME [options]
969
969
  >
970
970
  > When the fee is billed, that project's hours in the period are linked to the fee line and marked invoiced, so they stop showing as outstanding work without ever being charged twice. Until then they stay uninvoiced and available.
971
971
 
972
+ > **Confidentiality:** rates, budgets, fixed fees, billing settings, billable amounts, invoices and retainers are manager+ only. For viewers, members and project managers these fields come back `null`, are ignored on writes, and invoice/retainer endpoints return 403.
973
+
972
974
  **Billing modes** — *how* an hourly rate is resolved (T&M only):
973
975
  | Mode | Description |
974
976
  |------|-------------|
@@ -1064,7 +1066,7 @@ ct projects members PROJECT_ID [--json]
1064
1066
  | `PROJECT_ID` | string (required) | Project slug or UUID |
1065
1067
  | `--json` | flag | Output as JSON |
1066
1068
 
1067
- Lists all members assigned to a project with their project role, org role, hourly rate, and user ID.
1069
+ Lists all members assigned to a project with their project role, org role, hourly rate, and user ID. Rates are `null` below manager.
1068
1070
 
1069
1071
  **Usage examples:**
1070
1072
  ```bash
@@ -1106,7 +1108,7 @@ ct projects update-member PROJECT_ID USER_ID [options]
1106
1108
  | `PROJECT_ID` | string (required) | Project slug or UUID |
1107
1109
  | `USER_ID` | string (required) | User UUID or email |
1108
1110
  | `--role` | string | Project-level role override (e.g. `project_manager`, `member`, `viewer`) |
1109
- | `--rate` | float | Hourly rate override for this project member |
1111
+ | `--rate` | float | Hourly rate override for this project member (manager+; ignored for other roles) |
1110
1112
  | `--json` | flag | JSON output |
1111
1113
 
1112
1114
  Updates a member's project-specific role and/or rate. The `--role` sets a `project_role` that overrides the member's org-level role for this project only. Response includes `effective_role`, `org_role`, and `org_hourly_rate` for context.
@@ -1119,6 +1121,20 @@ ct projects update-member <project-id> <user-id> --rate 120
1119
1121
 
1120
1122
  Endpoint: `PATCH /projects/{id}/members/{user_id}/`
1121
1123
 
1124
+ ### ct projects assignable-users
1125
+
1126
+ ```
1127
+ ct projects assignable-users PROJECT_ID [--json]
1128
+ ```
1129
+
1130
+ | Option | Type | Description |
1131
+ |--------|------|-------------|
1132
+ | `--json` | flag | JSON output |
1133
+
1134
+ Lists every **active organization member not already on the project**, with their user IDs and org roles. This is the correct source for the user IDs that `ct projects add-members` needs.
1135
+
1136
+ It is deliberately not filtered by review visibility — `ct team list` and the timesheet endpoints answer "whose time can I review", which is a narrower set and the wrong question when staffing a project. Requires permission to manage the project (org project_manager+ assigned to it, an account manager or above, or a per-project `project_role` of `project_manager`). Endpoint: `GET /projects/<id>/assignable-users/`
1137
+
1122
1138
  ### ct projects add-members
1123
1139
 
1124
1140
  ```
@@ -1130,7 +1146,7 @@ ct projects add-members PROJECT_ID --user USER_ID [--user USER_ID ...] [--role R
1130
1146
  | `PROJECT_ID` | string (required) | Project slug or UUID |
1131
1147
  | `--user` | string (repeatable, required) | User UUID or email (specify multiple times) |
1132
1148
  | `--role` | string | Project role for all added members |
1133
- | `--rate` | float | Hourly rate for all added members |
1149
+ | `--rate` | float | Hourly rate for all added members (manager+; ignored for other roles) |
1134
1150
  | `--json` | flag | JSON output |
1135
1151
 
1136
1152
  Bulk-adds multiple members to a project in a single operation.
@@ -1373,8 +1389,21 @@ ct ai parse TEXT [--dry-run] [--force/-f] [--json]
1373
1389
  | `--force`, `-f` | flag | Skip confirmation |
1374
1390
  | `--json` | flag | JSON output |
1375
1391
 
1376
- Shows parsed: description, project, task, duration, date, billable, confidence, ambiguities.
1377
- Endpoints: `POST /ai/parse/` then `POST /ai/parse/confirm/`
1392
+ Shows parsed: description, project, task, duration, date(s), billable, confidence, ambiguities.
1393
+
1394
+ **Recurring input creates several entries.** When the text describes work repeating over
1395
+ several days ("daily standup all week", "1h every day this week", "2h monday and
1396
+ wednesday"), the parse expands it into explicit dates and lists them all; confirming
1397
+ creates one entry per date in a single transaction. A one-off description still produces
1398
+ exactly one entry.
1399
+
1400
+ ```
1401
+ ct ai parse "daily 15 min standup on argo all week" # → 5 entries, one per weekday
1402
+ ```
1403
+
1404
+ The confirm response is a plain time entry for a single date, and `{count, entries}` when
1405
+ several were created. Endpoints: `POST /ai/parse/` then `POST /ai/parse/confirm/`
1406
+ (the latter accepts `dates` to create a subset of what was parsed).
1378
1407
 
1379
1408
  ### ct ai suggest
1380
1409
 
@@ -1699,7 +1728,9 @@ ct timesheet submit --from DATE --to DATE [--force/-f] [--json]
1699
1728
  | `--force`, `-f` | flag | Skip confirmation prompt |
1700
1729
  | `--json` | flag | JSON output |
1701
1730
 
1702
- Submits a timesheet for the given period (any date range — not limited to full weeks). Supports partial-week submissions for end-of-month splits. Shows a preview of entries and hours before confirming (use `--force` to skip). Rejects zero-hour submissions and periods that overlap with already submitted/approved timesheets. Endpoint: `POST /timesheets/submit/`
1731
+ Submits a timesheet for the given period (any date range — not limited to full weeks). Supports partial-week submissions for end-of-month splits. Shows a preview of entries and hours before confirming (use `--force` to skip). Rejects periods that overlap with already submitted/approved timesheets.
1732
+
1733
+ A zero-hour period is rejected **unless every working day in it is covered by approved time off** — a full vacation week has no time entries by design and is submitted as-is. If someone was away and the submit is rejected, the fix is an approved PTO entry (`ct pto request`), not a fake time entry. Endpoint: `POST /timesheets/submit/`
1703
1734
 
1704
1735
  ### ct timesheet approve
1705
1736
 
@@ -2703,9 +2734,9 @@ ct pto request 2026-08-04:2026-08-08 --kind vacation --json
2703
2734
  ct pto add <user> <dates> [--kind/-k KIND] [--hours-per-day/-h HOURS] [--notes/-n TEXT] [--json]
2704
2735
  ```
2705
2736
 
2706
- Add a PTO entry on behalf of another user (manager, admin, or owner only). The entry is created with `status=approved` immediately — no pending state. Use when an employee notified you via Slack or email and you are logging it for them.
2737
+ Add a PTO entry on behalf of another user (project manager and above). The entry is created with `status=approved` immediately — no pending state. Use when an employee notified you via Slack or email and you are logging it for them.
2707
2738
 
2708
- **Who can call it:** `manager`, `admin`, `owner` (within their visibility scope — see `references/pto.md`).
2739
+ **Who can call it:** `project_manager`, `manager`, `admin`, `owner`. Project managers and account managers may only name a user inside their visibility scope — a subject outside it returns `400` on the `user` field, not `403`. Admins and owners may name any active member. See `references/pto.md`.
2709
2740
 
2710
2741
  | Argument/Option | Type | Required | Description |
2711
2742
  |-----------------|------|----------|-------------|
@@ -2714,6 +2745,7 @@ Add a PTO entry on behalf of another user (manager, admin, or owner only). The e
2714
2745
  | `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
2715
2746
  | `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default |
2716
2747
  | `--notes`, `-n` | string | no | Reason or context notes |
2748
+ | `--paid` / `--unpaid` | flag | no | Whether the hours are paid. **Account manager+ only** — sending it as a project manager returns `400`, it is NOT ignored. Omit to follow the org policy for the kind |
2717
2749
  | `--json` | flag | no | JSON output |
2718
2750
 
2719
2751
  **Response (201):** full entry serializer with `status=approved`, `decided_by=<your user>`, `decided_at=<now>`.
@@ -2721,7 +2753,11 @@ Add a PTO entry on behalf of another user (manager, admin, or owner only). The e
2721
2753
  **Errors:**
2722
2754
  - `400` — date overlap, invalid dates, or hours_per_day > 24.
2723
2755
  - `400` — user is not an active member of this organization.
2724
- - `403` — caller lacks manager+ role, or user is outside caller's visibility scope.
2756
+ - `400` on the `user` field — caller is below `project_manager`, or the named
2757
+ user is outside the caller's visibility scope. This is a `400`, not a `403`:
2758
+ the check lives in the serializer, so treat it as a validation error.
2759
+ - `400` on the `is_paid` field — caller is below account manager and sent
2760
+ `--paid`/`--unpaid`.
2725
2761
 
2726
2762
  **Usage examples:**
2727
2763
 
@@ -2743,6 +2779,22 @@ ct pto add jane@example.com 2026-06-09:2026-06-13 --kind vacation --json
2743
2779
 
2744
2780
  ---
2745
2781
 
2782
+ ### PTO pending count (no dedicated command)
2783
+
2784
+ `ct` with no arguments surfaces a "PTO requests pending review" line driven by
2785
+ `GET /organizations/<slug>/time-off/pending-count/` (project manager+). There is
2786
+ no `ct pto pending-count` subcommand — use `ct pto list --team --status pending`
2787
+ when you need the entries themselves.
2788
+
2789
+ The endpoint returns `{"count": N}` for the requests **routed to the caller**,
2790
+ excluding their own. For admins and owners that is not every pending request in
2791
+ the org — requests with a named approver (a direct manager, or a PM on the
2792
+ person's project) are excluded, because those were emailed to that person
2793
+ instead. Never recompute this by listing and filtering; you cannot see the
2794
+ routing from the list payload.
2795
+
2796
+ ---
2797
+
2746
2798
  ### ct pto list
2747
2799
 
2748
2800
  ```
@@ -2800,7 +2852,7 @@ ct pto show <id> [--json]
2800
2852
 
2801
2853
  Show full details of a single PTO entry.
2802
2854
 
2803
- **Who can call it:** own entries (any role); manager+ for entries within their visibility scope; admin/owner for any entry.
2855
+ **Who can call it:** own entries (any role); project manager+ for entries within their visibility scope; admin/owner for any entry.
2804
2856
 
2805
2857
  | Argument | Type | Required | Description |
2806
2858
  |----------|------|----------|-------------|
@@ -2987,7 +3039,7 @@ ct pto calendar --from DATE --to DATE [--user/-u USER] [--status/-s STATUSES] [-
2987
3039
 
2988
3040
  Render-ready calendar slice of PTO entries overlapping the specified date window, scoped to visible users. **Returns approved entries only unless `--status` is passed.**
2989
3041
 
2990
- **Who can call it:** any active member (sees own entries); manager+ sees entries for their visibility scope; admin/owner sees all.
3042
+ **Who can call it:** any active member (sees own entries); project manager+ sees entries for their visibility scope; admin/owner sees all.
2991
3043
 
2992
3044
  | Option | Type | Required | Description |
2993
3045
  |--------|------|----------|-------------|
@@ -3036,7 +3088,7 @@ ct pto summary --from DATE --to DATE [--user/-u USER] [--json]
3036
3088
 
3037
3089
  Aggregate **approved** PTO hours and days within the specified window, clipping partial overlaps to the window boundaries. Shows totals, breakdown by kind, and breakdown by user.
3038
3090
 
3039
- **Who can call it:** any active member (sees own data); manager+ sees summaries for their visibility scope; admin/owner sees all.
3091
+ **Who can call it:** any active member (sees own data); project manager+ sees summaries for their visibility scope; admin/owner sees all.
3040
3092
 
3041
3093
  | Option | Type | Required | Description |
3042
3094
  |--------|------|----------|-------------|
@@ -3148,3 +3200,43 @@ Displays all available shortcut commands in a formatted table. Shows each alias
3148
3200
  | `ct f <id>` | `ct favorites start <id>` |
3149
3201
  | `ct ex` | `ct expense` |
3150
3202
  | `ct "text"` | `ct ai parse "text"` |
3203
+
3204
+
3205
+ ---
3206
+
3207
+ ## ct calendar
3208
+
3209
+ Google Calendar integration. Events are fetched live from Google on every call and are never stored by CrowdTime — only a time entry the user creates from one is.
3210
+
3211
+ Connecting a calendar needs the Google consent screen, so it happens in the web app (Settings → Integrations). The CLI can read and disconnect only.
3212
+
3213
+ The connect flow is `POST /calendar/authorize/` → Google → `POST /calendar/callback/`. The authorize step returns a single-use `state` bound to the caller; the callback requires that exact value back and rejects anything else, so an authorization code cannot be redeemed by or planted on a different user.
3214
+
3215
+ ### ct calendar status
3216
+
3217
+ ```
3218
+ ct calendar status [--json]
3219
+ ```
3220
+
3221
+ Reports whether a calendar is connected, which Google account, when it was last read, and the last error if the connection has broken. Endpoint: `GET /calendar/`
3222
+
3223
+ ### ct calendar events
3224
+
3225
+ ```
3226
+ ct calendar events [--date DATE] [--json]
3227
+ ```
3228
+
3229
+ | Option | Type | Description |
3230
+ |--------|------|-------------|
3231
+ | `--date`, `-d` | string | Day to read (YYYY-MM-DD). Defaults to today |
3232
+ | `--json` | flag | JSON output |
3233
+
3234
+ Lists that day's events with durations rounded to the nearest quarter hour. All-day events show `—` for hours; declined invitations are marked. There is **no automatic conversion** — read the events, then create entries explicitly with `ct log`. Endpoint: `GET /calendar/events/`
3235
+
3236
+ ### ct calendar disconnect
3237
+
3238
+ ```
3239
+ ct calendar disconnect [--force/-f]
3240
+ ```
3241
+
3242
+ Deletes the stored connection and asks Google to revoke the grant. Endpoint: `DELETE /calendar/`
@@ -31,6 +31,25 @@ If the user doesn't specify a kind, default to `vacation` but **confirm before s
31
31
 
32
32
  ---
33
33
 
34
+ ## Paid vs unpaid
35
+
36
+ Every entry resolves to paid or unpaid, exposed as `resolved_is_paid`:
37
+
38
+ 1. The entry's own `is_paid`, when set.
39
+ 2. Otherwise the organization's policy for that kind (`time_off_paid_kinds`).
40
+ 3. Otherwise paid.
41
+
42
+ **Paid** hours count towards the hours a person is paid for that month. **Unpaid**
43
+ hours still explain the absence — reports and anomaly checks treat the person as
44
+ away either way — but do not count towards what they are paid.
45
+
46
+ Only account managers and above can set `is_paid`, via `ct pto add --paid/--unpaid`. A project manager passing the flag gets a `400` on the `is_paid` field — the request is refused, not silently booked under the org default. Omit the flag and the organization's policy for the kind applies.
47
+ A member submitting their own request never chooses; it follows the org policy.
48
+ Do not tell a user their leave is paid because they asked for it — read
49
+ `resolved_is_paid` back from the entry.
50
+
51
+ ---
52
+
34
53
  ## Status lifecycle
35
54
 
36
55
  ```
@@ -80,7 +99,7 @@ The resolved value is surfaced as `resolved_hours_per_day` in API responses —
80
99
  | `admin` | All entries in the organization |
81
100
  | `owner` | All entries in the organization |
82
101
 
83
- Managers using `ct pto list --team` or `ct pto add <user>` can only target users in their visibility scope. Attempting to act on a user outside the scope returns `403`.
102
+ Project managers and account managers using `ct pto list --team` or `ct pto add <user>` can only target users in their visibility scope. On `ct pto add` an out-of-scope user returns a **`400`** on the `user` field (the check is in the serializer, so it is a validation error, not a permission error); the detail-route actions (`approve`/`reject`/`cancel`) return `403` or `404` instead, because scope there is enforced by the queryset.
84
103
 
85
104
  ---
86
105
 
@@ -89,7 +108,7 @@ Managers using `ct pto list --team` or `ct pto add <user>` can only target users
89
108
  | Operation | member/viewer | project_manager / manager | admin / owner |
90
109
  |-----------|--------------|--------------------------|---------------|
91
110
  | `ct pto request` (own) | Own, → pending | Own, → pending | Own, → pending |
92
- | `ct pto add <user>` (on-behalf) | No | Within scope → approved | Any user → approved |
111
+ | `ct pto add <user>` (on-behalf) | No | Within scope → approved (400 outside it) | Any user → approved |
93
112
  | `ct pto list / show` | Own only | Within scope | Any |
94
113
  | `ct pto edit` | Own + pending only | Within scope (any status) | Any |
95
114
  | `ct pto cancel` | Own + pending only | Within scope (any non-canceled) | Any |
@@ -99,6 +118,25 @@ Managers using `ct pto list --team` or `ct pto add <user>` can only target users
99
118
 
100
119
  ---
101
120
 
121
+ ## Approver notifications and the pending count
122
+
123
+ Creating a **pending** entry (`ct pto request`, or the web request form) emails
124
+ the requester's approvers automatically — one message each. Recipients are
125
+ resolved per person, not by role sweep: whoever manages them directly, or runs
126
+ a project they work on; org admins and owners only when nobody else does. An
127
+ on-behalf `ct pto add` lands approved and notifies nobody.
128
+
129
+ `GET /organizations/<slug>/time-off/pending-count/` (project manager+) returns
130
+ `{"count": N}` — the requests **routed to the caller**, excluding their own. It
131
+ is not "all pending in the org": for an admin or owner it counts only the
132
+ requests with no named approver, so it agrees with who was actually emailed.
133
+ `ct` (bare status) uses it for the "PTO requests pending review" line.
134
+
135
+ Do not compute this by listing and filtering — that cannot tell which requests
136
+ were routed elsewhere, and will over-count for admins and owners.
137
+
138
+ ---
139
+
102
140
  ## LLM guidelines for PTO
103
141
 
104
142
  1. **Never use magic routing or `ct ai parse` for PTO.** Always construct explicit `ct pto ...` commands. You are an LLM capable of building the correct command — no AI-parse intermediary is needed.
@@ -110,8 +148,8 @@ Managers using `ct pto list --team` or `ct pto add <user>` can only target users
110
148
  4. **Check for overlap before requesting.** Run `ct pto list --mine --json` first to confirm there are no conflicting pending or approved entries for the same user and date range. The API will reject overlaps, but catching them proactively avoids a confusing 400 error.
111
149
 
112
150
  5. **`ct pto request` vs `ct pto add`:**
113
- - `ct pto request` → creates `status=pending`, requires manager approval. Use for the member's own PTO.
114
- - `ct pto add <user>` → creates `status=approved` immediately. Use when the manager is logging pre-agreed PTO on behalf of someone else.
151
+ - `ct pto request` → creates `status=pending`, requires approval. Use for the member's own PTO. Their approvers are emailed automatically — do not tell the user to chase someone manually.
152
+ - `ct pto add <user>` → creates `status=approved` immediately, and notifies nobody. Use when an approver is logging pre-agreed PTO on behalf of someone else. Requires `project_manager+` **and** the subject must be inside the caller's scope; a `400` on the `user` field means they are out of scope, not that the role is wrong.
115
153
 
116
154
  6. **Rejection notes are required.** `ct pto reject` will fail with `400` if `--notes` is empty or omitted. Always ask the manager for a reason before running the command.
117
155
 
@@ -241,6 +241,8 @@ ct favorites start <id> -n "Sprint 42 planning"
241
241
 
242
242
  Set up a complete project with billing configuration, task presets, and team members in one go.
243
243
 
244
+ > Rates, budgets and billing settings are manager+ only. For project managers and below, `--rate`, `--budget`, `--fixed-fee` and `--billing-mode` are ignored by the API — omit them.
245
+
244
246
  ```bash
245
247
  # 1. Create the project with a billing mode
246
248
  ct projects create "Website Redesign" --client "Acme Corp" --budget 200 --code WR -b --billing-mode per_person
@@ -264,7 +266,7 @@ ct projects switch website-redesign
264
266
 
265
267
  ### Configure Per-Person Billing
266
268
 
267
- When using `per_person` billing mode, each team member can have their own rate on the project.
269
+ When using `per_person` billing mode, each team member can have their own rate on the project. Requires manager+.
268
270
 
269
271
  ```bash
270
272
  # Create project with per-person billing
@@ -406,7 +408,7 @@ ct org revoke-invite <invitation-id>
406
408
  # Change a member's role
407
409
  ct org update-member <user-id> --role project_manager
408
410
 
409
- # Update hourly rate
411
+ # Update hourly rate (admin+; rates are never shown to members, viewers or project managers)
410
412
  ct org update-member <user-id> --rate 150
411
413
 
412
414
  # Update weekly capacity
@@ -1472,11 +1474,11 @@ ct pto request 2026-07-15 --kind sick --notes "Cita médica"
1472
1474
  ct pto request 2026-08-20 --kind personal --hours-per-day 4 --notes "Trámites personales"
1473
1475
  ```
1474
1476
 
1475
- After running: confirm the output shows `status: pending`. Explain to the user that the entry is queued for manager approval — it is NOT yet in effect.
1477
+ After running: confirm the output shows `status: pending`. Explain to the user that the entry is queued for approval — it is NOT yet in effect. Their approvers are emailed automatically, so there is no need to chase anyone manually.
1476
1478
 
1477
1479
  ---
1478
1480
 
1479
- ### Workflow B: Review and Approve Team PTO (manager+)
1481
+ ### Workflow B: Review and Approve Team PTO (project manager+)
1480
1482
 
1481
1483
  Use this when a manager wants to review pending requests from their team and approve or reject them.
1482
1484
 
File without changes
File without changes