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:
Marc-André Bombeck committed 2026-09-25 07:05:03 +02:00
1 parent adec5bd045
commit 0390d124cb
14 files changed
+285 -20

No files matched your search

+3 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -8231,7 +8231,9 @@
"flowSpotting": "점상 출혈",
"flowLight": "적음",
"flowMedium": "보통",
"flowHeavy": "많음"
"flowHeavy": "많음",
"legendIntercourse": "성관계",
"legendOtherEntries": "기타 기록"
},
"predictions": {
"title": "다음에 올 일",
+3 -1
View File
@@ -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,
};
}
+119 -13
View File
@@ -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)}
+6
View File
@@ -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
+13
View File
@@ -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 {