feat(insights): reorder the overview, fix briefing legibility and steps link, trim the coach prompts

Reorder the Insights overview top → bottom to Hero + Coach, Wellnesswerte,
Vitalwerte, Trends, period retrospective, then today's signals — the
wellness-score strip and vitals grid already render in that sequence inside
the vitals dashboard, so the change is the surrounding section order plus
moving the signal cards below the retrospective.

Make the steps reference value in the daily briefing tappable: it was the one
key finding with no destination, so it now routes to the generic readings view
for its measurement type like every other metric drills in.

Paint the health-score headline number through the semantic success / warning /
destructive tokens instead of the raw dracula primitives, which carry no
light-mode override and read illegibly on the Alucard light theme.

Widen the wellness-score strip so the tiles always fill the row: the fixed
five-column desktop track left an empty cell on the common four-score case and
read as a missing tile.

Drop the duplicate footer "prepare assessments" control — the tab-strip
regenerate button is the single affordance — and remove the now-dead warm hook
and its strings.

Trim the coach suggested prompts to the two that read as a clear next step
(ask the doctor, is the medication working) and remove the three speculative
data-specific openers, across all six locales.

(cherry picked from commit e819c9c12777f76b66b5b466b8d159b26f169868)
This commit is contained in:
Marc-André Bombeck committed 2026-06-05 10:42:47 +02:00
1 parent 89d51c5380
commit 9cf2b3086a
15 files changed
+75 -168

No files matched your search

