fix(insights): honest discovery footer, commensurable better-days ranking, settings tap-target + prop cleanup

- Mood discovery footer no longer pairs a mood-only numerator with the
  full behaviour-by-outcome denominator. Reword it to surface the mood
  relationships out of all day-to-day pairs the engine tested across
  every metric, so the count it reports is honest about its scope.
  Update all six locales.
- Rank the better-days board on a comparable scale: standardize the tag
  mood-point delta into a Cohen's-d effect (delta over the pooled
  with/without mood SD, clamped to [0,1]) so it sits alongside a metric
  |r| instead of the old |delta|/2 heuristic. Fall back to |delta|/2 only
  when the pooled SD is undefined. The pooled SD is ranking-only and is
  stripped before the row enters the snapshot payload; the raw delta and
  r the cards show are unchanged.
- Normalize the Withings and Whoop resume buttons to the min-h-11 scale
  token, dropping the arbitrary min-h-[44px] spelling.
- Give the API token copy button a 44px tap target on small viewports,
  scaling back to the tighter icon size from sm up.
- Drop the dead cached prop from InsightStatusCard and every caller; the
  card has carried no cached affordance since the badge was removed.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 19:39:37 +02:00
1 parent 766b395900
commit 874e286379
23 files changed
+162 -41

No files matched your search

