mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
Merge remote-tracking branch 'origin/work/v131-slice4-askwhy' into release/v1.31.0
This commit is contained in:
commit
7b51f9edaf
26 files changed
+1495
-75
No files matched your search
@@ -39,6 +39,19 @@ export interface CoachLaunchButtonProps {
|
||||
* argument (mirrors every other ambient-scope call site).
|
||||
*/
|
||||
scope?: CoachLaunchScope;
|
||||
/**
|
||||
* v1.31.0 — optional workout scope. When set, the opened conversation's
|
||||
* FIRST turn carries one bounded, numbers-only evidence section about that
|
||||
* session, so "Ask why" on a workout answers about THAT workout rather than
|
||||
* about the user's training in general.
|
||||
*/
|
||||
workoutId?: string;
|
||||
/**
|
||||
* v1.31.0 — auto-send the prefill as the conversation's first turn instead
|
||||
* of only seeding the composer, so the answer lands directly. Mirrors
|
||||
* `<AskCoachAction>`'s flag; defaults to false (seed-only).
|
||||
*/
|
||||
autoSend?: boolean;
|
||||
/** Optional className passthrough for inline overrides. */
|
||||
className?: string;
|
||||
/**
|
||||
@@ -58,6 +71,8 @@ export function CoachLaunchButton({
|
||||
label,
|
||||
prefill,
|
||||
scope,
|
||||
workoutId,
|
||||
autoSend,
|
||||
className,
|
||||
variant = "inline",
|
||||
}: CoachLaunchButtonProps) {
|
||||
@@ -92,7 +107,9 @@ export function CoachLaunchButton({
|
||||
data-slot="coach-launch-icon"
|
||||
aria-label={accessibleLabel}
|
||||
title={accessibleLabel}
|
||||
onClick={() => launch.askCoach(prefill ?? null, scope)}
|
||||
onClick={() =>
|
||||
launch.askCoach(prefill ?? null, scope, autoSend, null, workoutId)
|
||||
}
|
||||
className={cn("text-muted-foreground hover:text-foreground", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
@@ -110,7 +127,9 @@ export function CoachLaunchButton({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null, scope)}
|
||||
onClick={() =>
|
||||
launch.askCoach(prefill ?? null, scope, autoSend, null, workoutId)
|
||||
}
|
||||
className={cn("inline-flex h-10 gap-2 self-end", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
|
||||
@@ -232,6 +232,15 @@ export interface CoachConversationProps {
|
||||
* the loaded conversation's `attachments` + sticky `fenced` flag instead.
|
||||
*/
|
||||
initialDocumentId?: string | null;
|
||||
/**
|
||||
* v1.31.0 — the workout a FRESH conversation is scoped to ("Ask why" on the
|
||||
* workout-detail page, or `/coach?workout=<id>`). Threads onto the first
|
||||
* turn only, exactly like `launchScope`: once the thread exists the server
|
||||
* has already pinned the workout's evidence section onto its snapshot, so
|
||||
* re-sending it would be per-turn work that buys nothing. That gate is what
|
||||
* keeps snapshot-once intact.
|
||||
*/
|
||||
initialWorkoutId?: string | null;
|
||||
/**
|
||||
* v1.28.52 (Documents R3) — hand the chrome an imperative getter for the
|
||||
* live conversation id (parallel to `registerReset`). The drawer reads it at
|
||||
@@ -258,6 +267,7 @@ export function CoachConversation({
|
||||
initialConversationId,
|
||||
autoOpenMostRecent = false,
|
||||
initialDocumentId,
|
||||
initialWorkoutId,
|
||||
registerConversationIdGetter,
|
||||
}: CoachConversationProps) {
|
||||
const { t } = useTranslations();
|
||||
@@ -628,12 +638,18 @@ export function CoachConversation({
|
||||
// `guidedQuestion`) are suppressed on a fenced turn — that endpoint has no
|
||||
// snapshot and no guided flow. `pendingAttachmentIds` travels ONLY on a
|
||||
// brand-new conversation (first-turn attach).
|
||||
// Same first-turn gate as `scope` — see `initialWorkoutId`.
|
||||
const workoutId =
|
||||
currentConversationId === null
|
||||
? (initialWorkoutId ?? undefined)
|
||||
: undefined;
|
||||
const wasFreshFenced = currentConversationId === null && fenced;
|
||||
const resolvedId = await send.send({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
guidedQuestion: fenced ? undefined : (guidedQuestion ?? undefined),
|
||||
scope: fenced ? undefined : scope,
|
||||
workoutId: fenced ? undefined : workoutId,
|
||||
fenced,
|
||||
pendingAttachmentIds:
|
||||
currentConversationId === null ? pendingAttachmentIds : undefined,
|
||||
@@ -706,6 +722,10 @@ export function CoachConversation({
|
||||
conversationId: currentConversationId ?? undefined,
|
||||
message: trimmed,
|
||||
scope: fenced ? undefined : scope,
|
||||
workoutId:
|
||||
fenced || currentConversationId !== null
|
||||
? undefined
|
||||
: (initialWorkoutId ?? undefined),
|
||||
fenced,
|
||||
pendingAttachmentIds:
|
||||
currentConversationId === null ? pendingAttachmentIds : undefined,
|
||||
|
||||
@@ -37,14 +37,22 @@ export {
|
||||
* so the exact thread re-opens on the page, scope and all.
|
||||
* - else a document scope (a doc-scoped drawer maximized before its first
|
||||
* turn) → `/coach?doc=<id>` so the fresh page chat stays scoped.
|
||||
* - else a workout scope (v1.31.0, same pre-first-turn case) →
|
||||
* `/coach?workout=<id>`.
|
||||
* - else the plain `/coach` new-chat surface.
|
||||
*
|
||||
* A document scope wins over a workout scope: the two are never set together
|
||||
* by any launch call site, and a fenced document conversation has the stricter
|
||||
* transport, so it is the safer arm to prefer if they ever collided.
|
||||
*/
|
||||
export function coachMaximizeHref(
|
||||
conversationId: string | null,
|
||||
documentId?: string | null,
|
||||
workoutId?: string | null,
|
||||
): string {
|
||||
if (conversationId) return `/coach?c=${conversationId}`;
|
||||
if (documentId) return `/coach?doc=${documentId}`;
|
||||
if (workoutId) return `/coach?workout=${workoutId}`;
|
||||
return "/coach";
|
||||
}
|
||||
|
||||
@@ -87,6 +95,13 @@ export interface CoachDrawerProps {
|
||||
* exists yet (`/coach?doc=<id>`).
|
||||
*/
|
||||
documentId?: string | null;
|
||||
/**
|
||||
* v1.31.0 — the workout this drawer chat is scoped to. Threads onto the
|
||||
* FIRST turn only; the maximize control preserves it when no thread exists
|
||||
* yet. Unlike `documentId` it does not switch the transport — the workout
|
||||
* evidence is a numbers-only server projection on the normal Coach route.
|
||||
*/
|
||||
workoutId?: string | null;
|
||||
}
|
||||
|
||||
export function CoachDrawer({
|
||||
@@ -96,6 +111,7 @@ export function CoachDrawer({
|
||||
scope,
|
||||
autoSend,
|
||||
documentId,
|
||||
workoutId,
|
||||
}: CoachDrawerProps) {
|
||||
const { t } = useTranslations();
|
||||
const router = useRouter();
|
||||
@@ -146,13 +162,14 @@ export function CoachDrawer({
|
||||
const href = coachMaximizeHref(
|
||||
conversationIdGetterRef.current?.() ?? null,
|
||||
documentId,
|
||||
workoutId,
|
||||
);
|
||||
// Close the drawer (aborts any in-flight stream via `handleOpenChange`)
|
||||
// then route to the dedicated page.
|
||||
onOpenChange(false);
|
||||
resetRef.current?.();
|
||||
router.push(href);
|
||||
}, [onOpenChange, router, documentId]);
|
||||
}, [onOpenChange, router, documentId, workoutId]);
|
||||
|
||||
// v1.21.4 (Coach-UI B) — the drawer's "Conversations" affordance now hands
|
||||
// off to the dedicated conversation-history page (`/coach/conversations`),
|
||||
@@ -198,6 +215,7 @@ export function CoachDrawer({
|
||||
// document (vault "Ask the Coach"); the send path is unchanged (the
|
||||
// fenced document endpoint), only WHERE it renders (drawer vs page).
|
||||
initialDocumentId={documentId}
|
||||
initialWorkoutId={workoutId}
|
||||
autoFocusComposer
|
||||
registerReset={registerReset}
|
||||
// Lets the maximize control read the live thread id at hand-off time.
|
||||
|
||||
@@ -495,6 +495,14 @@ export interface SendCoachMessageParams {
|
||||
* `POST /api/insights/chat/{id}/attachments`.
|
||||
*/
|
||||
pendingAttachmentIds?: string[];
|
||||
/**
|
||||
* v1.31.0 — the workout a FRESH conversation is scoped to. Travels ONLY on
|
||||
* the first turn (the caller gates on a null conversation id) and never on
|
||||
* the fenced path, which has no snapshot to pin a section onto. The server
|
||||
* ignores it on any later turn regardless, so snapshot-once holds even if a
|
||||
* client re-sent it.
|
||||
*/
|
||||
workoutId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -541,6 +549,7 @@ export function resolveCoachSendTarget(params: SendCoachMessageParams): {
|
||||
locale: params.locale,
|
||||
scope: params.scope,
|
||||
guidedQuestion: params.guidedQuestion,
|
||||
workoutId: params.workoutId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
/**
|
||||
* v1.31.0 — the derived-score sheets' outbound edge.
|
||||
*
|
||||
* The score sheets were the one assessment surface with NO way out: they
|
||||
* mounted the same `<InsightStatusCard>` every metric page uses but passed
|
||||
* neither an opener nor a scope, so the card painted no action at all and the
|
||||
* sheet read one-way.
|
||||
*
|
||||
* A composite has no snapshot block of its own, so each sheet must hand off
|
||||
* narrowed to the INPUTS that drive it — a scope of `undefined` would open the
|
||||
* default all-source snapshot and lose the context the sheet is about.
|
||||
*
|
||||
* The status card is stubbed to a probe recording its props: assert the
|
||||
* resolved hand-off, never rendered text (responsive classes have broken
|
||||
* text-based queries in this repo).
|
||||
*/
|
||||
|
||||
const cardProps = vi.fn();
|
||||
vi.mock("@/components/insights/insight-status-card", () => ({
|
||||
InsightStatusCard: (props: Record<string, unknown>) => {
|
||||
cardProps(props);
|
||||
return <div data-slot="insight-assessment-probe" />;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/context", () => ({
|
||||
useTranslations: () => ({
|
||||
t: (key: string, vars?: Record<string, string>) =>
|
||||
vars ? `${key}:${Object.values(vars).join(",")}` : key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const derived = { current: {} as Record<string, unknown> };
|
||||
vi.mock("../use-derived-metric", () => ({
|
||||
useDerivedMetric: () => derived.current,
|
||||
}));
|
||||
|
||||
vi.mock("../score-anatomy-view", () => ({
|
||||
ScoreAnatomyView: () => <div data-slot="score-anatomy-view-probe" />,
|
||||
}));
|
||||
|
||||
import { CompositeScoreAnatomy } from "../composite-score-anatomy";
|
||||
import type { AnatomyMetricId } from "../composite-score-anatomy";
|
||||
|
||||
const ALL_METRICS: AnatomyMetricId[] = [
|
||||
"SLEEP_SCORE",
|
||||
"READINESS",
|
||||
"RECOVERY_SCORE",
|
||||
"STRESS_SCORE",
|
||||
"STRAIN_SCORE",
|
||||
];
|
||||
|
||||
function renderSheet(metric: AnatomyMetricId): Record<string, unknown> {
|
||||
derived.current = {
|
||||
isLoading: false,
|
||||
data: {
|
||||
status: "ok",
|
||||
// Each metric reads a different value shape: SLEEP_SCORE decomposes into
|
||||
// `subScores`, READINESS / RECOVERY_SCORE into `components`, and
|
||||
// STRESS / STRAIN carry the ring alone.
|
||||
value: {
|
||||
score: 72,
|
||||
band: "good",
|
||||
subScores: [{ key: "duration", value: 70, weight: 0.5 }],
|
||||
components: [
|
||||
{ key: "hrv", value: 68, weight: 0.4 },
|
||||
{ key: "restingHr", value: 74, weight: 0.3 },
|
||||
],
|
||||
},
|
||||
coverage: 1,
|
||||
confidence: "high",
|
||||
provenance: {},
|
||||
assessment: {
|
||||
text: "Your recovery sat mid-range.",
|
||||
updatedAt: "2026-07-19T06:00:00.000Z",
|
||||
},
|
||||
},
|
||||
};
|
||||
cardProps.mockClear();
|
||||
renderToStaticMarkup(<CompositeScoreAnatomy metric={metric} />);
|
||||
const calls = cardProps.mock.calls;
|
||||
return calls[calls.length - 1][0] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("derived-score sheets — every sheet has an outbound coach edge", () => {
|
||||
it.each(ALL_METRICS)(
|
||||
"%s carries an opener, a scope and auto-send",
|
||||
(metric) => {
|
||||
const props = renderSheet(metric);
|
||||
// The affordance only paints when an opener is supplied — an absent
|
||||
// question is precisely the one-way street this closes.
|
||||
expect(props.coachQuestion).toBeTruthy();
|
||||
// A composite must narrow to its inputs, never open unscoped.
|
||||
expect(props.coachScope).toBeTruthy();
|
||||
expect((props.coachScope as { metric: string }).metric).toBeTruthy();
|
||||
// Auto-send lands the answer directly rather than only seeding.
|
||||
expect(props.coachAutoSend).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("names the sheet's own title in the opener", () => {
|
||||
const props = renderSheet("STRAIN_SCORE");
|
||||
expect(String(props.coachQuestion)).toContain(
|
||||
"insights.coach.assessmentPrompt",
|
||||
);
|
||||
expect(String(props.coachQuestion)).toContain(
|
||||
"insights.derived.scores.strain",
|
||||
);
|
||||
});
|
||||
|
||||
it("anchors the sleep sheet on sleep and the strain sheet on workouts", () => {
|
||||
expect(
|
||||
(renderSheet("SLEEP_SCORE").coachScope as { metric: string }).metric,
|
||||
).toBe("sleep");
|
||||
expect(
|
||||
(renderSheet("STRAIN_SCORE").coachScope as { metric: string }).metric,
|
||||
).toBe("workouts");
|
||||
});
|
||||
|
||||
it("widens recovery-style sheets to the inputs that drive them", () => {
|
||||
// Recovery is a synthesis of HRV + resting HR + sleep; narrowing to one
|
||||
// of the three would hide the reason the score moved.
|
||||
const scope = renderSheet("RECOVERY_SCORE").coachScope as {
|
||||
metric: string;
|
||||
also?: string[];
|
||||
};
|
||||
expect(scope.metric).toBe("hrv");
|
||||
expect(scope.also).toEqual(expect.arrayContaining(["resting_hr", "sleep"]));
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type AnatomyContributor,
|
||||
} from "./score-anatomy-view";
|
||||
import type { RingHue } from "./ring-hues";
|
||||
import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context";
|
||||
import { METRIC_PROVENANCE } from "./standards";
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,36 @@ export interface CompositeScoreAnatomyProps {
|
||||
* leans the same colour (continuity from the tile tap). Mirrors the per-metric
|
||||
* `hue` the wellness strip passes its `RingTile`s.
|
||||
*/
|
||||
/**
|
||||
* v1.31.0 — the Coach scope each derived-score sheet hands off to.
|
||||
*
|
||||
* The score sheets were the one assessment surface with NO outbound edge: they
|
||||
* render the same `<InsightStatusCard>` as every metric page but passed
|
||||
* neither an opener nor a scope, so the card painted no action at all and the
|
||||
* sheet read one-way. A composite has no snapshot block of its own — it is a
|
||||
* synthesis — so each maps to the INPUTS that drive it, the same way the
|
||||
* recovery metric page already anchors on HRV + resting HR + sleep.
|
||||
*/
|
||||
const METRIC_COACH_SCOPE: Record<AnatomyMetricId, CoachLaunchScope> = {
|
||||
SLEEP_SCORE: { metric: "sleep", also: ["hrv", "resting_hr"] },
|
||||
READINESS: {
|
||||
metric: "hrv",
|
||||
also: ["resting_hr", "sleep"],
|
||||
window: "last7days",
|
||||
},
|
||||
RECOVERY_SCORE: {
|
||||
metric: "hrv",
|
||||
also: ["resting_hr", "sleep"],
|
||||
window: "last7days",
|
||||
},
|
||||
STRESS_SCORE: { metric: "hrv", also: ["resting_hr"], window: "last7days" },
|
||||
STRAIN_SCORE: {
|
||||
metric: "workouts",
|
||||
also: ["active_energy", "pulse"],
|
||||
window: "last7days",
|
||||
},
|
||||
};
|
||||
|
||||
const METRIC_HUE: Record<AnatomyMetricId, RingHue> = {
|
||||
SLEEP_SCORE: "sleep",
|
||||
READINESS: "readiness",
|
||||
@@ -241,6 +272,14 @@ export function CompositeScoreAnatomy({
|
||||
text={assessment.text}
|
||||
hasProvider
|
||||
updatedAt={assessment.updatedAt}
|
||||
// The outbound edge. Same shared opener + auto-send hand-off the
|
||||
// metric pages use, so the answer lands directly instead of only
|
||||
// seeding the composer.
|
||||
coachQuestion={t("insights.coach.assessmentPrompt", {
|
||||
metric: title,
|
||||
})}
|
||||
coachScope={METRIC_COACH_SCOPE[metric]}
|
||||
coachAutoSend
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -63,6 +63,10 @@ export function LayoutCoachMount() {
|
||||
// "Ask the Coach" action opens the REAL fenced conversation in the drawer
|
||||
// scoped to that document (maximizing then preserves the scope).
|
||||
documentId={launch.documentId}
|
||||
// v1.31.0 — carry the workout scope so the workout-detail "Ask why"
|
||||
// action opens a conversation whose first turn reads that session's own
|
||||
// numbers.
|
||||
workoutId={launch.workoutId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user