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 ? (