feat(workouts): generate the per-workout insight on arrival, never on view

The spine dispatches a generator-free enqueue; the worker resolves the
provider. Five gates stand in front of it in ascending cost order — both
modules on, ten-minute duration floor, a hard count of four a day in the
user's own timezone, an input hash over the deterministic evidence, and the
token ledger inside the shared status chokepoint. Each refusal returns a
status rather than throwing, because retrying against a ceiling that does not
move until midnight is an unbounded loop.

The detail route only reads: a workout with no row serves null and renders no
card, which is every workout that predates this and every re-synced one. An
undecryptable paragraph degrades to no card rather than failing the page.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 13:56:50 +02:00
1 parent e53a8ce8a0
commit 1a469301b3
9 files changed
+556 -29

No files matched your search

+22 -5
View File
@@ -3031,10 +3031,12 @@ paths:
summary: Workout detail (v1.4.32)
description: Single-workout envelope. Owns the optional `WorkoutRoute` GeoJSON geometry + `canonicalId` pointer that
resolves to the cluster winner so deep-links into non-canonical twin rows can redirect cleanly. Additive
enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`, reserved `aiInsight`) are computed
server-side. `compact=1` (sent by the web client) drops the raw `samples.samples` HR blob and
`route.sampleTimestamps` array from the response; without it the payload is byte-identical to the v1.4.32
contract. Cross-user rows surface as 404 (existence channel sealed).
enrichment fields (`hrSeries`, `zones`, `splits`, `sportContext`) are computed server-side. `aiInsight` is a
pure read of a per-workout paragraph written by a background job when the workout landed; it is null for every
workout that predates the feature or was re-synced, and reading this endpoint never generates one. `compact=1`
(sent by the web client) drops the raw `samples.samples` HR blob and `route.sampleTimestamps` array from the
response; without it the payload is byte-identical to the v1.4.32 contract. Cross-user rows surface as 404
(existence channel sealed).
parameters:
- in: path
name: id
@@ -18341,7 +18343,9 @@ components:
- $ref: "#/components/schemas/WorkoutSportContext"
- type: "null"
aiInsight:
type: "null"
anyOf:
- $ref: "#/components/schemas/WorkoutActivityInsight"
- type: "null"
canonicalId:
type: string
required:
@@ -18540,6 +18544,19 @@ components:
- avgDistanceM
- avgAvgHr
additionalProperties: false
WorkoutActivityInsight:
type: object
properties:
paragraph:
type: string
generatedAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- paragraph
- generatedAt
additionalProperties: false
BatchWorkoutsResponse:
type: object
properties: