mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
feat(cycle): cycle-phase correlation insight + Coach phase dimension
Add the menstrual-cycle phase as a channel in the existing FDR-guarded correlation machinery, the same way rated mood factors were folded in — no new statistics, the same Welch + Benjamini-Hochberg pipeline, the same day floors and q = 0.10 gate. Two complementary mechanisms, both confined to a gender-gated surface: - a categorical luteal-vs-follicular contrast per outcome metric (resting heart rate, HRV, sleep, steps, weight, basal/wrist/skin temperature), surfacing one headline finding (resting-heart-rate-by-phase, falling back to HRV); and - the continuous phase ordinal folded into the lagged-Pearson matrix. Phase never reaches the general correlations route — the temperature channels held out of the general matrix are un-blocked only here, behind the cycle gate, so a reproductive inference can never leak onto a non-gated surface. Every row shows n, effect size, p and q; nothing is fabricated on thin data and nothing is framed as causal. Surface it through a new gated GET /api/cycle/insights and a drop-in card that mirrors the mood-factor crosstab. Add a cycle dimension to the Coach snapshot: current phase + day-of-cycle, the next predicted event (period range + confidence + method, fertile window goal-gated), and the headline phase finding — present only for a cycle-enabled account, so a non-cycle snapshot is unchanged. A new ground rule keeps the Coach descriptive: never contraception-grade, never a "safe day" claim. OpenAPI regenerated; phase-correlation + snapshot-inclusion tests added; copy localised across all six locales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
5f485ca780
commit
77a652a47c
18 files changed
+2057
-4
No files matched your search
@@ -0,0 +1,202 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* v1.15 — CYCLE_PHASE × vital crosstab card.
|
||||
*
|
||||
* Mirrors the mood-factor × vital crosstab (`mood-factor-metric-crosstab.tsx`):
|
||||
* for each outcome metric, the vital's mean on LUTEAL-phase days vs
|
||||
* FOLLICULAR-phase days, the signed delta, n per group, q, and a confidence
|
||||
* chip. The math is pre-computed in `src/lib/cycle/phase-crosstab.ts`
|
||||
* (`computePhaseMetricCrosstab`) — the same Welch t-test + day floors +
|
||||
* Benjamini-Hochberg FDR the rest of the relations surface uses; only
|
||||
* FDR-surviving rows reach here. Observational only — the page-level
|
||||
* "associations, not causes" footer carries the caveat once. This surface is
|
||||
* gender-gated by its `/api/cycle/insights` route; phase never renders on the
|
||||
* general correlations surface.
|
||||
*/
|
||||
|
||||
export type CyclePhaseCrosstabDisplay =
|
||||
| "hours"
|
||||
| "steps"
|
||||
| "bpm"
|
||||
| "ms"
|
||||
| "kg"
|
||||
| "celsius";
|
||||
|
||||
export type CyclePhaseCrosstabConfidence = "low" | "medium" | "high";
|
||||
|
||||
export interface CyclePhaseCrosstabRow {
|
||||
metricKey: string;
|
||||
display: CyclePhaseCrosstabDisplay;
|
||||
lutealDays: number;
|
||||
follicularDays: number;
|
||||
lutealAvg: number;
|
||||
follicularAvg: number;
|
||||
delta: number;
|
||||
pValue: number;
|
||||
qValue: number;
|
||||
confidence: CyclePhaseCrosstabConfidence;
|
||||
}
|
||||
|
||||
const METRIC_LABEL_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.crosstab.metricRestingHeartRate",
|
||||
heartRateVariability: "cycle.insights.crosstab.metricHrv",
|
||||
sleepDuration: "cycle.insights.crosstab.metricSleepDuration",
|
||||
steps: "cycle.insights.crosstab.metricSteps",
|
||||
weight: "cycle.insights.crosstab.metricWeight",
|
||||
basalBodyTemp: "cycle.insights.crosstab.metricBasalBodyTemp",
|
||||
wristTemperature: "cycle.insights.crosstab.metricWristTemperature",
|
||||
skinTemperature: "cycle.insights.crosstab.metricSkinTemperature",
|
||||
};
|
||||
|
||||
const UNIT_KEY: Record<CyclePhaseCrosstabDisplay, string> = {
|
||||
hours: "cycle.insights.crosstab.unitHours",
|
||||
steps: "cycle.insights.crosstab.unitSteps",
|
||||
bpm: "cycle.insights.crosstab.unitBpm",
|
||||
ms: "cycle.insights.crosstab.unitMs",
|
||||
kg: "cycle.insights.crosstab.unitKg",
|
||||
celsius: "cycle.insights.crosstab.unitCelsius",
|
||||
};
|
||||
|
||||
const CONFIDENCE_KEY: Record<CyclePhaseCrosstabConfidence, string> = {
|
||||
low: "cycle.insights.crosstab.confidenceLow",
|
||||
medium: "cycle.insights.crosstab.confidenceMedium",
|
||||
high: "cycle.insights.crosstab.confidenceHigh",
|
||||
};
|
||||
|
||||
const CONFIDENCE_CLASS: Record<CyclePhaseCrosstabConfidence, string> = {
|
||||
low: "bg-secondary text-muted-foreground",
|
||||
medium: "bg-info/15 text-info",
|
||||
high: "bg-success/15 text-success",
|
||||
};
|
||||
|
||||
/** Whole numbers for steps; one decimal otherwise. */
|
||||
function fmt(value: number, display: CyclePhaseCrosstabDisplay): string {
|
||||
return display === "steps" ? Math.round(value).toString() : value.toFixed(1);
|
||||
}
|
||||
|
||||
const HEADLINE_KEY: Record<string, string> = {
|
||||
restingHeartRate: "cycle.insights.headline.restingHeartRate",
|
||||
heartRateVariability: "cycle.insights.headline.heartRateVariability",
|
||||
};
|
||||
|
||||
/**
|
||||
* The single headline finding rendered prominently above the grid. Resting
|
||||
* heart rate / HRV get bespoke phrasing; every other metric uses the generic
|
||||
* line. Returns null when nothing cleared the FDR gate — an honest empty state.
|
||||
*/
|
||||
export function CyclePhaseHeadline({
|
||||
headline,
|
||||
}: {
|
||||
headline: CyclePhaseCrosstabRow | null;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (!headline) {
|
||||
return (
|
||||
<p
|
||||
data-slot="cycle-phase-headline-empty"
|
||||
className="text-muted-foreground text-sm"
|
||||
>
|
||||
{t("cycle.insights.headline.empty")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const metricLabel = t(METRIC_LABEL_KEY[headline.metricKey] ?? headline.metricKey);
|
||||
const unit = t(UNIT_KEY[headline.display]);
|
||||
const up = headline.delta >= 0;
|
||||
const dir = t(
|
||||
up ? "cycle.insights.headline.dirHigher" : "cycle.insights.headline.dirLower",
|
||||
);
|
||||
const params = {
|
||||
metric: metricLabel,
|
||||
delta: fmt(Math.abs(headline.delta), headline.display),
|
||||
unit,
|
||||
dir,
|
||||
};
|
||||
const key = HEADLINE_KEY[headline.metricKey] ?? "cycle.insights.headline.generic";
|
||||
return (
|
||||
<p
|
||||
data-slot="cycle-phase-headline"
|
||||
data-metric={headline.metricKey}
|
||||
className="text-foreground text-sm font-medium"
|
||||
>
|
||||
{t(key, params)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function CyclePhaseCrosstab({
|
||||
rows,
|
||||
}: {
|
||||
rows: CyclePhaseCrosstabRow[];
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
if (rows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div data-slot="cycle-phase-crosstab">
|
||||
<p className="text-muted-foreground mb-2 text-sm">
|
||||
{t("cycle.insights.crosstab.description")}
|
||||
</p>
|
||||
<ul className="divide-border divide-y">
|
||||
{rows.map((row) => {
|
||||
const metricLabel = t(METRIC_LABEL_KEY[row.metricKey] ?? row.metricKey);
|
||||
const unit = t(UNIT_KEY[row.display]);
|
||||
// `delta` = lutealAvg − follicularAvg: positive = the vital runs
|
||||
// higher in the luteal phase. The number stays NEUTRAL — higher is
|
||||
// good for steps but bad for resting HR, and this board is
|
||||
// observational, so colouring it would imply a verdict the data
|
||||
// doesn't support. The sign prefix + `data-direction` carry it.
|
||||
const up = row.delta >= 0;
|
||||
const deltaText = `${up ? "+" : ""}${fmt(row.delta, row.display)} ${unit}`;
|
||||
return (
|
||||
<li
|
||||
key={row.metricKey}
|
||||
className="flex flex-col gap-1.5 py-2"
|
||||
data-slot="cycle-phase-crosstab-row"
|
||||
data-metric={row.metricKey}
|
||||
data-direction={up ? "up" : "down"}
|
||||
data-confidence={row.confidence}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className="text-foreground min-w-0 flex-1 truncate"
|
||||
title={metricLabel}
|
||||
>
|
||||
{t("cycle.insights.crosstab.pairLabel", { metric: metricLabel })}
|
||||
</span>
|
||||
<span className="text-foreground shrink-0 text-sm font-semibold tabular-nums">
|
||||
{deltaText}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium",
|
||||
CONFIDENCE_CLASS[row.confidence],
|
||||
)}
|
||||
>
|
||||
{t(CONFIDENCE_KEY[row.confidence])}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("cycle.insights.crosstab.detail", {
|
||||
metric: metricLabel,
|
||||
lutealAvg: fmt(row.lutealAvg, row.display),
|
||||
follicularAvg: fmt(row.follicularAvg, row.display),
|
||||
unit,
|
||||
lutealDays: row.lutealDays,
|
||||
follicularDays: row.follicularDays,
|
||||
})}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-3 text-xs">
|
||||
{t("cycle.insights.crosstab.footer")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user