tetra-cli 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tetra_cli/__init__.py +6 -0
- tetra_cli/api_client/__init__.py +10 -0
- tetra_cli/api_client/client.py +173 -0
- tetra_cli/api_client/config.py +125 -0
- tetra_cli/api_client/operations/__init__.py +9 -0
- tetra_cli/api_client/operations/accounts.py +303 -0
- tetra_cli/api_client/operations/ai.py +278 -0
- tetra_cli/api_client/operations/analysis.py +190 -0
- tetra_cli/api_client/operations/api_keys.py +145 -0
- tetra_cli/api_client/operations/archive.py +114 -0
- tetra_cli/api_client/operations/awards.py +123 -0
- tetra_cli/api_client/operations/capacity.py +84 -0
- tetra_cli/api_client/operations/conversations.py +447 -0
- tetra_cli/api_client/operations/conversations_2.py +262 -0
- tetra_cli/api_client/operations/cosmetics.py +148 -0
- tetra_cli/api_client/operations/dashboard.py +282 -0
- tetra_cli/api_client/operations/data.py +250 -0
- tetra_cli/api_client/operations/events.py +734 -0
- tetra_cli/api_client/operations/gamification.py +470 -0
- tetra_cli/api_client/operations/goals.py +1144 -0
- tetra_cli/api_client/operations/groups.py +647 -0
- tetra_cli/api_client/operations/issues.py +198 -0
- tetra_cli/api_client/operations/offset.py +61 -0
- tetra_cli/api_client/operations/onboarding.py +284 -0
- tetra_cli/api_client/operations/outcome_schemas.py +292 -0
- tetra_cli/api_client/operations/peer_connections.py +243 -0
- tetra_cli/api_client/operations/plaid.py +329 -0
- tetra_cli/api_client/operations/reminders.py +273 -0
- tetra_cli/api_client/operations/scratches.py +280 -0
- tetra_cli/api_client/operations/skill_trees.py +160 -0
- tetra_cli/api_client/operations/social_2.py +560 -0
- tetra_cli/api_client/operations/social_3.py +618 -0
- tetra_cli/api_client/operations/social_4.py +527 -0
- tetra_cli/api_client/operations/strava.py +215 -0
- tetra_cli/api_client/operations/stripe.py +113 -0
- tetra_cli/api_client/operations/tags.py +488 -0
- tetra_cli/api_client/operations/values.py +867 -0
- tetra_cli/api_client/operations/values_2.py +584 -0
- tetra_cli/api_client/operations/watch.py +105 -0
- tetra_cli/api_client/operations/webhooks.py +50 -0
- tetra_cli/api_client/operations/xp.py +27 -0
- tetra_cli/cli/__init__.py +5 -0
- tetra_cli/cli/__main__.py +5 -0
- tetra_cli/cli/app.py +86 -0
- tetra_cli/cli/commands/__init__.py +1 -0
- tetra_cli/cli/commands/auth.py +201 -0
- tetra_cli/cli/commands/guide.py +8 -0
- tetra_cli/cli/commands/messages.py +161 -0
- tetra_cli/cli/commands/skill.py +71 -0
- tetra_cli/cli/context.py +13 -0
- tetra_cli/cli/generate.py +282 -0
- tetra_cli/cli/output.py +58 -0
- tetra_cli/mcp_gen.py +137 -0
- tetra_cli/ontology.py +70 -0
- tetra_cli/registry.py +118 -0
- tetra_cli/skill/SKILL.md +69 -0
- tetra_cli/skill/__init__.py +1 -0
- tetra_cli-0.2.0.dist-info/METADATA +140 -0
- tetra_cli-0.2.0.dist-info/RECORD +62 -0
- tetra_cli-0.2.0.dist-info/WHEEL +5 -0
- tetra_cli-0.2.0.dist-info/entry_points.txt +2 -0
- tetra_cli-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
"""Pure async operations for the goals API."""
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from tetra_cli.api_client import TetraClient, TetraClientError
|
|
5
|
+
from tetra_cli.registry import arg, operation, opt
|
|
6
|
+
|
|
7
|
+
# Mapping of status labels (case-insensitive, whitespace-tolerant) to the
|
|
8
|
+
# numeric values defined in ``tetra.core.core.Status``. Kept here — inside the
|
|
9
|
+
# op layer — so both the CLI and the MCP surface (both generated from these
|
|
10
|
+
# ops) inherit the same label→int normalization. The previous CLI-only sugar
|
|
11
|
+
# (removed when the goals group moved to the generator) used this exact map.
|
|
12
|
+
_STATUS_LABELS: dict[str, int] = {
|
|
13
|
+
"created": 1,
|
|
14
|
+
"in_progress": 2,
|
|
15
|
+
"archived": 3,
|
|
16
|
+
"abandoned": 4,
|
|
17
|
+
"completed": 5,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _normalize_status(value: int | str) -> int:
|
|
22
|
+
"""Coerce a status given as an int or a known label string to its int.
|
|
23
|
+
|
|
24
|
+
Accepts plain ints (1-5) unchanged, integer-as-string ("3"), and the
|
|
25
|
+
canonical labels (case-insensitive, whitespace-tolerant). Raises
|
|
26
|
+
``TetraClientError`` — the same error type ops raise for bad input, which
|
|
27
|
+
the CLI generator renders as a clean ``api_error`` — on an unknown label.
|
|
28
|
+
"""
|
|
29
|
+
if isinstance(value, int):
|
|
30
|
+
return value
|
|
31
|
+
raw = value.strip()
|
|
32
|
+
if raw.lstrip("-").isdigit():
|
|
33
|
+
return int(raw)
|
|
34
|
+
label = raw.lower()
|
|
35
|
+
if label in _STATUS_LABELS:
|
|
36
|
+
return _STATUS_LABELS[label]
|
|
37
|
+
accepted = ", ".join(sorted(_STATUS_LABELS))
|
|
38
|
+
raise TetraClientError(
|
|
39
|
+
f"--status {value!r} is not recognized; accepted: {accepted}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@operation(
|
|
44
|
+
cli="goals list",
|
|
45
|
+
summary="List all goals with their hierarchy and status.",
|
|
46
|
+
covers=[("GET", "/api/v1/goals/")],
|
|
47
|
+
)
|
|
48
|
+
async def list_goals(client: TetraClient) -> dict[str, Any]:
|
|
49
|
+
"""List all goals with their hierarchy and status.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
client: Authenticated TetraClient
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Dict with 'goals' list
|
|
56
|
+
"""
|
|
57
|
+
return await client.get("/api/v1/goals/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@operation(
|
|
61
|
+
cli="goals get",
|
|
62
|
+
summary="Get a single goal by UID or hierarchical name.",
|
|
63
|
+
covers=[("GET", "/api/v1/goals/{uid_or_name}")],
|
|
64
|
+
params={"uid_or_name": arg(help="Goal UID or full hierarchical name")},
|
|
65
|
+
)
|
|
66
|
+
async def get_goal(client: TetraClient, uid_or_name: str) -> dict[str, Any]:
|
|
67
|
+
"""Get a single goal by UID or hierarchical name.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
client: Authenticated TetraClient
|
|
71
|
+
uid_or_name: Goal UID or full hierarchical name (e.g., 'Exercise > Weight Training')
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
Goal dict
|
|
75
|
+
"""
|
|
76
|
+
return await client.get(f"/api/v1/goals/{uid_or_name}")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@operation(
|
|
80
|
+
cli="goals create",
|
|
81
|
+
summary="Create a new goal.",
|
|
82
|
+
covers=[("POST", "/api/v1/goals/")],
|
|
83
|
+
params={
|
|
84
|
+
"name": arg(help="Leaf name (e.g. 'Bench Press')"),
|
|
85
|
+
"values": opt("--value", repeatable=True, help="Value name (repeatable)"),
|
|
86
|
+
"depends_on": opt("--depends-on", repeatable=True,
|
|
87
|
+
help="Prerequisite goal name (repeatable)"),
|
|
88
|
+
"outcome_names": opt("--outcome-name", repeatable=True,
|
|
89
|
+
help="Record-outcome field name (repeatable)"),
|
|
90
|
+
"metric_config": opt("--metric-config", json=True,
|
|
91
|
+
help="Metric configuration as a JSON dict"),
|
|
92
|
+
"importance": opt("--importance", min=1, max=5, help="Importance (1-5)"),
|
|
93
|
+
"urgency": opt("--urgency", min=1, max=5, help="Urgency (1-5)"),
|
|
94
|
+
"expected_time": opt("--expected-time",
|
|
95
|
+
help="Expected time to complete, in seconds"),
|
|
96
|
+
},
|
|
97
|
+
)
|
|
98
|
+
async def create_goal(
|
|
99
|
+
client: TetraClient,
|
|
100
|
+
*,
|
|
101
|
+
name: str,
|
|
102
|
+
parent: str | None = None,
|
|
103
|
+
values: list[str] | None = None,
|
|
104
|
+
depends_on: list[str] | None = None,
|
|
105
|
+
description: str | None = None,
|
|
106
|
+
importance: int | None = None,
|
|
107
|
+
urgency: int | None = None,
|
|
108
|
+
expected_time: int | None = None,
|
|
109
|
+
metric_config: dict[str, Any] | None = None,
|
|
110
|
+
outcome_shape: str | None = None,
|
|
111
|
+
outcome_names: list[str] | None = None,
|
|
112
|
+
group_uid: str | None = None,
|
|
113
|
+
for_account_uid: str | None = None,
|
|
114
|
+
) -> dict[str, Any]:
|
|
115
|
+
"""Create a new goal.
|
|
116
|
+
|
|
117
|
+
Only the fields explicitly provided are included in the request body;
|
|
118
|
+
optional fields that are None are omitted so API defaults apply.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
client: Authenticated TetraClient
|
|
122
|
+
name: Leaf name for the goal (e.g., 'Bench Press')
|
|
123
|
+
parent: Parent goal's full hierarchical name
|
|
124
|
+
values: Value names this goal contributes to
|
|
125
|
+
depends_on: Names of prerequisite goals that must complete first
|
|
126
|
+
description: Human-readable description
|
|
127
|
+
importance: Importance level (1-5)
|
|
128
|
+
urgency: Urgency level (1-5)
|
|
129
|
+
expected_time: Expected time to complete the goal, in seconds.
|
|
130
|
+
metric_config: Custom metric configuration dict
|
|
131
|
+
outcome_shape: Shape of outcome data ('numeric', 'record', etc.)
|
|
132
|
+
outcome_names: Field names for record-type outcomes
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
Created goal dict with assigned UID
|
|
136
|
+
"""
|
|
137
|
+
body: dict[str, Any] = {"name": name}
|
|
138
|
+
if parent:
|
|
139
|
+
body["parent"] = parent
|
|
140
|
+
if values:
|
|
141
|
+
body["values"] = values
|
|
142
|
+
if depends_on:
|
|
143
|
+
body["depends_on"] = depends_on
|
|
144
|
+
if description:
|
|
145
|
+
body["description"] = description
|
|
146
|
+
if importance is not None:
|
|
147
|
+
body["importance"] = importance
|
|
148
|
+
if urgency is not None:
|
|
149
|
+
body["urgency"] = urgency
|
|
150
|
+
if expected_time is not None:
|
|
151
|
+
body["expected_time"] = expected_time
|
|
152
|
+
if metric_config:
|
|
153
|
+
body["metric_config"] = metric_config
|
|
154
|
+
if outcome_shape:
|
|
155
|
+
body["outcome_shape"] = outcome_shape
|
|
156
|
+
if outcome_names:
|
|
157
|
+
body["outcome_names"] = outcome_names
|
|
158
|
+
# Creation context — pair (group_uid, for_account_uid) picks shape
|
|
159
|
+
# A/B/C per docs/architecture/creation-context.md.
|
|
160
|
+
if group_uid is not None:
|
|
161
|
+
body["group_uid"] = group_uid
|
|
162
|
+
if for_account_uid is not None:
|
|
163
|
+
body["for_account_uid"] = for_account_uid
|
|
164
|
+
return await client.post("/api/v1/goals/", json=body)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@operation(
|
|
168
|
+
cli="goals update",
|
|
169
|
+
summary="Update an existing goal. Only provided fields are changed.",
|
|
170
|
+
covers=[("PUT", "/api/v1/goals/{uid}")],
|
|
171
|
+
params={
|
|
172
|
+
"uid": arg(help="Goal UID"),
|
|
173
|
+
"name": opt("--name", help="New leaf name"),
|
|
174
|
+
"values": opt("--value", repeatable=True, clears=True,
|
|
175
|
+
help="Value name (repeatable; replaces all)"),
|
|
176
|
+
"depends_on": opt("--depends-on", repeatable=True, clears=True,
|
|
177
|
+
help="Prerequisite goal name (repeatable; replaces all)"),
|
|
178
|
+
"metric_config": opt("--metric-config", json=True,
|
|
179
|
+
help="Metric configuration as a JSON dict"),
|
|
180
|
+
"outcome_names": opt("--outcome-name", repeatable=True,
|
|
181
|
+
help="Record-outcome field name (repeatable)"),
|
|
182
|
+
"importance": opt("--importance", min=1, max=5, help="Importance (1-5)"),
|
|
183
|
+
"urgency": opt("--urgency", min=1, max=5, help="Urgency (1-5)"),
|
|
184
|
+
"expected_time": opt("--expected-time",
|
|
185
|
+
help="Expected time to complete, in seconds"),
|
|
186
|
+
"status": opt(
|
|
187
|
+
"--status",
|
|
188
|
+
help=("Goal status as a label (created | in_progress | archived | "
|
|
189
|
+
"abandoned | completed) or its numeric form (1-5)."),
|
|
190
|
+
),
|
|
191
|
+
},
|
|
192
|
+
)
|
|
193
|
+
async def update_goal(
|
|
194
|
+
client: TetraClient,
|
|
195
|
+
*,
|
|
196
|
+
uid: str,
|
|
197
|
+
name: str | None = None,
|
|
198
|
+
parent: str | None = None,
|
|
199
|
+
description: str | None = None,
|
|
200
|
+
importance: int | None = None,
|
|
201
|
+
urgency: int | None = None,
|
|
202
|
+
expected_time: int | None = None,
|
|
203
|
+
values: list[str] | None = None,
|
|
204
|
+
depends_on: list[str] | None = None,
|
|
205
|
+
metric_config: dict[str, Any] | None = None,
|
|
206
|
+
outcome_shape: str | None = None,
|
|
207
|
+
outcome_names: list[str] | None = None,
|
|
208
|
+
status: str | None = None,
|
|
209
|
+
) -> dict[str, Any]:
|
|
210
|
+
"""Update an existing goal. Only provided (non-None) fields are changed.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
client: Authenticated TetraClient
|
|
214
|
+
uid: Goal UID (required)
|
|
215
|
+
name: New leaf name
|
|
216
|
+
parent: New parent path (empty string to move to root)
|
|
217
|
+
description: New description
|
|
218
|
+
importance: New importance (1-5)
|
|
219
|
+
urgency: New urgency (1-5)
|
|
220
|
+
expected_time: New expected time to complete, in seconds.
|
|
221
|
+
values: New value names (replaces all)
|
|
222
|
+
depends_on: New prerequisite goal names (replaces all)
|
|
223
|
+
metric_config: New metric configuration
|
|
224
|
+
outcome_shape: New outcome shape
|
|
225
|
+
outcome_names: New outcome field names
|
|
226
|
+
status: New status as either a numeric value (1=CREATED,
|
|
227
|
+
2=IN_PROGRESS, 3=ARCHIVED, 4=ABANDONED, 5=COMPLETED) or a label
|
|
228
|
+
string ("created" … "completed", case-insensitive). The op
|
|
229
|
+
normalizes labels→int so the CLI/MCP surfaces share the behavior.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Updated goal dict
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
TetraClientError: If ``status`` is a string that is neither a known
|
|
236
|
+
label nor a numeric value.
|
|
237
|
+
"""
|
|
238
|
+
body: dict[str, Any] = {}
|
|
239
|
+
if name is not None:
|
|
240
|
+
body["name"] = name
|
|
241
|
+
if parent is not None:
|
|
242
|
+
body["parent"] = parent
|
|
243
|
+
if description is not None:
|
|
244
|
+
body["description"] = description
|
|
245
|
+
if importance is not None:
|
|
246
|
+
body["importance"] = importance
|
|
247
|
+
if urgency is not None:
|
|
248
|
+
body["urgency"] = urgency
|
|
249
|
+
if expected_time is not None:
|
|
250
|
+
body["expected_time"] = expected_time
|
|
251
|
+
if values is not None:
|
|
252
|
+
body["values"] = values
|
|
253
|
+
if depends_on is not None:
|
|
254
|
+
body["depends_on"] = depends_on
|
|
255
|
+
if metric_config is not None:
|
|
256
|
+
body["metric_config"] = metric_config
|
|
257
|
+
if outcome_shape is not None:
|
|
258
|
+
body["outcome_shape"] = outcome_shape
|
|
259
|
+
if outcome_names is not None:
|
|
260
|
+
body["outcome_names"] = outcome_names
|
|
261
|
+
if status is not None:
|
|
262
|
+
body["status"] = _normalize_status(status)
|
|
263
|
+
return await client.put(f"/api/v1/goals/{uid}", json=body)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@operation(
|
|
267
|
+
cli="goals delete",
|
|
268
|
+
summary="Delete a goal permanently.",
|
|
269
|
+
covers=[("DELETE", "/api/v1/goals/{uid}")],
|
|
270
|
+
params={"uid": arg(help="Goal UID to delete")},
|
|
271
|
+
)
|
|
272
|
+
async def delete_goal(client: TetraClient, uid: str) -> dict[str, Any]:
|
|
273
|
+
"""Delete a goal permanently.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
client: Authenticated TetraClient
|
|
277
|
+
uid: Goal UID to delete
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Deletion confirmation dict
|
|
281
|
+
"""
|
|
282
|
+
return await client.delete(f"/api/v1/goals/{uid}")
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
@operation(
|
|
286
|
+
cli="goals status",
|
|
287
|
+
summary="Get achievement progress and status for a goal.",
|
|
288
|
+
covers=[("GET", "/api/v1/goals/{uid}/status")],
|
|
289
|
+
params={"uid": arg(help="Goal UID")},
|
|
290
|
+
)
|
|
291
|
+
async def get_goal_status(client: TetraClient, uid: str) -> dict[str, Any]:
|
|
292
|
+
"""Get achievement progress and status for a goal.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
client: Authenticated TetraClient
|
|
296
|
+
uid: Goal UID
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
Dict with status, percentage, current_value, target, and metric_type
|
|
300
|
+
"""
|
|
301
|
+
return await client.get(f"/api/v1/goals/{uid}/status")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
@operation(
|
|
305
|
+
cli="goals rankings",
|
|
306
|
+
summary="Get all goals ranked by progress, tier, and achievement status.",
|
|
307
|
+
covers=[("GET", "/api/v1/goals/rankings")],
|
|
308
|
+
)
|
|
309
|
+
async def get_goal_rankings(client: TetraClient) -> dict[str, Any]:
|
|
310
|
+
"""Get all goals ranked by progress, tier, and achievement status.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
client: Authenticated TetraClient
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Dict with 'rankings' list of goals sorted by progress
|
|
317
|
+
"""
|
|
318
|
+
return await client.get("/api/v1/goals/rankings")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@operation(
|
|
322
|
+
cli="goals metrics",
|
|
323
|
+
summary="List all metrics for a goal (active + archived history).",
|
|
324
|
+
covers=[("GET", "/api/v1/goals/{uid}/metrics")],
|
|
325
|
+
params={"goal_uid": arg(help="Goal UID")},
|
|
326
|
+
)
|
|
327
|
+
async def list_goal_metrics(
|
|
328
|
+
client: TetraClient, goal_uid: str,
|
|
329
|
+
) -> list[dict[str, Any]]:
|
|
330
|
+
"""List all metrics for a goal (active + archived).
|
|
331
|
+
|
|
332
|
+
Reading metrics before adding new ones prevents accidental duplication.
|
|
333
|
+
Each metric exposes ``is_active`` (True for the current one) and the
|
|
334
|
+
full ``metric_config`` so an agent can summarize the existing rule.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
client: Authenticated TetraClient
|
|
338
|
+
goal_uid: Goal UID
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
List of metric dicts ordered by creation. Empty list if none.
|
|
342
|
+
"""
|
|
343
|
+
return await client.get(f"/api/v1/goals/{goal_uid}/metrics")
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@operation(
|
|
347
|
+
cli="goals metric-active",
|
|
348
|
+
summary="Get the single active metric for a goal (null if none).",
|
|
349
|
+
covers=[("GET", "/api/v1/goals/{uid}/metrics/active")],
|
|
350
|
+
params={"goal_uid": arg(help="Goal UID")},
|
|
351
|
+
)
|
|
352
|
+
async def get_active_goal_metric(
|
|
353
|
+
client: TetraClient, goal_uid: str,
|
|
354
|
+
) -> dict[str, Any] | None:
|
|
355
|
+
"""Return the single active metric for a goal, or None if there is none.
|
|
356
|
+
|
|
357
|
+
The active-metric endpoint returns ``null`` (not 404) when no active
|
|
358
|
+
metric exists, so this is safe to call before deciding whether to
|
|
359
|
+
create one.
|
|
360
|
+
"""
|
|
361
|
+
return await client.get(f"/api/v1/goals/{goal_uid}/metrics/active")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@operation(
|
|
365
|
+
cli="goals metric-create",
|
|
366
|
+
summary="Create a new metric for a goal (archives the current active one).",
|
|
367
|
+
covers=[("POST", "/api/v1/goals/{uid}/metrics")],
|
|
368
|
+
params={
|
|
369
|
+
"goal_uid": arg(help="Goal UID"),
|
|
370
|
+
"metric_config": opt("--metric-config", json=True,
|
|
371
|
+
help="MetricConfig payload as a JSON dict"),
|
|
372
|
+
"tier_override": opt("--tier-override", choices=["D", "C", "B", "A"],
|
|
373
|
+
help="Optional user-set difficulty tier (D/C/B/A)."),
|
|
374
|
+
},
|
|
375
|
+
)
|
|
376
|
+
async def create_goal_metric(
|
|
377
|
+
client: TetraClient,
|
|
378
|
+
goal_uid: str,
|
|
379
|
+
*,
|
|
380
|
+
name: str,
|
|
381
|
+
metric_config: dict[str, Any],
|
|
382
|
+
tier_override: str | None = None,
|
|
383
|
+
) -> dict[str, Any]:
|
|
384
|
+
"""Create a new metric for a goal.
|
|
385
|
+
|
|
386
|
+
Creating a metric automatically archives any currently-active metric
|
|
387
|
+
on the goal — there is at most one active metric at any time.
|
|
388
|
+
|
|
389
|
+
The server derives the computed tier from metric semantics and
|
|
390
|
+
cumulative effort. Pass ``tier_override`` to mark the goal as harder
|
|
391
|
+
than the metric suggests — accepted values are D/C/B/A. S is rejected
|
|
392
|
+
because reaching it requires the globally-shared-goal + top-decile
|
|
393
|
+
gate, not opt-in.
|
|
394
|
+
|
|
395
|
+
Args:
|
|
396
|
+
client: Authenticated TetraClient
|
|
397
|
+
goal_uid: Goal UID
|
|
398
|
+
name: Human-readable metric name (e.g. "Quarterly call")
|
|
399
|
+
metric_config: ``MetricConfig`` payload — see docs/architecture/rules-spec.md
|
|
400
|
+
tier_override: Optional user-set tier (D/C/B/A).
|
|
401
|
+
"""
|
|
402
|
+
body: dict[str, Any] = {"name": name, "metric_config": metric_config}
|
|
403
|
+
if tier_override is not None:
|
|
404
|
+
body["tier_override"] = tier_override
|
|
405
|
+
return await client.post(f"/api/v1/goals/{goal_uid}/metrics", json=body)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
# Sentinel for "field not provided" so callers can distinguish leaving
|
|
409
|
+
# tier_override alone from clearing it (passing None).
|
|
410
|
+
_UNSET: Any = object()
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@operation(
|
|
414
|
+
cli="goals metric-update",
|
|
415
|
+
summary="Update an existing goal metric (name, config, tier, or activate).",
|
|
416
|
+
covers=[("PUT", "/api/v1/goals/{uid}/metrics/{metric_uid}")],
|
|
417
|
+
params={
|
|
418
|
+
"goal_uid": arg(help="Goal UID"),
|
|
419
|
+
"metric_uid": arg(help="Metric UID"),
|
|
420
|
+
"metric_config": opt("--metric-config", json=True,
|
|
421
|
+
help="MetricConfig payload as a JSON dict"),
|
|
422
|
+
"tier_override": opt("--tier-override", choices=["D", "C", "B", "A"],
|
|
423
|
+
help="Set the user difficulty tier (D/C/B/A)."),
|
|
424
|
+
},
|
|
425
|
+
)
|
|
426
|
+
async def update_goal_metric(
|
|
427
|
+
client: TetraClient,
|
|
428
|
+
goal_uid: str,
|
|
429
|
+
metric_uid: str,
|
|
430
|
+
*,
|
|
431
|
+
name: str | None = None,
|
|
432
|
+
metric_config: dict[str, Any] | None = None,
|
|
433
|
+
tier_override: Any = _UNSET,
|
|
434
|
+
activate: bool | None = None,
|
|
435
|
+
) -> dict[str, Any]:
|
|
436
|
+
"""Update an existing goal metric.
|
|
437
|
+
|
|
438
|
+
``activate=True`` makes this metric active (archiving the previously
|
|
439
|
+
active one). Pass any subset of fields you want to change.
|
|
440
|
+
|
|
441
|
+
Pass ``tier_override`` to set the user override (D/C/B/A) or
|
|
442
|
+
explicitly ``None`` to clear it; omit it to leave the existing
|
|
443
|
+
override untouched. The server still computes the underlying tier
|
|
444
|
+
from metric semantics — override is a display/XP overlay.
|
|
445
|
+
"""
|
|
446
|
+
body: dict[str, Any] = {}
|
|
447
|
+
if name is not None:
|
|
448
|
+
body["name"] = name
|
|
449
|
+
if metric_config is not None:
|
|
450
|
+
body["metric_config"] = metric_config
|
|
451
|
+
if tier_override is not _UNSET:
|
|
452
|
+
body["tier_override"] = tier_override
|
|
453
|
+
if activate is not None:
|
|
454
|
+
body["activate"] = activate
|
|
455
|
+
return await client.put(
|
|
456
|
+
f"/api/v1/goals/{goal_uid}/metrics/{metric_uid}", json=body,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
async def delete_goal_metric(
|
|
461
|
+
client: TetraClient, goal_uid: str, metric_uid: str,
|
|
462
|
+
) -> dict[str, Any]:
|
|
463
|
+
"""Permanently delete a goal metric."""
|
|
464
|
+
return await client.delete(
|
|
465
|
+
f"/api/v1/goals/{goal_uid}/metrics/{metric_uid}",
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@operation(
|
|
470
|
+
cli="goals move",
|
|
471
|
+
summary="Move a goal to a new parent and/or position in the sibling order.",
|
|
472
|
+
covers=[("POST", "/api/v1/goals/move")],
|
|
473
|
+
params={
|
|
474
|
+
"uid": arg(help="Goal UID to move"),
|
|
475
|
+
"new_parent": opt("--new-parent",
|
|
476
|
+
help="New parent goal name (omit to keep current)."),
|
|
477
|
+
"new_priority": opt("--priority", min=1,
|
|
478
|
+
help="New priority among siblings (1 = top)."),
|
|
479
|
+
},
|
|
480
|
+
)
|
|
481
|
+
async def move_goal(
|
|
482
|
+
client: TetraClient,
|
|
483
|
+
*,
|
|
484
|
+
uid: str,
|
|
485
|
+
new_parent: str | None = None,
|
|
486
|
+
new_priority: int | None = None,
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
"""Move a goal to a new parent and/or position in the sibling order.
|
|
489
|
+
|
|
490
|
+
The API treats ``new_parent: null`` as "promote to root" — there is no
|
|
491
|
+
"keep current parent" sentinel on the wire. So when the caller only
|
|
492
|
+
wants to reorder (priority change, no parent change), this helper
|
|
493
|
+
fetches the goal first and forwards its current parent so the route
|
|
494
|
+
doesn't accidentally promote it to root.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
client: Authenticated TetraClient
|
|
498
|
+
uid: Goal UID to move
|
|
499
|
+
new_parent: New parent goal name. Omit to keep current parent.
|
|
500
|
+
Pass an empty string ``""`` to explicitly promote to root.
|
|
501
|
+
new_priority: Target priority among siblings (1 = top). The API
|
|
502
|
+
normalizes neighboring siblings atomically.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
Move result with ``uid``, ``name``, ``parent``, ``priority``,
|
|
506
|
+
``siblings_updated``.
|
|
507
|
+
"""
|
|
508
|
+
body: dict[str, Any] = {"uid": uid}
|
|
509
|
+
if new_parent is None:
|
|
510
|
+
# Caller didn't specify — pre-fetch and use current parent so the
|
|
511
|
+
# API doesn't interpret missing as "move to root."
|
|
512
|
+
current = await client.get(f"/api/v1/goals/{uid}")
|
|
513
|
+
if current.get("parent"):
|
|
514
|
+
body["new_parent"] = current["parent"]
|
|
515
|
+
# else: goal is already a root, omit new_parent (API treats omitted
|
|
516
|
+
# the same as null = root, which is correct in this case).
|
|
517
|
+
elif new_parent == "":
|
|
518
|
+
# Explicit root promotion: send null on the wire.
|
|
519
|
+
body["new_parent"] = None
|
|
520
|
+
else:
|
|
521
|
+
body["new_parent"] = new_parent
|
|
522
|
+
if new_priority is not None:
|
|
523
|
+
body["new_priority"] = new_priority
|
|
524
|
+
return await client.post("/api/v1/goals/move", json=body)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@operation(
|
|
528
|
+
cli="goals batch-delete",
|
|
529
|
+
summary="Soft-delete multiple goals at once (archived for restore).",
|
|
530
|
+
covers=[("POST", "/api/v1/goals/batch-delete")],
|
|
531
|
+
params={
|
|
532
|
+
"uids": opt("--uid", repeatable=True, help="Goal UID to delete (repeatable)."),
|
|
533
|
+
},
|
|
534
|
+
)
|
|
535
|
+
async def batch_delete_goals(
|
|
536
|
+
client: TetraClient,
|
|
537
|
+
*,
|
|
538
|
+
uids: list[str],
|
|
539
|
+
delete_children: bool = False,
|
|
540
|
+
delete_events: bool = False,
|
|
541
|
+
reason: str | None = None,
|
|
542
|
+
) -> dict[str, Any]:
|
|
543
|
+
"""Soft-delete multiple goals at once.
|
|
544
|
+
|
|
545
|
+
Each goal is archived for potential restoration. Children and events are
|
|
546
|
+
either deleted alongside the goal or have their references cleared,
|
|
547
|
+
depending on the flags.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
client: Authenticated TetraClient
|
|
551
|
+
uids: Goal UIDs to delete
|
|
552
|
+
delete_children: If True, delete child goals; else clear their parent.
|
|
553
|
+
delete_events: If True, delete events; else remove the goal from them.
|
|
554
|
+
reason: Optional human-readable reason recorded with the deletion.
|
|
555
|
+
|
|
556
|
+
Returns:
|
|
557
|
+
Dict with ``deleted`` (list of UIDs) and ``failed`` (list of errors).
|
|
558
|
+
"""
|
|
559
|
+
body: dict[str, Any] = {
|
|
560
|
+
"uids": uids,
|
|
561
|
+
"delete_children": delete_children,
|
|
562
|
+
"delete_events": delete_events,
|
|
563
|
+
}
|
|
564
|
+
if reason is not None:
|
|
565
|
+
body["reason"] = reason
|
|
566
|
+
return await client.post("/api/v1/goals/batch-delete", json=body)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
@operation(
|
|
570
|
+
cli="goals batch-priorities",
|
|
571
|
+
summary="Batch-reorder goals within a sibling group.",
|
|
572
|
+
covers=[("POST", "/api/v1/goals/batch-update-priorities")],
|
|
573
|
+
params={
|
|
574
|
+
"updates": opt("--updates", json=True,
|
|
575
|
+
help="JSON map of {goal_uid: new_priority} (1-based, sequential)."),
|
|
576
|
+
"parent": opt("--parent", help="Parent goal name (omit for root siblings)."),
|
|
577
|
+
},
|
|
578
|
+
)
|
|
579
|
+
async def batch_update_goal_priorities(
|
|
580
|
+
client: TetraClient,
|
|
581
|
+
*,
|
|
582
|
+
updates: dict[str, int],
|
|
583
|
+
parent: str | None = None,
|
|
584
|
+
) -> dict[str, Any]:
|
|
585
|
+
"""Batch-update priorities for goals within one sibling group.
|
|
586
|
+
|
|
587
|
+
All goals must share the same parent. Priorities must be sequential
|
|
588
|
+
starting from 1 (the server validates this).
|
|
589
|
+
|
|
590
|
+
Args:
|
|
591
|
+
client: Authenticated TetraClient
|
|
592
|
+
updates: Map of ``{goal_uid: new_priority}`` (1 = top).
|
|
593
|
+
parent: Parent goal name (null/omit for root-level siblings).
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
Dict with ``updated`` count and the affected ``uids``.
|
|
597
|
+
"""
|
|
598
|
+
body: dict[str, Any] = {"updates": updates}
|
|
599
|
+
if parent is not None:
|
|
600
|
+
body["parent"] = parent
|
|
601
|
+
return await client.post("/api/v1/goals/batch-update-priorities", json=body)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@operation(
|
|
605
|
+
cli="goals merge",
|
|
606
|
+
summary="Merge multiple goals into a new goal, reassigning their events.",
|
|
607
|
+
covers=[("POST", "/api/v1/goals/merge")],
|
|
608
|
+
params={
|
|
609
|
+
"source_uids": opt("--source-uid", repeatable=True,
|
|
610
|
+
help="Source goal UID to merge (repeatable; 2+ required)."),
|
|
611
|
+
"new_goal": opt("--new-goal", json=True,
|
|
612
|
+
help="New goal data as a JSON dict (at least {\"name\": ...})."),
|
|
613
|
+
},
|
|
614
|
+
)
|
|
615
|
+
async def merge_goals(
|
|
616
|
+
client: TetraClient,
|
|
617
|
+
*,
|
|
618
|
+
source_uids: list[str],
|
|
619
|
+
new_goal: dict[str, Any],
|
|
620
|
+
) -> dict[str, Any]:
|
|
621
|
+
"""Merge two or more goals into a new goal.
|
|
622
|
+
|
|
623
|
+
Events from all source goals are reassigned to the new goal, and the
|
|
624
|
+
source goals are soft-deleted after the merge.
|
|
625
|
+
|
|
626
|
+
Args:
|
|
627
|
+
client: Authenticated TetraClient
|
|
628
|
+
source_uids: UIDs of the goals to merge (at least 2).
|
|
629
|
+
new_goal: New goal payload (must include ``name``; may include
|
|
630
|
+
``parent``, ``values``, etc.).
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
The created goal dict.
|
|
634
|
+
"""
|
|
635
|
+
body: dict[str, Any] = {"source_uids": source_uids, "new_goal": new_goal}
|
|
636
|
+
return await client.post("/api/v1/goals/merge", json=body)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@operation(
|
|
640
|
+
cli="goals status-all",
|
|
641
|
+
summary="Get achievement status for all goals (two-phase: cached + pending).",
|
|
642
|
+
covers=[("GET", "/api/v1/goals/status/all")],
|
|
643
|
+
params={
|
|
644
|
+
"as_of": opt("--as-of", help="Status as of this date (YYYY-MM-DD) or ISO datetime."),
|
|
645
|
+
"group_uid": opt("--group-uid", help="Group UID (omit for personal goals)."),
|
|
646
|
+
"target_account_uid": opt("--target-account-uid",
|
|
647
|
+
help="View a specific member's status (needs group_uid)."),
|
|
648
|
+
},
|
|
649
|
+
)
|
|
650
|
+
async def get_all_goals_status(
|
|
651
|
+
client: TetraClient,
|
|
652
|
+
*,
|
|
653
|
+
as_of: str | None = None,
|
|
654
|
+
group_uid: str | None = None,
|
|
655
|
+
target_account_uid: str | None = None,
|
|
656
|
+
) -> dict[str, Any]:
|
|
657
|
+
"""Get achievement status for all goals.
|
|
658
|
+
|
|
659
|
+
Returns cached statuses immediately plus a list of pending goal UIDs
|
|
660
|
+
whose status is being recalculated in the background.
|
|
661
|
+
|
|
662
|
+
Args:
|
|
663
|
+
client: Authenticated TetraClient
|
|
664
|
+
as_of: Optional point-in-time for the calculation.
|
|
665
|
+
group_uid: Optional group scope (omit for personal goals).
|
|
666
|
+
target_account_uid: Optional member to view (requires group_uid).
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Dict with ``statuses``, ``pending_goal_uids``, and ``calculation_id``.
|
|
670
|
+
"""
|
|
671
|
+
params: dict[str, Any] = {}
|
|
672
|
+
if as_of is not None:
|
|
673
|
+
params["as_of"] = as_of
|
|
674
|
+
if group_uid is not None:
|
|
675
|
+
params["group_uid"] = group_uid
|
|
676
|
+
if target_account_uid is not None:
|
|
677
|
+
params["target_account_uid"] = target_account_uid
|
|
678
|
+
return await client.get("/api/v1/goals/status/all", params=params or None)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
@operation(
|
|
682
|
+
cli="goals status-recalculate",
|
|
683
|
+
summary="Clear the goal status cache and recalculate all goal statuses.",
|
|
684
|
+
covers=[("POST", "/api/v1/goals/status/recalculate")],
|
|
685
|
+
params={
|
|
686
|
+
"as_of": opt("--as-of", help="Recalculate as of this date/datetime (default now)."),
|
|
687
|
+
},
|
|
688
|
+
)
|
|
689
|
+
async def recalculate_all_goals(
|
|
690
|
+
client: TetraClient,
|
|
691
|
+
*,
|
|
692
|
+
as_of: str | None = None,
|
|
693
|
+
) -> dict[str, Any]:
|
|
694
|
+
"""Clear all goal status cache and recalculate every goal's status.
|
|
695
|
+
|
|
696
|
+
Args:
|
|
697
|
+
client: Authenticated TetraClient
|
|
698
|
+
as_of: Optional point-in-time for the recalculation (default: now).
|
|
699
|
+
|
|
700
|
+
Returns:
|
|
701
|
+
Dict with ``cache_cleared`` and ``goals_recalculated`` counts.
|
|
702
|
+
"""
|
|
703
|
+
params: dict[str, Any] = {}
|
|
704
|
+
if as_of is not None:
|
|
705
|
+
params["as_of"] = as_of
|
|
706
|
+
return await client.post(
|
|
707
|
+
"/api/v1/goals/status/recalculate"
|
|
708
|
+
+ ("?" + "&".join(f"{k}={v}" for k, v in params.items()) if params else ""),
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
@operation(
|
|
713
|
+
cli="goals children",
|
|
714
|
+
summary="Get the direct child goals of a goal.",
|
|
715
|
+
covers=[("GET", "/api/v1/goals/{uid}/children")],
|
|
716
|
+
params={
|
|
717
|
+
"uid": arg(help="Goal UID"),
|
|
718
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
719
|
+
},
|
|
720
|
+
)
|
|
721
|
+
async def get_goal_children(
|
|
722
|
+
client: TetraClient,
|
|
723
|
+
uid: str,
|
|
724
|
+
*,
|
|
725
|
+
group_uid: str | None = None,
|
|
726
|
+
) -> list[dict[str, Any]]:
|
|
727
|
+
"""Get the direct children of a goal.
|
|
728
|
+
|
|
729
|
+
Args:
|
|
730
|
+
client: Authenticated TetraClient
|
|
731
|
+
uid: Parent goal UID.
|
|
732
|
+
group_uid: Optional group scope.
|
|
733
|
+
|
|
734
|
+
Returns:
|
|
735
|
+
List of child goal dicts.
|
|
736
|
+
"""
|
|
737
|
+
params: dict[str, Any] = {}
|
|
738
|
+
if group_uid is not None:
|
|
739
|
+
params["group_uid"] = group_uid
|
|
740
|
+
return await client.get(
|
|
741
|
+
f"/api/v1/goals/{uid}/children", params=params or None,
|
|
742
|
+
)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
@operation(
|
|
746
|
+
cli="goals completion-status",
|
|
747
|
+
summary="Check whether a goal can be marked complete (lists blockers).",
|
|
748
|
+
covers=[("GET", "/api/v1/goals/{uid}/completion-status")],
|
|
749
|
+
params={"uid": arg(help="Goal UID")},
|
|
750
|
+
)
|
|
751
|
+
async def get_goal_completion_status(
|
|
752
|
+
client: TetraClient, uid: str,
|
|
753
|
+
) -> dict[str, Any]:
|
|
754
|
+
"""Get a goal's completion status (can it be marked complete?).
|
|
755
|
+
|
|
756
|
+
Reports unmet transitive dependencies and unachieved child goals that
|
|
757
|
+
block completion.
|
|
758
|
+
|
|
759
|
+
Args:
|
|
760
|
+
client: Authenticated TetraClient
|
|
761
|
+
uid: Goal UID.
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
Dict with ``can_complete``, ``blocking_dependencies``,
|
|
765
|
+
``blocking_children``.
|
|
766
|
+
"""
|
|
767
|
+
return await client.get(f"/api/v1/goals/{uid}/completion-status")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
@operation(
|
|
771
|
+
cli="goals detail-analytics",
|
|
772
|
+
summary="Get comprehensive analytics for a single goal's detail view.",
|
|
773
|
+
covers=[("GET", "/api/v1/goals/{uid}/detail-analytics")],
|
|
774
|
+
params={
|
|
775
|
+
"uid": arg(help="Goal UID"),
|
|
776
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
777
|
+
"target_account_uid": opt("--target-account-uid",
|
|
778
|
+
help="View a member's analytics (requires mentor permission)."),
|
|
779
|
+
},
|
|
780
|
+
)
|
|
781
|
+
async def get_goal_detail_analytics(
|
|
782
|
+
client: TetraClient,
|
|
783
|
+
uid: str,
|
|
784
|
+
*,
|
|
785
|
+
group_uid: str | None = None,
|
|
786
|
+
target_account_uid: str | None = None,
|
|
787
|
+
) -> dict[str, Any]:
|
|
788
|
+
"""Get comprehensive detail-view analytics for a goal.
|
|
789
|
+
|
|
790
|
+
Includes XP history, streaks, summary factoids, consistency heatmap,
|
|
791
|
+
mood-vs-performance scatter, and missed-reasons word cloud.
|
|
792
|
+
|
|
793
|
+
Args:
|
|
794
|
+
client: Authenticated TetraClient
|
|
795
|
+
uid: Goal UID.
|
|
796
|
+
group_uid: Optional group scope.
|
|
797
|
+
target_account_uid: Optional member to view.
|
|
798
|
+
|
|
799
|
+
Returns:
|
|
800
|
+
Goal detail analytics dict.
|
|
801
|
+
"""
|
|
802
|
+
params: dict[str, Any] = {}
|
|
803
|
+
if group_uid is not None:
|
|
804
|
+
params["group_uid"] = group_uid
|
|
805
|
+
if target_account_uid is not None:
|
|
806
|
+
params["target_account_uid"] = target_account_uid
|
|
807
|
+
return await client.get(
|
|
808
|
+
f"/api/v1/goals/{uid}/detail-analytics", params=params or None,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
@operation(
|
|
813
|
+
cli="goals events",
|
|
814
|
+
summary="Get paginated events for a goal (with filtering and sorting).",
|
|
815
|
+
covers=[("GET", "/api/v1/goals/{uid}/events")],
|
|
816
|
+
params={
|
|
817
|
+
"uid": arg(help="Goal UID"),
|
|
818
|
+
"tag_uids": opt("--tag-uid", repeatable=True,
|
|
819
|
+
help="Tag UID filter (repeatable; event must match)."),
|
|
820
|
+
"sort_order": opt("--sort-order", choices=["asc", "desc"],
|
|
821
|
+
help="Date sort order (default desc = newest first)."),
|
|
822
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
823
|
+
"target_account_uid": opt("--target-account-uid",
|
|
824
|
+
help="View a specific member's events."),
|
|
825
|
+
},
|
|
826
|
+
)
|
|
827
|
+
async def get_goal_events(
|
|
828
|
+
client: TetraClient,
|
|
829
|
+
uid: str,
|
|
830
|
+
*,
|
|
831
|
+
include_descendants: bool = True,
|
|
832
|
+
group_uid: str | None = None,
|
|
833
|
+
target_account_uid: str | None = None,
|
|
834
|
+
limit: int = 50,
|
|
835
|
+
offset: int = 0,
|
|
836
|
+
tag_uids: list[str] | None = None,
|
|
837
|
+
search: str | None = None,
|
|
838
|
+
date_from: str | None = None,
|
|
839
|
+
date_to: str | None = None,
|
|
840
|
+
sort_order: str = "desc",
|
|
841
|
+
) -> dict[str, Any]:
|
|
842
|
+
"""Get paginated events for a goal.
|
|
843
|
+
|
|
844
|
+
Tag UIDs are sent comma-joined (the backend splits on commas and treats
|
|
845
|
+
them as an AND filter expanded to descendants).
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
client: Authenticated TetraClient
|
|
849
|
+
uid: Goal UID.
|
|
850
|
+
include_descendants: Include events from child goals (default True).
|
|
851
|
+
group_uid: Optional group scope.
|
|
852
|
+
target_account_uid: Optional member whose events to show.
|
|
853
|
+
limit: Max events per page (1-500, default 50).
|
|
854
|
+
offset: Events to skip (default 0).
|
|
855
|
+
tag_uids: Tag UID filters (event must have all).
|
|
856
|
+
search: Case-insensitive description search.
|
|
857
|
+
date_from: Inclusive start date (YYYY-MM-DD).
|
|
858
|
+
date_to: Inclusive end date (YYYY-MM-DD).
|
|
859
|
+
sort_order: ``asc`` or ``desc`` (default ``desc``).
|
|
860
|
+
|
|
861
|
+
Returns:
|
|
862
|
+
Dict with ``items``, ``has_more``, ``offset``, ``limit``.
|
|
863
|
+
"""
|
|
864
|
+
params: dict[str, Any] = {
|
|
865
|
+
"include_descendants": include_descendants,
|
|
866
|
+
"limit": limit,
|
|
867
|
+
"offset": offset,
|
|
868
|
+
"sort_order": sort_order,
|
|
869
|
+
}
|
|
870
|
+
if group_uid is not None:
|
|
871
|
+
params["group_uid"] = group_uid
|
|
872
|
+
if target_account_uid is not None:
|
|
873
|
+
params["target_account_uid"] = target_account_uid
|
|
874
|
+
if tag_uids:
|
|
875
|
+
params["tag_uids"] = ",".join(tag_uids)
|
|
876
|
+
if search is not None:
|
|
877
|
+
params["search"] = search
|
|
878
|
+
if date_from is not None:
|
|
879
|
+
params["date_from"] = date_from
|
|
880
|
+
if date_to is not None:
|
|
881
|
+
params["date_to"] = date_to
|
|
882
|
+
return await client.get(f"/api/v1/goals/{uid}/events", params=params)
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@operation(
|
|
886
|
+
cli="goals progress-history",
|
|
887
|
+
summary="Get a goal's progress snapshots over time (for charting).",
|
|
888
|
+
covers=[("GET", "/api/v1/goals/{uid}/progress-history")],
|
|
889
|
+
params={
|
|
890
|
+
"uid": arg(help="Goal UID"),
|
|
891
|
+
"metric_config": opt("--metric-config", json=True,
|
|
892
|
+
help="Override MetricConfig as a JSON dict (else the active one)."),
|
|
893
|
+
"tuple_fields": opt("--tuple-field", repeatable=True,
|
|
894
|
+
help="Field name for tuple outcomes (repeatable)."),
|
|
895
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
896
|
+
"target_account_uid": opt("--target-account-uid",
|
|
897
|
+
help="View a member's progress history."),
|
|
898
|
+
},
|
|
899
|
+
)
|
|
900
|
+
async def get_goal_progress_history(
|
|
901
|
+
client: TetraClient,
|
|
902
|
+
uid: str,
|
|
903
|
+
*,
|
|
904
|
+
start: str | None = None,
|
|
905
|
+
end: str | None = None,
|
|
906
|
+
metric_config: dict[str, Any] | None = None,
|
|
907
|
+
tuple_fields: list[str] | None = None,
|
|
908
|
+
group_uid: str | None = None,
|
|
909
|
+
target_account_uid: str | None = None,
|
|
910
|
+
) -> dict[str, Any]:
|
|
911
|
+
"""Get progress history for a goal at each event date.
|
|
912
|
+
|
|
913
|
+
The backend takes ``metric_config`` and ``tuple_fields`` as encoded query
|
|
914
|
+
params (JSON string and comma-joined list, respectively), so this op
|
|
915
|
+
serializes them before sending.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
client: Authenticated TetraClient
|
|
919
|
+
uid: Goal UID.
|
|
920
|
+
start: Inclusive start datetime (ISO).
|
|
921
|
+
end: Inclusive end datetime (ISO).
|
|
922
|
+
metric_config: Optional metric config override (else the goal's active).
|
|
923
|
+
tuple_fields: Field names for tuple outcomes.
|
|
924
|
+
group_uid: Optional group scope.
|
|
925
|
+
target_account_uid: Optional member to view.
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
Dict with ``goal_uid`` and ``points`` (progress snapshots).
|
|
929
|
+
"""
|
|
930
|
+
import json as _json
|
|
931
|
+
|
|
932
|
+
params: dict[str, Any] = {}
|
|
933
|
+
if start is not None:
|
|
934
|
+
params["start"] = start
|
|
935
|
+
if end is not None:
|
|
936
|
+
params["end"] = end
|
|
937
|
+
if metric_config is not None:
|
|
938
|
+
params["metric_config"] = _json.dumps(metric_config)
|
|
939
|
+
if tuple_fields:
|
|
940
|
+
params["tuple_fields"] = ",".join(tuple_fields)
|
|
941
|
+
if group_uid is not None:
|
|
942
|
+
params["group_uid"] = group_uid
|
|
943
|
+
if target_account_uid is not None:
|
|
944
|
+
params["target_account_uid"] = target_account_uid
|
|
945
|
+
return await client.get(
|
|
946
|
+
f"/api/v1/goals/{uid}/progress-history", params=params or None,
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
@operation(
|
|
951
|
+
cli="goals time-history",
|
|
952
|
+
summary="Get time/count-per-period aggregation history for a goal's events.",
|
|
953
|
+
covers=[("GET", "/api/v1/goals/{uid}/time-history")],
|
|
954
|
+
params={
|
|
955
|
+
"uid": arg(help="Goal UID"),
|
|
956
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
957
|
+
"target_account_uid": opt("--target-account-uid",
|
|
958
|
+
help="View a member's time history."),
|
|
959
|
+
},
|
|
960
|
+
)
|
|
961
|
+
async def get_goal_time_history(
|
|
962
|
+
client: TetraClient,
|
|
963
|
+
uid: str,
|
|
964
|
+
*,
|
|
965
|
+
start: str | None = None,
|
|
966
|
+
end: str | None = None,
|
|
967
|
+
group_uid: str | None = None,
|
|
968
|
+
target_account_uid: str | None = None,
|
|
969
|
+
) -> dict[str, Any]:
|
|
970
|
+
"""Get time-based aggregation history for a goal's events.
|
|
971
|
+
|
|
972
|
+
Returns duration-per-period or event-count-per-period data, used as a
|
|
973
|
+
fallback chart on goal detail when the goal has no metric.
|
|
974
|
+
|
|
975
|
+
Args:
|
|
976
|
+
client: Authenticated TetraClient
|
|
977
|
+
uid: Goal UID.
|
|
978
|
+
start: Inclusive start date (YYYY-MM-DD).
|
|
979
|
+
end: Inclusive end date (YYYY-MM-DD).
|
|
980
|
+
group_uid: Optional group scope.
|
|
981
|
+
target_account_uid: Optional member to view.
|
|
982
|
+
|
|
983
|
+
Returns:
|
|
984
|
+
Time history aggregation dict.
|
|
985
|
+
"""
|
|
986
|
+
params: dict[str, Any] = {}
|
|
987
|
+
if start is not None:
|
|
988
|
+
params["start"] = start
|
|
989
|
+
if end is not None:
|
|
990
|
+
params["end"] = end
|
|
991
|
+
if group_uid is not None:
|
|
992
|
+
params["group_uid"] = group_uid
|
|
993
|
+
if target_account_uid is not None:
|
|
994
|
+
params["target_account_uid"] = target_account_uid
|
|
995
|
+
return await client.get(
|
|
996
|
+
f"/api/v1/goals/{uid}/time-history", params=params or None,
|
|
997
|
+
)
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
@operation(
|
|
1001
|
+
cli="goals week-events",
|
|
1002
|
+
summary="Get this-week and last-week events for a goal, with summaries.",
|
|
1003
|
+
covers=[("GET", "/api/v1/goals/{uid}/week-events")],
|
|
1004
|
+
params={
|
|
1005
|
+
"uid": arg(help="Goal UID"),
|
|
1006
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
1007
|
+
"target_account_uid": opt("--target-account-uid",
|
|
1008
|
+
help="View a member's week events."),
|
|
1009
|
+
},
|
|
1010
|
+
)
|
|
1011
|
+
async def get_goal_week_events(
|
|
1012
|
+
client: TetraClient,
|
|
1013
|
+
uid: str,
|
|
1014
|
+
*,
|
|
1015
|
+
group_uid: str | None = None,
|
|
1016
|
+
target_account_uid: str | None = None,
|
|
1017
|
+
) -> dict[str, Any]:
|
|
1018
|
+
"""Get this-week and last-week events for a goal, with summaries.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
client: Authenticated TetraClient
|
|
1022
|
+
uid: Goal UID.
|
|
1023
|
+
group_uid: Optional group scope.
|
|
1024
|
+
target_account_uid: Optional member to view.
|
|
1025
|
+
|
|
1026
|
+
Returns:
|
|
1027
|
+
Dict with this-week/last-week event lists and summaries.
|
|
1028
|
+
"""
|
|
1029
|
+
params: dict[str, Any] = {}
|
|
1030
|
+
if group_uid is not None:
|
|
1031
|
+
params["group_uid"] = group_uid
|
|
1032
|
+
if target_account_uid is not None:
|
|
1033
|
+
params["target_account_uid"] = target_account_uid
|
|
1034
|
+
return await client.get(
|
|
1035
|
+
f"/api/v1/goals/{uid}/week-events", params=params or None,
|
|
1036
|
+
)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
@operation(
|
|
1040
|
+
cli="goals status-stages",
|
|
1041
|
+
summary="Get the stage-by-stage breakdown of a goal's status calculation.",
|
|
1042
|
+
covers=[("GET", "/api/v1/goals/{uid}/status/stages")],
|
|
1043
|
+
params={
|
|
1044
|
+
"uid": arg(help="Goal UID"),
|
|
1045
|
+
"as_of": opt("--as-of", help="Compute as of this date (YYYY-MM-DD) or ISO datetime."),
|
|
1046
|
+
"group_uid": opt("--group-uid", help="Group UID for group-scoped lookup."),
|
|
1047
|
+
},
|
|
1048
|
+
)
|
|
1049
|
+
async def get_goal_status_stages(
|
|
1050
|
+
client: TetraClient,
|
|
1051
|
+
uid: str,
|
|
1052
|
+
*,
|
|
1053
|
+
as_of: str | None = None,
|
|
1054
|
+
group_uid: str | None = None,
|
|
1055
|
+
) -> dict[str, Any]:
|
|
1056
|
+
"""Get the stage-by-stage breakdown of a goal's status calculation.
|
|
1057
|
+
|
|
1058
|
+
Only stage-based metric types (count, duration, money, outcome,
|
|
1059
|
+
moneytime) produce stages; other types return an empty stages list.
|
|
1060
|
+
|
|
1061
|
+
Args:
|
|
1062
|
+
client: Authenticated TetraClient
|
|
1063
|
+
uid: Goal UID.
|
|
1064
|
+
as_of: Optional point-in-time for the calculation.
|
|
1065
|
+
group_uid: Optional group scope.
|
|
1066
|
+
|
|
1067
|
+
Returns:
|
|
1068
|
+
Dict with ``stages``, ``achieved``, ``actual``, ``target``.
|
|
1069
|
+
"""
|
|
1070
|
+
params: dict[str, Any] = {}
|
|
1071
|
+
if as_of is not None:
|
|
1072
|
+
params["as_of"] = as_of
|
|
1073
|
+
if group_uid is not None:
|
|
1074
|
+
params["group_uid"] = group_uid
|
|
1075
|
+
return await client.get(
|
|
1076
|
+
f"/api/v1/goals/{uid}/status/stages", params=params or None,
|
|
1077
|
+
)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
@operation(
|
|
1081
|
+
cli="goals rename-field",
|
|
1082
|
+
summary="Rename a record/tuple field on a goal and all its events.",
|
|
1083
|
+
covers=[("POST", "/api/v1/goals/{uid}/rename-field")],
|
|
1084
|
+
params={
|
|
1085
|
+
"uid": arg(help="Goal UID"),
|
|
1086
|
+
"old_name": opt("--old-name", help="Current field name to rename."),
|
|
1087
|
+
"new_name": opt("--new-name", help="New field name."),
|
|
1088
|
+
},
|
|
1089
|
+
)
|
|
1090
|
+
async def rename_goal_field(
|
|
1091
|
+
client: TetraClient,
|
|
1092
|
+
uid: str,
|
|
1093
|
+
*,
|
|
1094
|
+
old_name: str,
|
|
1095
|
+
new_name: str,
|
|
1096
|
+
) -> dict[str, Any]:
|
|
1097
|
+
"""Rename a tuple/record field on a goal and across all its events.
|
|
1098
|
+
|
|
1099
|
+
Atomically updates ``goal.outcome_names`` and every event's outcome
|
|
1100
|
+
records so the field name stays consistent.
|
|
1101
|
+
|
|
1102
|
+
Args:
|
|
1103
|
+
client: Authenticated TetraClient
|
|
1104
|
+
uid: Goal UID.
|
|
1105
|
+
old_name: Existing field name.
|
|
1106
|
+
new_name: Replacement field name.
|
|
1107
|
+
|
|
1108
|
+
Returns:
|
|
1109
|
+
Dict with ``success``, ``events_updated``, and the new
|
|
1110
|
+
``outcome_names``.
|
|
1111
|
+
"""
|
|
1112
|
+
body: dict[str, Any] = {"old_name": old_name, "new_name": new_name}
|
|
1113
|
+
return await client.post(f"/api/v1/goals/{uid}/rename-field", json=body)
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
@operation(
|
|
1117
|
+
cli="goals reorder",
|
|
1118
|
+
summary="Persist the root ordering of the interleaved 'all' goals view.",
|
|
1119
|
+
covers=[("POST", "/api/v1/goals/reorder-all")],
|
|
1120
|
+
params={
|
|
1121
|
+
"order": opt("--uid", repeatable=True,
|
|
1122
|
+
help="Root goal UID, in top-to-bottom order (repeatable)."),
|
|
1123
|
+
},
|
|
1124
|
+
)
|
|
1125
|
+
async def reorder_all_goals(
|
|
1126
|
+
client: TetraClient,
|
|
1127
|
+
*,
|
|
1128
|
+
order: list[str],
|
|
1129
|
+
) -> dict[str, Any]:
|
|
1130
|
+
"""Persist the root ordering of the interleaved "all" goals view.
|
|
1131
|
+
|
|
1132
|
+
Stored as a per-account overlay; canonical per-scope priorities are never
|
|
1133
|
+
touched, so personal and group orders stay independent.
|
|
1134
|
+
|
|
1135
|
+
Args:
|
|
1136
|
+
client: Authenticated TetraClient
|
|
1137
|
+
order: Full top-to-bottom sequence of root goal UIDs across scopes.
|
|
1138
|
+
|
|
1139
|
+
Returns:
|
|
1140
|
+
Dict with ``updated`` (count of overlay rows written).
|
|
1141
|
+
"""
|
|
1142
|
+
return await client.post(
|
|
1143
|
+
"/api/v1/goals/reorder-all", json={"order": order}
|
|
1144
|
+
)
|