crowdtime-cli 0.15.0__tar.gz → 0.17.0__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.0}/PKG-INFO +1 -1
  2. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/pyproject.toml +1 -1
  3. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/__init__.py +1 -1
  4. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/ai_cmd.py +17 -1
  5. crowdtime_cli-0.17.0/src/crowdtime_cli/commands/calendar_cmd.py +136 -0
  6. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/log_cmd.py +4 -2
  7. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/projects_cmd.py +46 -0
  8. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/pto_cmd.py +23 -11
  9. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/formatters.py +9 -1
  10. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/main.py +26 -12
  11. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/models.py +20 -0
  12. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +68 -8
  13. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +99 -9
  14. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +42 -4
  15. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +2 -2
  16. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/.gitignore +0 -0
  17. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/LICENSE +0 -0
  18. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/README.md +0 -0
  19. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/auth.py +0 -0
  20. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/client.py +0 -0
  21. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/__init__.py +0 -0
  22. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
  23. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
  24. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
  25. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
  26. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
  27. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
  28. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
  29. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
  30. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
  31. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
  32. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
  33. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
  34. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
  35. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/team_cmd.py +0 -0
  36. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
  37. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/timesheet_cmd.py +0 -0
  38. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
  39. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/config.py +0 -0
  40. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/oauth.py +0 -0
  41. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/resolvers.py +0 -0
  42. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/src/crowdtime_cli/utils.py +0 -0
  43. {crowdtime_cli-0.15.0 → crowdtime_cli-0.17.0}/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.0
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.0"
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.0"
@@ -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).**
@@ -137,10 +164,21 @@ ct p -a # include archived
137
164
 
138
165
  #### Project Members
139
166
  ```bash
167
+ ct projects assignable-users <project-id> # Who can still be added (gives you the user IDs)
140
168
  ct projects update-member <project-id> <user-id> --role project_manager --rate 150 # Set per-project role & rate
141
169
  ct projects add-members <project-id> --user <uid1> --user <uid2> --role member --rate 120 # Bulk add
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
@@ -343,22 +381,44 @@ ct billing events -n 50 # Show last 50 events
343
381
  ct b # billing status
344
382
  ```
345
383
 
384
+ ### Calendar (Google)
385
+
386
+ ```bash
387
+ ct calendar status # Is a calendar connected?
388
+ ct calendar events # Today's meetings, fetched live from Google
389
+ ct calendar events --date 2026-06-01 # A specific day
390
+ ct calendar disconnect # Revoke access
391
+ ```
392
+
393
+ Connecting requires the Google consent screen, so it happens in the web app
394
+ (Settings → Integrations); the CLI can only read and disconnect. Events are
395
+ fetched live on every call and are never stored by CrowdTime.
396
+
397
+ **Turning a meeting into an entry:** read the events, then log the time
398
+ explicitly — there is no automatic conversion:
399
+
400
+ ```bash
401
+ ct calendar events # e.g. "Daily standup", 0.25h
402
+ ct log 0.25 --project argo --task BE --notes "Daily standup"
403
+ ```
404
+
346
405
  ### Time Off (PTO)
347
406
 
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).
407
+ 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
408
 
350
409
  > **IMPORTANT**: Never use magic routing (`ct "..."`) or `ct ai parse` for PTO operations. Always construct explicit `ct pto ...` commands.
351
410
  > Full reference: `references/pto.md` and `references/commands.md` § PTO.
352
411
 
353
412
  ```bash
354
413
  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)
414
+ ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved (project manager+, in scope)
415
+ ct pto add <user> <dates> --unpaid # ... marked unpaid (account manager+; 400 for anyone below, not ignored)
356
416
  ct pto list [--mine|--team] # List PTO entries with optional filters
357
417
  ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
358
418
  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+)
419
+ ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+ any)
420
+ ct pto approve <id> [--notes "..."] # Approve a pending entry (project manager+)
421
+ ct pto reject <id> --notes "..." # Reject a pending entry — notes required (project manager+)
362
422
  ct pto calendar [--from --to --user --status] # PTO slice for a date window (approved only unless --status)
363
423
  ct pto summary [--from --to --user] # Aggregate approved PTO hours/days, by kind and by user
364
424
  ```
@@ -481,8 +541,8 @@ Read `references/workflows.md` for detailed multi-step workflow patterns includi
481
541
  - Checking project budgets
482
542
  - Retainer management and withdrawals
483
543
  - **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)
544
+ - **Reviewing and approving team PTO** (project manager+ reviews pending requests)
545
+ - **Adding PTO on behalf of someone** (approver logs pre-agreed leave, auto-approved)
486
546
  - **Reconciling missing hours with PTO** (diagnose low-hour weeks: check PTO coverage, then anomaly report)
487
547
 
488
548
  ## Complete Command Reference
