feat(cycle): symptom-by-phase patterns on the insights tab

Add the cycle-native counterpart to the phase×vitals crosstab: for each
logged symptom, where in the cycle it clusters. A pure aggregator tallies
each symptom's occurrences across the phase-labelled days (the same phase
map the calendar uses), surfaces only symptoms logged on at least three
phased days, and reports per-phase counts + the dominant phase + its
share. The insights tab renders it as a proportional phase-hued bar with
the dominant phase named in words (never colour-only), observational and
never causal.

Also correct the phase-crosstab OpenAPI row enum, which had drifted
behind the runtime (mood + glucose channels were missing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-06 13:47:04 +02:00
1 parent d2da8d2657
commit eec3557137
14 files changed
+447 -7

No files matched your search

+14
View File
@@ -35,6 +35,10 @@ import {
MOOD_CHANNEL_KEY,
} from "@/lib/cycle/phase-crosstab";
import type { CrossMetricMeasurement } from "@/lib/insights/mood-aggregates";
import {
computeSymptomPhasePatterns,
type SymptomDay,
} from "@/lib/cycle/symptom-phase";
import { addDays } from "@/lib/cycle/day-math";
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
@@ -79,6 +83,7 @@ export const GET = apiHandler(async () => {
basalBodyTempC: true,
ovulationTest: true,
cervicalMucus: true,
symptomLinks: { select: { symptom: { select: { key: true } } } },
},
}),
// Passive wrist temperature feeds the temperature-trend ovulation layer
@@ -196,6 +201,13 @@ export const GET = apiHandler(async () => {
userPriorityJson,
});
// Cycle-NATIVE insight: where each logged symptom clusters across the phases.
const symptomDays: SymptomDay[] = dayLogRows.map((d) => ({
date: d.date,
keys: d.symptomLinks.map((l) => l.symptom.key),
}));
const symptomPatterns = computeSymptomPhasePatterns(symptomDays, phaseByDay);
annotate({
action: { name: "cycle.insights.read" },
meta: {
@@ -205,6 +217,7 @@ export const GET = apiHandler(async () => {
has_headline: headline !== null,
lagged_discovered: lagged.discovered.length,
lagged_pairs_tested: lagged.pairsTested,
symptom_patterns: symptomPatterns.length,
},
});
@@ -217,6 +230,7 @@ export const GET = apiHandler(async () => {
fdrQ: lagged.fdrQ,
minPairs: lagged.minPairs,
},
symptomPatterns,
contrast: { high: "LUTEAL", low: "FOLLICULAR" },
windowDays: WINDOW_DAYS,
cyclesObserved: cycles.length,
@@ -0,0 +1,128 @@
"use client";
import { useMemo } from "react";
import { Activity } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTranslations } from "@/lib/i18n/context";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
import { PHASE_HUE } from "./phase-tokens";
import type { CyclePhase } from "./types";
import type { SymptomPhaseRow } from "./use-cycle";
/**
* v1.15.0 — symptom patterns by phase (the cycle-NATIVE insight).
*
* The phase×vitals crosstab correlates the cycle against your measurements;
* this card answers the complementary, cycle-own question: which of your logged
* symptoms cluster in which phase. Each row shows the symptom's per-phase
* distribution as a proportional 4-segment bar (phase-hued, never colour-only —
* the dominant phase is named in words) plus "mostly in your luteal phase
* (7 of 9 days)". Observational, never causal; only symptoms logged on ≥3
* phase-labelled days surface (the server floor).
*/
const PHASE_ORDER: CyclePhase[] = [
"MENSTRUAL",
"FOLLICULAR",
"OVULATORY",
"LUTEAL",
];
export interface CycleSymptomPatternsProps {
rows: SymptomPhaseRow[];
}
export function CycleSymptomPatterns({ rows }: CycleSymptomPatternsProps) {
const { t } = useTranslations();
// Flatten the catalog once: symptom key → { labelKey, icon }.
const byKey = useMemo(() => {
const map = new Map<
string,
{
labelKey: string;
icon: (typeof CYCLE_SYMPTOM_CATALOG)[number]["symptoms"][number]["icon"];
}
>();
for (const cat of CYCLE_SYMPTOM_CATALOG) {
for (const s of cat.symptoms) {
map.set(s.key, { labelKey: s.labelKey, icon: s.icon });
}
}
return map;
}, []);
return (
<Card data-slot="cycle-symptom-patterns">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Activity className="text-primary h-4 w-4" aria-hidden="true" />
{t("cycle.insights.symptomPatternsTitle")}
</CardTitle>
</CardHeader>
<CardContent>
{rows.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("cycle.insights.symptomPatternsEmpty")}
</p>
) : (
<ul className="space-y-3" data-slot="cycle-symptom-pattern-list">
{rows.map((row) => {
const meta = byKey.get(row.symptomKey);
const Icon = meta?.icon;
const name = meta ? t(meta.labelKey) : row.symptomKey;
const phaseName = t(`cycle.phase.${row.topPhase}`);
return (
<li key={row.symptomKey} className="space-y-1.5">
<div className="flex items-center justify-between gap-3">
<span className="text-foreground flex items-center gap-1.5 text-sm font-medium">
{Icon ? (
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
) : null}
{name}
</span>
<span className="text-muted-foreground text-xs">
{t("cycle.insights.symptomMostlyIn", {
phase: phaseName,
count: row.counts[row.topPhase],
total: row.total,
})}
</span>
</div>
{/* Proportional phase-distribution bar — phase-hued, with the
dominant phase named above so it is never colour-only. */}
<div
className="bg-muted flex h-2 overflow-hidden rounded-full"
role="img"
aria-label={t("cycle.insights.symptomMostlyIn", {
phase: phaseName,
count: row.counts[row.topPhase],
total: row.total,
})}
>
{PHASE_ORDER.map((phase) => {
const share =
row.total > 0 ? row.counts[phase] / row.total : 0;
if (share <= 0) return null;
return (
<span
key={phase}
style={{
width: `${share * 100}%`,
backgroundColor: PHASE_HUE[phase],
opacity: phase === row.topPhase ? 1 : 0.45,
}}
/>
);
})}
</div>
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}
+4
View File
@@ -15,6 +15,7 @@ import { CycleDisclaimer } from "./cycle-disclaimer";
import { LogDaySheet } from "./log-day-sheet";
import { PredictionsPanel } from "./predictions-panel";
import { CyclePhaseHeadline, CyclePhaseCrosstab } from "./cycle-phase-crosstab";
import { CycleSymptomPatterns } from "./cycle-symptom-patterns";
import { CycleSettings } from "./cycle-settings";
import { deriveWheelState } from "./wheel-state";
import { PHASE_HUE } from "./phase-tokens";
@@ -263,6 +264,9 @@ export function CycleView() {
headline={insights.data?.headline ?? null}
/>
<CyclePhaseCrosstab rows={insights.data?.rows ?? []} />
<CycleSymptomPatterns
rows={insights.data?.symptomPatterns ?? []}
/>
</>
)}
</TabsContent>
+15
View File
@@ -31,10 +31,25 @@ import type {
} from "./types";
import type { CyclePhaseCrosstabRow } from "./cycle-phase-crosstab";
/** One symptom's per-phase clustering (mirrors `SymptomPhasePatternRow`). */
export interface SymptomPhaseRow {
symptomKey: string;
counts: {
MENSTRUAL: number;
FOLLICULAR: number;
OVULATORY: number;
LUTEAL: number;
};
total: number;
topPhase: "MENSTRUAL" | "FOLLICULAR" | "OVULATORY" | "LUTEAL";
topShare: number;
}
/** The `/api/cycle/insights` read: the phase-contrast rows + the headline. */
export interface CycleInsightsResponse {
rows: CyclePhaseCrosstabRow[];
headline: CyclePhaseCrosstabRow | null;
symptomPatterns: SymptomPhaseRow[];
}
async function unwrap<T>(res: Response): Promise<T> {
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import {
computeSymptomPhasePatterns,
SYMPTOM_PHASE_MIN_DAYS,
type SymptomDay,
} from "../symptom-phase";
import type { CyclePhase } from "../types";
function phaseMap(entries: [string, CyclePhase][]): Map<string, CyclePhase> {
return new Map(entries);
}
describe("computeSymptomPhasePatterns", () => {
it("clusters a symptom into its dominant phase", () => {
const days: SymptomDay[] = [
{ date: "2026-06-01", keys: ["cramps"] },
{ date: "2026-06-02", keys: ["cramps"] },
{ date: "2026-06-03", keys: ["cramps"] },
{ date: "2026-06-20", keys: ["cramps"] },
];
const phases = phaseMap([
["2026-06-01", "MENSTRUAL"],
["2026-06-02", "MENSTRUAL"],
["2026-06-03", "MENSTRUAL"],
["2026-06-20", "LUTEAL"],
]);
const [row] = computeSymptomPhasePatterns(days, phases);
expect(row.symptomKey).toBe("cramps");
expect(row.total).toBe(4);
expect(row.counts.MENSTRUAL).toBe(3);
expect(row.counts.LUTEAL).toBe(1);
expect(row.topPhase).toBe("MENSTRUAL");
expect(row.topShare).toBeCloseTo(0.75, 5);
});
it("drops symptoms below the min-day floor", () => {
const days: SymptomDay[] = [
{ date: "2026-06-01", keys: ["rare"] },
{ date: "2026-06-02", keys: ["rare"] },
];
const phases = phaseMap([
["2026-06-01", "MENSTRUAL"],
["2026-06-02", "FOLLICULAR"],
]);
// 2 < SYMPTOM_PHASE_MIN_DAYS (3) → no row.
expect(SYMPTOM_PHASE_MIN_DAYS).toBe(3);
expect(computeSymptomPhasePatterns(days, phases)).toHaveLength(0);
});
it("ignores symptom days that fall outside any phase window", () => {
const days: SymptomDay[] = [
{ date: "2026-06-01", keys: ["cramps"] },
{ date: "2026-06-02", keys: ["cramps"] },
{ date: "2026-06-03", keys: ["cramps"] },
{ date: "2026-06-30", keys: ["cramps"] }, // unphased → ignored
];
const phases = phaseMap([
["2026-06-01", "LUTEAL"],
["2026-06-02", "LUTEAL"],
["2026-06-03", "LUTEAL"],
]);
const [row] = computeSymptomPhasePatterns(days, phases);
expect(row.total).toBe(3);
expect(row.counts.LUTEAL).toBe(3);
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* v1.15.0 — symptom-by-phase pattern aggregation.
*
* The cycle-NATIVE counterpart to the phase×vitals crosstab: for each logged
* symptom, where in the cycle does it cluster? Pure + DB-free — the gated
* `/api/cycle/insights` route does the reads and the gender gate, then hands the
* already-fetched per-day symptom keys + the phase-day map (built identically to
* the calendar grid) to this function.
*
* Honest, never causal: a symptom is surfaced only once it has been logged on at
* least `SYMPTOM_PHASE_MIN_DAYS` phase-labelled days, so a one-off entry never
* produces a "pattern". The output reports the raw per-phase counts and the
* dominant phase + its share, so the UI states "logged mostly in your luteal
* phase (7 of 9 days)" — an observation, not a diagnosis.
*/
import type { CyclePhase } from "@/lib/cycle/types";
/** A symptom must appear on at least this many phased days to surface a pattern. */
export const SYMPTOM_PHASE_MIN_DAYS = 3;
/** Cap the surfaced rows so the card stays scannable (most-logged first). */
export const SYMPTOM_PHASE_MAX_ROWS = 10;
const PHASES: CyclePhase[] = ["MENSTRUAL", "FOLLICULAR", "OVULATORY", "LUTEAL"];
/** One day's logged symptom keys (presence only — severity is not needed here). */
export interface SymptomDay {
date: string;
keys: readonly string[];
}
export interface SymptomPhasePatternRow {
symptomKey: string;
/** Per-phase count of phase-labelled days this symptom was logged. */
counts: Record<CyclePhase, number>;
/** Total phase-labelled days the symptom was logged (sum of `counts`). */
total: number;
/** The phase the symptom clusters in most. */
topPhase: CyclePhase;
/** topPhase count / total, in [0,1] — how concentrated the pattern is. */
topShare: number;
}
function emptyCounts(): Record<CyclePhase, number> {
return { MENSTRUAL: 0, FOLLICULAR: 0, OVULATORY: 0, LUTEAL: 0 };
}
/**
* Tally each symptom's occurrences by cycle phase across the window. Only days
* that carry a phase label count (a symptom logged outside any cycle window is
* ignored). Rows below the min-day floor are dropped; the rest sort by total
* desc (ties broken by key for determinism) and cap at `SYMPTOM_PHASE_MAX_ROWS`.
*/
export function computeSymptomPhasePatterns(
symptomDays: readonly SymptomDay[],
phaseByDay: ReadonlyMap<string, CyclePhase>,
): SymptomPhasePatternRow[] {
const byKey = new Map<string, Record<CyclePhase, number>>();
for (const day of symptomDays) {
const phase = phaseByDay.get(day.date);
if (!phase) continue;
for (const key of day.keys) {
let counts = byKey.get(key);
if (!counts) {
counts = emptyCounts();
byKey.set(key, counts);
}
counts[phase] += 1;
}
}
const rows: SymptomPhasePatternRow[] = [];
for (const [symptomKey, counts] of byKey) {
const total = PHASES.reduce((s, p) => s + counts[p], 0);
if (total < SYMPTOM_PHASE_MIN_DAYS) continue;
// Dominant phase — deterministic tie-break by the fixed phase order.
let topPhase: CyclePhase = PHASES[0];
for (const p of PHASES) {
if (counts[p] > counts[topPhase]) topPhase = p;
}
rows.push({
symptomKey,
counts,
total,
topPhase,
topShare: counts[topPhase] / total,
});
}
rows.sort(
(a, b) => b.total - a.total || a.symptomKey.localeCompare(b.symptomKey),
);
return rows.slice(0, SYMPTOM_PHASE_MAX_ROWS);
}
+39 -1
View File
@@ -2426,8 +2426,19 @@ const cyclePhaseCrosstabRow = z
"basalBodyTemp",
"wristTemperature",
"skinTemperature",
"bloodGlucose",
"mood",
]),
display: z.enum([
"hours",
"steps",
"bpm",
"ms",
"kg",
"celsius",
"glucose",
"mood",
]),
display: z.enum(["hours", "steps", "bpm", "ms", "kg", "celsius"]),
lutealDays: z.number().int(),
follicularDays: z.number().int(),
lutealAvg: z.number(),
@@ -2460,6 +2471,32 @@ const cyclePhaseLaggedPair = z
"One FDR-surviving lagged-Pearson pair from the continuous CYCLE_PHASE ordinal × outcome matrix (mechanism B). Descriptive, never causal.",
});
const cyclePhaseEnumForCount = z.enum([
"MENSTRUAL",
"FOLLICULAR",
"OVULATORY",
"LUTEAL",
]);
const cycleSymptomPhaseRow = z
.object({
symptomKey: z.string(),
counts: z.object({
MENSTRUAL: z.number().int(),
FOLLICULAR: z.number().int(),
OVULATORY: z.number().int(),
LUTEAL: z.number().int(),
}),
total: z.number().int(),
topPhase: cyclePhaseEnumForCount,
topShare: z.number(),
})
.meta({
id: "CycleSymptomPhaseRow",
description:
"Where a logged symptom clusters across the cycle phases. Surfaced only once logged on ≥3 phase-labelled days. Observational, never causal.",
});
const cycleInsightsResponse = z.object({
rows: z.array(cyclePhaseCrosstabRow),
headline: cyclePhaseCrosstabRow.nullable(),
@@ -2469,6 +2506,7 @@ const cycleInsightsResponse = z.object({
fdrQ: z.number(),
minPairs: z.number().int(),
}),
symptomPatterns: z.array(cycleSymptomPhaseRow),
contrast: z.object({
high: z.literal("LUTEAL"),
low: z.literal("FOLLICULAR"),