mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 06:32:03 +00:00
feat(cycle): sectioned log-day sheet with summary-badge disclosure
This commit is contained in:
1 parent
9e1e285fe4
commit
5b67dc636d
8 files changed
+252
-80
No files matched your search
+4
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user