mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
Reference in new issue
Block a user