+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Entdeckte Muster",
"description": "Tag-zu-Tag-Zusammenhänge rund um deine Stimmung, die auch nach Korrektur für viele gleichzeitige Tests bestehen.",
"footer": "{discovered} von {tested} getesteten Zusammenhängen haben die Korrektur für Fehlentdeckungen überstanden.",
"footer": "Herausgefiltert aus {tested} Tag-zu-Tag-Paaren, die über all deine Werte getestet wurden, nach der Korrektur für Fehlentdeckungen.",
"empty": "Bisher hat kein Stimmungszusammenhang die statistische Hürde genommen. Logge weiter.",
"disclaimer": "Statistisch gefiltert, aber weiterhin beobachtend – ein Muster, das einen Blick wert ist, keine Ursache.",
"moodUp": "Höheres {factor} geht in deinen Daten tendenziell mit besserer Stimmung am Folgetag einher.",
+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Discovered patterns",
"description": "Day-to-day relationships involving your mood that hold up after correcting for testing many pairs at once.",
"footer": "{discovered} of {tested} relationships tested survived the false-discovery correction.",
"footer": "Surfaced from {tested} day-to-day pairs tested across all your metrics, after the false-discovery correction.",
"empty": "No mood relationships cleared the statistical bar yet. Keep logging.",
"disclaimer": "Statistically filtered, but still observational — a pattern worth watching, not a cause.",
"moodUp": "Higher {factor} tends to go with higher next-day mood in your data.",
+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Patrones descubiertos",
"description": "Relaciones de un día a otro con tu estado de ánimo que se mantienen tras corregir el haber probado muchos pares a la vez.",
"footer": "{discovered} de {tested} relaciones probadas superaron la corrección de falsos descubrimientos.",
"footer": "Extraídas de {tested} pares de un día a otro probados en todas tus métricas, tras la corrección de falsos descubrimientos.",
"empty": "Todavía ninguna relación de ánimo superó el umbral estadístico. Sigue registrando.",
"disclaimer": "Filtrado estadísticamente, pero aún observacional: un patrón que vale la pena observar, no una causa.",
"moodUp": "Un mayor {factor} suele coincidir con mejor ánimo al día siguiente en tus datos.",
+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Tendances détectées",
"description": "Des relations d'un jour à l'autre autour de votre humeur qui tiennent après correction des tests multiples.",
"footer": "{discovered} relations sur {tested} testées ont passé la correction des fausses découvertes.",
"footer": "Issues de {tested} paires d'un jour à l'autre testées sur l'ensemble de vos mesures, après correction des fausses découvertes.",
"empty": "Aucune relation d'humeur n'a encore franchi le seuil statistique. Continuez à enregistrer.",
"disclaimer": "Filtré statistiquement, mais toujours observationnel : une tendance à surveiller, pas une cause.",
"moodUp": "Un {factor} plus élevé tend à coïncider avec une meilleure humeur le lendemain dans vos données.",
+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Schemi rilevati",
"description": "Relazioni giorno per giorno legate al tuo umore che reggono dopo aver corretto per i test multipli.",
"footer": "{discovered} relazioni su {tested} testate hanno superato la correzione per le false scoperte.",
"footer": "Estratte da {tested} coppie giorno per giorno testate su tutte le tue misure, dopo la correzione per le false scoperte.",
"empty": "Nessuna relazione sull'umore ha ancora superato la soglia statistica. Continua a registrare.",
"disclaimer": "Filtrato statisticamente, ma sempre osservazionale: uno schema da tenere d'occhio, non una causa.",
"moodUp": "Un {factor} più alto tende a coincidere con un umore migliore il giorno dopo nei tuoi dati.",
+1 -1
View File
@@ -2564,7 +2564,7 @@
"discovery": {
"title": "Wykryte wzorce",
"description": "Relacje dzień po dniu dotyczące Twojego nastroju, które utrzymują się po korekcie wielokrotnych testów.",
"footer": "{discovered} z {tested} testowanych relacji przeszło korektę fałszywych odkryć.",
"footer": "Wyłonione z {tested} par dzień po dniu testowanych na wszystkich Twoich wskaźnikach, po korekcie fałszywych odkryć.",
"empty": "Żadna relacja nastroju nie przekroczyła jeszcze progu statystycznego. Notuj dalej.",
"disclaimer": "Przefiltrowane statystycznie, ale wciąż obserwacyjne — wzorzec warty uwagi, nie przyczyna.",
"moodUp": "Wyższy {factor} zwykle wiąże się z lepszym nastrojem następnego dnia w Twoich danych.",
-1
View File
@@ -138,7 +138,6 @@ export default function InsightsBlutdruckPage() {
icon={<HeartPulse className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
-1
View File
@@ -141,7 +141,6 @@ export default function InsightsBmiPage() {
icon={<Ruler className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
-1
View File
@@ -288,7 +288,6 @@ export default function InsightsMedikamentePage() {
icon={<Pill className="h-5 w-5" />}
text={status?.summary ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
-1
View File
@@ -118,7 +118,6 @@ export default function InsightsStimmungPage() {
icon={<Smile className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
-1
View File
@@ -151,7 +151,6 @@ export default function InsightsPulsPage() {
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
-1
View File
@@ -116,7 +116,6 @@ export default function InsightsGewichtPage() {
icon={<Scale className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
preparing={status?.preparing ?? false}
@@ -24,7 +24,6 @@ const baseProps = {
icon: null,
text: "Your pulse is stable.",
hasProvider: true,
cached: false,
updatedAt: null,
};
@@ -24,7 +24,6 @@ const baseProps = {
title: "Pulse",
icon: null,
hasProvider: true,
cached: false,
updatedAt: null,
};
@@ -90,14 +89,14 @@ describe("<InsightStatusCard>", () => {
expect(html).toContain("No analysis yet.");
});
it("never surfaces a cached badge, even when the text came from the warm cache", () => {
// v1.11.5 — the top-right "cached" label was removed. The caching
// behaviour is unchanged (the prop is still threaded), but the card
// no longer announces it, so the assessment reads as authoritative.
it("never surfaces a cached badge — the card has no cached affordance", () => {
// v1.11.5 — the top-right "cached" label was removed and the dead
// `cached` prop was dropped from the contract. The caching behaviour
// upstream is unchanged; the card simply never announced it, so the
// assessment reads as authoritative.
const html = render(
<InsightStatusCard
{...baseProps}
cached
text="Pulse stayed inside the band."
/>,
);
@@ -15,14 +15,6 @@ interface InsightStatusCardProps {
icon: React.ReactNode;
text: string | null;
hasProvider: boolean;
/**
* Whether the rendered text came from the warm cache rather than a
* fresh generation. Retained on the contract so every caller can keep
* threading it, but the card no longer surfaces a "cached" badge — the
* label devalued the assessment and the caching is an implementation
* detail the user does not need to see.
*/
cached: boolean;
updatedAt: string | null;
loading?: boolean;
/**
@@ -43,7 +43,6 @@ export function MetricStatusCard({
icon={icon}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isLoading}
preparing={status?.preparing ?? false}
@@ -111,7 +111,7 @@ describe("<MoodDiscoveredRelations>", () => {
expect(html).toContain("lower next-day");
});
it("filters to mood pairs and shows the honest tested footer", () => {
it("filters to mood pairs and shows the honest full-family footer", () => {
const html = renderWith(
[
pair({ behaviour: "TIME_IN_DAYLIGHT", outcome: "MOOD" }),
@@ -122,7 +122,8 @@ describe("<MoodDiscoveredRelations>", () => {
expect((html.match(/data-slot="mood-discovered-pair"/g) ?? []).length).toBe(
1,
);
// footer reports 1 mood pair of 12 tested
expect(html).toContain("1 of 12");
// Footer's denominator is the full behaviour×outcome family the engine
// tested, not the mood-only subset — so it must report 12, not 1 of 1.
expect(html).toContain("from 12 day-to-day pairs");
});
});
@@ -124,7 +124,6 @@ export function MoodDiscoveredRelations() {
</ul>
<p className="text-muted-foreground text-[11px]">
{t("insights.mood.discovery.footer", {
discovered: moodPairs.length,
tested: data.pairsTested,
})}
</p>
+1 -1
View File
@@ -303,7 +303,7 @@ function ApiTokensCard() {
type="button"
variant="outline"
size="icon"
className="h-9 w-9 shrink-0"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => void handleCopyToken()}
aria-label={t("settings.tokenCopy")}
data-slot="settings-api-token-copy"
@@ -464,7 +464,7 @@ function WithingsCard({
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="withings-resume-button"
className="min-h-[44px] sm:min-h-0"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
@@ -897,7 +897,7 @@ function WhoopCard({
onClick={() => resume.mutate()}
disabled={resume.isPending}
data-testid="whoop-resume-button"
className="min-h-[44px] sm:min-h-0"
className="min-h-11"
>
{resume.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
@@ -638,7 +638,8 @@ describe("computeBetterDays", () => {
withoutDays: 10,
withAvg: 4.4,
withoutAvg: 2.4,
delta: 2, // effect 1.0 (capped)
delta: 2,
pooledSd: 1, // |d| = 2 → standardized effect 1.0 (capped)
pValue: 0.001,
confidence: "high",
},
@@ -680,6 +681,7 @@ describe("computeBetterDays", () => {
withAvg: 2.2,
withoutAvg: 3.8,
delta: -1.6,
pooledSd: 0.9,
pValue: 0.01,
confidence: "medium",
},
@@ -698,6 +700,88 @@ describe("computeBetterDays", () => {
expect(board[0].delta).toBe(-1.6);
});
it("ranks tags by the standardized (Cohen's-d) effect, not the raw delta", () => {
// "tight" has the smaller raw delta but a far smaller pooled SD, so its
// standardized effect (0.8 / 0.4 = 2.0 → capped 1.0) beats "wide"
// (1.2 / 2.0 = 0.6). The legacy |delta|/2 heuristic would have ordered
// them the other way (0.6 vs 0.4), so this pins the new behaviour.
const tagInfluence: TagInfluence = {
flat: [
{
tag: "wide",
labelKey: null,
categoryKey: null,
icon: null,
withDays: 10,
withoutDays: 10,
withAvg: 4.2,
withoutAvg: 3.0,
delta: 1.2,
pooledSd: 2.0,
pValue: 0.02,
confidence: "medium",
},
{
tag: "tight",
labelKey: null,
categoryKey: null,
icon: null,
withDays: 10,
withoutDays: 10,
withAvg: 4.0,
withoutAvg: 3.2,
delta: 0.8,
pooledSd: 0.4,
pValue: 0.001,
confidence: "high",
},
],
structured: [],
};
const board = computeBetterDays(tagInfluence, {
sleep: emptyCorr,
steps: emptyCorr,
pulse: emptyCorr,
weight: emptyCorr,
bloodPressureSystolic: emptyCorr,
});
expect(board.map((f) => f.key)).toEqual(["tight", "wide"]);
// Raw deltas shown in the UI are untouched by the ranking change.
expect(board[0].delta).toBe(0.8);
expect(board[1].delta).toBe(1.2);
});
it("falls back to the |delta|/2 heuristic when a tag has no pooled SD", () => {
const tagInfluence: TagInfluence = {
flat: [
{
tag: "constant",
labelKey: null,
categoryKey: null,
icon: null,
withDays: 10,
withoutDays: 10,
withAvg: 4.0,
withoutAvg: 2.0,
delta: 2,
pooledSd: null, // both groups perfectly constant
pValue: 1,
confidence: "low",
},
],
structured: [],
};
const board = computeBetterDays(tagInfluence, {
sleep: emptyCorr,
steps: emptyCorr,
pulse: emptyCorr,
weight: emptyCorr,
bloodPressureSystolic: emptyCorr,
});
expect(board).toHaveLength(1);
expect(board[0].effectSize).toBe(1); // min(1, |2|/2)
});
it("returns an empty board when nothing clears the gates", () => {
const board = computeBetterDays(
{ flat: [], structured: [] },
@@ -723,6 +807,7 @@ describe("computeBetterDays", () => {
withAvg: 4,
withoutAvg: 3,
delta: 1 - i * 0.01,
pooledSd: 1,
pValue: 0.02,
confidence: "low" as const,
}));
+41 -3
View File
@@ -313,6 +313,13 @@ export interface TagInfluenceRow {
withoutAvg: number;
/** withAvg − withoutAvg, rounded. Positive = higher mood with the tag. */
delta: number;
/**
* Pooled mood SD across the with/without groups (Cohen's-d denominator).
* `null` when neither group has testable spread (both constant). Lets the
* board standardize the raw delta into a unitless effect comparable to a
* Pearson |r|. Not surfaced in the UI — ranking only.
*/
pooledSd: number | null;
/** Welch two-sided p-value for the difference of means. */
pValue: number;
/** Discrete confidence band derived from p-value + per-group sample size. */
@@ -431,6 +438,22 @@ function influenceForTag(
const delta = withAvg - withoutAvg;
const minGroupDays = Math.min(withVals.length, withoutVals.length);
// Pooled SD (Cohen's-d denominator) so the board can standardize this raw
// mood-point delta into a unitless effect comparable to a Pearson |r|.
// Unbiased (n−1) per-group variances pooled on (nWith + nWithout − 2) df;
// null when there is no testable spread (both groups perfectly constant).
const nWith = withVals.length;
const nWithout = withoutVals.length;
const varWith =
withVals.reduce((s, v) => s + (v - withAvg) ** 2, 0) / (nWith - 1);
const varWithout =
withoutVals.reduce((s, v) => s + (v - withoutAvg) ** 2, 0) /
(nWithout - 1);
const pooledVar =
((nWith - 1) * varWith + (nWithout - 1) * varWithout) /
(nWith + nWithout - 2);
const pooledSd = pooledVar > 0 ? Math.sqrt(pooledVar) : null;
const pValue = welch.status === "ok" ? welch.pValue : 1;
// A zero delta with no spread is not an "influence" — drop it so the
// board never shows a 0.0 row.
@@ -446,6 +469,7 @@ function influenceForTag(
withAvg: round(withAvg, 2),
withoutAvg: round(withoutAvg, 2),
delta: round(delta, 2),
pooledSd: pooledSd === null ? null : round(pooledSd, 3),
pValue,
confidence: influenceConfidence(pValue, minGroupDays),
};
@@ -586,8 +610,16 @@ function metricConfidence(
* non-"keine" strength (|r| ≥ 0.2) are folded in — a near-zero r is not
* an association.
*
* Ranking is by `effectSize` desc (|r| for metrics, |delta|/2 capped for
* tags), then by confidence, then key for stability. Observational only.
* Ranking is by `effectSize` desc, then confidence, then key for stability.
* The two sources are put on one comparable scale: metric rows use |r|
* (already unitless in [0,1]); tag rows use a standardized effect — the raw
* mood-point `delta` divided by the pooled with/without mood SD (Cohen's d),
* clamped to [0,1]. A |d| ≥ 1 (a mean shift of one full SD) saturates the
* scale, mirroring an |r| near its ceiling, so neither source dominates the
* board for scale reasons alone. When a tag has no pooled SD (both groups
* perfectly constant), it falls back to the legacy |delta|/2 heuristic — a
* rare degenerate case that can't be standardized. The raw delta / r the UI
* shows is unchanged; this only governs the sort order. Observational only.
*/
export function computeBetterDays(
tagInfluence: TagInfluence,
@@ -606,7 +638,13 @@ export function computeBetterDays(
direction: row.delta >= 0 ? "up" : "down",
n: Math.min(row.withDays, row.withoutDays),
confidence: row.confidence,
effectSize: Math.min(1, Math.abs(row.delta) / 2),
// Cohen's-d standardization so the tag effect is commensurable with a
// metric |r|; fall back to the legacy |delta|/2 only when the pooled
// SD is unavailable (both groups perfectly constant).
effectSize:
row.pooledSd && row.pooledSd > 0
? Math.min(1, Math.abs(row.delta) / row.pooledSd)
: Math.min(1, Math.abs(row.delta) / 2),
delta: row.delta,
r: null,
});
+17 -2
View File
@@ -8,6 +8,7 @@ import {
import { pearsonCorrelation } from "@/lib/analytics/correlations";
import { applyPayloadBudget } from "@/lib/insights/bucket-series";
import {
type TagInfluenceRow,
MOOD_GREEN_MAX,
MOOD_GREEN_MIN,
MOOD_ORANGE_MAX,
@@ -47,6 +48,17 @@ import { returnTimeoutFallback } from "@/lib/insights/timeout-fallback";
import { annotate } from "@/lib/logging/context";
import { toBerlinDayKey } from "@/lib/tz/resolver";
/**
* Drop the ranking-only `pooledSd` before a tag-influence row enters the
* snapshot payload — it exists solely to standardize the better-days sort
* and has no place in the model prompt or the UI.
*/
function stripRankingOnly(row: TagInfluenceRow): Omit<TagInfluenceRow, "pooledSd"> {
const { pooledSd: _pooledSd, ...rest } = row;
void _pooledSd;
return rest;
}
export async function generateMoodStatusForUser(
userId: string,
options?: {
@@ -352,10 +364,13 @@ export async function generateMoodStatusForUser(
tagInfluence:
tagInfluence.flat.length > 0 || tagInfluence.structured.length > 0
? {
flat: tagInfluence.flat.length > 0 ? tagInfluence.flat : null,
flat:
tagInfluence.flat.length > 0
? tagInfluence.flat.map(stripRankingOnly)
: null,
structured:
tagInfluence.structured.length > 0
? tagInfluence.structured
? tagInfluence.structured.map(stripRankingOnly)
: null,
}
: null,