", () => {
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,