From 99fec791770aadb100de544e57f50f718d40a5a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 21 Oct 2026 12:32:18 +0200 Subject: [PATCH] fix(visits): start the visit preparation and its report at the matching visit Both surfaces read the start of their window from `preparation` and say why it starts there: "Since your last visit with on ", "Since your last visit on ", "Since your last visit of the same kind () on ", or "The last 90 days" with the reason that no earlier visit matched. The visits tab no longer picks the last visit of any kind from the past list. The report panel's appointment offer now sets both dates, the start from the server and the end on the appointment's day, instead of the end alone beside whatever start the panel held. Both stay editable. --- messages/de.json | 10 ++- messages/en.json | 10 ++- messages/es.json | 10 ++- messages/fr.json | 10 ++- messages/it.json | 10 ++- messages/ko.json | 10 ++- messages/nl.json | 10 ++- messages/pl.json | 10 ++- src/components/day/since-last-visit.tsx | 68 ++++++++++----- .../__tests__/visits-section.test.tsx | 86 +++++++++++++++---- src/components/encounters/preparation-text.ts | 69 +++++++++++++++ src/components/encounters/visits-section.tsx | 24 ++++-- .../__tests__/report-visit-offer.test.tsx | 57 +++++++++++- .../report-selection/report-visit-offer.tsx | 72 ++++++++++++---- .../report-selection/selection-panel.tsx | 7 +- 15 files changed, 377 insertions(+), 86 deletions(-) create mode 100644 src/components/encounters/preparation-text.ts diff --git a/messages/de.json b/messages/de.json index b5519dc87..d5c341731 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Das Exportlimit für diese Stunde ist erreicht.", "errorModuleDisabled": "Der Gesundheitsakten-Export ist für dieses Konto ausgeschaltet.", "visitOffer": "Am {when} steht ein Termin an: {what}.", - "visitOfferApply": "Bericht bis dahin" + "visitOfferApplyWindow": "Zeitraum übernehmen" }, "globalExcludedInjectionSitesLabel": "Global ausgeschlossene Injektionsstellen", "globalExcludedInjectionSitesHint": "Nie angeboten und beim Erfassen abgelehnt, auch wenn eine Medikation sie bevorzugt.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nichts passt zu diesem Filter", "clearFilter": "Filter zurücksetzen", "loadError": "Die Eingriffe konnten nicht geladen werden." + }, + "preparation": { + "practitioner": "Seit deinem letzten Termin bei {name} am {date}", + "specialty": "Seit deinem letzten Termin ({specialty}) am {date}", + "kind": "Seit deinem letzten Termin der gleichen Art ({kind}) am {date}", + "default": "Die letzten {days} Tage", + "defaultReason": "Kein früherer Termin bei derselben Praxis, in derselben Fachrichtung oder derselben Art." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Schnitt aus {count} Messungen", "meanOfReadingsOther": "Schnitt aus {count} Messungen", "sinceLastVisit": { - "title": "Seit dem letzten Besuch am {date}", "countOne": "1 Eintrag", "countFew": "{count} Einträge", "countOther": "{count} Einträge", diff --git a/messages/en.json b/messages/en.json index 83af58f77..dd7f54457 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "You have reached the export limit for this hour.", "errorModuleDisabled": "The health record export is switched off for this account.", "visitOffer": "An appointment is coming up on {when}: {what}.", - "visitOfferApply": "Report up to then" + "visitOfferApplyWindow": "Use this window" }, "globalExcludedInjectionSitesLabel": "Globally excluded injection sites", "globalExcludedInjectionSitesHint": "Never offered and rejected when logging a dose, even if a medication prefers them.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nothing matches this filter", "clearFilter": "Clear filter", "loadError": "Procedures could not be loaded." + }, + "preparation": { + "practitioner": "Since your last visit with {name} on {date}", + "specialty": "Since your last {specialty} visit on {date}", + "kind": "Since your last visit of the same kind ({kind}) on {date}", + "default": "The last {days} days", + "defaultReason": "No earlier visit with the same practice, specialty or kind." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Average of {count} readings", "meanOfReadingsOther": "Average of {count} readings", "sinceLastVisit": { - "title": "Since the last visit on {date}", "countOne": "1 entry", "countFew": "{count} entries", "countOther": "{count} entries", diff --git a/messages/es.json b/messages/es.json index 40801b3e7..b9b593b9d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Ha alcanzado el límite de exportaciones de esta hora.", "errorModuleDisabled": "La exportación del expediente está desactivada para esta cuenta.", "visitOffer": "El {when} hay una cita: {what}.", - "visitOfferApply": "Informe hasta esa fecha" + "visitOfferApplyWindow": "Usar este periodo" }, "globalExcludedInjectionSitesLabel": "Sitios de inyección excluidos globalmente", "globalExcludedInjectionSitesHint": "Nunca se ofrecen y se rechazan al registrar una dosis, aunque un medicamento los prefiera.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nada coincide con este filtro", "clearFilter": "Quitar filtro", "loadError": "No se pudieron cargar los procedimientos." + }, + "preparation": { + "practitioner": "Desde tu última visita con {name} el {date}", + "specialty": "Desde tu última visita ({specialty}) el {date}", + "kind": "Desde tu última visita del mismo tipo ({kind}) el {date}", + "default": "Los últimos {days} días", + "defaultReason": "No hay ninguna visita anterior con la misma consulta, especialidad o tipo." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Media de {count} mediciones", "meanOfReadingsOther": "Media de {count} mediciones", "sinceLastVisit": { - "title": "Desde la última visita el {date}", "countOne": "1 entrada", "countFew": "{count} entradas", "countOther": "{count} entradas", diff --git a/messages/fr.json b/messages/fr.json index 843183ae9..d2ba94ff6 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Vous avez atteint la limite d'exports pour cette heure.", "errorModuleDisabled": "L'export du dossier est désactivé pour ce compte.", "visitOffer": "Un rendez-vous est prévu le {when} : {what}.", - "visitOfferApply": "Rapport jusque-là" + "visitOfferApplyWindow": "Utiliser cette période" }, "globalExcludedInjectionSitesLabel": "Sites d'injection exclus globalement", "globalExcludedInjectionSitesHint": "Jamais proposés et refusés à l'enregistrement, même si un médicament les privilégie.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Rien ne correspond à ce filtre", "clearFilter": "Effacer le filtre", "loadError": "Les interventions n’ont pas pu être chargées." + }, + "preparation": { + "practitioner": "Depuis votre dernière visite chez {name} le {date}", + "specialty": "Depuis votre dernière visite ({specialty}) le {date}", + "kind": "Depuis votre dernière visite du même type ({kind}) le {date}", + "default": "Les {days} derniers jours", + "defaultReason": "Aucune visite antérieure au même cabinet, dans la même spécialité ou du même type." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Moyenne de {count} mesures", "meanOfReadingsOther": "Moyenne de {count} mesures", "sinceLastVisit": { - "title": "Depuis la dernière visite le {date}", "countOne": "1 entrée", "countFew": "{count} entrées", "countOther": "{count} entrées", diff --git a/messages/it.json b/messages/it.json index b157dc025..a59ab9b1e 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Hai raggiunto il limite di esportazioni per questa ora.", "errorModuleDisabled": "L'esportazione della cartella è disattivata per questo account.", "visitOffer": "Il {when} c'è un appuntamento: {what}.", - "visitOfferApply": "Referto fino a quella data" + "visitOfferApplyWindow": "Usa questo periodo" }, "globalExcludedInjectionSitesLabel": "Siti di iniezione esclusi globalmente", "globalExcludedInjectionSitesHint": "Mai proposti e rifiutati alla registrazione, anche se un farmaco li preferisce.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nessun risultato per questo filtro", "clearFilter": "Rimuovi filtro", "loadError": "Non è stato possibile caricare gli interventi." + }, + "preparation": { + "practitioner": "Dalla tua ultima visita con {name} del {date}", + "specialty": "Dalla tua ultima visita ({specialty}) del {date}", + "kind": "Dalla tua ultima visita dello stesso tipo ({kind}) del {date}", + "default": "Gli ultimi {days} giorni", + "defaultReason": "Nessuna visita precedente presso lo stesso studio, nella stessa specialità o dello stesso tipo." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Media di {count} misurazioni", "meanOfReadingsOther": "Media di {count} misurazioni", "sinceLastVisit": { - "title": "Dall’ultima visita del {date}", "countOne": "1 voce", "countFew": "{count} voci", "countOther": "{count} voci", diff --git a/messages/ko.json b/messages/ko.json index 486967c65..c9a79810d 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "이번 시간의 내보내기 한도에 도달했어요.", "errorModuleDisabled": "이 계정에서는 건강 기록 내보내기가 꺼져 있어요.", "visitOffer": "{when}에 예정된 진료가 있어요: {what}.", - "visitOfferApply": "그날까지로 리포트 만들기" + "visitOfferApplyWindow": "이 기간 사용하기" }, "globalExcludedInjectionSitesLabel": "전체 제외 주사 부위", "globalExcludedInjectionSitesHint": "약이 선호하더라도 용량을 기록할 때 추천하지 않고 선택도 막아요.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "이 필터에 맞는 항목이 없습니다", "clearFilter": "필터 지우기", "loadError": "시술 목록을 불러올 수 없습니다." + }, + "preparation": { + "practitioner": "{date} {name} 마지막 방문 이후", + "specialty": "{date} 마지막 {specialty} 방문 이후", + "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", + "default": "최근 {days}일", + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "측정 {count}회 평균", "meanOfReadingsOther": "측정 {count}회 평균", "sinceLastVisit": { - "title": "{date} 마지막 방문 이후", "countOne": "기록 1개", "countFew": "기록 {count}개", "countOther": "기록 {count}개", diff --git a/messages/nl.json b/messages/nl.json index 75249dbd8..90aea8b4d 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Je hebt de exportlimiet voor dit uur bereikt.", "errorModuleDisabled": "De export van het gezondheidsdossier is uitgeschakeld voor dit account.", "visitOffer": "Er staat een afspraak gepland op {when}: {what}.", - "visitOfferApply": "Rapport tot die datum" + "visitOfferApplyWindow": "Deze periode gebruiken" }, "globalExcludedInjectionSitesLabel": "Overal uitgesloten injectieplaatsen", "globalExcludedInjectionSitesHint": "Worden nooit aangeboden en geweigerd bij het vastleggen van een dosis, ook als een medicijn er de voorkeur aan geeft.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Niets komt overeen met dit filter", "clearFilter": "Filter wissen", "loadError": "Ingrepen konden niet worden geladen." + }, + "preparation": { + "practitioner": "Sinds je laatste bezoek bij {name} op {date}", + "specialty": "Sinds je laatste bezoek ({specialty}) op {date}", + "kind": "Sinds je laatste bezoek van hetzelfde soort ({kind}) op {date}", + "default": "De afgelopen {days} dagen", + "defaultReason": "Geen eerder bezoek bij dezelfde praktijk, in hetzelfde specialisme of van hetzelfde soort." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Gemiddelde van {count} metingen", "meanOfReadingsOther": "Gemiddelde van {count} metingen", "sinceLastVisit": { - "title": "Sinds het laatste bezoek op {date}", "countOne": "1 registratie", "countFew": "{count} registraties", "countOther": "{count} registraties", diff --git a/messages/pl.json b/messages/pl.json index e74a5abc5..1b645eb94 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6851,7 +6851,7 @@ "errorRateLimit": "Osiągnięto limit eksportów w tej godzinie.", "errorModuleDisabled": "Eksport dokumentacji jest wyłączony dla tego konta.", "visitOffer": "{when} zaplanowano wizytę: {what}.", - "visitOfferApply": "Raport do tego dnia" + "visitOfferApplyWindow": "Użyj tego okresu" }, "globalExcludedInjectionSitesLabel": "Globalnie wykluczone miejsca iniekcji", "globalExcludedInjectionSitesHint": "Nigdy nieproponowane i odrzucane przy rejestracji, nawet jeśli lek je preferuje.", @@ -12723,6 +12723,13 @@ "noMatchTitle": "Nic nie pasuje do tego filtra", "clearFilter": "Wyczyść filtr", "loadError": "Nie udało się wczytać zabiegów." + }, + "preparation": { + "practitioner": "Od ostatniej wizyty ({name}) {date}", + "specialty": "Od ostatniej wizyty ({specialty}) {date}", + "kind": "Od ostatniej wizyty tego samego rodzaju ({kind}) {date}", + "default": "Ostatnie {days} dni", + "defaultReason": "Brak wcześniejszej wizyty w tym samym gabinecie, tej samej specjalizacji lub tego samego rodzaju." } }, "checkups": { @@ -12972,7 +12979,6 @@ "meanOfReadingsFew": "Średnia z {count} pomiarów", "meanOfReadingsOther": "Średnia z {count} pomiarów", "sinceLastVisit": { - "title": "Od ostatniej wizyty {date}", "countOne": "1 wpis", "countFew": "{count} wpisy", "countOther": "{count} wpisów", diff --git a/src/components/day/since-last-visit.tsx b/src/components/day/since-last-visit.tsx index 08f0cf5b6..488781784 100644 --- a/src/components/day/since-last-visit.tsx +++ b/src/components/day/since-last-visit.tsx @@ -17,10 +17,17 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { + Card, + CardContent, + CardDescription, + CardHeader, +} from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { QueryErrorRow } from "@/components/ui/query-error-row"; +import { preparationText } from "@/components/encounters/preparation-text"; import type { EncounterDTO } from "@/lib/encounters/dto"; +import type { EncounterPreparationDTO } from "@/lib/encounters/preparation"; import { apiGet } from "@/lib/api/api-fetch"; import type { DateKey, @@ -46,14 +53,20 @@ import { useTodayKey } from "./use-today-key"; /** * "Since the last visit": the preparation the next planned appointment * otherwise lacks. Dose changes, episodes that began, new results, extreme - * readings and gaps between the last visit and today, each dated, each date - * opening its day. Compiled from the record without any judgement; the - * wording is the day view's own. + * readings and gaps between the start of the window and today, each dated, + * each date opening its day. Compiled from the record without any judgement; + * the wording is the day view's own. * - * One read, `GET /api/day/notable` for the window from the last visit to - * today: the server lists every context change in it (a dose change always, - * however quiet the day around it) and every notable observation. Nothing is - * recomputed here; this only spells the rows. + * The window starts where the server says (`EncounterDTO.preparation`): the + * last visit with the same practitioner, else the same specialty, else the + * same kind, else the last 90 days — never simply the last visit of any kind, + * which handed a dermatology appointment a week of an internist's data. The + * card's title says which, so the start date explains itself. + * + * One read, `GET /api/day/notable` for that window up to today: the server + * lists every context change in it (a dose change always, however quiet the + * day around it) and every notable observation. Nothing is recomputed here; + * this only spells the rows. * * The component owns its card and draws nothing until it has a row to show: * not while the window loads, not when it holds nothing worth listing. A card @@ -132,12 +145,12 @@ export function preparationRows( export function SinceLastVisit({ next, - last, + preparation, }: { /** The next planned visit, the one this prepares. */ next: EncounterDTO; - /** The last visit that took place. */ - last: EncounterDTO; + /** The window before it, as the server resolved it. */ + preparation: EncounterPreparationDTO; }) { const { t, tCount } = useTranslations(); const fmt = useFormatters(); @@ -147,16 +160,17 @@ export function SinceLastVisit({ const notableText = useNotableText(); const [showAll, setShowAll] = useState(false); - const lastDay = dateKeyOfInstant(last.occurredAt, timeZone); - // From the last visit's own day (a dose changed at the visit belongs to - // what the next one should know) to today. The server caps the span. + // From the server's start day (the matched visit's own day: a dose changed + // at that visit belongs to what the next one should know) to today. The + // notable read caps the span. + const fromDay: DateKey = preparation.from; const window = useQuery({ - queryKey: queryKeys.dayNotable(lastDay, today), + queryKey: queryKeys.dayNotable(fromDay, today), queryFn: () => { - const params = new URLSearchParams({ from: lastDay, to: today }); + const params = new URLSearchParams({ from: fromDay, to: today }); return apiGet(`/api/day/notable?${params}`); }, - enabled: lastDay <= today, + enabled: fromDay <= today, staleTime: 5 * 60_000, }); @@ -194,7 +208,15 @@ export function SinceLastVisit({ }) : []; - const lastLabel = fmt.dateShortSmartCalendar(lastDay); + const fromLabel = fmt.dateShortSmartCalendar(fromDay); + // Where the window starts and why, in one spelling shared with the report + // panel's offer. + const heading = preparationText(t, fmt.dateShortSmartCalendar, preparation); + const reason = heading.reason ? ( + + {heading.reason} + + ) : null; // A failed read keeps the card and its title, so the page does not jump // and the reader still sees what could not be loaded. @@ -206,8 +228,9 @@ export function SinceLastVisit({ size="sm" icon={Stethoscope} titleAs="h3" - title={t("day.sinceLastVisit.title", { date: lastLabel })} + title={heading.title} /> + {reason} {tCount("day.sinceLastVisit.count", rows.length)} } /> + {reason}
diff --git a/src/components/encounters/__tests__/visits-section.test.tsx b/src/components/encounters/__tests__/visits-section.test.tsx index 31f95b25f..74531b746 100644 --- a/src/components/encounters/__tests__/visits-section.test.tsx +++ b/src/components/encounters/__tests__/visits-section.test.tsx @@ -47,7 +47,7 @@ vi.mock("@/lib/api/api-fetch", async () => { }; }); -import { dateKeyOfInstant, todayKeyInZone } from "@/components/day/day-url"; +import { todayKeyInZone } from "@/components/day/day-url"; import { ApiError } from "@/lib/api/api-fetch"; import type { DayNotableWindowResponse } from "@/lib/day/contract"; import { I18nProvider } from "@/lib/i18n/context"; @@ -174,13 +174,32 @@ describe("", () => { * to wrap it in a card, so whenever it had nothing to list (still loading, a * quiet window, a failed read) an empty bordered card sat under the visit. * It now draws its own card, and only once it has a row. + * + * The window is the server's (`preparation` on the booked visit). The section + * used to start it at the last visit of any kind it found in the past list; + * the fixtures carry such a visit, newer than the window's start, and the + * read must not move to it. */ describe(" since the last visit", () => { + const sameDoctor = { + id: "derm-before", + occurredAt: "2026-03-02T09:00:00.000Z", + kind: "SPECIALIST" as const, + practitionerName: "Skin practice", + specialty: "Dermatology", + }; const next = visit({ id: "next", status: "PLANNED", occurredAt: "2026-09-20T09:00:00.000Z", + preparation: { + from: "2026-03-02", + basis: "practitioner", + since: sameDoctor, + }, }); + const unprepared = visit({ ...next, preparation: null }); + // A newer visit elsewhere: what the old client rule anchored on. const last = visit({ id: "last", occurredAt: "2026-08-01T09:00:00.000Z" }); /** Cards painted inside the upcoming list. */ @@ -194,12 +213,8 @@ describe(" since the last visit", () => { function seedWindow(window: DayNotableWindowResponse | null) { return (queryClient: QueryClient) => { if (!window) return; - const tz = DEFAULT_TIMEZONE; queryClient.setQueryData( - queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), - ), + queryKeys.dayNotable("2026-03-02", todayKeyInZone(DEFAULT_TIMEZONE)), window, ); }; @@ -219,10 +234,10 @@ describe(" since the last visit", () => { }); it("draws no card while the window is still loading", () => { - const withLast = render({ upcoming: [next], past: [last] }); - const withoutLast = render({ upcoming: [next], past: [] }); - expect(withLast).not.toContain('data-slot="since-last-visit"'); - expect(upcomingCards(withLast)).toBe(upcomingCards(withoutLast)); + const loading = render({ upcoming: [next], past: [last] }); + const without = render({ upcoming: [unprepared], past: [last] }); + expect(loading).not.toContain('data-slot="since-last-visit"'); + expect(upcomingCards(loading)).toBe(upcomingCards(without)); }); it("draws no card when the window holds nothing worth listing", () => { @@ -230,17 +245,16 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedWindow(emptyWindow), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).not.toContain('data-slot="since-last-visit"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline)); }); function seedFailure(error: Error) { return (queryClient: QueryClient) => { - const tz = DEFAULT_TIMEZONE; const queryKey = queryKeys.dayNotable( - dateKeyOfInstant(last.occurredAt, tz), - todayKeyInZone(tz), + "2026-03-02", + todayKeyInZone(DEFAULT_TIMEZONE), ); // The state the section sees once the real read has given up: a mount // does not retry it. @@ -257,14 +271,14 @@ describe(" since the last visit", () => { { upcoming: [next], past: [last] }, seedFailure(new ApiError("window read failed", 500)), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit-error"'); expect(html).toContain('role="alert"'); expect(html).not.toContain('data-slot="since-last-visit"'); // The failed window keeps its own card and title, so the page does not // jump; the visit cards beside it stay as they were. expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); - expect(html).toContain("Since the last visit on"); + expect(html).toContain("Since your last visit with Skin practice on"); }); // Visits need the profile section, the window reads measurements, and a @@ -296,10 +310,15 @@ describe(" since the last visit", () => { ], } as unknown as DayNotableWindowResponse), ); - const baseline = render({ upcoming: [next], past: [] }); + const baseline = render({ upcoming: [unprepared], past: [last] }); expect(html).toContain('data-slot="since-last-visit"'); expect(html).toContain('data-slot="since-last-visit-row"'); expect(upcomingCards(html)).toBe(upcomingCards(baseline) + 1); + // The title says where the window starts and why; the report starts + // there too, not at the newer visit elsewhere. + expect(html).toContain("Since your last visit with Skin practice on"); + expect(html).toContain("reportFrom=2026-03-02&"); + expect(html).not.toContain('data-slot="since-last-visit-reason"'); // The card heads like every compact card: TileHeader at the small size, // a real heading under the section's own, at the compact density. const card = html.slice( @@ -314,4 +333,37 @@ describe(" since the last visit", () => { /

{ + const html = render( + { + upcoming: [ + visit({ + ...next, + preparation: { from: "2026-03-02", basis: "default", since: null }, + }), + ], + past: [last], + }, + seedWindow({ + ...emptyWindow, + changes: [ + { + date: "2026-08-03", + kind: "doseChange", + section: "medications", + id: "d1", + title: "Example 5 mg", + count: 1, + href: null, + }, + ], + } as unknown as DayNotableWindowResponse), + ); + expect(html).toContain("The last 90 days"); + expect(html).toContain('data-slot="since-last-visit-reason"'); + expect(html).toContain( + "No earlier visit with the same practice, specialty or kind.", + ); + }); }); diff --git a/src/components/encounters/preparation-text.ts b/src/components/encounters/preparation-text.ts new file mode 100644 index 000000000..3d5211228 --- /dev/null +++ b/src/components/encounters/preparation-text.ts @@ -0,0 +1,69 @@ +/** + * The window before a booked visit, in words: the line that says where the + * visit preparation and the offered doctor report start, and why there. + * + * Spells only what the server resolved (`EncounterDTO.preparation`); nothing + * here chooses a date. One spelling for both surfaces that show it, so the + * visits tab and the report panel cannot give two reasons for one window. + */ +import type { EncounterKind } from "@/generated/prisma/client"; +import { + PREPARATION_DEFAULT_DAYS, + type EncounterPreparationDTO, +} from "@/lib/encounters/preparation"; + +import { encounterKindText } from "./encounter-labels"; + +type Translate = ( + key: string, + params?: Record, +) => string; + +export interface PreparationText { + /** Where the window starts: "Since your last visit with … on …". */ + title: string; + /** Why, when the title alone does not say: only on the default window. */ + reason: string | null; +} + +export function preparationText( + t: Translate, + formatDay: (day: string) => string, + preparation: EncounterPreparationDTO, +): PreparationText { + const date = formatDay(preparation.from); + const since = preparation.since; + switch (preparation.basis) { + case "practitioner": + return { + title: t("encounters.preparation.practitioner", { + name: since?.practitionerName ?? "", + date, + }), + reason: null, + }; + case "specialty": + return { + title: t("encounters.preparation.specialty", { + specialty: since?.specialty ?? "", + date, + }), + reason: null, + }; + case "kind": + return { + title: t("encounters.preparation.kind", { + kind: since ? encounterKindText(t, since.kind as EncounterKind) : "", + date, + }), + reason: null, + }; + case "default": + return { + title: t("encounters.preparation.default", { + days: PREPARATION_DEFAULT_DAYS, + }), + reason: t("encounters.preparation.defaultReason"), + }; + } +} diff --git a/src/components/encounters/visits-section.tsx b/src/components/encounters/visits-section.tsx index dde6fea21..015e6c008 100644 --- a/src/components/encounters/visits-section.tsx +++ b/src/components/encounters/visits-section.tsx @@ -72,8 +72,11 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) { resolve: fetchVisit, }); const empty = upcoming.length === 0 && past.length === 0; - // The last visit that took place: the start of the visit preparation. - const lastDone = past.find((encounter) => encounter.status === "DONE"); + // The next booked visit carries the window before it, resolved by the + // server (`EncounterDTO.preparation`): which earlier visit it starts at and + // why. Never picked here from the past list, which would be the last visit + // of any kind — the rule the server replaced. + const prepared = upcoming.find((encounter) => encounter.preparation); const addButton = canAddVisit ? (