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

@@ -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> {