Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:38:34 +02:00
commit ee23409b77
37 files changed
+1236 -101

No files matched your search

+28
View File
@@ -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
+6 -3
View File
@@ -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"
}
}
}
+6 -3
View File
@@ -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}"
}
}
}
+6 -3
View File
@@ -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}"
}
}
}
+6 -3
View File
@@ -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é"
}
}
}
+6 -3
View File
@@ -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"
}
}
}
+6 -3
View File
@@ -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} 연결 해제됨"
}
}
}
+6 -3
View File
@@ -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}"
}
}
}
@@ -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<string, unknown> = {}) {
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<Record<string, unknown>[]> {
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<string, unknown>[];
}
function day(days: Record<string, unknown>[], 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<string, unknown>;
};
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);
});
});
+35 -12
View File
@@ -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),
+5 -1
View File
@@ -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=<id>`, 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(
() =>
+8 -2
View File
@@ -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 <PageAuthGate />;
}
return <VaccinationsView />;
// The view reads `?dose=` through `useSearchParams`, which needs a
// boundary on a statically rendered page.
return (
<Suspense fallback={<PageAuthGate />}>
<VaccinationsView />
</Suspense>
);
}
@@ -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,
};
}
+120 -14
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 =
@@ -296,7 +364,7 @@ export function CycleCalendar({
oval lives behind the number, so it needs no row dot. */}
<span
aria-hidden="true"
className="relative z-10 mt-0.5 flex h-1.5 items-center gap-0.5"
className="relative z-10 mt-0.5 flex h-2 items-center gap-0.5"
>
{isPredictedOvulationDot ? (
<span
@@ -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="mx-px size-2 rotate-45"
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",
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 {
@@ -165,4 +165,78 @@ describe("<DocumentRecordLinks>", () => {
expect(html).not.toContain('data-slot="document-vaccination-links-add"');
expect(html).not.toContain('data-slot="document-visit-links-add"');
});
describe("chips open the record they point at (#1024)", () => {
it("a manager's dose chip carries the dose date and opens the dose", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: "tetanus",
vaccineName: null,
},
],
})}
canManage
seedFresh
onChange={() => undefined}
/>,
);
const open = html.match(
/<a[^>]*data-slot="document-vaccination-links-chip-open"[^>]*>/,
);
expect(open?.[0]).toContain('href="/vaccinations?dose=dose-1"');
// The date tells two doses of one vaccine apart.
expect(open?.[0]).toBeDefined();
expect(html).toMatch(/>Tetanus<\/span><span[^>]*>04\/02\/1991</);
// The label is not the unlink control; the X is.
expect(html).toContain(
'data-slot="document-vaccination-links-chip-remove"',
);
});
it("a reader's dose chip opens that dose, not the whole list", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
vaccinationLinks: [
{
vaccinationId: "dose-1",
occurredAt: "1991-04-02T00:00:00.000Z",
catalogSlug: null,
vaccineName: "DTP",
},
],
})}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/vaccinations?dose=dose-1"');
});
it("a reader's visit chip opens the visit", () => {
const html = render(
<DocumentRecordLinks
doc={doc({
encounterLinks: [
{
encounterId: "enc-1",
kind: "CHECKUP",
occurredAt: "2026-03-01T09:00:00.000Z",
},
],
} as never)}
canManage={false}
seedFresh
onChange={() => undefined}
/>,
);
expect(html).toContain('href="/checkups?visit=enc-1"');
});
});
});
@@ -47,6 +47,7 @@ export function recordOptions(
label: string;
meta?: string | null;
occurredAt: string;
href?: string | null;
}>,
anchor: string | null,
labels: { suggested: string; date: (iso: string) => string },
@@ -59,6 +60,7 @@ export function recordOptions(
label: record.label,
meta: record.meta ?? null,
dateLabel: labels.date(record.occurredAt),
href: record.href ?? null,
};
if (isNearAnchor(record.occurredAt, anchor)) {
near.push({
@@ -73,6 +75,16 @@ export function recordOptions(
return [...near, ...rest];
}
/** Where a visit opens: its sheet on the checkups page. */
function visitHref(encounterId: string): string {
return `/checkups?visit=${encodeURIComponent(encounterId)}`;
}
/** Where a dose opens: its sheet on the vaccinations page. */
function doseHref(vaccinationId: string): string {
return `/vaccinations?dose=${encodeURIComponent(vaccinationId)}`;
}
/** A dose named the way the vaccination page names it. */
function doseName(
t: Translate,
@@ -146,6 +158,7 @@ export function DocumentRecordLinks({
label: encounterKindText(t, visit.kind as EncounterKind),
meta: visit.practitioner?.name ?? null,
occurredAt: visit.occurredAt,
href: visitHref(visit.id),
})),
anchor,
labels,
@@ -155,6 +168,7 @@ export function DocumentRecordLinks({
id: dose.id,
label: doseName(t, dose.catalogEntry?.slug ?? null, dose.vaccineName),
occurredAt: dose.occurredAt,
href: doseHref(dose.id),
})),
anchor,
labels,
@@ -194,7 +208,7 @@ export function DocumentRecordLinks({
{doc.encounterLinks.map((link) => (
<Link
key={link.encounterId}
href={`/documents?encounter=${encodeURIComponent(link.encounterId)}`}
href={visitHref(link.encounterId)}
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="truncate">
@@ -244,7 +258,7 @@ export function DocumentRecordLinks({
{(doc.vaccinationLinks ?? []).map((link) => (
<Link
key={link.vaccinationId}
href="/vaccinations"
href={doseHref(link.vaccinationId)}
data-slot="document-vaccination-link"
className="bg-muted text-foreground hover:bg-muted/70 focus-visible:ring-ring/50 inline-flex max-w-64 items-center gap-1.5 rounded-full px-2.5 py-1 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
@@ -132,6 +132,7 @@ export function EncounterLinkPickers({
id: episode.id,
label: episode.label,
dateLabel: episode.onsetAt ? format.date(episode.onsetAt) : null,
href: `/illness/${encodeURIComponent(episode.id)}`,
}),
);
@@ -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=<id>` — 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 ? (
@@ -7,6 +7,7 @@ import {
EntityLinkPicker,
filterOptions,
groupOptions,
restoreLink,
toggleAll,
type EntityLinkOption,
} from "../entity-link-picker";
@@ -166,3 +167,66 @@ describe("<EntityLinkPicker> inline summary", () => {
expect(html).not.toContain("Nothing to link");
});
});
describe("<EntityLinkPicker> chips (#1024)", () => {
const base = {
icon: FolderOpen,
title: "Vaccinations",
slot: "test-link",
searchPlaceholder: "Search",
emptyLabel: "Nothing to link",
onChange: () => undefined,
pending: false,
};
const DOSES: EntityLinkOption[] = [
{
id: "d1",
label: "COVID-19",
dateLabel: "12 Sep 2025",
href: "/vaccinations?dose=d1",
},
{ id: "d2", label: "COVID-19", dateLabel: "3 Jan 2024" },
];
it("the chip itself is not the remove control", () => {
// Clicking a chip used to unlink it: the whole chip was the button.
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
expect(html).not.toMatch(/<button[^>]*data-slot="test-link-chip"/);
// One labelled remove button per chip, separate from the label.
const removes = html.match(
/<button[^>]*data-slot="test-link-chip-remove"[^>]*>/g,
);
expect(removes).toHaveLength(2);
expect(removes![0]).toContain('aria-label="Remove COVID-19, 12 Sep 2025"');
});
it("two doses of the same vaccine are told apart by their date", () => {
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
expect(html).toContain("12 Sep 2025");
expect(html).toContain("3 Jan 2024");
});
it("a chip with a target opens the linked record", () => {
const html = render(
<EntityLinkPicker {...base} options={DOSES} selected={["d1", "d2"]} />,
);
const open = html.match(/<a[^>]*data-slot="test-link-chip-open"[^>]*>/);
expect(open?.[0]).toContain('href="/vaccinations?dose=d1"');
// No target, no link: the label is plain text, never a remove button.
expect(html.match(/data-slot="test-link-chip-open"/g)).toHaveLength(1);
});
});
describe("restoreLink", () => {
it("puts an unlinked id back", () => {
expect(restoreLink(["a"], "b")).toEqual(["a", "b"]);
});
it("never duplicates an id that is already back", () => {
expect(restoreLink(["a", "b"], "b")).toEqual(["a", "b"]);
});
});
+108 -21
View File
@@ -19,8 +19,20 @@
* empty, and the link cap stays authoritative on the server. This surface only
* ever raises the FETCH limit so grouping has something to group; it never
* raises what may be linked.
*
* **A chip is a record, not a remove button.** The whole chip used to be the
* unlink control with an X drawn inside it, so a tap meant to open the linked
* record removed the link instead. The label now opens the record when the
* option names a target (`href`) and is plain text when it does not; only the
* separate X, labelled with what it removes, unlinks, and every unlink offers
* an undo right under the chips. The undo is inline rather than in a toast on
* purpose: this block lives inside a modal sheet, and a toast sits outside the
* sheet where its button cannot be pressed while the sheet is open. The chip
* carries the option's date, so two doses of one vaccine read as two
* different records.
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { Check, Plus, Search, X } from "lucide-react";
import type { LucideIcon } from "lucide-react";
@@ -45,6 +57,11 @@ export interface EntityLinkOption {
* Options with the same key collapse under one heading, in first-seen order.
*/
group?: { key: string; label: string } | null;
/**
* Where the linked record opens, or null/omitted when it has no page of its
* own. The chip's label becomes a link to it; the X stays the only unlink.
*/
href?: string | null;
}
/** Lowercased haystack for the client search over one option. */
@@ -116,6 +133,18 @@ export function toggleAll(selected: string[], ids: string[]): string[] {
return merged;
}
/** Put an unlinked id back (the undo), never twice. */
export function restoreLink(selected: string[], id: string): string[] {
return selected.includes(id) ? selected : [...selected, id];
}
/** The chip's full name: the label and, when it has one, the date. */
function chipName(option: EntityLinkOption): string {
return option.dateLabel
? `${option.label}, ${option.dateLabel}`
: option.label;
}
function toggleOne(selected: string[], id: string): string[] {
return selected.includes(id)
? selected.filter((entry) => entry !== id)
@@ -170,6 +199,19 @@ export function EntityLinkPicker({
[options, term],
);
// The link the last X removed, offered back until the next change.
const [lastRemoved, setLastRemoved] = useState<EntityLinkOption | null>(null);
const change = (ids: string[]) => {
setLastRemoved(null);
onChange(ids);
};
const unlink = (option: EntityLinkOption) => {
onChange(selected.filter((id) => id !== option.id));
setLastRemoved(option);
};
const selectedSet = new Set(selected);
const selectedChips = selected
.map((id) => optionById.get(id))
@@ -201,28 +243,75 @@ export function EntityLinkPicker({
<div className="space-y-2">
{selectedChips.length > 0 ? (
<ul className="flex flex-wrap gap-1.5" data-slot={`${slot}-chips`}>
{selectedChips.map((option) => (
<li key={option.id}>
<button
type="button"
data-slot={`${slot}-chip`}
onClick={() => onChange(toggleOne(selected, option.id))}
className="border-border bg-muted/40 hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 items-center gap-1.5 rounded-full border px-3 text-xs focus-visible:ring-[3px] focus-visible:outline-none"
>
<span className="text-foreground max-w-[12rem] truncate">
{selectedChips.map((option) => {
const name = (
<>
<span className="text-foreground max-w-48 truncate">
{option.label}
</span>
<X
className="text-muted-foreground size-3 shrink-0"
aria-hidden
/>
<span className="sr-only">{t("links.picker.remove")}</span>
</button>
</li>
))}
{option.dateLabel ? (
<span className="text-muted-foreground shrink-0">
{option.dateLabel}
</span>
) : null}
</>
);
return (
<li
key={option.id}
data-slot={`${slot}-chip`}
className="border-border bg-muted/40 flex min-h-8 items-center rounded-full border text-xs"
>
{option.href ? (
<Link
href={option.href}
data-slot={`${slot}-chip-open`}
className="hover:bg-muted focus-visible:ring-ring/50 flex min-h-8 min-w-0 items-center gap-1.5 rounded-full py-1 pr-1.5 pl-3 focus-visible:ring-[3px] focus-visible:outline-none"
>
{name}
</Link>
) : (
<span className="flex min-w-0 items-center gap-1.5 py-1 pr-1.5 pl-3">
{name}
</span>
)}
<button
type="button"
data-slot={`${slot}-chip-remove`}
aria-label={t("links.picker.removeNamed", {
name: chipName(option),
})}
onClick={() => unlink(option)}
className="text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-ring/50 mr-0.5 flex size-7 shrink-0 items-center justify-center rounded-full focus-visible:ring-[3px] focus-visible:outline-none"
>
<X className="size-3.5" aria-hidden />
</button>
</li>
);
})}
</ul>
) : null}
{lastRemoved ? (
<p
role="status"
data-slot={`${slot}-undo`}
className="text-muted-foreground flex flex-wrap items-center gap-x-2 text-xs"
>
<span>
{t("links.picker.removed", { name: chipName(lastRemoved) })}
</span>
<button
type="button"
data-slot={`${slot}-undo-button`}
onClick={() => change(restoreLink(selected, lastRemoved.id))}
className="text-foreground focus-visible:ring-ring/50 min-h-8 rounded-sm font-medium underline underline-offset-2 focus-visible:ring-[3px] focus-visible:outline-none"
>
{t("common.undo")}
</button>
</p>
) : null}
<Button
type="button"
variant="outline"
@@ -281,9 +370,7 @@ export function EntityLinkPicker({
<button
type="button"
data-slot={`${slot}-group-select`}
onClick={() =>
onChange(toggleAll(selected, groupIds))
}
onClick={() => change(toggleAll(selected, groupIds))}
className="text-muted-foreground hover:text-foreground text-xs underline-offset-2 hover:underline"
>
{t("links.picker.selectAll")}
@@ -300,7 +387,7 @@ export function EntityLinkPicker({
aria-pressed={on}
data-slot={`${slot}-option`}
onClick={() =>
onChange(toggleOne(selected, option.id))
change(toggleOne(selected, option.id))
}
className={cn(
"border-border hover:bg-muted/50 focus-visible:ring-ring/50 flex min-h-11 w-full items-center gap-2 rounded-md border px-3 text-left focus-visible:ring-[3px] focus-visible:outline-none",
@@ -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({
@@ -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([]);
});
});
@@ -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<Vaccination>(`${BASE}/${encodeURIComponent(id ?? "")}`),
});
}
/**
* Create / edit / soft-delete / restore a dose.
*
@@ -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}
@@ -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({
<VaccinationDocumentPicker
enabled={user?.modules?.inboundDocuments === true}
anchor={anchor}
documentIds={draft.documentIds}
documentIds={draft.documentIds ?? savedDocuments?.ids ?? []}
seedPending={
draft.documentIds === null && (savedDocuments?.pending ?? false)
}
seedError={
draft.documentIds === null && (savedDocuments?.error ?? false)
}
onRetrySeed={savedDocuments?.retry}
onChange={(documentIds) => patch({ documentIds })}
/>
</div>
@@ -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<VaccinationDraft>(() =>
@@ -125,7 +132,22 @@ export function VaccinationSheet({
}
>
<div className="space-y-4">
<VaccinationForm draft={draft} onChange={setDraft} />
<VaccinationForm
draft={draft}
onChange={setDraft}
savedDocuments={
vaccination
? {
ids:
detail.data?.documents?.map((document) => document.id) ??
null,
pending: detail.isPending,
error: detail.isError,
retry: () => void detail.refetch(),
}
: undefined
}
/>
{error ? (
<p role="alert" className="text-destructive text-sm">
{error}
@@ -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=<id>` — 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 ? (
<Button
className="min-h-11 sm:min-h-9"
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useEffect, useRef } from "react";
import { useSearchParams } from "next/navigation";
/**
* Open the record a link points at: `/vaccinations?dose=<id>`,
* `/checkups?visit=<id>`.
*
* The link chips on a document name the record they point at, and a chip that
* lands on a list with nothing selected leaves the person searching the list
* for the one row they just tapped. Once the list has loaded, the row named by
* `param` is scrolled into view and handed to `open` (the page's own edit
* sheet, or nothing for a reader who may not edit). Each id is acted on once,
* so closing the sheet does not reopen it on the next render.
*
* An id the list does not hold (deleted since, or outside what this grant
* shows) is ignored: the page simply renders as it would without the link.
*/
export function useDeepLinkedRecord<T extends { id: string }>({
param,
records,
anchorAttribute,
open,
}: {
/** The query parameter carrying the id. */
param: string;
/** The loaded list, or undefined while it loads. */
records: readonly T[] | undefined;
/** The data attribute each row carries its id in, for the scroll. */
anchorAttribute: string;
/** Called once with the linked record; omit to only scroll. */
open?: (record: T) => void;
}): void {
// Null outside the app router (a component rendered on its own, as the
// unit tests do): no link, nothing to open.
const wanted = useSearchParams()?.get(param) ?? null;
const handled = useRef<string | null>(null);
useEffect(() => {
if (!wanted || !records || handled.current === wanted) return;
handled.current = wanted;
const record = records.find((entry) => entry.id === wanted);
if (!record) return;
document
.querySelector(`[${anchorAttribute}="${CSS.escape(wanted)}"]`)
?.scrollIntoView({ block: "center" });
open?.(record);
}, [wanted, records, anchorAttribute, open]);
}
@@ -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(
+46 -17
View File
@@ -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,
+39 -1
View File
@@ -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,
});
}
+18 -1
View File
@@ -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