Files
HealthLog/src/components/cycle/cycle-symptom-patterns.tsx
T
Marc-André BombeckandClaude Opus 4.8 eec3557137 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>
2026-06-06 13:47:04 +02:00

129 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}