mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user