From 874e2863799f4b649fa275677c9d1c2512fb349d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 19:39:05 +0200 Subject: [PATCH] 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. --- messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- src/app/insights/blood-pressure/page.tsx | 1 - src/app/insights/bmi/page.tsx | 1 - src/app/insights/medications/page.tsx | 1 - src/app/insights/mood/page.tsx | 1 - src/app/insights/pulse/page.tsx | 1 - src/app/insights/weight/page.tsx | 1 - .../insight-status-card-flag-gate.test.tsx | 1 - .../__tests__/insight-status-card.test.tsx | 11 ++- .../insights/insight-status-card.tsx | 8 -- .../insights/metric-status-card.tsx | 1 - .../mood-discovered-relations.test.tsx | 7 +- .../mood/mood-discovered-relations.tsx | 1 - src/components/settings/api-section.tsx | 2 +- .../settings/integrations-section.tsx | 4 +- .../__tests__/mood-aggregates.test.ts | 87 ++++++++++++++++++- src/lib/insights/mood-aggregates.ts | 44 +++++++++- src/lib/insights/mood-status.ts | 19 +++- 23 files changed, 162 insertions(+), 41 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8181f7efb..a7421034d 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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.", diff --git a/messages/en.json b/messages/en.json index 4727f2170..4d483d001 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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.", diff --git a/messages/es.json b/messages/es.json index 2e2b7f3ae..a2e3a9331 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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.", diff --git a/messages/fr.json b/messages/fr.json index 6c17c69f4..3e43c1dbd 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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.", diff --git a/messages/it.json b/messages/it.json index 119147de9..a1fa85e22 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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.", diff --git a/messages/pl.json b/messages/pl.json index 9c8455fa7..eef8da327 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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.", diff --git a/src/app/insights/blood-pressure/page.tsx b/src/app/insights/blood-pressure/page.tsx index c4c0bf9a8..87fd0d52c 100644 --- a/src/app/insights/blood-pressure/page.tsx +++ b/src/app/insights/blood-pressure/page.tsx @@ -138,7 +138,6 @@ export default function InsightsBlutdruckPage() { icon={} text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/app/insights/bmi/page.tsx b/src/app/insights/bmi/page.tsx index b60d7145a..a405931e1 100644 --- a/src/app/insights/bmi/page.tsx +++ b/src/app/insights/bmi/page.tsx @@ -141,7 +141,6 @@ export default function InsightsBmiPage() { icon={} text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index 09344b5a5..835f561e2 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -288,7 +288,6 @@ export default function InsightsMedikamentePage() { icon={} text={status?.summary ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/app/insights/mood/page.tsx b/src/app/insights/mood/page.tsx index d53a91c2b..9f3d3d9fc 100644 --- a/src/app/insights/mood/page.tsx +++ b/src/app/insights/mood/page.tsx @@ -118,7 +118,6 @@ export default function InsightsStimmungPage() { icon={} text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 041d92aa6..becdb4e0a 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -151,7 +151,6 @@ export default function InsightsPulsPage() { icon={} text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/app/insights/weight/page.tsx b/src/app/insights/weight/page.tsx index 47c765cbf..b9e4824b5 100644 --- a/src/app/insights/weight/page.tsx +++ b/src/app/insights/weight/page.tsx @@ -116,7 +116,6 @@ export default function InsightsGewichtPage() { icon={} text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} - cached={status?.cached ?? false} updatedAt={status?.updatedAt ?? null} loading={isStatusLoading} preparing={status?.preparing ?? false} diff --git a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx index 8a76abcd8..5f67f5f96 100644 --- a/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx +++ b/src/components/insights/__tests__/insight-status-card-flag-gate.test.tsx @@ -24,7 +24,6 @@ const baseProps = { icon: null, text: "Your pulse is stable.", hasProvider: true, - cached: false, updatedAt: null, }; diff --git a/src/components/insights/__tests__/insight-status-card.test.tsx b/src/components/insights/__tests__/insight-status-card.test.tsx index da2994d2b..b58fcda94 100644 --- a/src/components/insights/__tests__/insight-status-card.test.tsx +++ b/src/components/insights/__tests__/insight-status-card.test.tsx @@ -24,7 +24,6 @@ const baseProps = { title: "Pulse", icon: null, hasProvider: true, - cached: false, updatedAt: null, }; @@ -90,14 +89,14 @@ describe("", () => { 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( , ); diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index c728988a3..0f1680ca2 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -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; /** diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 4634d6ba6..9a2993cff 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -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} diff --git a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx index e451048d6..05ea3957e 100644 --- a/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx +++ b/src/components/insights/mood/__tests__/mood-discovered-relations.test.tsx @@ -111,7 +111,7 @@ describe("", () => { 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("", () => { 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"); }); }); diff --git a/src/components/insights/mood/mood-discovered-relations.tsx b/src/components/insights/mood/mood-discovered-relations.tsx index d9168158f..89db870c6 100644 --- a/src/components/insights/mood/mood-discovered-relations.tsx +++ b/src/components/insights/mood/mood-discovered-relations.tsx @@ -124,7 +124,6 @@ export function MoodDiscoveredRelations() {

{t("insights.mood.discovery.footer", { - discovered: moodPairs.length, tested: data.pairsTested, })}

diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx index 8479e5bb1..4e93b26bc 100644 --- a/src/components/settings/api-section.tsx +++ b/src/components/settings/api-section.tsx @@ -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" diff --git a/src/components/settings/integrations-section.tsx b/src/components/settings/integrations-section.tsx index df5452da3..710e7a555 100644 --- a/src/components/settings/integrations-section.tsx +++ b/src/components/settings/integrations-section.tsx @@ -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 ? ( @@ -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 ? ( diff --git a/src/lib/insights/__tests__/mood-aggregates.test.ts b/src/lib/insights/__tests__/mood-aggregates.test.ts index 5d2f574c6..234fb9c30 100644 --- a/src/lib/insights/__tests__/mood-aggregates.test.ts +++ b/src/lib/insights/__tests__/mood-aggregates.test.ts @@ -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, })); diff --git a/src/lib/insights/mood-aggregates.ts b/src/lib/insights/mood-aggregates.ts index 5795e8519..e0be00212 100644 --- a/src/lib/insights/mood-aggregates.ts +++ b/src/lib/insights/mood-aggregates.ts @@ -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, }); diff --git a/src/lib/insights/mood-status.ts b/src/lib/insights/mood-status.ts index 9e93b64d3..aef2db266 100644 --- a/src/lib/insights/mood-status.ts +++ b/src/lib/insights/mood-status.ts @@ -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 { + 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,