mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(cycle): mark intercourse and other logged entries on the calendar
An intercourse entry left no trace on the month grid and the legend had no entry for it. The day now carries a diamond marker, and anything else logged that day with no marker of its own (a test, contraception, spotting, a temperature, a mucus or cervix reading, a note) shows a hollow ring, so a day with one of those logged no longer looks empty. Both are named in the legend and in the cell's accessible label.
This commit is contained in:
1 parent
adec5bd045
commit
0390d124cb
14 files changed
+285
-20
No files matched your search
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "Schmierblutung",
|
||||
"flowLight": "leicht",
|
||||
"flowMedium": "mittel",
|
||||
"flowHeavy": "stark"
|
||||
"flowHeavy": "stark",
|
||||
"legendIntercourse": "Geschlechtsverkehr",
|
||||
"legendOtherEntries": "Weitere Einträge"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Was als Nächstes kommt",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "light",
|
||||
"flowMedium": "medium",
|
||||
"flowHeavy": "heavy"
|
||||
"flowHeavy": "heavy",
|
||||
"legendIntercourse": "Intercourse",
|
||||
"legendOtherEntries": "Other entries"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "What comes next",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "manchado",
|
||||
"flowLight": "ligero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abundante"
|
||||
"flowHeavy": "abundante",
|
||||
"legendIntercourse": "Relaciones",
|
||||
"legendOtherEntries": "Otras entradas"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Qué viene después",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "léger",
|
||||
"flowMedium": "moyen",
|
||||
"flowHeavy": "abondant"
|
||||
"flowHeavy": "abondant",
|
||||
"legendIntercourse": "Rapports",
|
||||
"legendOtherEntries": "Autres saisies"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Ce qui arrive ensuite",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "spotting",
|
||||
"flowLight": "leggero",
|
||||
"flowMedium": "medio",
|
||||
"flowHeavy": "abbondante"
|
||||
"flowHeavy": "abbondante",
|
||||
"legendIntercourse": "Rapporti",
|
||||
"legendOtherEntries": "Altre voci"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Cosa arriva dopo",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "점상 출혈",
|
||||
"flowLight": "적음",
|
||||
"flowMedium": "보통",
|
||||
"flowHeavy": "많음"
|
||||
"flowHeavy": "많음",
|
||||
"legendIntercourse": "성관계",
|
||||
"legendOtherEntries": "기타 기록"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "다음에 올 일",
|
||||
|
||||
+3
-1
@@ -8231,7 +8231,9 @@
|
||||
"flowSpotting": "plamienie",
|
||||
"flowLight": "lekkie",
|
||||
"flowMedium": "średnie",
|
||||
"flowHeavy": "obfite"
|
||||
"flowHeavy": "obfite",
|
||||
"legendIntercourse": "Współżycie",
|
||||
"legendOtherEntries": "Inne wpisy"
|
||||
},
|
||||
"predictions": {
|
||||
"title": "Co dalej",
|
||||
|
||||
@@ -28,6 +28,15 @@ function day(date: string, overrides: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,9 +28,27 @@ function dayBase(date: string): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The opening tag of one day's cell, by its date in the aria-label. */
|
||||
function cellTag(html: string, date: string): string {
|
||||
const match = html.match(
|
||||
new RegExp(`<(?:button|div)[^>]*aria-label="${date}[^"]*"[^>]*>`),
|
||||
);
|
||||
if (!match) throw new Error(`no cell for ${date}`);
|
||||
return match[0];
|
||||
}
|
||||
|
||||
describe("<CycleCalendar>", () => {
|
||||
const today = "2026-06-15";
|
||||
|
||||
@@ -130,4 +148,85 @@ describe("<CycleCalendar>", () => {
|
||||
);
|
||||
expect(html).toContain('data-fertile="true"');
|
||||
});
|
||||
|
||||
describe("per-day entries beyond period and symptoms (#1032)", () => {
|
||||
it("marks an intercourse day with its own marker and names it", () => {
|
||||
const days = [{ ...dayBase("2026-06-05"), sexualActivity: true }];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-05");
|
||||
expect(cell).toContain('data-intercourse="true"');
|
||||
expect(cell).toContain("Intercourse");
|
||||
// Only that day.
|
||||
expect(cellTag(html, "2026-06-06")).not.toContain("data-intercourse");
|
||||
expect(html).toContain('data-slot="cycle-calendar-intercourse"');
|
||||
});
|
||||
|
||||
it("the legend names intercourse and the other-entries marker", () => {
|
||||
const html = render(
|
||||
<CycleCalendar days={[]} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
expect(html).toContain('data-legend="cycle.calendar.legendIntercourse"');
|
||||
expect(html).toContain('data-legend="cycle.calendar.legendOtherEntries"');
|
||||
expect(html).toContain("Intercourse");
|
||||
expect(html).toContain("Other entries");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a pregnancy test",
|
||||
{ pregnancyTest: "POSITIVE" },
|
||||
"Pregnancy test (Positive)",
|
||||
],
|
||||
[
|
||||
"a progesterone test",
|
||||
{ progesteroneTest: "NEGATIVE" },
|
||||
"Progesterone test (Negative)",
|
||||
],
|
||||
["contraception", { contraceptive: "ORAL" }, "Contraceptive (Pill)"],
|
||||
[
|
||||
"spotting",
|
||||
{ intermenstrualBleeding: true },
|
||||
"Spotting between periods",
|
||||
],
|
||||
["a note", { hasNote: true }, "Note"],
|
||||
["a temperature", { basalBodyTempC: 36.6 }, "Basal body temperature"],
|
||||
[
|
||||
"an ovulation test",
|
||||
{ ovulationTest: "POSITIVE_LH_SURGE" },
|
||||
"Ovulation test (LH surge)",
|
||||
],
|
||||
[
|
||||
"a mucus reading",
|
||||
{ cervicalMucus: "CREAMY" },
|
||||
"Cervical mucus (Creamy)",
|
||||
],
|
||||
["a cervix sign", { cervixPosition: "HIGH" }, "Cervix"],
|
||||
] as const)(
|
||||
"a day with only %s logged no longer looks empty",
|
||||
(_label, over, aria) => {
|
||||
const days = [{ ...dayBase("2026-06-08"), ...over } as CalendarDay];
|
||||
const html = render(
|
||||
<CycleCalendar days={days} today={today} onSelectDay={() => {}} />,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-08");
|
||||
expect(cell).toContain('data-other-entries="true"');
|
||||
expect(cell).toContain(aria);
|
||||
},
|
||||
);
|
||||
|
||||
it("an empty day carries neither marker", () => {
|
||||
const html = render(
|
||||
<CycleCalendar
|
||||
days={[dayBase("2026-06-08")]}
|
||||
today={today}
|
||||
onSelectDay={() => {}}
|
||||
/>,
|
||||
);
|
||||
const cell = cellTag(html, "2026-06-08");
|
||||
expect(cell).not.toContain("data-other-entries");
|
||||
expect(cell).not.toContain("data-intercourse");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -281,6 +281,15 @@ function gridDay(date: string, over: Partial<CalendarDay> = {}): CalendarDay {
|
||||
basalBodyTempC: null,
|
||||
ovulationTest: null,
|
||||
cervicalMucus: null,
|
||||
cervixPosition: null,
|
||||
cervixFirmness: null,
|
||||
cervixOpening: null,
|
||||
intermenstrualBleeding: false,
|
||||
sexualActivity: false,
|
||||
pregnancyTest: null,
|
||||
progesteroneTest: null,
|
||||
contraceptive: null,
|
||||
hasNote: false,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import type { CalendarDay } from "./types";
|
||||
import {
|
||||
FERTILE_HUE,
|
||||
FLOW_HUE,
|
||||
INTERCOURSE_HUE,
|
||||
OVULATION_HUE,
|
||||
flowOpacity,
|
||||
} from "./phase-tokens";
|
||||
@@ -25,7 +26,11 @@ import {
|
||||
* single dated dot); the fertile window (already goal-gated server-side — the
|
||||
* API nulls it unless TRYING_TO_CONCEIVE) renders as a green ring; the
|
||||
* predicted-ovulation day gets an amber marker; per-day symptom presence
|
||||
* shows a small dot. Selecting a day calls `onSelectDay` (the log-day sheet).
|
||||
* shows a small dot; intercourse shows a diamond; anything else logged that
|
||||
* day (a test, contraception, spotting, a temperature, a mucus or cervix
|
||||
* reading, a note) shows a hollow ring, so a day with only one of those
|
||||
* logged no longer looks like a day with nothing logged. Selecting a day
|
||||
* calls `onSelectDay` (the log-day sheet).
|
||||
*
|
||||
* a11y: each day is a real `<button>` with an aria-label restating the date
|
||||
* + its markers; the colour markers are paired with the aria text so the
|
||||
@@ -60,6 +65,64 @@ function mondayIndex(d: Date): number {
|
||||
return (d.getDay() + 6) % 7;
|
||||
}
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
|
||||
/** `LABEL (VALUE)`, or the bare label when there is no value to name. */
|
||||
function named(t: Translate, labelKey: string, valueKey?: string): string {
|
||||
return valueKey ? `${t(labelKey)} (${t(valueKey)})` : t(labelKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* The entries a day holds beyond flow, symptoms, intercourse and the
|
||||
* predictions, each spelled out for the cell's aria-label. Empty means the
|
||||
* other-entries ring stays off. Every field here is one the day log can hold
|
||||
* and the grid has no marker of its own for.
|
||||
*/
|
||||
export function otherEntryLabels(day: CalendarDay, t: Translate): string[] {
|
||||
const out: string[] = [];
|
||||
if (day.intermenstrualBleeding)
|
||||
out.push(t("cycle.sheet.intermenstrualBleeding"));
|
||||
if (day.basalBodyTempC != null) out.push(t("cycle.sheet.temperature"));
|
||||
if (day.ovulationTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.ovulationTest",
|
||||
`cycle.ovulationTest.${day.ovulationTest}`,
|
||||
),
|
||||
);
|
||||
if (day.cervicalMucus)
|
||||
out.push(named(t, "cycle.sheet.mucus", `cycle.mucus.${day.cervicalMucus}`));
|
||||
if (day.cervixPosition || day.cervixFirmness || day.cervixOpening)
|
||||
out.push(t("cycle.sheet.cervix"));
|
||||
if (day.pregnancyTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.pregnancyTest",
|
||||
`cycle.testResult.${day.pregnancyTest}`,
|
||||
),
|
||||
);
|
||||
if (day.progesteroneTest)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.progesteroneTest",
|
||||
`cycle.testResult.${day.progesteroneTest}`,
|
||||
),
|
||||
);
|
||||
if (day.contraceptive)
|
||||
out.push(
|
||||
named(
|
||||
t,
|
||||
"cycle.sheet.contraceptive",
|
||||
`cycle.contraceptive.${day.contraceptive}`,
|
||||
),
|
||||
);
|
||||
if (day.hasNote) out.push(t("cycle.sheet.note"));
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface CycleCalendarProps {
|
||||
days: CalendarDay[];
|
||||
/** YYYY-MM-DD of today (server tz-anchored) for the "today" ring. */
|
||||
@@ -222,6 +285,11 @@ export function CycleCalendar({
|
||||
markers.push(t("cycle.calendar.legendOvulation"));
|
||||
if (info?.hasSymptoms)
|
||||
markers.push(t("cycle.calendar.legendSymptoms"));
|
||||
const hasIntercourse = !!info?.sexualActivity;
|
||||
if (hasIntercourse)
|
||||
markers.push(t("cycle.calendar.legendIntercourse"));
|
||||
const others = info ? otherEntryLabels(info, t) : [];
|
||||
markers.push(...others);
|
||||
|
||||
const aria = `${date}${markers.length ? `, ${markers.join(", ")}` : ""}`;
|
||||
const flowLevel =
|
||||
@@ -307,6 +375,19 @@ export function CycleCalendar({
|
||||
{info?.hasSymptoms ? (
|
||||
<span className="bg-muted-foreground/70 h-1 w-1 rounded-full" />
|
||||
) : null}
|
||||
{hasIntercourse ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-intercourse"
|
||||
className="size-1.5 rotate-45 rounded-xs"
|
||||
style={{ backgroundColor: INTERCOURSE_HUE }}
|
||||
/>
|
||||
) : null}
|
||||
{others.length > 0 ? (
|
||||
<span
|
||||
data-slot="cycle-calendar-other"
|
||||
className="border-muted-foreground size-1.5 rounded-full border"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -330,6 +411,8 @@ export function CycleCalendar({
|
||||
: isPredictedOvulationDot
|
||||
? "predicted"
|
||||
: undefined,
|
||||
"data-intercourse": hasIntercourse ? "true" : undefined,
|
||||
"data-other-entries": others.length > 0 ? "true" : undefined,
|
||||
};
|
||||
|
||||
return onSelectDay ? (
|
||||
@@ -363,11 +446,20 @@ function CalendarLegend() {
|
||||
// Each swatch mirrors its grid affordance: period = filled, predicted =
|
||||
// dashed, fertile = a soft band fill, predicted-ovulation = a filled dot,
|
||||
// confirmed-ovulation = a ringed light oval (distinct from the dot), symptom
|
||||
// = the small grey marker dot the grid draws (QA M4).
|
||||
// = the small grey marker dot the grid draws (QA M4), intercourse = the
|
||||
// diamond, other entries = the hollow ring.
|
||||
const items: {
|
||||
hue: string;
|
||||
labelKey: string;
|
||||
variant: "fill" | "dashed" | "band" | "oval" | "dot";
|
||||
variant:
|
||||
| "fill"
|
||||
| "dashed"
|
||||
| "band"
|
||||
| "oval"
|
||||
| "dot"
|
||||
| "smallDot"
|
||||
| "diamond"
|
||||
| "ring";
|
||||
}[] = [
|
||||
{ hue: FLOW_HUE, labelKey: "cycle.calendar.legendPeriod", variant: "fill" },
|
||||
{
|
||||
@@ -393,7 +485,17 @@ function CalendarLegend() {
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
labelKey: "cycle.calendar.legendSymptom",
|
||||
variant: "dot",
|
||||
variant: "smallDot",
|
||||
},
|
||||
{
|
||||
hue: INTERCOURSE_HUE,
|
||||
labelKey: "cycle.calendar.legendIntercourse",
|
||||
variant: "diamond",
|
||||
},
|
||||
{
|
||||
hue: "var(--muted-foreground)",
|
||||
labelKey: "cycle.calendar.legendOtherEntries",
|
||||
variant: "ring",
|
||||
},
|
||||
];
|
||||
return (
|
||||
@@ -407,14 +509,16 @@ function CalendarLegend() {
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
it.variant === "oval"
|
||||
? "h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset"
|
||||
: "rounded-full",
|
||||
it.variant === "dot" && it.hue === "var(--muted-foreground)"
|
||||
? "h-1 w-1"
|
||||
: it.variant === "oval"
|
||||
? ""
|
||||
: "h-2.5 w-2.5",
|
||||
it.variant === "oval" &&
|
||||
"h-2.5 w-3.5 rounded-full ring-[1.5px] ring-inset",
|
||||
(it.variant === "fill" ||
|
||||
it.variant === "dashed" ||
|
||||
it.variant === "band" ||
|
||||
it.variant === "dot") &&
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
it.variant === "smallDot" && "h-1 w-1 rounded-full",
|
||||
it.variant === "diamond" && "mx-0.5 size-2 rotate-45 rounded-xs",
|
||||
it.variant === "ring" && "size-2 rounded-full border",
|
||||
it.variant === "dashed" && "opacity-60",
|
||||
it.variant === "band" && "opacity-30",
|
||||
)}
|
||||
@@ -425,7 +529,9 @@ function CalendarLegend() {
|
||||
opacity: 0.3,
|
||||
"--tw-ring-color": it.hue,
|
||||
} as React.CSSProperties)
|
||||
: { backgroundColor: it.hue }
|
||||
: it.variant === "ring"
|
||||
? { borderColor: it.hue }
|
||||
: { backgroundColor: it.hue }
|
||||
}
|
||||
/>
|
||||
{t(it.labelKey)}
|
||||
|
||||
@@ -22,6 +22,12 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
/**
|
||||
* Intercourse marker tint (luteal family). Never colour-only: the marker is a
|
||||
* diamond, a shape no other calendar marker uses, and the cell's aria-label
|
||||
* names it.
|
||||
*/
|
||||
export const INTERCOURSE_HUE = "var(--cycle-phase-luteal)";
|
||||
|
||||
/**
|
||||
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface CalendarDay {
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
cervicalMucus: CervicalMucus | null;
|
||||
/** Logged cervix signs, each or null. */
|
||||
cervixPosition: string | null;
|
||||
cervixFirmness: string | null;
|
||||
cervixOpening: string | null;
|
||||
/** Spotting outside the period logged on this day. */
|
||||
intermenstrualBleeding: boolean;
|
||||
/** Intercourse logged on this day (resolved server-side, envelope included). */
|
||||
sexualActivity: boolean;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
/** Whether the day carries a note; the text is only on the day-log read. */
|
||||
hasNote: boolean;
|
||||
}
|
||||
|
||||
export interface CyclePrediction {
|
||||
|
||||
Reference in new issue
Block a user