@@ -55,8 +55,9 @@ describe("<HealthScoreCard>", () => {
expect(html).toMatch(/data-slot="health-score-card-number"/);
expect(html).toContain(">86<");
expect(html).toContain("/ 100");
// Green band uses the dracula-green token.
expect(html).toContain("text-dracula-green");
// v1.12.4 — the headline number paints through the semantic `--success`
// token (AA-safe on both themes) rather than the raw dracula primitive.
expect(html).toContain("text-success");
});
it("paints the yellow band correctly", () => {
@@ -69,7 +70,7 @@ describe("<HealthScoreCard>", () => {
/>,
);
expect(html).toMatch(/data-band="yellow"/);
expect(html).toContain("text-dracula-orange");
expect(html).toContain("text-warning");
});
it("paints the red band correctly", () => {
@@ -82,7 +83,7 @@ describe("<HealthScoreCard>", () => {
/>,
);
expect(html).toMatch(/data-band="red"/);
expect(html).toContain("text-dracula-red");
expect(html).toContain("text-destructive");
});
it("renders the unavailable-delta caption when delta is null", () => {
@@ -157,7 +157,7 @@ describe("<HeroStrip>", () => {
expect(html).toMatch(/data-slot="insights-hero-strip-prompts"/);
expect(html).toMatch(/data-slot="insights-suggested-prompts"/);
expect(html).toContain("Try asking");
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("What should I tell my doctor?");
});
it("forwards onPickPrompt down to the prompt strip without throwing", () => {
@@ -7,7 +7,7 @@ import { SuggestedPrompts } from "../suggested-prompts";
* v1.4.20 phase B1 — "Try asking" prompt-chip strip.
*
* Renders a horizontal row of clickable prompts below the hero action
* band. Tests cover: default 5-chip rendering, custom prompt list,
* band. Tests cover: default two-chip rendering, custom prompt list,
* onPick wiring, locale-aware label.
*/
@@ -18,20 +18,21 @@ function render(node: React.ReactNode, locale: "en" | "de" = "en") {
}
describe("<SuggestedPrompts>", () => {
it("renders the 5 default prompt chips in English", () => {
it("renders the two default prompt chips in English", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("How did weight loss affect my pulse?");
expect(html).toContain("Compare this week to last month");
expect(html).toContain("What should I tell my doctor?");
expect(html).toContain("Is my medication working?");
// The speculative data-specific openers were dropped in v1.12.4.
expect(html).not.toContain("Why was BP higher on Monday?");
expect(html).not.toContain("How did weight loss affect my pulse?");
expect(html).not.toContain("Compare this week to last month");
});
it("renders the 5 default prompt chips in German", () => {
it("renders the two default prompt chips in German", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />, "de");
expect(html).toContain("Warum war der Blutdruck am Montag höher?");
expect(html).toContain("Wirkt mein Medikament?");
expect(html).toContain("Was sollte ich meinem Arzt sagen?");
expect(html).not.toContain("Warum war der Blutdruck am Montag höher?");
});
it("renders the 'Try asking' label in English", () => {
@@ -49,7 +50,7 @@ describe("<SuggestedPrompts>", () => {
const html = render(<SuggestedPrompts onPick={() => {}} />);
const matches =
html.match(/data-slot="insights-suggested-prompts-chip"/g) ?? [];
expect(matches.length).toBe(5);
expect(matches.length).toBe(2);
});
it("accepts a custom prompts array and renders only those", () => {
@@ -80,10 +81,10 @@ describe("<SuggestedPrompts>", () => {
// Render to ensure no throw + capture the prompt strings the
// component would forward.
const html = render(<SuggestedPrompts onPick={handler} />);
expect(html).toContain("Why was BP higher on Monday?");
expect(html).toContain("What should I tell my doctor?");
// Direct call — SSR can't drive a click; smoke-check the contract.
handler("Why was BP higher on Monday?");
expect(handler).toHaveBeenCalledWith("Why was BP higher on Monday?");
handler("What should I tell my doctor?");
expect(handler).toHaveBeenCalledWith("What should I tell my doctor?");
});
it("forwards a custom className for layout overrides", () => {
+6 -1
View File
@@ -49,7 +49,12 @@ const METRIC_HREF: Record<DailyBriefingKeyFinding["sourceMetric"], string | null
hrv: null,
sleep: "/insights/sleep",
resting_hr: null,
steps: null,
// v1.12.4 — steps has no dedicated metric sub-page, so it routes to the
// generic readings view keyed to its `MeasurementType` (the same target
// the category pages use to drill into a single type). Every other
// briefing finding with a real destination is already tappable; steps
// was the lone static row.
steps: "/insights/values/ACTIVITY_STEPS",
active_energy: null,
flights: null,
distance: null,
@@ -167,6 +167,20 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps)
// Whole strip un-mounts when no score has data — never an empty section.
if (tiles.length === 0) return null;
// v1.12.4 — the strip used a fixed `lg:grid-cols-5`, so the common case of
// four active scores left one empty cell on the right and read as "a tile
// is missing". Track the desktop column count to the actual number of tiles
// (capped at 5) so the row always fills its full width. The static class
// map keeps every variant in the Tailwind output (no purge surprise).
const LG_COLS: Record<number, string> = {
1: "lg:grid-cols-1",
2: "lg:grid-cols-2",
3: "lg:grid-cols-3",
4: "lg:grid-cols-4",
5: "lg:grid-cols-5",
};
const lgCols = LG_COLS[Math.min(tiles.length, 5)] ?? "lg:grid-cols-5";
return (
<section
data-slot="wellness-scores"
@@ -178,7 +192,7 @@ export function WellnessScores({ read, isLoading, className }: ScoreStripProps)
</h2>
<div
data-slot="wellness-scores-grid"
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
className={cn("grid grid-cols-2 gap-3 sm:grid-cols-3", lgCols)}
>
{tiles}
</div>
@@ -106,10 +106,16 @@ const SOURCE_PILL_CLASS: Record<HealthScoreComponentSource, string> = {
none: "border-muted-foreground/30 text-muted-foreground",
};
// v1.12.4 — the headline score number used the raw `--dracula-*` primitives,
// which carry no light-mode override and render illegibly on the Alucard
// light theme (the band-tokens helper documents the same trap). Route through
// the semantic `--success` / `--warning` / `--destructive` tokens, which both
// themes override to an AA-safe tone — same mapping the wellness-score rings
// already use.
const BAND_NUMBER_CLASS: Record<HealthScoreBand, string> = {
green: "text-dracula-green",
yellow: "text-dracula-orange",
red: "text-dracula-red",
green: "text-success",
yellow: "text-warning",
red: "text-destructive",
};
const BAND_BORDER_CLASS: Record<HealthScoreBand, string> = {
@@ -25,7 +25,7 @@ import { cn } from "@/lib/utils";
export interface SuggestedPromptsProps {
/**
* Override the default prompt list. When omitted, the component
* resolves the 5 defaults from `insights.suggestedPrompts.*`.
* resolves the two defaults from `insights.suggestedPrompts.*`.
* Useful for tests + future per-user prompt sets.
*/
prompts?: string[];
@@ -35,13 +35,11 @@ export interface SuggestedPromptsProps {
className?: string;
}
const DEFAULT_PROMPT_KEYS = [
"whyMonday",
"weightVsPulse",
"weekVsMonth",
"tellMyDoctor",
"medicationWorking",
] as const;
// v1.12.4 — trimmed from five speculative prompts to the two that read as a
// clear next step: ask what to tell the doctor, and whether the medication is
// working. The data-specific openers ("why was Monday higher", weight × pulse,
// week vs month) were guesses that did not always match the user's own log.
const DEFAULT_PROMPT_KEYS = ["tellMyDoctor", "medicationWorking"] as const;
export function SuggestedPrompts({
prompts,
@@ -89,7 +87,7 @@ export function SuggestedPrompts({
data-slot="insights-suggested-prompts-chip"
// v1.8.5 W4a — denser chips. The tap target stays comfortable
// (`min-h-9` ≈ 36 px, the WCAG/iOS floor) but the horizontal
// padding + type size drop a notch so five prompts pack into
// padding + type size drop a notch so the two prompts pack into
// fewer rows and the left column stops towering over the
// Health-Score card.
className={cn(
@@ -1,67 +0,0 @@
"use client";
import { useCallback } from "react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
/**
* On-demand full assessment warm.
*
* POSTs `/api/insights/pregenerate`, which enqueues a forced warm of every
* AI assessment (comprehensive insight + the seven specialised status cards
* + every data-bearing generic metric assessment) for the calling user on
* the worker and returns immediately. The cards then fill via their existing
* stale-while-revalidate GETs — there is nothing to invalidate here.
*
* This is the explicit "prepare assessments" button only. There is no
* warm-on-mount: the nightly cron (04:30 Europe/Berlin) keeps every user's
* caches warm, and the per-metric status GETs revalidate gently on their
* own — so a page visit reads cached text instead of fanning out a full
* provider warm that would contend with foreground requests.
*/
async function postWarm(): Promise<void> {
// Same-origin relative-path fetch — exempt from the safe-fetch rule by
// construction. A 429 (warm already in progress) is an expected,
// harmless outcome, not an error.
const res = await fetch("/api/insights/pregenerate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
if (!res.ok && res.status !== 429) {
throw new Error(`HTTP ${res.status}`);
}
}
export interface UseInsightsWarmResult {
/** Manual trigger — POSTs the warm and shows a toast. */
warm: () => void;
/** True while the warm POST is in flight. */
isWarming: boolean;
}
export function useInsightsWarm(): UseInsightsWarmResult {
const { t } = useTranslations();
const mutation = useMutation({
mutationKey: queryKeys.insightsPregenerate(),
mutationFn: postWarm,
});
const { mutate } = mutation;
const warm = useCallback(() => {
mutate(undefined, {
onSuccess: () => toast.success(t("insights.warmStarted")),
});
}, [mutate, t]);
return {
warm,
isWarming: mutation.isPending,
};
}