fix(insights): localize and auto-send the coach assessment prompt

Thread an autoSend flag through the coach-launch flow so a card's
"ask the coach about this" hand-off dispatches its seeded prompt as
the conversation's first turn instead of only seeding the composer.
The flag rides the launch context, the layout mount, the drawer, and
the shared conversation surface, where a ref-guarded mount effect fires
the send exactly once for a fresh thread.

Localize the previously hardcoded English assessment opener in the
metric status card behind insights.coach.assessmentPrompt with a
{metric} placeholder, and localize the resting-pulse coach prefill.
Both keys land in all six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-28 13:20:24 +02:00
1 parent 42b433f2e8
commit d8c82910a7
14 files changed
+125 -27

No files matched your search

+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Gibt es einen echten Zusammenhang zwischen meiner Stimmung und meinem Puls? Hilf mir, diesen Zusammenhang zu lesen.",
"correlationWeightWeekday": "Folgt mein Gewicht einem Wochenmuster? Was sagt mir dieser Wochentags-Zusammenhang?",
"recommendation": "Erzähl mir mehr zu dieser Empfehlung: \"{text}\" — warum trifft sie auf mich zu, und was sollte ich tun?"
}
},
"assessmentPrompt": "Erkläre mir meine {metric}-Einschätzung — was bedeutet sie und was sollte ich tun?"
},
"relativeJustNow": "gerade eben",
"relativeMinutesAgoOne": "vor {count} Minute",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Noch kein Ruhepuls",
"description": "Der Ruhepuls erscheint, sobald die Apple Watch oder ein kompatibles Withings-Gerät einen ruhigen Tag durchgetragen hat."
}
},
"coachPrefill": "Ich habe noch keinen Ruhepuls erfasst — warum unterscheidet sich der tägliche Ruhewert von einem punktuellen Puls, und wie verbessere ich ihn?"
},
"oxygenSaturation": {
"title": "Sauerstoffsättigung",
+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Is there a real link between my mood and my pulse? Help me read this correlation.",
"correlationWeightWeekday": "Does my weight follow a weekly pattern? What does this day-of-week correlation tell me?",
"recommendation": "Tell me more about this recommendation: \"{text}\" — why does it apply to me, and what should I do about it?"
}
},
"assessmentPrompt": "Walk me through my {metric} assessment — what does it mean and what should I do about it?"
},
"relativeJustNow": "just now",
"relativeMinutesAgoOne": "{count} minute ago",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "No resting heart rate yet",
"description": "Resting heart rate arrives once Apple Watch or a compatible Withings device has worn through one quiet day."
}
},
"coachPrefill": "I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
},
"oxygenSaturation": {
"title": "Oxygen saturation",
+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "¿Existe una relación real entre mi estado de ánimo y mi pulso? Ayúdame a interpretar esta correlación.",
"correlationWeightWeekday": "¿Mi peso sigue un patrón semanal? ¿Qué me dice esta correlación por día de la semana?",
"recommendation": "Cuéntame más sobre esta recomendación: \"{text}\" — ¿por qué se aplica a mí y qué debería hacer al respecto?"
}
},
"assessmentPrompt": "Explícame mi evaluación de {metric}: ¿qué significa y qué debería hacer al respecto?"
},
"relativeJustNow": "ahora mismo",
"relativeMinutesAgoOne": "hace {count} minuto",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Aún no hay FC en reposo",
"description": "La FC en reposo aparece cuando Apple Watch o un Withings compatible ha llevado puesto un día tranquilo."
}
},
"coachPrefill": "Aún no he registrado ninguna frecuencia cardíaca en reposo: ¿por qué el valor diario en reposo difiere de un pulso puntual y cómo puedo mejorarlo?"
},
"oxygenSaturation": {
"title": "Saturación de oxígeno",
+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Y a-t-il un lien réel entre mon humeur et mon pouls ? Aide-moi à interpréter cette corrélation.",
"correlationWeightWeekday": "Mon poids suit-il un schéma hebdomadaire ? Que m'indique cette corrélation selon le jour de la semaine ?",
"recommendation": "Parle-moi davantage de cette recommandation : \"{text}\" — pourquoi s'applique-t-elle à moi, et que devrais-je faire ?"
}
},
"assessmentPrompt": "Explique-moi mon évaluation de {metric} : qu'est-ce que cela signifie et que dois-je faire ?"
},
"relativeJustNow": "à l’instant",
"relativeMinutesAgoOne": "il y a {count} minute",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Aucune FC au repos",
"description": "La FC au repos arrive dès que l'Apple Watch ou un appareil Withings compatible a porté une journée calme."
}
},
"coachPrefill": "Je n'ai pas encore enregistré de fréquence cardiaque au repos — pourquoi la valeur quotidienne au repos diffère-t-elle d'un pouls ponctuel, et comment l'améliorer ?"
},
"oxygenSaturation": {
"title": "Saturation en oxygène",
+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "C'è un legame reale tra il mio umore e il mio battito? Aiutami a interpretare questa correlazione.",
"correlationWeightWeekday": "Il mio peso segue uno schema settimanale? Cosa mi dice questa correlazione per giorno della settimana?",
"recommendation": "Dimmi di più su questa raccomandazione: \"{text}\" — perché si applica a me e cosa dovrei fare?"
}
},
"assessmentPrompt": "Spiegami la mia valutazione di {metric}: cosa significa e cosa dovrei fare?"
},
"relativeJustNow": "proprio ora",
"relativeMinutesAgoOne": "{count} minuto fa",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Ancora nessuna FC a riposo",
"description": "La FC a riposo arriva quando Apple Watch o un Withings compatibile ha indossato una giornata tranquilla."
}
},
"coachPrefill": "Non ho ancora registrato alcuna frequenza cardiaca a riposo: perché il valore giornaliero a riposo è diverso da un battito occasionale e come posso migliorarlo?"
},
"oxygenSaturation": {
"title": "Saturazione di ossigeno",
+4 -2
View File
@@ -2372,7 +2372,8 @@
"correlationMoodPulse": "Czy istnieje rzeczywisty związek między moim nastrojem a tętnem? Pomóż mi odczytać tę korelację.",
"correlationWeightWeekday": "Czy moja waga podlega cotygodniowemu wzorcowi? Co mówi mi ta korelacja według dnia tygodnia?",
"recommendation": "Powiedz mi więcej o tej rekomendacji: \"{text}\" — dlaczego mnie dotyczy i co powinienem z nią zrobić?"
}
},
"assessmentPrompt": "Wyjaśnij mi moją ocenę {metric} — co oznacza i co powinienem z tym zrobić?"
},
"relativeJustNow": "przed chwilą",
"relativeMinutesAgoOne": "{count} minutę temu",
@@ -2444,7 +2445,8 @@
"emptyState": {
"title": "Brak tętna spoczynkowego",
"description": "Tętno spoczynkowe pojawi się, gdy Apple Watch lub zgodne urządzenie Withings spędzi spokojny dzień na nadgarstku."
}
},
"coachPrefill": "Nie zarejestrowałem jeszcze tętna spoczynkowego — dlaczego dzienna wartość spoczynkowa różni się od pojedynczego pomiaru tętna i jak ją poprawić?"
},
"oxygenSaturation": {
"title": "Saturacja tlenu",
+3 -1
View File
@@ -3,6 +3,7 @@
import { Heart } from "lucide-react";
import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page";
import { useTranslations } from "@/lib/i18n/context";
/**
* v1.4.32 — `/insights/resting-pulse`.
@@ -14,6 +15,7 @@ import { HealthKitMetricPage } from "@/components/insights/healthkit-metric-page
* trend that's not muddied by mid-walk readings.
*/
export default function InsightsRestingHrPage() {
const { t } = useTranslations();
return (
<HealthKitMetricPage
measurementType="RESTING_HEART_RATE"
@@ -27,7 +29,7 @@ export default function InsightsRestingHrPage() {
yAxisUnit="bpm"
emptyStateIcon={<Heart className="size-6" />}
emptyStateCtaType={null}
coachPrefill="I haven't logged any resting heart rate yet — why is the daily resting value different from a spot pulse, and how do I improve it?"
coachPrefill={t("insights.restingHr.coachPrefill")}
/>
);
}
+8 -1
View File
@@ -44,6 +44,12 @@ export interface AskCoachActionProps {
* briefing), which read better against the default all-source snapshot.
*/
scope?: CoachLaunchScope;
/**
* Auto-send the seeded question as the conversation's first turn instead
* of only seeding the composer. The assessment hand-off sets this so the
* Coach answers immediately; defaults to false (seed-only).
*/
autoSend?: boolean;
/** Optional visible-label override; defaults to the shared CTA copy. */
label?: string;
/** className passthrough for per-card alignment. */
@@ -53,6 +59,7 @@ export interface AskCoachActionProps {
export function AskCoachAction({
question,
scope,
autoSend,
label,
className,
}: AskCoachActionProps) {
@@ -76,7 +83,7 @@ export function AskCoachAction({
variant="ghost"
size="sm"
data-slot="ask-coach-action"
onClick={() => launch.askCoach(question, scope)}
onClick={() => launch.askCoach(question, scope, autoSend)}
className={cn(
// Discreet: muted text, hover lifts to foreground, no fill. Tight
// height so it sits as a quiet footer link, not a primary CTA.
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useReducer, useState } from "react";
import { useEffect, useReducer, useRef, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -117,6 +117,13 @@ export interface CoachConversationProps {
* established scope. Null → the route's default all-source snapshot.
*/
launchScope?: CoachLaunchScope | null;
/**
* When true, the `prefill` is dispatched as the conversation's first turn
* automatically, exactly once on mount (ref-guarded). Used by the
* assessment hand-off so the answer lands without a manual send. The send
* only fires for a fresh conversation with a non-empty prefill.
*/
autoSend?: boolean;
/**
* Renders the conversation title. The surface passes the resolved
* title string; the drawer wraps it in `<SheetTitle>`, the page in an
@@ -188,6 +195,7 @@ export interface CoachConversationProps {
export function CoachConversation({
prefill,
launchScope,
autoSend,
renderTitle,
renderDescription,
leadingHeaderActions,
@@ -340,6 +348,31 @@ export function CoachConversation({
}
}
// Auto-send the prefill as the conversation's first turn, exactly once.
// A card hand-off (e.g. the assessment "ask about this") opens the Coach
// with `autoSend` so the answer lands without a manual send. Ref-guarded so
// it fires a single time per mount; only for a fresh conversation with a
// non-empty prefill and no stream in flight.
const autoSentRef = useRef(false);
useEffect(() => {
if (!autoSend) return;
if (autoSentRef.current) return;
if (currentConversationId !== null) return;
if (send.isStreaming) return;
const seed = (prefill ?? "").trim();
if (!seed) return;
autoSentRef.current = true;
void handleSubmit(seed);
// `handleSubmit` is re-created each render but the ref guard ensures a
// single dispatch; the remaining deps gate the one allowed fire.
}, [
autoSend,
prefill,
currentConversationId,
send.isStreaming,
handleSubmit,
]);
// v1.22 — "Try again": regenerate an assistant reply by resubmitting the
// user turn that produced it as a FRESH turn. The composer value is left
// untouched (unlike `handleSubmit`, which clears it) so a half-typed
@@ -54,6 +54,11 @@ export interface CoachDrawerProps {
* source(s) + window. Null → the route's default all-source snapshot.
*/
scope?: CoachLaunchScope | null;
/**
* When true, the conversation auto-sends `prefill` as its first turn
* exactly once on open. Used by the assessment hand-off.
*/
autoSend?: boolean;
}
export function CoachDrawer({
@@ -61,6 +66,7 @@ export function CoachDrawer({
onOpenChange,
prefill,
scope,
autoSend,
}: CoachDrawerProps) {
const { t } = useTranslations();
const router = useRouter();
@@ -139,6 +145,7 @@ export function CoachDrawer({
surface="drawer"
prefill={prefill}
launchScope={scope}
autoSend={autoSend}
autoFocusComposer
registerReset={registerReset}
// v1.16.1 — the "Conversations" affordance hands off to a
@@ -69,6 +69,11 @@ interface InsightStatusCardProps {
*/
coachQuestion?: string;
coachScope?: CoachLaunchScope;
/**
* Auto-send the seeded opener as the first turn (assessment hand-off)
* instead of only seeding the composer. Defaults to false.
*/
coachAutoSend?: boolean;
}
// ─── Main Component ───────────────────────────────────────
@@ -83,6 +88,7 @@ export function InsightStatusCard({
preparing = false,
coachQuestion,
coachScope,
coachAutoSend,
}: InsightStatusCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
@@ -248,7 +254,11 @@ export function InsightStatusCard({
caller supplied an opener. */}
{coachQuestion ? (
<div className="flex justify-end">
<AskCoachAction question={coachQuestion} scope={coachScope} />
<AskCoachAction
question={coachQuestion}
scope={coachScope}
autoSend={coachAutoSend}
/>
</div>
) : null}
</CardContent>
@@ -56,6 +56,9 @@ export function LayoutCoachMount() {
// opened from a metric surface or insight card is pre-narrowed to
// the relevant source(s).
scope={launch.scope}
// Auto-send the prefill as the first turn when the launch requested it
// (assessment hand-off), so the answer lands without a manual send.
autoSend={launch.autoSend}
/>
);
}
@@ -46,12 +46,9 @@ export function MetricStatusCard({
// carry a snapshot block. Unmapped metrics still get a metric-named
// opener, just against the default snapshot.
const coachSource = scopeSourceFromMetricKey(metric);
const coachQuestion = `Walk me through my ${metric
.toLowerCase()
.replace(
/_/g,
" ",
)} assessment — what does it mean and what should I do about it?`;
const coachQuestion = t("insights.coach.assessmentPrompt", {
metric: metric.toLowerCase().replace(/_/g, " "),
});
return (
<InsightStatusCard
@@ -62,6 +59,7 @@ export function MetricStatusCard({
updatedAt={status?.updatedAt ?? null}
coachQuestion={coachQuestion}
coachScope={coachSource ? { metric: coachSource } : undefined}
coachAutoSend
// Same hydration gate as `<SlugInsightStatusCard>`: the hook is
// `enabled: isAuthenticated && enabled`, and both inputs can settle
// before a late-hydrating boundary replays its first render — the
+31 -5
View File
@@ -67,6 +67,12 @@ interface CoachLaunchValue {
open: boolean;
/** Current prefill string (or null when the next open should start blank). */
prefill: string | null;
/**
* When true, the opened conversation auto-sends the prefill as its first
* turn (exactly once), so a card's "ask about this" hand-off lands the
* answer directly instead of only seeding the composer. Cleared on close.
*/
autoSend: boolean;
/**
* Scope the next/open conversation is narrowed to (or null for the
* default all-source snapshot). Set by `askCoach(prefill, scope)`, or
@@ -74,8 +80,16 @@ interface CoachLaunchValue {
* explicit one. `<LayoutCoachMount>` forwards it to the drawer.
*/
scope: CoachLaunchScope | null;
/** Open the drawer with an optional prefill + scope hint. */
askCoach: (prefill?: string | null, scope?: CoachLaunchScope) => void;
/**
* 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).
*/
askCoach: (
prefill?: string | null,
scope?: CoachLaunchScope,
autoSend?: boolean,
) => void;
/**
* v1.21.0 (C4 H1) — register the metric surface the user is currently
* looking at so the global FAB (which calls `askCoach()` with no args)
@@ -110,6 +124,8 @@ export interface CoachLaunchProviderProps {
export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const [open, setOpen] = useState<boolean>(false);
const [prefill, setPrefill] = useState<string | null>(null);
// Whether the next open auto-sends its prefill as the first turn.
const [autoSend, setAutoSend] = useState<boolean>(false);
// Scope the open conversation is narrowed to. `null` → default snapshot.
const [scope, setScope] = useState<CoachLaunchScope | null>(null);
// Ambient scope + seed opener of the metric surface currently on screen.
@@ -121,7 +137,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const ambientPrefillRef = useRef<string | null>(null);
const askCoach = useCallback(
(nextPrefill?: string | null, nextScope?: CoachLaunchScope) => {
(
nextPrefill?: string | null,
nextScope?: CoachLaunchScope,
nextAutoSend?: boolean,
) => {
// v1.21.0 (C4 H1/H4) — scope is live. An explicit scope (insight
// card, metric-card affordance) wins; otherwise inherit the metric
// page's ambient scope so the FAB opens contextual to where the user
@@ -135,6 +155,9 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
(usingAmbientScope ? ambientPrefillRef.current : null) ??
null,
);
// Auto-send only applies when an explicit prefill is given (a card
// hand-off), never for an ambient/blank open.
setAutoSend(Boolean(nextAutoSend) && Boolean(nextPrefill));
setOpen(true);
},
[],
@@ -160,9 +183,11 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
const handleSetOpen = useCallback((next: boolean) => {
setOpen(next);
if (!next) {
// Drop the prefill + scope on close so the next open starts clean.
// Drop the prefill + scope + auto-send on close so the next open
// starts clean.
setPrefill(null);
setScope(null);
setAutoSend(false);
}
}, []);
@@ -170,12 +195,13 @@ export function CoachLaunchProvider({ children }: CoachLaunchProviderProps) {
() => ({
open,
prefill,
autoSend,
scope,
askCoach,
registerScope,
setOpen: handleSetOpen,
}),
[open, prefill, scope, askCoach, registerScope, handleSetOpen],
[open, prefill, autoSend, scope, askCoach, registerScope, handleSetOpen],
);
return (