mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
feat(coach): give every read-only assessment surface a way out
Status cards and derived-score sheets read one way: they state a verdict and
end there. The metric pages already carried the hand-off; the score sheets
passed neither an opener nor a scope, so they painted no action at all.
- Score sheets (sleep / readiness / recovery / stress / strain) gain the same
auto-sent opener the metric pages use, each scoped to the inputs that drive
the composite rather than to a block it has no equivalent of.
- Workout detail's coach launch carries the workout itself. The chat request
gains an optional workoutId; on the FIRST turn the route narrows by
{ id, userId } and pins ONE bounded evidence section onto the snapshot.
Later turns reuse the pinned snapshot, so per-turn work stays constant in
conversation length.
- /coach?workout=<id> covers the full-page and maximize paths.
The workout block deliberately does not take the fenced transport the stored-
document path takes: a workout row is the user's own sensor record, and the
projection admits no free text. That is enforced, not assumed — sport_type is
a TEXT column, so it is re-asserted against the closed sport union, metadata
is excluded apart from its numeric zone durations read through a Zod slice,
and a recursive guard throws on any non-numeric leaf.
Own-history sport averages move to src/lib/workouts/sport-context.ts so the
narrative and the detail page cannot quote different figures.
This commit is contained in:
1 parent
f9112925d2
commit
096c8c799b
20 files changed
+672
-74
No files matched your search
@@ -144,6 +144,18 @@ export const coachChatRequestSchema = z.object({
|
||||
* persisted user turn stays the answer alone.
|
||||
*/
|
||||
guidedQuestion: z.string().min(1).max(500).optional(),
|
||||
/**
|
||||
* v1.31.0 — a conversation launched from one workout ("Ask why" on the
|
||||
* workout-detail page, or `/coach?workout=<id>`). On the FIRST turn the
|
||||
* route narrows by `{ id: workoutId, userId }` — the universal tenancy
|
||||
* narrow, so a foreign id simply finds nothing — and pins ONE bounded,
|
||||
* numbers-only evidence section onto the conversation's snapshot.
|
||||
*
|
||||
* Snapshot-once is untouched: the client sends this only while
|
||||
* `conversationId` is absent, and the route ignores it on every later
|
||||
* turn, so per-turn work does not grow with conversation length.
|
||||
*/
|
||||
workoutId: z.string().max(64).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Per-workout evidence block for a Coach conversation launched from a
|
||||
* workout ("Ask why" on the workout-detail page, or `/coach?workout=<id>`).
|
||||
*
|
||||
* ── Why this block is NOT fenced ─────────────────────────────────────
|
||||
*
|
||||
* The stored-document path (`?doc=`) routes every turn through the hardened
|
||||
* fenced endpoint because document text is third-party prose transcribed out
|
||||
* of a PDF — an injection surface by construction. A workout row is not:
|
||||
* it is the user's own structured sensor record. This block therefore rides
|
||||
* the normal Coach prompt as one additional snapshot section.
|
||||
*
|
||||
* That reasoning only holds if the projection really is numbers-only, so the
|
||||
* closure is enforced here rather than assumed:
|
||||
*
|
||||
* - Every emitted leaf is a NUMBER, a BOOLEAN, or a token drawn from a
|
||||
* closed server-side vocabulary. `assertNumbersOnly()` walks the built
|
||||
* object and throws on anything else, so a future field that smuggles a
|
||||
* string in fails loudly at build time instead of silently reaching a
|
||||
* prompt.
|
||||
* - `sportType` is a TEXT column at the DB layer (see `Workout.sport_type`
|
||||
* in `prisma/schema.prisma`). The write path constrains it to
|
||||
* `workoutSportTypeEnum`, but the backup-restore path accepts
|
||||
* `z.string().min(1)`, so a restored row can carry arbitrary text. It is
|
||||
* therefore re-asserted against the closed enum here and folded to
|
||||
* `"other"` on a miss — the same whitelist posture the raw-SQL splices
|
||||
* use.
|
||||
* - `metadata` is EXCLUDED wholesale. It carries device bundle ids and
|
||||
* HKWorkoutEvent markers — free text with no numeric value to the
|
||||
* narrative. Only the WHOOP zone durations are read out of it, and only
|
||||
* through the narrow numeric Zod slice `parseWhoopZoneDurations()`.
|
||||
* - `externalId` / `externalSourceVersion` are EXCLUDED — opaque
|
||||
* device-assigned strings.
|
||||
* - Route GEOMETRY is EXCLUDED. The per-point coordinate trace is the
|
||||
* user's location history; the climb figure the narrative needs is
|
||||
* already the numeric `elevationM` column.
|
||||
*
|
||||
* The conversation-level tenancy narrow (`{ id, userId }`) lives at the call
|
||||
* site in the chat route; this module never widens it.
|
||||
*/
|
||||
import { workoutSportTypeEnum } from "@/lib/validations/workout";
|
||||
import type { HrSeriesPoint } from "@/lib/workouts/hr-series";
|
||||
import type { WorkoutZones } from "@/lib/workouts/zones";
|
||||
import type { WorkoutSportContext } from "@/lib/workouts/sport-context";
|
||||
|
||||
/**
|
||||
* Fold an arbitrary stored sport string onto the closed union.
|
||||
*
|
||||
* `sport_type` is a TEXT column, so this fold is load-bearing (see the
|
||||
* module docblock). `source`, by contrast, is a Postgres ENUM — the
|
||||
* database itself is the closed vocabulary there, so it is admitted as-is
|
||||
* rather than re-listed here. A hand-maintained copy of the source list
|
||||
* would only drift from `enum MeasurementSource`.
|
||||
*/
|
||||
export function closedSportType(raw: string): string {
|
||||
const parsed = workoutSportTypeEnum.safeParse(raw);
|
||||
return parsed.success ? parsed.data : "other";
|
||||
}
|
||||
|
||||
export interface WorkoutEvidenceInput {
|
||||
sportType: string;
|
||||
source: string;
|
||||
startedAt: Date;
|
||||
durationSec: number;
|
||||
totalEnergyKcal: number | null;
|
||||
totalDistanceM: number | null;
|
||||
avgHeartRate: number | null;
|
||||
maxHeartRate: number | null;
|
||||
minHeartRate: number | null;
|
||||
stepCount: number | null;
|
||||
elevationM: number | null;
|
||||
pauseDurationSec: number | null;
|
||||
/** Resolved server-side; all-numeric bands. */
|
||||
zones: WorkoutZones | null;
|
||||
/** The SAME series the detail route serves as `hrSeries`. */
|
||||
hrPoints: HrSeriesPoint[];
|
||||
/** The user's own last-180-days average for this sport. */
|
||||
sportContext: WorkoutSportContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic HR-shape derivations. Nothing here is a verdict — they are
|
||||
* the numbers a narrative needs in order to describe the session's arc
|
||||
* instead of restating the averages the stats grid already shows.
|
||||
*/
|
||||
export interface WorkoutHrShape {
|
||||
peakBpm: number;
|
||||
/** Elapsed seconds at which the peak bucket sits. */
|
||||
peakAtSec: number;
|
||||
/**
|
||||
* Seconds from the peak until HR first falls back below the session mean.
|
||||
* `null` when it never does (the peak sits at or near the session end).
|
||||
*/
|
||||
settleSec: number | null;
|
||||
/** Mean bpm across the first / second half of the session. */
|
||||
firstHalfMeanBpm: number;
|
||||
secondHalfMeanBpm: number;
|
||||
/** `secondHalfMeanBpm - firstHalfMeanBpm`, signed. Cardiac drift proxy. */
|
||||
driftBpm: number;
|
||||
}
|
||||
|
||||
export function deriveHrShape(points: HrSeriesPoint[]): WorkoutHrShape | null {
|
||||
// Two points cannot describe an arc; the caller omits the shape entirely
|
||||
// rather than narrating a straight line as a "pattern".
|
||||
if (points.length < 4) return null;
|
||||
|
||||
let peak = points[0];
|
||||
for (const p of points) {
|
||||
if (p.max > peak.max) peak = p;
|
||||
}
|
||||
|
||||
const sessionMean =
|
||||
points.reduce((sum, p) => sum + p.mean, 0) / points.length;
|
||||
|
||||
const peakIndex = points.indexOf(peak);
|
||||
let settleSec: number | null = null;
|
||||
for (let i = peakIndex + 1; i < points.length; i += 1) {
|
||||
if (points[i].mean < sessionMean) {
|
||||
settleSec = points[i].tSec - peak.tSec;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const mid = Math.floor(points.length / 2);
|
||||
const meanOf = (slice: HrSeriesPoint[]) =>
|
||||
slice.reduce((sum, p) => sum + p.mean, 0) / slice.length;
|
||||
const firstHalfMeanBpm = Math.round(meanOf(points.slice(0, mid)));
|
||||
const secondHalfMeanBpm = Math.round(meanOf(points.slice(mid)));
|
||||
|
||||
return {
|
||||
peakBpm: Math.round(peak.max),
|
||||
peakAtSec: peak.tSec,
|
||||
settleSec,
|
||||
firstHalfMeanBpm,
|
||||
secondHalfMeanBpm,
|
||||
driftBpm: secondHalfMeanBpm - firstHalfMeanBpm,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursive closure guard. Throws when any leaf is not a number, boolean,
|
||||
* or null — the invariant that lets this block skip the fence. Closed-
|
||||
* vocabulary tokens are passed in via `allowedStrings` so the guard stays
|
||||
* honest about exactly which strings were deliberately admitted.
|
||||
*/
|
||||
export function assertNumbersOnly(
|
||||
value: unknown,
|
||||
allowedStrings: ReadonlySet<string>,
|
||||
path = "$",
|
||||
): void {
|
||||
if (value === null || typeof value === "number" || typeof value === "boolean") {
|
||||
if (typeof value === "number" && !Number.isFinite(value)) {
|
||||
throw new Error(`workout evidence: non-finite number at ${path}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
if (!allowedStrings.has(value)) {
|
||||
throw new Error(
|
||||
`workout evidence: free-text leaf at ${path} — only closed-vocabulary tokens may be emitted`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((v, i) => assertNumbersOnly(v, allowedStrings, `${path}[${i}]`));
|
||||
return;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
assertNumbersOnly(v, allowedStrings, `${path}.${k}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw new Error(`workout evidence: unsupported leaf type at ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one bounded evidence section pinned onto a workout-launched
|
||||
* conversation's snapshot. Returns a plain JSON-serialisable record.
|
||||
*/
|
||||
export function buildWorkoutEvidence(
|
||||
input: WorkoutEvidenceInput,
|
||||
): Record<string, unknown> {
|
||||
const sport = closedSportType(input.sportType);
|
||||
const source = input.source;
|
||||
const shape = deriveHrShape(input.hrPoints);
|
||||
|
||||
const evidence: Record<string, unknown> = {
|
||||
// Day granularity only — the exact clock time adds nothing to the
|
||||
// narrative and the ISO day key is a deterministic server projection.
|
||||
date: input.startedAt.toISOString().slice(0, 10),
|
||||
sport,
|
||||
recordedBy: source,
|
||||
durationSec: input.durationSec,
|
||||
energyKcal: input.totalEnergyKcal,
|
||||
distanceM: input.totalDistanceM,
|
||||
avgHr: input.avgHeartRate,
|
||||
maxHr: input.maxHeartRate,
|
||||
minHr: input.minHeartRate,
|
||||
stepCount: input.stepCount,
|
||||
elevationM: input.elevationM,
|
||||
pauseDurationSec: input.pauseDurationSec,
|
||||
zones:
|
||||
input.zones != null
|
||||
? {
|
||||
model: input.zones.model,
|
||||
hrMax: input.zones.hrMax,
|
||||
bands: input.zones.zones.map((z) => ({
|
||||
zone: z.zone,
|
||||
lowBpm: z.lowBpm,
|
||||
highBpm: z.highBpm,
|
||||
seconds: z.seconds,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
hrShape: shape,
|
||||
// Own-history only. Never a population band — the non-diagnostic
|
||||
// standard binds this block exactly as it binds the detail page.
|
||||
ownHistory: input.sportContext,
|
||||
};
|
||||
|
||||
// The closure guard. Every admitted string is enumerated: the sport token,
|
||||
// the source token, the zone-model literal, and the ISO day key.
|
||||
const allowed = new Set<string>([
|
||||
sport,
|
||||
source,
|
||||
evidence.date as string,
|
||||
"whoop",
|
||||
"tanaka",
|
||||
]);
|
||||
assertNumbersOnly(evidence, allowed);
|
||||
|
||||
return evidence;
|
||||
}
|
||||
@@ -90,17 +90,29 @@ interface CoachLaunchValue {
|
||||
* `<CoachConversation>`. Cleared on close alongside `prefill` / `scope`.
|
||||
*/
|
||||
documentId: string | null;
|
||||
/**
|
||||
* v1.31.0 — the workout a fresh conversation is scoped to, or null. Set by
|
||||
* `askCoach(..., workoutId)` from the workout-detail "Ask why" action so the
|
||||
* first turn carries ONE bounded, numbers-only evidence section about that
|
||||
* session. Unlike `documentId` this does NOT route through the hardened
|
||||
* fenced endpoint: a workout row is the user's own structured sensor record,
|
||||
* and the server projection admits no free text (see `workout-evidence.ts`).
|
||||
* Cleared on close alongside `prefill` / `scope` / `documentId`.
|
||||
*/
|
||||
workoutId: string | null;
|
||||
/**
|
||||
* Open the drawer with an optional prefill + scope hint. When `autoSend`
|
||||
* is true the prefill is dispatched as the conversation's first turn
|
||||
* automatically (used by the assessment hand-off). `documentId` scopes the
|
||||
* opened conversation to a stored document (vault "Ask the Coach").
|
||||
* opened conversation to a stored document (vault "Ask the Coach");
|
||||
* `workoutId` scopes it to one workout (workout-detail "Ask why").
|
||||
*/
|
||||
askCoach: (
|
||||
prefill?: string | null,
|
||||
scope?: CoachLaunchScope,
|
||||
autoSend?: boolean,
|
||||
documentId?: string | null,
|
||||
workoutId?: string | null,
|
||||
) => void;
|
||||
/**
|
||||
* v1.21.0 (C4 H1) — register the metric surface the user is currently
|
||||
@@ -137,6 +149,7 @@ export interface ResolvedLaunchState {
|
||||
prefill: string | null;
|
||||
scope: CoachLaunchScope | null;
|
||||
documentId: string | null;
|
||||
workoutId: string | null;
|
||||
autoSend: boolean;
|
||||
}
|
||||
|
||||
@@ -145,6 +158,7 @@ export function resolveLaunchState(input: {
|
||||
nextScope?: CoachLaunchScope;
|
||||
nextAutoSend?: boolean;
|
||||
nextDocumentId?: string | null;
|
||||
nextWorkoutId?: string | null;
|
||||
ambientScope: CoachLaunchScope | null;
|
||||
ambientPrefill: string | null;
|
||||
}): ResolvedLaunchState {
|
||||
@@ -164,6 +178,9 @@ export function resolveLaunchState(input: {
|
||||
// A document scope is always explicit (the vault "Ask the Coach" action);
|
||||
// it is never inherited from ambient page state.
|
||||
documentId: input.nextDocumentId ?? null,
|
||||
// Same rule for the workout scope — explicit only, never ambient. A metric
|
||||
// page's ambient scope must not silently pin an unrelated workout.
|
||||
workoutId: input.nextWorkoutId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,6 +199,8 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const [scope, setScope] = useState<CoachLaunchScope | null>(null);
|
||||
// Stored-document scope of the open conversation. `null` → health chat.
|
||||
const [documentId, setDocumentId] = useState<string | null>(null);
|
||||
// Workout scope of the open conversation. `null` → no workout section.
|
||||
const [workoutId, setWorkoutId] = useState<string | null>(null);
|
||||
// Ambient scope + seed opener of the metric surface currently on screen.
|
||||
// The FAB's `askCoach()` (no args) falls back to these so opening the
|
||||
// Coach from a metric page still lands a pre-scoped, pre-seeded
|
||||
@@ -196,6 +215,7 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
nextScope?: CoachLaunchScope,
|
||||
nextAutoSend?: boolean,
|
||||
nextDocumentId?: string | null,
|
||||
nextWorkoutId?: string | null,
|
||||
) => {
|
||||
// v1.21.0 (C4 H1/H4) — scope is live; v1.28.52 — document scope threads
|
||||
// through so the vault "Ask the Coach" action opens the drawer scoped to
|
||||
@@ -206,6 +226,7 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
nextScope,
|
||||
nextAutoSend,
|
||||
nextDocumentId,
|
||||
nextWorkoutId,
|
||||
ambientScope: ambientScopeRef.current,
|
||||
ambientPrefill: ambientPrefillRef.current,
|
||||
});
|
||||
@@ -213,6 +234,7 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
setPrefill(resolved.prefill);
|
||||
setAutoSend(resolved.autoSend);
|
||||
setDocumentId(resolved.documentId);
|
||||
setWorkoutId(resolved.workoutId);
|
||||
setOpen(true);
|
||||
},
|
||||
[],
|
||||
@@ -238,12 +260,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
const handleSetOpen = useCallback((next: boolean) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
// Drop the prefill + scope + document + auto-send on close so the next
|
||||
// open starts clean.
|
||||
// Drop the prefill + scope + document + workout + auto-send on close so
|
||||
// the next open starts clean.
|
||||
setPrefill(null);
|
||||
setScope(null);
|
||||
setAutoSend(false);
|
||||
setDocumentId(null);
|
||||
setWorkoutId(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -254,6 +277,7 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
setOpen: handleSetOpen,
|
||||
@@ -264,6 +288,7 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
|
||||
autoSend,
|
||||
scope,
|
||||
documentId,
|
||||
workoutId,
|
||||
askCoach,
|
||||
registerScope,
|
||||
handleSetOpen,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Own-history comparison for a single sport.
|
||||
*
|
||||
* The user's own recent average for a sport, rendered as one muted
|
||||
* comparison line on the workout-detail page and as the comparison leg of
|
||||
* the Coach's per-workout evidence block. Cross-source twins are collapsed
|
||||
* through `pickCanonicalWorkoutRows()` first so a run recorded by two
|
||||
* paired watches counts once. Comparisons are to the user's own history
|
||||
* only (non-diagnostic standard) — never to a population band.
|
||||
*
|
||||
* Lifted out of `GET /api/workouts/{id}` so the Coach's workout evidence
|
||||
* block reads the SAME figures the detail page shows. Two surfaces quoting
|
||||
* different averages for one sport is the drift this move removes.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import { pickCanonicalWorkoutRows } from "@/lib/measurements/pick-canonical-workout-rows";
|
||||
|
||||
/** Own-history lookback for the sport-average comparison line. */
|
||||
export const SPORT_CONTEXT_LOOKBACK_DAYS = 180;
|
||||
/** Slim-row cap for the sport-average read. */
|
||||
export const SPORT_CONTEXT_MAX_ROWS = 500;
|
||||
|
||||
export interface WorkoutSportContext {
|
||||
count: number;
|
||||
avgDurationSec: number;
|
||||
avgDistanceM: number | null;
|
||||
avgAvgHr: number | null;
|
||||
}
|
||||
|
||||
export async function buildSportContext(
|
||||
userId: string,
|
||||
sportType: string,
|
||||
sourcePriorityJson: unknown,
|
||||
): Promise<WorkoutSportContext | null> {
|
||||
const since = new Date(
|
||||
Date.now() - SPORT_CONTEXT_LOOKBACK_DAYS * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const rows = await prisma.workout.findMany({
|
||||
// `userId` stays in the where clause — the universal tenancy narrow.
|
||||
where: { userId, sportType, startedAt: { gte: since } },
|
||||
orderBy: [{ startedAt: "asc" }, { id: "asc" }],
|
||||
take: SPORT_CONTEXT_MAX_ROWS,
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
startedAt: true,
|
||||
sportType: true,
|
||||
durationSec: true,
|
||||
totalDistanceM: true,
|
||||
avgHeartRate: true,
|
||||
},
|
||||
});
|
||||
const canonical = pickCanonicalWorkoutRows(rows, sourcePriorityJson);
|
||||
if (canonical.length === 0) return null;
|
||||
|
||||
let durationSum = 0;
|
||||
let distanceSum = 0;
|
||||
let distanceCount = 0;
|
||||
let hrSum = 0;
|
||||
let hrCount = 0;
|
||||
for (const r of canonical) {
|
||||
durationSum += r.durationSec;
|
||||
if (r.totalDistanceM != null) {
|
||||
distanceSum += r.totalDistanceM;
|
||||
distanceCount += 1;
|
||||
}
|
||||
if (r.avgHeartRate != null) {
|
||||
hrSum += r.avgHeartRate;
|
||||
hrCount += 1;
|
||||
}
|
||||
}
|
||||
return {
|
||||
count: canonical.length,
|
||||
avgDurationSec: Math.round(durationSum / canonical.length),
|
||||
avgDistanceM: distanceCount > 0 ? distanceSum / distanceCount : null,
|
||||
avgAvgHr: hrCount > 0 ? Math.round(hrSum / hrCount) : null,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user