feat(cycle): sectioned log-day sheet with summary-badge disclosure

This commit is contained in:
Marc-André Bombeck committed 2026-06-14 18:57:38 +02:00
1 parent 9e1e285fe4
commit 5b67dc636d
8 files changed
+252 -80

No files matched your search

+4 -1
View File
@@ -5931,7 +5931,10 @@
"contraceptive": "Verhütung",
"severity": "Schweregrad",
"severityLevel": "Schweregrad {level} von 4",
"delete": "Löschen"
"delete": "Löschen",
"temperatureSection": "Temperatur & Eisprung",
"intimacySection": "Intimität & Verhütung",
"testsSection": "Tests"
},
"settings": {
"title": "Zyklus-Einstellungen",
+4 -1
View File
@@ -5931,7 +5931,10 @@
"saved": "Day saved",
"saveError": "Could not save. Try again.",
"periodStarted": "Period start logged",
"clear": "None"
"clear": "None",
"temperatureSection": "Temperature & ovulation",
"intimacySection": "Intimacy & contraception",
"testsSection": "Tests"
},
"testResult": {
"POSITIVE": "Positive",
+4 -1
View File
@@ -5931,7 +5931,10 @@
"contraceptive": "Anticonceptivo",
"severity": "Intensidad",
"severityLevel": "Intensidad {level} de 4",
"delete": "Eliminar"
"delete": "Eliminar",
"temperatureSection": "Temperatura y ovulación",
"intimacySection": "Intimidad y anticoncepción",
"testsSection": "Pruebas"
},
"settings": {
"title": "Ajustes del ciclo",
+4 -1
View File
@@ -5931,7 +5931,10 @@
"contraceptive": "Contraception",
"severity": "Intensité",
"severityLevel": "Intensité {level} sur 4",
"delete": "Supprimer"
"delete": "Supprimer",
"temperatureSection": "Température et ovulation",
"intimacySection": "Intimité et contraception",
"testsSection": "Tests"
},
"settings": {
"title": "Réglages du cycle",
+4 -1
View File
@@ -5931,7 +5931,10 @@
"contraceptive": "Contraccettivo",
"severity": "Intensità",
"severityLevel": "Intensità {level} su 4",
"delete": "Elimina"
"delete": "Elimina",
"temperatureSection": "Temperatura e ovulazione",
"intimacySection": "Intimità e contraccezione",
"testsSection": "Test"
},
"settings": {
"title": "Impostazioni del ciclo",
+4 -1
View File
@@ -5931,7 +5931,10 @@
"contraceptive": "Antykoncepcja",
"severity": "Nasilenie",
"severityLevel": "Nasilenie {level} z 4",
"delete": "Usuń"
"delete": "Usuń",
"temperatureSection": "Temperatura i owulacja",
"intimacySection": "Intymność i antykoncepcja",
"testsSection": "Testy"
},
"settings": {
"title": "Ustawienia cyklu",
@@ -3,6 +3,11 @@ import { describe, it, expect } from "vitest";
import {
buildDayLogPatch,
buildDayLogInput,
symptomsCount,
temperatureCount,
intimacyCount,
testsCount,
noteCount,
type DayLogFormState,
} from "../log-day-sheet";
@@ -136,3 +141,56 @@ describe("buildDayLogInput (new row → omit empties)", () => {
expect("temperatureExcluded" in input).toBe(false);
});
});
describe("section summary-badge counts (v1.17.0)", () => {
it("counts each logged symptom", () => {
expect(symptomsCount(blank())).toBe(0);
const s = { ...blank(), symptoms: new Map([["cramps", 2], ["headache", null]]) };
expect(symptomsCount(s)).toBe(2);
});
it("counts only the rendered symptothermal sign alongside BBT and OPK", () => {
expect(temperatureCount(blank(), false)).toBe(0);
// A non-finite / empty BBT string does not count.
expect(temperatureCount({ ...blank(), bbt: "abc" }, false)).toBe(0);
const s: DayLogFormState = {
...blank(),
bbt: "36.5",
opk: "POSITIVE_LH_SURGE",
mucus: "EGG_WHITE",
cervixPosition: "HIGH",
cervixFirmness: "SOFT",
cervixOpening: "OPEN",
};
// Mucus surface (showCervix=false): BBT + OPK + mucus = 3; cervix signs
// are not rendered, so they don't count.
expect(temperatureCount(s, false)).toBe(3);
// Cervix surface (showCervix=true): BBT + OPK + 3 cervix signs = 5; the
// stale mucus value is not rendered, so it doesn't count.
expect(temperatureCount(s, true)).toBe(5);
});
it("counts intercourse and contraceptive", () => {
expect(intimacyCount(blank())).toBe(0);
expect(
intimacyCount({ ...blank(), intercourse: true, contraceptive: "ORAL" }),
).toBe(2);
});
it("counts each home-test result", () => {
expect(testsCount(blank())).toBe(0);
expect(
testsCount({
...blank(),
pregnancyTest: "NEGATIVE",
progesteroneTest: "POSITIVE",
}),
).toBe(2);
});
it("counts a note only when it carries non-whitespace content", () => {
expect(noteCount(blank())).toBe(0);
expect(noteCount({ ...blank(), note: " " })).toBe(0);
expect(noteCount({ ...blank(), note: "tired today" })).toBe(1);
});
});
+170 -74
View File
@@ -42,6 +42,10 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
SheetSection,
SheetSectionCount,
} from "@/components/ui/sheet-section";
import { useTranslations } from "@/lib/i18n/context";
import { CUSTOM_SYMPTOM_ICON_ALLOWLIST } from "@/lib/cycle/custom-symptoms-shared";
import { CYCLE_SYMPTOM_CATALOG } from "./symptom-catalog";
@@ -249,6 +253,62 @@ export function buildDayLogInput(
};
}
/**
* v1.17.0 — section summary-badge counts for the sectioned log-day sheet.
*
* Each helper reports how many fields a collapsed section holds, so the
* badge communicates its contents without expanding it. Pure + exported
* for the unit tests.
*/
export function symptomsCount(s: DayLogFormState): number {
return s.symptoms.size;
}
/**
* Temperature & ovulation: BBT reading, OPK, and the symptothermal secondary
* sign. Only the sign the section actually renders is counted — mucus when
* `showCervix` is false, the three cervix observations when true — so the
* badge never reports a stale value from the surface that isn't shown (e.g.
* after the user flips the advanced-settings secondary-symptom choice).
*/
export function temperatureCount(
s: DayLogFormState,
showCervix: boolean,
): number {
let n = 0;
if (resolveBbt(s.bbt) != null) n += 1;
if (s.opk) n += 1;
if (showCervix) {
if (s.cervixPosition) n += 1;
if (s.cervixFirmness) n += 1;
if (s.cervixOpening) n += 1;
} else if (s.mucus) {
n += 1;
}
return n;
}
/** Intimacy & contraception: intercourse logged, and any contraceptive. */
export function intimacyCount(s: DayLogFormState): number {
let n = 0;
if (s.intercourse) n += 1;
if (s.contraceptive) n += 1;
return n;
}
/** Tests: pregnancy + progesterone home-test results. */
export function testsCount(s: DayLogFormState): number {
let n = 0;
if (s.pregnancyTest) n += 1;
if (s.progesteroneTest) n += 1;
return n;
}
/** Note: 1 when the free-text note carries content. */
export function noteCount(s: DayLogFormState): number {
return s.note.trim() ? 1 : 0;
}
export interface LogDaySheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -421,25 +481,28 @@ export function LogDaySheet({
});
}
// The live form state, shared by the save-payload builders and the section
// summary badges (so a collapsed section reports what it holds).
const formState: DayLogFormState = {
flow,
intermenstrual,
bbt,
bbtDisturbed,
opk,
mucus,
cervixPosition,
cervixFirmness,
cervixOpening,
intercourse,
protectedSex,
pregnancyTest,
progesteroneTest,
contraceptive,
note,
symptoms,
};
async function handleSave() {
const state: DayLogFormState = {
flow,
intermenstrual,
bbt,
bbtDisturbed,
opk,
mucus,
cervixPosition,
cervixFirmness,
cervixOpening,
intercourse,
protectedSex,
pregnancyTest,
progesteroneTest,
contraceptive,
note,
symptoms,
};
// v1.16.4 — catch so a rejected save doesn't escape as an unhandled
// rejection; the inline `logDay.isError || patchDay.isError` strip in the
// footer carries the visible failure signal and the sheet stays open.
@@ -449,10 +512,10 @@ export function LogDaySheet({
// chip actually CLEARS (the POST merge can only add/keep — QA W-2).
await patchDay.mutateAsync({
id: rowId,
patch: buildDayLogPatch(state),
patch: buildDayLogPatch(formState),
});
} else {
await logDay.mutateAsync(buildDayLogInput(state, date));
await logDay.mutateAsync(buildDayLogInput(formState, date));
}
onOpenChange(false);
} catch {
@@ -573,7 +636,9 @@ export function LogDaySheet({
) : null}
</div>
{/* Flow */}
{/* Quick row — Flow is the single most common period sign, so it
stays always-open at the top alongside the intermenstrual toggle.
Everything else lives in collapsible sections below. */}
<Field label={t("cycle.sheet.flow")}>
<div className="flex flex-wrap gap-2">
{FLOW_LEVELS.map((f) => (
@@ -599,7 +664,10 @@ export function LogDaySheet({
</Field>
{/* Symptoms */}
<Field label={t("cycle.sheet.symptoms")}>
<SheetSection
title={t("cycle.sheet.symptoms")}
summary={<SheetSectionCount count={symptomsCount(formState)} />}
>
<div className="space-y-3">
{CYCLE_SYMPTOM_CATALOG.map((cat) => (
<div key={cat.key} className="space-y-1.5">
@@ -656,10 +724,18 @@ export function LogDaySheet({
</div>
</div>
</div>
</Field>
</SheetSection>
{/* BBT */}
<Field label={t("cycle.sheet.temperature")}>
{/* Temperature & ovulation — BBT, OPK, and the symptothermal secondary
sign (mucus or cervix) grouped under one disclosure. */}
<SheetSection
title={t("cycle.sheet.temperatureSection")}
summary={
<SheetSectionCount count={temperatureCount(formState, showCervix)} />
}
>
{/* BBT */}
<Field label={t("cycle.sheet.temperature")}>
<input
type="number"
inputMode="decimal"
@@ -756,9 +832,15 @@ export function LogDaySheet({
</div>
</Field>
)}
</SheetSection>
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
{/* Intimacy & contraception */}
<SheetSection
title={t("cycle.sheet.intimacySection")}
summary={<SheetSectionCount count={intimacyCount(formState)} />}
>
{/* Intercourse + protection */}
<Field label={t("cycle.sheet.intercourse")}>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="cycle-intercourse" className="text-sm font-normal">
@@ -786,57 +868,71 @@ export function LogDaySheet({
</div>
) : null}
</div>
</Field>
</Field>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={pregnancyTest === v}
onClick={() => setPregnancyTest((cur) => (cur === v ? null : v))}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Contraceptive */}
<Field label={t("cycle.sheet.contraceptive")}>
<div className="flex flex-wrap gap-2">
{CONTRACEPTIVE_KINDS.map((v) => (
<Chip
key={v}
active={contraceptive === v}
onClick={() =>
setContraceptive((cur) => (cur === v ? null : v))
}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
))}
</div>
</Field>
</SheetSection>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={progesteroneTest === v}
onClick={() =>
setProgesteroneTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Tests */}
<SheetSection
title={t("cycle.sheet.testsSection")}
summary={<SheetSectionCount count={testsCount(formState)} />}
>
{/* Pregnancy test */}
<Field label={t("cycle.sheet.pregnancyTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={pregnancyTest === v}
onClick={() =>
setPregnancyTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Contraceptive */}
<Field label={t("cycle.sheet.contraceptive")}>
<div className="flex flex-wrap gap-2">
{CONTRACEPTIVE_KINDS.map((v) => (
<Chip
key={v}
active={contraceptive === v}
onClick={() => setContraceptive((cur) => (cur === v ? null : v))}
>
{t(`cycle.contraceptive.${v}`)}
</Chip>
))}
</div>
</Field>
{/* Progesterone test */}
<Field label={t("cycle.sheet.progesteroneTest")}>
<div className="flex flex-wrap gap-2">
{TEST_RESULTS.map((v) => (
<Chip
key={v}
active={progesteroneTest === v}
onClick={() =>
setProgesteroneTest((cur) => (cur === v ? null : v))
}
>
{t(`cycle.testResult.${v}`)}
</Chip>
))}
</div>
</Field>
</SheetSection>
{/* Note */}
<Field label={t("cycle.sheet.note")}>
<SheetSection
title={t("cycle.sheet.note")}
summary={<SheetSectionCount count={noteCount(formState)} />}
>
<Textarea
value={note}
onChange={(e) => setNote(e.target.value)}
@@ -845,7 +941,7 @@ export function LogDaySheet({
placeholder={t("cycle.sheet.notePlaceholder")}
aria-label={t("cycle.sheet.note")}
/>
</Field>
</SheetSection>
{logDay.isError || patchDay.isError ? (
<p className="text-destructive text-sm" role="alert">