mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Merge branch 'fix/v1391-cycle-and-links' into release/v1.39.1
This commit is contained in:
commit
ee23409b77
37 files changed
+1236
-101
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -22,6 +22,12 @@ export const FLOW_HUE = "var(--cycle-phase-menstrual)";
|
||||
export const FERTILE_HUE = "var(--cycle-phase-follicular)";
|
||||
/** Ovulation marker tint (amber-gold family). */
|
||||
export const OVULATION_HUE = "var(--cycle-phase-ovulatory)";
|
||||
/**
|
||||
* Intercourse marker tint (luteal family). Never colour-only: the marker is a
|
||||
* diamond, a shape no other calendar marker uses, and the cell's aria-label
|
||||
* names it.
|
||||
*/
|
||||
export const INTERCOURSE_HUE = "var(--cycle-phase-luteal)";
|
||||
|
||||
/**
|
||||
* Flow-intensity shading — a SINGLE-HUE OPACITY LADDER on the menstrual rose
|
||||
|
||||
@@ -45,6 +45,19 @@ export interface CalendarDay {
|
||||
basalBodyTempC: number | null;
|
||||
ovulationTest: OvulationTest | null;
|
||||
cervicalMucus: CervicalMucus | null;
|
||||
/** Logged cervix signs, each or null. */
|
||||
cervixPosition: string | null;
|
||||
cervixFirmness: string | null;
|
||||
cervixOpening: string | null;
|
||||
/** Spotting outside the period logged on this day. */
|
||||
intermenstrualBleeding: boolean;
|
||||
/** Intercourse logged on this day (resolved server-side, envelope included). */
|
||||
sexualActivity: boolean;
|
||||
pregnancyTest: string | null;
|
||||
progesteroneTest: string | null;
|
||||
contraceptive: string | null;
|
||||
/** Whether the day carries a note; the text is only on the day-log read. */
|
||||
hasNote: boolean;
|
||||
}
|
||||
|
||||
export interface CyclePrediction {
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user