diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index e36519abf..190b9430e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -31021,6 +31021,28 @@ components: - CLOSED - OPEN - type: "null" + intermenstrualBleeding: + type: boolean + description: Spotting or bleeding outside the period logged on this day. + sexualActivity: + type: boolean + description: Intercourse logged on this day. Resolved from the plaintext column or the encrypted envelope, exactly as + the day-log read resolves it, and served under the same cycle grant. + pregnancyTest: + anyOf: + - $ref: "#/components/schemas/HomeTestResult" + - type: "null" + progesteroneTest: + anyOf: + - $ref: "#/components/schemas/HomeTestResult" + - type: "null" + contraceptive: + anyOf: + - type: string + - type: "null" + hasNote: + type: boolean + description: Whether the day carries a note. The note text is only on the day-log read. required: - date - phase @@ -31041,6 +31063,12 @@ components: - cervixPosition - cervixFirmness - cervixOpening + - intermenstrualBleeding + - sexualActivity + - pregnancyTest + - progesteroneTest + - contraceptive + - hasNote additionalProperties: false meta: type: object diff --git a/messages/de.json b/messages/de.json index 7a91c4d3f..5670f4dd4 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8233,7 +8233,9 @@ "flowSpotting": "Schmierblutung", "flowLight": "leicht", "flowMedium": "mittel", - "flowHeavy": "stark" + "flowHeavy": "stark", + "legendIntercourse": "Geschlechtsverkehr", + "legendOtherEntries": "Weitere Einträge" }, "predictions": { "title": "Was als Nächstes kommt", @@ -10880,9 +10882,10 @@ "selectAll": "Alle auswählen", "searchPlaceholder": "Suchen", "searchNoMatch": "Kein Treffer", - "remove": "Entfernen", "suggested": "Um dieses Datum", - "loadError": "Die Liste konnte nicht geladen werden." + "loadError": "Die Liste konnte nicht geladen werden.", + "removeNamed": "{name} entfernen", + "removed": "Verknüpfung mit {name} gelöst" } } } diff --git a/messages/en.json b/messages/en.json index 755736370..692477404 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8233,7 +8233,9 @@ "flowSpotting": "spotting", "flowLight": "light", "flowMedium": "medium", - "flowHeavy": "heavy" + "flowHeavy": "heavy", + "legendIntercourse": "Intercourse", + "legendOtherEntries": "Other entries" }, "predictions": { "title": "What comes next", @@ -10880,9 +10882,10 @@ "selectAll": "Select all", "searchPlaceholder": "Search", "searchNoMatch": "No match", - "remove": "Remove", "suggested": "Around this date", - "loadError": "The list could not be loaded." + "loadError": "The list could not be loaded.", + "removeNamed": "Remove {name}", + "removed": "Unlinked {name}" } } } diff --git a/messages/es.json b/messages/es.json index e2599fed2..afafe8e32 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8233,7 +8233,9 @@ "flowSpotting": "manchado", "flowLight": "ligero", "flowMedium": "medio", - "flowHeavy": "abundante" + "flowHeavy": "abundante", + "legendIntercourse": "Relaciones", + "legendOtherEntries": "Otras entradas" }, "predictions": { "title": "Qué viene después", @@ -10880,9 +10882,10 @@ "selectAll": "Seleccionar todo", "searchPlaceholder": "Buscar", "searchNoMatch": "Sin coincidencias", - "remove": "Quitar", "suggested": "En torno a esta fecha", - "loadError": "No se pudo cargar la lista." + "loadError": "No se pudo cargar la lista.", + "removeNamed": "Quitar {name}", + "removed": "Se desvinculó {name}" } } } diff --git a/messages/fr.json b/messages/fr.json index e33df8040..dbc7b9952 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8233,7 +8233,9 @@ "flowSpotting": "spotting", "flowLight": "léger", "flowMedium": "moyen", - "flowHeavy": "abondant" + "flowHeavy": "abondant", + "legendIntercourse": "Rapports", + "legendOtherEntries": "Autres saisies" }, "predictions": { "title": "Ce qui arrive ensuite", @@ -10880,9 +10882,10 @@ "selectAll": "Tout sélectionner", "searchPlaceholder": "Rechercher", "searchNoMatch": "Aucun résultat", - "remove": "Retirer", "suggested": "Autour de cette date", - "loadError": "Impossible de charger la liste." + "loadError": "Impossible de charger la liste.", + "removeNamed": "Retirer {name}", + "removed": "{name} dissocié" } } } diff --git a/messages/it.json b/messages/it.json index 83a8d93e7..622062c21 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8233,7 +8233,9 @@ "flowSpotting": "spotting", "flowLight": "leggero", "flowMedium": "medio", - "flowHeavy": "abbondante" + "flowHeavy": "abbondante", + "legendIntercourse": "Rapporti", + "legendOtherEntries": "Altre voci" }, "predictions": { "title": "Cosa arriva dopo", @@ -10880,9 +10882,10 @@ "selectAll": "Seleziona tutto", "searchPlaceholder": "Cerca", "searchNoMatch": "Nessun risultato", - "remove": "Rimuovi", "suggested": "Intorno a questa data", - "loadError": "Impossibile caricare l’elenco." + "loadError": "Impossibile caricare l’elenco.", + "removeNamed": "Rimuovi {name}", + "removed": "Collegamento a {name} rimosso" } } } diff --git a/messages/ko.json b/messages/ko.json index f32dd4603..a4be37e2d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8233,7 +8233,9 @@ "flowSpotting": "점상 출혈", "flowLight": "적음", "flowMedium": "보통", - "flowHeavy": "많음" + "flowHeavy": "많음", + "legendIntercourse": "성관계", + "legendOtherEntries": "기타 기록" }, "predictions": { "title": "다음에 올 일", @@ -10880,9 +10882,10 @@ "selectAll": "전체 선택", "searchPlaceholder": "검색", "searchNoMatch": "결과 없음", - "remove": "제거", "suggested": "이 날짜 전후", - "loadError": "목록을 불러오지 못했습니다." + "loadError": "목록을 불러오지 못했습니다.", + "removeNamed": "{name} 제거", + "removed": "{name} 연결 해제됨" } } } diff --git a/messages/pl.json b/messages/pl.json index 8dd8149c2..82df9291c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8233,7 +8233,9 @@ "flowSpotting": "plamienie", "flowLight": "lekkie", "flowMedium": "średnie", - "flowHeavy": "obfite" + "flowHeavy": "obfite", + "legendIntercourse": "Współżycie", + "legendOtherEntries": "Inne wpisy" }, "predictions": { "title": "Co dalej", @@ -10880,9 +10882,10 @@ "selectAll": "Zaznacz wszystko", "searchPlaceholder": "Szukaj", "searchNoMatch": "Brak wyników", - "remove": "Usuń", "suggested": "Wokół tej daty", - "loadError": "Nie udało się wczytać listy." + "loadError": "Nie udało się wczytać listy.", + "removeNamed": "Usuń {name}", + "removed": "Odłączono: {name}" } } } diff --git a/src/app/api/cycle/calendar/__tests__/day-entries.test.ts b/src/app/api/cycle/calendar/__tests__/day-entries.test.ts new file mode 100644 index 000000000..48e25b400 --- /dev/null +++ b/src/app/api/cycle/calendar/__tests__/day-entries.test.ts @@ -0,0 +1,259 @@ +/** + * `GET /api/cycle/calendar` — what a person logged on a day reaches the grid. + * + * The calendar used to carry flow, symptoms, BBT, the ovulation test and the + * mucus reading, and nothing else from the day log. Intercourse, spotting, the + * pregnancy and progesterone tests, contraception, the cervix signs and the + * presence of a note were all dropped between the row and the wire, so a day + * with only one of those logged looked exactly like a day with nothing logged. + * + * Same harness as the verdict round trip: the real exported `GET`, the real + * builder, the real DTO mapping, the response read back as text. Two ends are + * pinned — what the route asks Prisma for (a field that is not selected never + * reaches the builder) and what comes back on the wire. + * + * The intent fields can sit in the encrypted envelope instead of the plaintext + * columns (`sensitiveCategoryEncryption`), so one case writes them there and + * expects the grid to read them just as the day-log read does. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +vi.mock("@/lib/db", () => ({ + prisma: { + menstrualCycle: { findMany: vi.fn() }, + cycleDayLog: { findMany: vi.fn() }, + measurement: { findMany: vi.fn() }, + cyclePrediction: { findUnique: vi.fn(), upsert: vi.fn() }, + auditLog: { create: vi.fn() }, + }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ allowed: true }), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/cycle/gate", () => ({ requireCycleEnabled: vi.fn() })); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { GET } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { encrypt } from "@/lib/crypto"; +import { cycleCalendarDayDto } from "@/lib/openapi/routes/cycle"; + +function session() { + return { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { + id: "user-1", + username: "tester", + role: "USER" as const, + gender: "FEMALE", + timezone: "UTC", + locale: "en", + }, + }; +} + +function profile() { + return { + userId: "user-1", + goal: "GENERAL_HEALTH", + typicalCycleLength: 28, + typicalPeriodLength: 5, + lutealPhaseLength: 14, + predictionEnabled: true, + rawChartMode: false, + secondarySymptom: "MUCUS", + cycleTrackingEnabled: true, + sensitiveCategoryEncryption: false, + }; +} + +/** A day-log row as Prisma returns it for the route's select. */ +function logRow(date: string, over: Record = {}) { + return { + date, + flow: null, + intermenstrualBleeding: false, + basalBodyTempC: null, + temperatureExcluded: false, + ovulationTest: null, + cervicalMucus: null, + cervixPosition: null, + cervixFirmness: null, + cervixOpening: null, + sexualActivity: false, + protectedSex: null, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, + sensitiveEncrypted: null, + notesEncrypted: null, + _count: { symptomLinks: 0 }, + ...over, + }; +} + +async function callCalendar(): Promise[]> { + const res = await GET( + new NextRequest( + "http://localhost/api/cycle/calendar?from=2026-09-01&to=2026-09-30", + ), + ); + expect(res.status).toBe(200); + const body = JSON.parse(await res.text()); + return body.data.days as Record[]; +} + +function day(days: Record[], date: string) { + const found = days.find((d) => d.date === date); + if (!found) throw new Error(`no grid day ${date}`); + return found; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-25T12:00:00Z")); + vi.mocked(getSession).mockResolvedValue(session() as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + profile: profile(), + } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([ + { + id: "cyc-1", + userId: "user-1", + startDate: "2025-12-29", + endDate: null, + periodEndDate: null, + ovulationDate: null, + ovulationConfirmed: false, + isPredicted: false, + }, + ] as never); + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("GET /api/cycle/calendar carries every per-day entry", () => { + it("asks Prisma for the fields the grid now shows", async () => { + await callCalendar(); + const args = vi.mocked(prisma.cycleDayLog.findMany).mock.calls[0][0] as { + select: Record; + }; + expect(args.select).toMatchObject({ + intermenstrualBleeding: true, + sexualActivity: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + notesEncrypted: true, + cervixPosition: true, + cervixFirmness: true, + cervixOpening: true, + }); + }); + + it("marks a day with intercourse logged, and only that day", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { sexualActivity: true }), + ] as never); + + const days = await callCalendar(); + expect(day(days, "2026-09-05").sexualActivity).toBe(true); + expect(day(days, "2026-09-04").sexualActivity).toBe(false); + expect(day(days, "2026-09-06").sexualActivity).toBe(false); + }); + + it("reads intercourse and the tests from the encrypted envelope", async () => { + // Encryption ON at write: the plaintext columns are NULL/false and the + // envelope carries the values. The grid must read what the day log reads. + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { + sexualActivity: false, + sensitiveEncrypted: encrypt( + JSON.stringify({ + sexualActivity: true, + protectedSex: true, + pregnancyTest: "NEGATIVE", + progesteroneTest: "POSITIVE", + contraceptive: "ORAL", + }), + ), + }), + ] as never); + + const d = day(await callCalendar(), "2026-09-05"); + expect(d.sexualActivity).toBe(true); + expect(d.pregnancyTest).toBe("NEGATIVE"); + expect(d.progesteroneTest).toBe("POSITIVE"); + expect(d.contraceptive).toBe("ORAL"); + }); + + it("an undecryptable envelope reads as nothing logged, never a 500", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-05", { sensitiveEncrypted: "v1:not-a-real-envelope" }), + ] as never); + + const d = day(await callCalendar(), "2026-09-05"); + expect(d.sexualActivity).toBe(false); + expect(d.pregnancyTest).toBeNull(); + }); + + it("carries spotting, the cervix signs and whether a note exists", async () => { + vi.mocked(prisma.cycleDayLog.findMany).mockResolvedValue([ + logRow("2026-09-10", { + intermenstrualBleeding: true, + cervixPosition: "HIGH", + cervixFirmness: "SOFT", + cervixOpening: "OPEN", + notesEncrypted: encrypt("a note"), + }), + ] as never); + + const days = await callCalendar(); + const d = day(days, "2026-09-10"); + expect(d.intermenstrualBleeding).toBe(true); + expect(d.cervixPosition).toBe("HIGH"); + expect(d.cervixFirmness).toBe("SOFT"); + expect(d.cervixOpening).toBe("OPEN"); + expect(d.hasNote).toBe(true); + // The note's text never rides the grid, only its presence. + expect(JSON.stringify(d)).not.toContain("a note"); + expect(day(days, "2026-09-11").hasNote).toBe(false); + }); + + it("every grid day carries exactly the fields the published contract names", async () => { + // The contract promised the cervix signs while the route never sent them. + // Pin the two against each other so neither can drift alone again. + const days = await callCalendar(); + const contractKeys = Object.keys(cycleCalendarDayDto.shape).sort(); + expect(Object.keys(days[0]).sort()).toEqual(contractKeys); + }); +}); diff --git a/src/app/api/cycle/calendar/route.ts b/src/app/api/cycle/calendar/route.ts index 007a75247..6e68e71f1 100644 --- a/src/app/api/cycle/calendar/route.ts +++ b/src/app/api/cycle/calendar/route.ts @@ -34,6 +34,7 @@ import { toCyclePredictionDTO, goalAllowsFertileWindow, cycleDisclaimerKey, + resolveSensitiveFields, } from "@/lib/cycle/dto"; import { resolveCycleVerdict } from "@/lib/cycle/verdict"; import { addDays, dayDiff } from "@/lib/cycle/day-math"; @@ -115,6 +116,19 @@ export const GET = apiHandler(async (request: NextRequest) => { cervixPosition: true, cervixFirmness: true, cervixOpening: true, + intermenstrualBleeding: true, + // The intent fields and their encrypted envelope, resolved below + // through the same reader the day-log route uses. Same `cycle` grant + // as that route (see sharing-surface-guard), so the grid shows a + // delegate nothing the day log would not. + sexualActivity: true, + protectedSex: true, + pregnancyTest: true, + progesteroneTest: true, + contraceptive: true, + sensitiveEncrypted: true, + // Selected for presence only; the text is never decrypted here. + notesEncrypted: true, _count: { select: { symptomLinks: true } }, }, }), @@ -141,18 +155,27 @@ export const GET = apiHandler(async (request: NextRequest) => { }), ]); - const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => ({ - date: l.date, - flow: l.flow, - basalBodyTempC: l.basalBodyTempC, - temperatureExcluded: l.temperatureExcluded, - ovulationTest: l.ovulationTest, - cervicalMucus: l.cervicalMucus, - cervixPosition: l.cervixPosition, - cervixFirmness: l.cervixFirmness, - cervixOpening: l.cervixOpening, - hasSymptoms: l._count.symptomLinks > 0, - })); + const dayLogs: CalendarDayLogRow[] = dayLogRows.map((l) => { + const sensitive = resolveSensitiveFields(l); + return { + date: l.date, + flow: l.flow, + basalBodyTempC: l.basalBodyTempC, + temperatureExcluded: l.temperatureExcluded, + ovulationTest: l.ovulationTest, + cervicalMucus: l.cervicalMucus, + cervixPosition: l.cervixPosition, + cervixFirmness: l.cervixFirmness, + cervixOpening: l.cervixOpening, + intermenstrualBleeding: l.intermenstrualBleeding, + sexualActivity: sensitive.sexualActivity, + pregnancyTest: sensitive.pregnancyTest, + progesteroneTest: sensitive.progesteroneTest, + contraceptive: sensitive.contraceptive, + hasSymptoms: l._count.symptomLinks > 0, + hasNote: l.notesEncrypted != null && l.notesEncrypted !== "", + }; + }); const nights = nightlyTemps.map((m) => ({ date: moodDateKey(m.measuredAt, tz), diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 7f6a08714..e36a95c13 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -2,6 +2,7 @@ import { useCallback, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; +import { useSearchParams } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; import { useMounted } from "@/hooks/use-mounted"; @@ -38,7 +39,10 @@ export default function CheckupsPageClient() { const mounted = useMounted(); const queryClient = useQueryClient(); const { t } = useTranslations(); - const [view, setView] = useState("vorsorge"); + // A link to one visit (`?visit=`, from a document's link chip) lands on + // the visits tab, where the section opens it. + const linkedVisit = useSearchParams()?.get("visit"); + const [view, setView] = useState(linkedVisit ? "visits" : "vorsorge"); const refresh = useCallback( () => diff --git a/src/app/vaccinations/page.tsx b/src/app/vaccinations/page.tsx index 13687ce7b..89ae8dc11 100644 --- a/src/app/vaccinations/page.tsx +++ b/src/app/vaccinations/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/hooks/use-auth"; @@ -42,5 +42,11 @@ export default function VaccinationsPage() { return ; } - return ; + // The view reads `?dose=` through `useSearchParams`, which needs a + // boundary on a statically rendered page. + return ( + }> + + + ); } diff --git a/src/components/cycle/__tests__/bbt-chart.test.tsx b/src/components/cycle/__tests__/bbt-chart.test.tsx index a07a707f2..e2adcb629 100644 --- a/src/components/cycle/__tests__/bbt-chart.test.tsx +++ b/src/components/cycle/__tests__/bbt-chart.test.tsx @@ -28,6 +28,15 @@ function day(date: string, overrides: Partial = {}): 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, }; } diff --git a/src/components/cycle/__tests__/cycle-calendar.test.tsx b/src/components/cycle/__tests__/cycle-calendar.test.tsx index 800802a61..1d8032356 100644 --- a/src/components/cycle/__tests__/cycle-calendar.test.tsx +++ b/src/components/cycle/__tests__/cycle-calendar.test.tsx @@ -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("", () => { const today = "2026-06-15"; @@ -130,4 +148,85 @@ describe("", () => { ); 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} />, + ); + 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( + {}} + />, + ); + const cell = cellTag(html, "2026-06-08"); + expect(cell).not.toContain("data-other-entries"); + expect(cell).not.toContain("data-intercourse"); + }); + }); }); diff --git a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx index e32bb1c3a..f727d776e 100644 --- a/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx +++ b/src/components/cycle/__tests__/cycle-view-sheet-context.test.tsx @@ -45,6 +45,15 @@ function gridDay(date: string, over: Partial = {}): 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, }; } diff --git a/src/components/cycle/__tests__/log-day-sheet.test.ts b/src/components/cycle/__tests__/log-day-sheet.test.ts index 9f0a1e56a..78e280933 100644 --- a/src/components/cycle/__tests__/log-day-sheet.test.ts +++ b/src/components/cycle/__tests__/log-day-sheet.test.ts @@ -281,6 +281,15 @@ function gridDay(date: string, over: Partial = {}): 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, }; } diff --git a/src/components/cycle/cycle-calendar.tsx b/src/components/cycle/cycle-calendar.tsx index c38cd30d8..f08aa7a32 100644 --- a/src/components/cycle/cycle-calendar.tsx +++ b/src/components/cycle/cycle-calendar.tsx @@ -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 ` - - ))} + {option.dateLabel ? ( + + {option.dateLabel} + + ) : null} + + ); + return ( +
  • + {option.href ? ( + + {name} + + ) : ( + + {name} + + )} + +
  • + ); + })} ) : null} + {lastRemoved ? ( +

    + + {t("links.picker.removed", { name: chipName(lastRemoved) })} + + +

    + ) : null} +