From adec5bd045792f535e4929b9d6be357bf3b8fbc1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:02:14 +0200 Subject: [PATCH 1/5] fix(cycle): carry every logged day entry on the calendar read The calendar grid carried flow, symptoms, BBT, the ovulation test and the mucus reading, and dropped the rest of the day log: intercourse, spotting, the pregnancy and progesterone tests, contraception and whether a note exists. The published contract also promised the three cervix signs while the route never sent them. The intent fields resolve through the same reader the day-log read uses, so an encrypted envelope reads the same on both, and the grid is served under the same cycle grant as the day log. Additive fields; the contract test pins the wire keys to the published schema. --- docs/api/openapi.yaml | 28 ++ .../calendar/__tests__/day-entries.test.ts | 259 ++++++++++++++++++ src/app/api/cycle/calendar/route.ts | 47 +++- .../cycle/__tests__/engine-adapter.test.ts | 6 + src/lib/cycle/dto.ts | 63 +++-- src/lib/cycle/engine-adapter.ts | 40 ++- src/lib/openapi/routes/cycle.ts | 19 +- 7 files changed, 431 insertions(+), 31 deletions(-) create mode 100644 src/app/api/cycle/calendar/__tests__/day-entries.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b407f67b8..6c8714466 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -30974,6 +30974,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 @@ -30994,6 +31016,12 @@ components: - cervixPosition - cervixFirmness - cervixOpening + - intermenstrualBleeding + - sexualActivity + - pregnancyTest + - progesteroneTest + - contraceptive + - hasNote additionalProperties: false meta: type: object 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/lib/cycle/__tests__/engine-adapter.test.ts b/src/lib/cycle/__tests__/engine-adapter.test.ts index 1c14e74c9..d91d77d69 100644 --- a/src/lib/cycle/__tests__/engine-adapter.test.ts +++ b/src/lib/cycle/__tests__/engine-adapter.test.ts @@ -80,7 +80,13 @@ describe("buildCalendar", () => { cervixPosition: null, cervixFirmness: null, cervixOpening: null, + intermenstrualBleeding: false, + sexualActivity: false, + pregnancyTest: null, + progesteroneTest: null, + contraceptive: null, hasSymptoms: true, + hasNote: false, }, ]; const { prediction, days } = buildCalendar( diff --git a/src/lib/cycle/dto.ts b/src/lib/cycle/dto.ts index 59fdd1851..761ea0ea0 100644 --- a/src/lib/cycle/dto.ts +++ b/src/lib/cycle/dto.ts @@ -111,11 +111,52 @@ function decryptSensitive(envelope: string | null): SensitiveEnvelope { } } +/** The five intent-revealing fields, as a reader should see them. */ +interface ResolvedSensitiveFields { + sexualActivity: boolean; + protectedSex: boolean | null; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +} + +/** + * Resolve the intent-revealing fields of one day-log row: from the encryption + * envelope when the row carries one, else from the plaintext columns (the + * flag-OFF path). The day-log read and the calendar grid both go through here, + * so a day cannot show intercourse in one and not the other. + */ +export function resolveSensitiveFields( + row: Pick< + CycleDayLog, + | "sensitiveEncrypted" + | "sexualActivity" + | "protectedSex" + | "pregnancyTest" + | "progesteroneTest" + | "contraceptive" + >, +): ResolvedSensitiveFields { + if (row.sensitiveEncrypted != null) { + const enc = decryptSensitive(row.sensitiveEncrypted); + return { + sexualActivity: enc.sexualActivity ?? false, + protectedSex: enc.protectedSex ?? null, + pregnancyTest: enc.pregnancyTest ?? null, + progesteroneTest: enc.progesteroneTest ?? null, + contraceptive: enc.contraceptive ?? null, + }; + } + return { + sexualActivity: row.sexualActivity, + protectedSex: row.protectedSex, + pregnancyTest: row.pregnancyTest, + progesteroneTest: row.progesteroneTest, + contraceptive: row.contraceptive, + }; +} + export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { - // Prefer the encryption envelope when present; else read the plaintext - // columns (the flag-OFF path). - const enc = decryptSensitive(row.sensitiveEncrypted); - const hasEnvelope = row.sensitiveEncrypted != null; return { id: row.id, date: row.date, @@ -129,19 +170,7 @@ export function toCycleDayLogDTO(row: DayLogWithLinks): CycleDayLogDTO { cervixPosition: row.cervixPosition, cervixFirmness: row.cervixFirmness, cervixOpening: row.cervixOpening, - sexualActivity: hasEnvelope - ? (enc.sexualActivity ?? false) - : row.sexualActivity, - protectedSex: hasEnvelope ? (enc.protectedSex ?? null) : row.protectedSex, - pregnancyTest: hasEnvelope - ? (enc.pregnancyTest ?? null) - : row.pregnancyTest, - progesteroneTest: hasEnvelope - ? (enc.progesteroneTest ?? null) - : row.progesteroneTest, - contraceptive: hasEnvelope - ? (enc.contraceptive ?? null) - : row.contraceptive, + ...resolveSensitiveFields(row), symptoms: (row.symptomLinks ?? []).map((l) => ({ key: l.symptom.key, severity: l.severity ?? null, diff --git a/src/lib/cycle/engine-adapter.ts b/src/lib/cycle/engine-adapter.ts index d7e3fba57..7eaa6ee53 100644 --- a/src/lib/cycle/engine-adapter.ts +++ b/src/lib/cycle/engine-adapter.ts @@ -69,6 +69,27 @@ export interface CalendarDayDTO { ovulationTest: string | null; /** Logged cervical-mucus quality, or null. */ cervicalMucus: string | null; + /** Logged cervix signs (symptothermal secondary indicator), each or null. */ + cervixPosition: string | null; + cervixFirmness: string | null; + cervixOpening: string | null; + /** Spotting / bleeding outside the period logged on this day. */ + intermenstrualBleeding: boolean; + /** + * Intercourse logged on this day. Read through the same resolver as the + * day-log itself (plaintext column or the encrypted envelope), and carried + * under the same `cycle` grant: a caller who may read this grid may read + * the day log it summarises, and one who may not reaches neither. + */ + sexualActivity: boolean; + /** Logged at-home pregnancy test result, or null. */ + pregnancyTest: string | null; + /** Logged at-home progesterone test result, or null. */ + progesteroneTest: string | null; + /** Logged contraceptive method, or null. */ + contraceptive: string | null; + /** Whether the day carries a note. The note's text never rides the grid. */ + hasNote: boolean; } /** Rows the calendar needs from each day-log. */ @@ -83,7 +104,15 @@ export type CalendarDayLogRow = Pick< | "cervixPosition" | "cervixFirmness" | "cervixOpening" -> & { hasSymptoms: boolean }; + | "intermenstrualBleeding" +> & { + hasSymptoms: boolean; + hasNote: boolean; + sexualActivity: boolean; + pregnancyTest: string | null; + progesteroneTest: string | null; + contraceptive: string | null; +}; /** Map MenstrualCycle rows (oldest→newest) to engine `CycleInput`. */ export function toCycleInputs( @@ -449,6 +478,15 @@ export function buildCalendar( temperatureExcluded: log?.temperatureExcluded ?? false, ovulationTest: log?.ovulationTest ?? null, cervicalMucus: log?.cervicalMucus ?? null, + cervixPosition: log?.cervixPosition ?? null, + cervixFirmness: log?.cervixFirmness ?? null, + cervixOpening: log?.cervixOpening ?? null, + intermenstrualBleeding: log?.intermenstrualBleeding ?? false, + sexualActivity: log?.sexualActivity ?? false, + pregnancyTest: log?.pregnancyTest ?? null, + progesteroneTest: log?.progesteroneTest ?? null, + contraceptive: log?.contraceptive ?? null, + hasNote: log?.hasNote ?? false, }); } diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 86306fa4d..fd37bebd5 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -186,7 +186,7 @@ const cyclePredictionDto = z "The materialised forecast. Fertile-window fields (and predictedOvulation/ovulationConfirmed) are server-suppressed (null/false) unless the goal is TRYING_TO_CONCEIVE or AVOID_PREGNANCY.", }); -const cycleCalendarDayDto = z.object({ +export const cycleCalendarDayDto = z.object({ date: z.string(), phase: cyclePhaseEnumOpenapi.nullable(), isPredictedPeriod: z.boolean(), @@ -218,6 +218,23 @@ const cycleCalendarDayDto = z.object({ cervixPosition: cervixPositionEnum.nullable(), cervixFirmness: cervixFirmnessEnum.nullable(), cervixOpening: cervixOpeningEnum.nullable(), + // v1.39.1 — the rest of what a day log can hold, so a day with only one of + // these logged no longer reads as an empty day. Additive: an older client + // that does not know a field ignores it. + intermenstrualBleeding: z.boolean().meta({ + description: "Spotting or bleeding outside the period logged on this day.", + }), + sexualActivity: z.boolean().meta({ + 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: homeTestResultEnumOpenapi.nullable(), + progesteroneTest: homeTestResultEnumOpenapi.nullable(), + contraceptive: z.string().nullable(), + hasNote: z.boolean().meta({ + description: + "Whether the day carries a note. The note text is only on the day-log read.", + }), }); const cycleVerdictDto = z From 0390d124cbcac773f97be0d0f13186e03388e300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:05:03 +0200 Subject: [PATCH 2/5] 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. --- messages/de.json | 4 +- messages/en.json | 4 +- messages/es.json | 4 +- messages/fr.json | 4 +- messages/it.json | 4 +- messages/ko.json | 4 +- messages/pl.json | 4 +- .../cycle/__tests__/bbt-chart.test.tsx | 9 ++ .../cycle/__tests__/cycle-calendar.test.tsx | 99 +++++++++++++ .../cycle-view-sheet-context.test.tsx | 9 ++ .../cycle/__tests__/log-day-sheet.test.ts | 9 ++ src/components/cycle/cycle-calendar.tsx | 132 ++++++++++++++++-- src/components/cycle/phase-tokens.ts | 6 + src/components/cycle/types.ts | 13 ++ 14 files changed, 285 insertions(+), 20 deletions(-) diff --git a/messages/de.json b/messages/de.json index 2629c846f..8557410f9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index b2d727fc7..0a02c12f6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8231,7 +8231,9 @@ "flowSpotting": "spotting", "flowLight": "light", "flowMedium": "medium", - "flowHeavy": "heavy" + "flowHeavy": "heavy", + "legendIntercourse": "Intercourse", + "legendOtherEntries": "Other entries" }, "predictions": { "title": "What comes next", diff --git a/messages/es.json b/messages/es.json index 638d44389..853bb3f69 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index a91aaba73..7942c43e9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index ba55d1bd9..bb9c4302e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8231,7 +8231,9 @@ "flowSpotting": "spotting", "flowLight": "leggero", "flowMedium": "medio", - "flowHeavy": "abbondante" + "flowHeavy": "abbondante", + "legendIntercourse": "Rapporti", + "legendOtherEntries": "Altre voci" }, "predictions": { "title": "Cosa arriva dopo", diff --git a/messages/ko.json b/messages/ko.json index 78925959c..e629f3528 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8231,7 +8231,9 @@ "flowSpotting": "점상 출혈", "flowLight": "적음", "flowMedium": "보통", - "flowHeavy": "많음" + "flowHeavy": "많음", + "legendIntercourse": "성관계", + "legendOtherEntries": "기타 기록" }, "predictions": { "title": "다음에 올 일", diff --git a/messages/pl.json b/messages/pl.json index 89b15b449..536400683 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8231,7 +8231,9 @@ "flowSpotting": "plamienie", "flowLight": "lekkie", "flowMedium": "średnie", - "flowHeavy": "obfite" + "flowHeavy": "obfite", + "legendIntercourse": "Współżycie", + "legendOtherEntries": "Inne wpisy" }, "predictions": { "title": "Co dalej", 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..126b41d1a 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} diff --git a/src/components/links/vault-document-options.ts b/src/components/links/vault-document-options.ts index 83422ae4a..1c2832320 100644 --- a/src/components/links/vault-document-options.ts +++ b/src/components/links/vault-document-options.ts @@ -113,6 +113,8 @@ export function documentOptionsWithSuggestions( id: doc.id, label: doc.title ?? doc.filename ?? doc.id, dateLabel: date ? labels.date(date) : null, + // The vault opens a document's sheet from `?doc=`. + href: `/documents?doc=${encodeURIComponent(doc.id)}`, }; if (isNearAnchor(own, anchorIso)) { suggested.push({ From 6e05f86e7b8954386cdcfaeadaf25281c2fb6c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:10:56 +0200 Subject: [PATCH 4/5] fix(vaccinations): show a dose's linked documents and keep them on edit A dose opened from the list carried no document links, since only the detail read has them. The edit sheet showed an empty picker, and saving any change sent that empty set, which the route reads as unlink everything. The sheet now reads the dose's own links before it shows them, and a save leaves the links alone unless they were changed. A link to one dose or visit (/vaccinations?dose=, /checkups?visit=) now scrolls to it and opens it for someone who may edit it. --- src/app/checkups/page-client.tsx | 6 +- src/app/vaccinations/page.tsx | 10 ++- src/components/encounters/visits-section.tsx | 10 +++ .../__tests__/vaccination-form.test.ts | 62 +++++++++++++++++++ .../vaccinations/use-vaccinations.ts | 13 ++++ .../vaccination-document-picker.tsx | 22 ++++++- .../vaccinations/vaccination-form.tsx | 36 +++++++++-- .../vaccinations/vaccination-sheet.tsx | 26 +++++++- .../vaccinations/vaccinations-view.tsx | 10 +++ src/hooks/use-deep-linked-record.ts | 50 +++++++++++++++ 10 files changed, 233 insertions(+), 12 deletions(-) create mode 100644 src/hooks/use-deep-linked-record.ts 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/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index 3b2ad8c9a..eef6bd78e 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -22,6 +22,7 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { SectionHeading } from "@/components/ui/section-heading"; import { Skeleton } from "@/components/ui/skeleton"; +import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useEncounters, type Encounter } from "@/hooks/use-encounters"; import { useTranslations } from "@/lib/i18n/context"; @@ -49,6 +50,15 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { const upcoming = list.data?.upcoming ?? []; const past = list.data?.past ?? []; + + // `?visit=` — a document's link chip names the visit it is filed + // against. Scroll to it, and open it for someone who may edit it. + useDeepLinkedRecord({ + param: "visit", + records: list.data ? [...upcoming, ...past] : undefined, + anchorAttribute: "data-encounter-id", + open: canManageVisits ? openSheet : undefined, + }); const empty = upcoming.length === 0 && past.length === 0; const addButton = canAddVisit ? ( diff --git a/src/components/vaccinations/__tests__/vaccination-form.test.ts b/src/components/vaccinations/__tests__/vaccination-form.test.ts index 0d53865b5..fac6d1426 100644 --- a/src/components/vaccinations/__tests__/vaccination-form.test.ts +++ b/src/components/vaccinations/__tests__/vaccination-form.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; +import type { Vaccination } from "../use-vaccinations"; import { + draftFromVaccination, draftHasIdentity, draftInstant, draftToBody, @@ -72,3 +74,63 @@ describe("vaccination form draft", () => { }); }); }); + +/** + * #1024 — a dose opened from the LIST carries no `documents` (the list omits + * them; only the detail read has them). The edit draft used to seed that + * absence as an empty link set and send it on Save, and the route reads a + * present array as "replace the links with this": editing any field of a dose + * silently unlinked every document filed against it. + */ +describe("vaccination form document links", () => { + const listRow = { + id: "dose-1", + occurredAt: "2025-09-12T00:00:00.000Z", + antigenSlug: "covid19", + vaccineName: null, + doseNumber: null, + seriesDoses: null, + lotNumber: null, + site: null, + catalogEntry: null, + series: [], + practitioner: null, + encounter: null, + reminderId: null, + note: null, + createdAt: "2025-09-12T00:00:00.000Z", + updatedAt: "2025-09-12T00:00:00.000Z", + } as Vaccination; + + it("an edit seeded from a list row does not claim the dose has no documents", () => { + const draft = draftFromVaccination(listRow); + expect(draft.documentIds).toBeNull(); + }); + + it("saving such an edit leaves the links alone", () => { + const body = draftToBody({ + ...draftFromVaccination(listRow), + lotNumber: "AB123", + }); + expect(body).not.toBeNull(); + expect(body).not.toHaveProperty("documentIds"); + }); + + it("a detail row seeds its linked documents", () => { + const draft = draftFromVaccination({ + ...listRow, + documents: [ + { id: "doc-1", label: "Record", date: null, redacted: false }, + ], + }); + expect(draft.documentIds).toEqual(["doc-1"]); + }); + + it("a deliberate unlink of every document is still sent", () => { + const body = draftToBody({ + ...draftFromVaccination(listRow), + documentIds: [], + }); + expect(body!.documentIds).toEqual([]); + }); +}); diff --git a/src/components/vaccinations/use-vaccinations.ts b/src/components/vaccinations/use-vaccinations.ts index 8cdd874d0..2834e093e 100644 --- a/src/components/vaccinations/use-vaccinations.ts +++ b/src/components/vaccinations/use-vaccinations.ts @@ -70,6 +70,19 @@ export function useVaccinations(antigenSlug?: string | null, enabled = true) { }); } +/** + * One dose with its links. The list omits each dose's documents, so an edit + * reads them here before it shows or sends them. + */ +export function useVaccination(id: string | null) { + return useQuery({ + queryKey: queryKeys.vaccination(id ?? ""), + enabled: id !== null, + queryFn: () => + apiGet(`${BASE}/${encodeURIComponent(id ?? "")}`), + }); +} + /** * Create / edit / soft-delete / restore a dose. * diff --git a/src/components/vaccinations/vaccination-document-picker.tsx b/src/components/vaccinations/vaccination-document-picker.tsx index baf8f2da5..d1778773b 100644 --- a/src/components/vaccinations/vaccination-document-picker.tsx +++ b/src/components/vaccinations/vaccination-document-picker.tsx @@ -17,6 +17,11 @@ * * Nothing here can block a save: the list starts empty and stays valid empty, * and linking is optional, capped and idempotent behind the link facade. + * + * On an edit the selection is what the dose is filed against on the server, + * read from the dose's detail route; the block waits for that read rather + * than showing an empty set, which would both hide the links and offer to + * save their removal. */ import { FolderOpen } from "lucide-react"; @@ -28,6 +33,9 @@ export function VaccinationDocumentPicker({ enabled, anchor, documentIds, + seedPending = false, + seedError = false, + onRetrySeed, onChange, }: { /** The `inboundDocuments` module flag — false blanks the block entirely. */ @@ -35,6 +43,11 @@ export function VaccinationDocumentPicker({ /** The dose's own date (ISO), for the suggestions on top. */ anchor: string | null; documentIds: string[]; + /** The dose's own links are still loading. */ + seedPending?: boolean; + /** Reading the dose's own links failed. */ + seedError?: boolean; + onRetrySeed?: () => void; onChange: (documentIds: string[]) => void; }) { const { t } = useTranslations(); @@ -48,10 +61,13 @@ export function VaccinationDocumentPicker({ icon={FolderOpen} title={t("vaccinations.form.linkDocuments")} slot="vaccination-document" - pending={vault.pending} - error={vault.error} + pending={vault.pending || seedPending} + error={vault.error || seedError} errorLabel={t("links.picker.loadError")} - onRetry={vault.retry} + onRetry={() => { + if (vault.error) vault.retry(); + if (seedError) onRetrySeed?.(); + }} selected={documentIds} onChange={onChange} options={vault.options} diff --git a/src/components/vaccinations/vaccination-form.tsx b/src/components/vaccinations/vaccination-form.tsx index 67d192faa..a000a3f39 100644 --- a/src/components/vaccinations/vaccination-form.tsx +++ b/src/components/vaccinations/vaccination-form.tsx @@ -59,7 +59,14 @@ export interface VaccinationDraft { practitioner: Practitioner | null; encounterId: string | null; note: string; - documentIds: string[]; + /** + * The documents the person has chosen, or `null` for "untouched: whatever + * the dose is filed against now". A dose opened from the list arrives + * without its links (only the detail read carries them), and an empty array + * here is sent as "unlink everything" — which is how editing a lot number + * used to strip every page filed against the dose. + */ + documentIds: string[] | null; } /** Today as a local `YYYY-MM-DD`, the DateField default and max. */ @@ -102,7 +109,7 @@ export function draftFromVaccination(row: Vaccination): VaccinationDraft { practitioner: row.practitioner, encounterId: row.encounter?.id ?? null, note: row.note ?? "", - documentIds: row.documents?.map((doc) => doc.id) ?? [], + documentIds: row.documents ? row.documents.map((doc) => doc.id) : null, }; } @@ -143,16 +150,30 @@ export function draftToBody( practitionerId: draft.practitioner?.id ?? null, encounterId: draft.encounterId, note: draft.note.trim() || null, - documentIds: draft.documentIds, + // Absent unless the person changed the links: the route reads a present + // array as the whole new set. + ...(draft.documentIds !== null ? { documentIds: draft.documentIds } : {}), }; } export function VaccinationForm({ draft, onChange, + savedDocuments, }: { draft: VaccinationDraft; onChange: (next: VaccinationDraft) => void; + /** + * What the dose is filed against on the server, for an edit: the ids, or + * `pending` while the detail read runs, or `error` when it failed. A create + * passes nothing and starts from an empty set. + */ + savedDocuments?: { + ids: string[] | null; + pending: boolean; + error: boolean; + retry: () => void; + }; }) { const { t } = useTranslations(); const { user } = useAuth(); @@ -306,7 +327,14 @@ export function VaccinationForm({ patch({ documentIds })} /> diff --git a/src/components/vaccinations/vaccination-sheet.tsx b/src/components/vaccinations/vaccination-sheet.tsx index d8c83951f..3f76cc21b 100644 --- a/src/components/vaccinations/vaccination-sheet.tsx +++ b/src/components/vaccinations/vaccination-sheet.tsx @@ -37,7 +37,11 @@ import { Button } from "@/components/ui/button"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { toastWrittenOutcome } from "@/components/outcome/outcome-toast"; import { useTranslations } from "@/lib/i18n/context"; -import { useVaccinationMutations, type Vaccination } from "./use-vaccinations"; +import { + useVaccination, + useVaccinationMutations, + type Vaccination, +} from "./use-vaccinations"; import { VaccinationForm, draftFromVaccination, @@ -63,6 +67,9 @@ export function VaccinationSheet({ }) { const { t } = useTranslations(); const { create, update, remove } = useVaccinationMutations(); + // The list row carries no document links; the detail read does. Until it + // answers, the draft's links stay "untouched" and Save leaves them alone. + const detail = useVaccination(vaccination?.id ?? null); const [confirmingDelete, setConfirmingDelete] = useState(false); // Seeded once at mount; the caller remounts on every open by varying `key`. const [draft, setDraft] = useState(() => @@ -125,7 +132,22 @@ export function VaccinationSheet({ } >
    - + document.id) ?? + null, + pending: detail.isPending, + error: detail.isError, + retry: () => void detail.refetch(), + } + : undefined + } + /> {error ? (

    {error} diff --git a/src/components/vaccinations/vaccinations-view.tsx b/src/components/vaccinations/vaccinations-view.tsx index 59acbd2c7..fd44e310d 100644 --- a/src/components/vaccinations/vaccinations-view.tsx +++ b/src/components/vaccinations/vaccinations-view.tsx @@ -19,6 +19,7 @@ import { EmptyState } from "@/components/ui/empty-state"; import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; +import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record"; import { useRecordCapabilities } from "@/hooks/use-record-capabilities"; import { useTranslations } from "@/lib/i18n/context"; @@ -65,6 +66,15 @@ export function VaccinationsView() { setSheetOpen(true); }; + // `?dose=` — a document's link chip names the dose it is filed + // against. Scroll to it, and open it for someone who may edit it. + useDeepLinkedRecord({ + param: "dose", + records: data?.vaccinations, + anchorAttribute: "data-vaccination-id", + open: canManageProfile ? openEdit : undefined, + }); + const addButton = canAddDose ? ( +

    + ) : null} +