@@ -508,4 +568,4 @@ Read `references/commands.md` for the full reference of every command, subcomman
508
568
  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
569
  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
570
  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.
571
+ 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 (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.
@@ -1119,6 +1119,20 @@ ct projects update-member <project-id> <user-id> --rate 120
1119
1119
 
1120
1120
  Endpoint: `PATCH /projects/{id}/members/{user_id}/`
1121
1121
 
1122
+ ### ct projects assignable-users
1123
+
1124
+ ```
1125
+ ct projects assignable-users PROJECT_ID [--json]
1126
+ ```
1127
+
1128
+ | Option | Type | Description |
1129
+ |--------|------|-------------|
1130
+ | `--json` | flag | JSON output |
1131
+
1132
+ 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.
1133
+
1134
+ 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/`
1135
+
1122
1136
  ### ct projects add-members
1123
1137
 
1124
1138
  ```
@@ -1373,8 +1387,21 @@ ct ai parse TEXT [--dry-run] [--force/-f] [--json]
1373
1387
  | `--force`, `-f` | flag | Skip confirmation |
1374
1388
  | `--json` | flag | JSON output |
1375
1389
 
1376
- Shows parsed: description, project, task, duration, date, billable, confidence, ambiguities.
1377
- Endpoints: `POST /ai/parse/` then `POST /ai/parse/confirm/`
1390
+ Shows parsed: description, project, task, duration, date(s), billable, confidence, ambiguities.
1391
+
1392
+ **Recurring input creates several entries.** When the text describes work repeating over
1393
+ several days ("daily standup all week", "1h every day this week", "2h monday and
1394
+ wednesday"), the parse expands it into explicit dates and lists them all; confirming
1395
+ creates one entry per date in a single transaction. A one-off description still produces
1396
+ exactly one entry.
1397
+
1398
+ ```
1399
+ ct ai parse "daily 15 min standup on argo all week" # → 5 entries, one per weekday
1400
+ ```
1401
+
1402
+ The confirm response is a plain time entry for a single date, and `{count, entries}` when
1403
+ several were created. Endpoints: `POST /ai/parse/` then `POST /ai/parse/confirm/`
1404
+ (the latter accepts `dates` to create a subset of what was parsed).
1378
1405
 
1379
1406
  ### ct ai suggest
1380
1407
 
@@ -1699,7 +1726,9 @@ ct timesheet submit --from DATE --to DATE [--force/-f] [--json]
1699
1726
  | `--force`, `-f` | flag | Skip confirmation prompt |
1700
1727
  | `--json` | flag | JSON output |
1701
1728
 
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/`
1729
+ 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.
1730
+
1731
+ 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
1732
 
1704
1733
  ### ct timesheet approve
1705
1734
 
@@ -2703,9 +2732,9 @@ ct pto request 2026-08-04:2026-08-08 --kind vacation --json
2703
2732
  ct pto add <user> <dates> [--kind/-k KIND] [--hours-per-day/-h HOURS] [--notes/-n TEXT] [--json]
2704
2733
  ```
2705
2734
 
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.
2735
+ 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
2736
 
2708
- **Who can call it:** `manager`, `admin`, `owner` (within their visibility scope — see `references/pto.md`).
2737
+ **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
2738
 
2710
2739
  | Argument/Option | Type | Required | Description |
2711
2740
  |-----------------|------|----------|-------------|
@@ -2714,6 +2743,7 @@ Add a PTO entry on behalf of another user (manager, admin, or owner only). The e
2714
2743
  | `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
2715
2744
  | `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default |
2716
2745
  | `--notes`, `-n` | string | no | Reason or context notes |
2746
+ | `--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
2747
  | `--json` | flag | no | JSON output |
2718
2748
 
2719
2749
  **Response (201):** full entry serializer with `status=approved`, `decided_by=<your user>`, `decided_at=<now>`.
@@ -2721,7 +2751,11 @@ Add a PTO entry on behalf of another user (manager, admin, or owner only). The e
2721
2751
  **Errors:**
2722
2752
  - `400` — date overlap, invalid dates, or hours_per_day > 24.
2723
2753
  - `400` — user is not an active member of this organization.
2724
- - `403` — caller lacks manager+ role, or user is outside caller's visibility scope.
2754
+ - `400` on the `user` field — caller is below `project_manager`, or the named
2755
+ user is outside the caller's visibility scope. This is a `400`, not a `403`:
2756
+ the check lives in the serializer, so treat it as a validation error.
2757
+ - `400` on the `is_paid` field — caller is below account manager and sent
2758
+ `--paid`/`--unpaid`.
2725
2759
 
2726
2760
  **Usage examples:**
2727
2761
 
@@ -2743,6 +2777,22 @@ ct pto add jane@example.com 2026-06-09:2026-06-13 --kind vacation --json
2743
2777
 
2744
2778
  ---
2745
2779
 
2780
+ ### PTO pending count (no dedicated command)
2781
+
2782
+ `ct` with no arguments surfaces a "PTO requests pending review" line driven by
2783
+ `GET /organizations/<slug>/time-off/pending-count/` (project manager+). There is
2784
+ no `ct pto pending-count` subcommand — use `ct pto list --team --status pending`
2785
+ when you need the entries themselves.
2786
+
2787
+ The endpoint returns `{"count": N}` for the requests **routed to the caller**,
2788
+ excluding their own. For admins and owners that is not every pending request in
2789
+ the org — requests with a named approver (a direct manager, or a PM on the
2790
+ person's project) are excluded, because those were emailed to that person
2791
+ instead. Never recompute this by listing and filtering; you cannot see the
2792
+ routing from the list payload.
2793
+
2794
+ ---
2795
+
2746
2796
  ### ct pto list
2747
2797
 
2748
2798
  ```
@@ -2800,7 +2850,7 @@ ct pto show <id> [--json]
2800
2850
 
2801
2851
  Show full details of a single PTO entry.
2802
2852
 
2803
- **Who can call it:** own entries (any role); manager+ for entries within their visibility scope; admin/owner for any entry.
2853
+ **Who can call it:** own entries (any role); project manager+ for entries within their visibility scope; admin/owner for any entry.
2804
2854
 
2805
2855
  | Argument | Type | Required | Description |
2806
2856
  |----------|------|----------|-------------|
@@ -2987,7 +3037,7 @@ ct pto calendar --from DATE --to DATE [--user/-u USER] [--status/-s STATUSES] [-
2987
3037
 
2988
3038
  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
3039
 
2990
- **Who can call it:** any active member (sees own entries); manager+ sees entries for their visibility scope; admin/owner sees all.
3040
+ **Who can call it:** any active member (sees own entries); project manager+ sees entries for their visibility scope; admin/owner sees all.
2991
3041
 
2992
3042
  | Option | Type | Required | Description |
2993
3043
  |--------|------|----------|-------------|
@@ -3036,7 +3086,7 @@ ct pto summary --from DATE --to DATE [--user/-u USER] [--json]
3036
3086
 
3037
3087
  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
3088
 
3039
- **Who can call it:** any active member (sees own data); manager+ sees summaries for their visibility scope; admin/owner sees all.
3089
+ **Who can call it:** any active member (sees own data); project manager+ sees summaries for their visibility scope; admin/owner sees all.
3040
3090
 
3041
3091
  | Option | Type | Required | Description |
3042
3092
  |--------|------|----------|-------------|
@@ -3148,3 +3198,43 @@ Displays all available shortcut commands in a formatted table. Shows each alias
3148
3198
  | `ct f <id>` | `ct favorites start <id>` |
3149
3199
  | `ct ex` | `ct expense` |
3150
3200
  | `ct "text"` | `ct ai parse "text"` |
3201
+
3202
+
3203
+ ---
3204
+
3205
+ ## ct calendar
3206
+
3207
+ 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.
3208
+
3209
+ Connecting a calendar needs the Google consent screen, so it happens in the web app (Settings → Integrations). The CLI can read and disconnect only.
3210
+
3211
+ 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.
3212
+
3213
+ ### ct calendar status
3214
+
3215
+ ```
3216
+ ct calendar status [--json]
3217
+ ```
3218
+
3219
+ 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/`
3220
+
3221
+ ### ct calendar events
3222
+
3223
+ ```
3224
+ ct calendar events [--date DATE] [--json]
3225
+ ```
3226
+
3227
+ | Option | Type | Description |
3228
+ |--------|------|-------------|
3229
+ | `--date`, `-d` | string | Day to read (YYYY-MM-DD). Defaults to today |
3230
+ | `--json` | flag | JSON output |
3231
+
3232
+ 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/`
3233
+
3234
+ ### ct calendar disconnect
3235
+
3236
+ ```
3237
+ ct calendar disconnect [--force/-f]
3238
+ ```
3239
+
3240
+ 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
 
@@ -1472,11 +1472,11 @@ ct pto request 2026-07-15 --kind sick --notes "Cita médica"
1472
1472
  ct pto request 2026-08-20 --kind personal --hours-per-day 4 --notes "Trámites personales"
1473
1473
  ```
1474
1474
 
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.
1475
+ 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
1476
 
1477
1477
  ---
1478
1478
 
1479
- ### Workflow B: Review and Approve Team PTO (manager+)
1479
+ ### Workflow B: Review and Approve Team PTO (project manager+)
1480
1480
 
1481
1481
  Use this when a manager wants to review pending requests from their team and approve or reject them.
1482
1482
 
File without changes
File without changes