diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 00ed782e5..9e1b3e8a8 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -71987,6 +71987,12 @@ components: $ref: "#/components/schemas/EncounterLinks" skipped: $ref: "#/components/schemas/EncounterSkipReport" + preparation: + description: "On the list read only (`GET /api/encounters`): the window before this visit when it is booked (PLANNED, + upcoming), null on every other row. Absent on the detail and on write responses." + anyOf: + - $ref: "#/components/schemas/EncounterPreparation" + - type: "null" createdAt: type: string updatedAt: @@ -72138,6 +72144,73 @@ components: checkup, which is a measurements-domain write a health-background delegate cannot perform. The visit still saves; the checkup stays due and the response says so, because somebody who believes a checkup was closed will not look at it again." + EncounterPreparation: + type: object + properties: + from: + type: string + description: "First day of the window, `YYYY-MM-DD` on the record's own calendar: the day of `since`, or 90 days before + today on `default`." + basis: + type: string + enum: + - practitioner + - specialty + - kind + - default + description: "The rule that chose `from`, first match wins over the visits that took place (DONE) before this one: + `practitioner` (the last visit with the same practitioner), `specialty` (the last visit with a practitioner + of the same specialty, compared without case or extra spaces), `kind` (the last visit of the same kind, for + ROUTINE, PREVENTIVE and THERAPY only), `default` (none matched: the last 90 days)." + since: + anyOf: + - $ref: "#/components/schemas/EncounterPreparationAnchor" + - type: "null" + required: + - from + - basis + - since + additionalProperties: false + description: "The window before a booked visit, resolved server-side: where the visit preparation and the doctor report + offered for the appointment start, and why there. Never the last visit of any kind, which is how a dermatology + appointment was offered a week of data from an internist visit. A client shows `from` with its reason and lets + the person change the dates; it does not recompute the rule." + EncounterPreparationAnchor: + type: object + properties: + id: + type: string + occurredAt: + type: string + kind: + type: string + enum: + - ROUTINE + - ACUTE + - SPECIALIST + - PREVENTIVE + - EMERGENCY + - HOSPITAL + - THERAPY + - OTHER + - PROCEDURE + practitionerName: + type: + - string + - "null" + specialty: + type: + - string + - "null" + required: + - id + - occurredAt + - kind + - practitionerName + - specialty + additionalProperties: false + description: "The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and + specialty as they read now." CreateEncounterEnvelope: type: object properties: diff --git a/messages/de.json b/messages/de.json index d62745355..b8afe311c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Dosis erfassen", "reconnect": "Neu verbinden", "viewCheckups": "Vorsorge ansehen", + "viewAppointment": "Termin ansehen", + "viewAppointments": "Termine ansehen", "checkinKeep": "Behalten", "checkinAdjust": "Anpassen", "checkinLetGo": "Loslassen", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 e302c4dff..8ec8c0e61 100644 --- a/messages/en.json +++ b/messages/en.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Log dose", "reconnect": "Reconnect", "viewCheckups": "View check-ups", + "viewAppointment": "View appointment", + "viewAppointments": "View appointments", "checkinKeep": "Keep it", "checkinAdjust": "Adjust", "checkinLetGo": "Let it go", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 a26e7bd46..80928dc2d 100644 --- a/messages/es.json +++ b/messages/es.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Registrar dosis", "reconnect": "Reconectar", "viewCheckups": "Ver revisiones", + "viewAppointment": "Ver cita", + "viewAppointments": "Ver citas", "checkinKeep": "Conservar", "checkinAdjust": "Ajustar", "checkinLetGo": "Dejarlo ir", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 6d96b26fd..4f7058a57 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Enregistrer la dose", "reconnect": "Reconnecter", "viewCheckups": "Voir les examens", + "viewAppointment": "Voir le rendez-vous", + "viewAppointments": "Voir les rendez-vous", "checkinKeep": "Garder", "checkinAdjust": "Ajuster", "checkinLetGo": "Laisser tomber", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 6ebc40c7d..a1dc08274 100644 --- a/messages/it.json +++ b/messages/it.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Registra dose", "reconnect": "Riconnetti", "viewCheckups": "Vedi controlli", + "viewAppointment": "Vedi appuntamento", + "viewAppointments": "Vedi appuntamenti", "checkinKeep": "Mantieni", "checkinAdjust": "Modifica", "checkinLetGo": "Lascia andare", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 2e991a5f3..8712f3f1a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -6860,7 +6860,7 @@ "errorRateLimit": "이번 시간의 내보내기 한도에 도달했어요.", "errorModuleDisabled": "이 계정에서는 건강 기록 내보내기가 꺼져 있어요.", "visitOffer": "{when}에 예정된 진료가 있어요: {what}.", - "visitOfferApply": "그날까지로 리포트 만들기" + "visitOfferApplyWindow": "이 기간 사용하기" }, "globalExcludedInjectionSitesLabel": "전체 제외 주사 부위", "globalExcludedInjectionSitesHint": "약이 선호하더라도 용량을 기록할 때 추천하지 않고 선택도 막아요.", @@ -12035,6 +12035,8 @@ "logDose": "복용 기록", "reconnect": "다시 연결", "viewCheckups": "건강검진 보기", + "viewAppointment": "예약 보기", + "viewAppointments": "예약 목록 보기", "checkinKeep": "계속하기", "checkinAdjust": "조정", "checkinLetGo": "놓아주기", @@ -12759,6 +12761,13 @@ "noMatchTitle": "이 필터에 맞는 항목이 없습니다", "clearFilter": "필터 지우기", "loadError": "시술 목록을 불러올 수 없습니다." + }, + "preparation": { + "practitioner": "{date} {name} 마지막 방문 이후", + "specialty": "{date} 마지막 {specialty} 방문 이후", + "kind": "{date} 같은 유형({kind})의 마지막 방문 이후", + "default": "최근 {days}일", + "defaultReason": "같은 병원, 진료과 또는 유형의 이전 방문이 없어요." } }, "checkups": { @@ -13008,7 +13017,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 d01313ca4..dc0c5e5f0 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Dosis registreren", "reconnect": "Opnieuw verbinden", "viewCheckups": "Controles bekijken", + "viewAppointment": "Afspraak bekijken", + "viewAppointments": "Afspraken bekijken", "checkinKeep": "Aanhouden", "checkinAdjust": "Aanpassen", "checkinLetGo": "Loslaten", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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 fd41253e7..625abe2a4 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -6860,7 +6860,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.", @@ -12035,6 +12035,8 @@ "logDose": "Zapisz dawkę", "reconnect": "Połącz ponownie", "viewCheckups": "Zobacz badania", + "viewAppointment": "Zobacz wizytę", + "viewAppointments": "Zobacz wizyty", "checkinKeep": "Zachowaj", "checkinAdjust": "Dostosuj", "checkinLetGo": "Odpuść", @@ -12759,6 +12761,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": { @@ -13008,7 +13017,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/__tests__/visit-link-guard.test.ts b/src/__tests__/visit-link-guard.test.ts new file mode 100644 index 000000000..bf1c20e51 --- /dev/null +++ b/src/__tests__/visit-link-guard.test.ts @@ -0,0 +1,169 @@ +/** + * A link to a visit is built by `src/lib/encounters/visit-links.ts`, and a + * bare `/checkups` is a link to the checkups, never to a visit. + * + * Visits are the second tab of `/checkups`. The bare path opens the first + * one, the preventive-care list, which never lists an appointment. Four + * surfaces showed an upcoming appointment and linked it to the bare path — + * the dashboard's next-appointment line, the Today rail item, the Today + * overview fact and the appointment's push — so a tap on the appointment + * landed on the list it is not on. Each had spelled the path by hand. + * + * Two rules, both over code lines (comments are skipped): + * + * 1. The visit and tab query forms (`/checkups?visit=`, `/checkups?view=`) + * appear only in the builder. + * 2. The bare `/checkups` literal appears only where the target IS the + * checkups list, counted per file. A new occurrence fails the run until + * someone decides whether it names a checkup (add it here, with the + * reason) or a visit (use the builder). + * + * Not matched, stated rather than hidden: a path assembled from parts + * (`"/check" + "ups"`), and a `/*` inside a string on the same line as the + * literal, which the comment skipper reads as a comment. + */ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { walkSourceFiles } from "./helpers/source-files"; + +const SRC = join(__dirname, ".."); +const BUILDER = "lib/encounters/visit-links.ts"; + +/** + * Where a bare `/checkups` is right, and how often: each names the + * preventive-care list itself, or lists routes rather than linking one. + */ +const BARE_CHECKUPS: Record = { + "components/layout/nav-model.ts": { count: 1, why: "the page's nav entry" }, + "components/measurement-reminders/vorsorge-dashboard-card.tsx": { + count: 1, + why: "the checkups card's empty state opens the checkups", + }, + "components/illness/illness-episode-detail.tsx": { + count: 2, + why: "the body-site view (`?site=`); the bare fallback never renders", + }, + "lib/navigation/shared-record.ts": { + count: 2, + why: "route lists for domain visibility, not links", + }, + "lib/daily/digest.ts": { count: 1, why: "the due check-ups rail item" }, + "lib/day/records.ts": { count: 1, why: "a check-up satisfied that day" }, + "lib/onboarding/tour-state.ts": { count: 1, why: "the checkups tour step" }, + "lib/notifications/tap-target.ts": { + count: 1, + why: "a checkup push; an appointment's names its visit as override", + }, +}; + +/** The code on each line, with line and block comments removed. */ +function codeLines(source: string): string[] { + const out: string[] = []; + let inBlock = false; + for (const raw of source.split("\n")) { + let line = raw; + let code = ""; + while (line.length > 0) { + if (inBlock) { + const end = line.indexOf("*/"); + if (end === -1) { + line = ""; + break; + } + inBlock = false; + line = line.slice(end + 2); + continue; + } + const start = line.indexOf("/*"); + const lineComment = line.search(/(^|[^:])\/\//); + if (lineComment !== -1 && (start === -1 || lineComment < start)) { + code += line.slice( + 0, + lineComment + (line[lineComment] === "/" ? 0 : 1), + ); + line = ""; + break; + } + if (start === -1) { + code += line; + line = ""; + break; + } + code += line.slice(0, start); + inBlock = true; + line = line.slice(start + 2); + } + out.push(code); + } + return out; +} + +const QUERY_FORM = /\/checkups\?(visit|view)=/; +// The bare page, or the page with a query the builder does not own. +const BARE = /["'`]\/checkups(?:["'`]|\?(?!visit=|view=))/g; + +function scan(files: { path: string; source: string }[]) { + const queryForms: string[] = []; + const bare: Record = {}; + for (const { path, source } of files) { + for (const line of codeLines(source)) { + if (QUERY_FORM.test(line)) queryForms.push(path); + const hits = line.match(BARE)?.length ?? 0; + if (hits > 0) bare[path] = (bare[path] ?? 0) + hits; + } + } + return { queryForms: [...new Set(queryForms)].sort(), bare }; +} + +describe("links to a visit go through the visit-links builder", () => { + const files = walkSourceFiles(SRC, { floor: 2500 }) + .filter((p) => !p.includes("__tests__/") && !p.startsWith("generated/")) + .map((path) => ({ path, source: readFileSync(join(SRC, path), "utf8") })); + + it("walks the tree", () => { + expect(files.length).toBeGreaterThan(2000); + }); + + it("spells the visit and tab paths only in the builder", () => { + // The builder itself must match, or the matcher proves nothing. + expect( + scan(files).queryForms, + "Build a link to a visit with visitPath() / visitsPath() / VISITS_PATH from src/lib/encounters/visit-links.ts.", + ).toEqual([BUILDER]); + }); + + it("links the bare checkups page only where the target is the checkups", () => { + const { bare } = scan(files); + delete bare[BUILDER]; + const expected = Object.fromEntries( + Object.entries(BARE_CHECKUPS).map(([path, { count }]) => [path, count]), + ); + expect( + bare, + "A new bare `/checkups`: if it leads to a visit, use the visit-links builder; if it really is the checkups list, add it to BARE_CHECKUPS with the reason.", + ).toEqual(expected); + }); + + it("skips comments and keeps code", () => { + const { queryForms, bare } = scan([ + { + path: "a.tsx", + source: [ + "/**", + " * `/checkups?visit=` in a docblock", + " */", + '// "/checkups" in a line comment', + '{/* a JSX comment naming "/checkups"', + ' over two lines "/checkups" */}', + 'const url = "https://example.test/x"; const bad = "/checkups";', + ].join("\n"), + }, + { path: "b.ts", source: "const p = `/checkups?visit=${id}`;" }, + { path: "c.ts", source: "const q = `/checkups?${params}`;" }, + ]); + expect(queryForms).toEqual(["b.ts"]); + expect(bare).toEqual({ "a.tsx": 1, "c.ts": 1 }); + }); +}); diff --git a/src/app/api/encounters/route.ts b/src/app/api/encounters/route.ts index 5686620b2..5c73973e6 100644 --- a/src/app/api/encounters/route.ts +++ b/src/app/api/encounters/route.ts @@ -35,6 +35,7 @@ import { } from "@/lib/validations/encounters"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { toEncounterDTO, type EncounterListDTO } from "@/lib/encounters/dto"; +import { loadVisitPreparations } from "@/lib/encounters/preparation-read"; import { ENCOUNTER_INCLUDE, applyEncounterLinks, @@ -143,12 +144,17 @@ export const GET = apiHandler(async (request: NextRequest) => { // them makes the first read zero and the second unfile everything on save. // Three grouped queries for the page, not three per row. const visible = await actingDomainVisibility(prisma, grantId); - const links = await loadEncounterLinksForMany( - prisma, - user.id, - [...upcoming, ...past].map((row) => row.id), - visible, - ); + // The window before each booked visit reads the whole history, not this + // page of it: the visit it starts at may sit outside the caller's filter. + const [links, preparations] = await Promise.all([ + loadEncounterLinksForMany( + prisma, + user.id, + [...upcoming, ...past].map((row) => row.id), + visible, + ), + loadVisitPreparations(prisma, user.id, upcoming, now), + ]); annotate({ action: { name: "encounter.visit.list", entity_type: "encounter" }, @@ -156,8 +162,14 @@ export const GET = apiHandler(async (request: NextRequest) => { }); const body: EncounterListDTO = { - upcoming: upcoming.map((row) => toEncounterDTO(row, links.get(row.id))), - past: past.map((row) => toEncounterDTO(row, links.get(row.id))), + upcoming: upcoming.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: preparations.get(row.id) ?? null, + })), + past: past.map((row) => ({ + ...toEncounterDTO(row, links.get(row.id)), + preparation: null, + })), }; return apiListSuccess(body, combinedCap(upcomingCap, pastCap)); }); diff --git a/src/app/checkups/__tests__/page-client-view.test.tsx b/src/app/checkups/__tests__/page-client-view.test.tsx new file mode 100644 index 000000000..7e65a4a93 --- /dev/null +++ b/src/app/checkups/__tests__/page-client-view.test.tsx @@ -0,0 +1,75 @@ +/** + * Which tab a link to `/checkups` opens. + * + * Visits are the page's second tab. `?visit=` opens one, `?view=visits` + * the list, and the bare path keeps opening the checkups, which is what the + * nav entry and the checkup surfaces mean by it. + */ +import { describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +let search = new URLSearchParams(); + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), + usePathname: () => "/checkups", + useSearchParams: () => search, +})); +vi.mock("@tanstack/react-query", async (importOriginal) => ({ + ...(await importOriginal()), + useQueryClient: () => ({}), +})); +vi.mock("@/hooks/use-auth", () => ({ + useAuth: () => ({ isAuthenticated: true, isLoading: false }), +})); +vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true })); +vi.mock("@/hooks/use-pull-to-refresh", () => ({ + usePullToRefresh: () => ({}), +})); +vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({ + PullToRefreshIndicator: () => null, +})); +vi.mock("@/components/measurement-reminders/vorsorge-section", () => ({ + VorsorgeSection: () =>
, +})); +vi.mock("@/components/encounters/visits-section", () => ({ + VisitsSection: () =>
, +})); +vi.mock("@/components/encounters/procedures-section", () => ({ + ProceduresSection: () =>
, +})); +vi.mock("@/components/body-sites/body-sites-section", () => ({ + BodySitesSection: () =>
, +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import CheckupsPageClient from "../page-client"; + +function openTab(query: string): string | null { + search = new URLSearchParams(query); + const html = renderToStaticMarkup( + + + , + ); + return html.match(/data-section="([^"]+)"/)?.[1] ?? null; +} + +describe("/checkups — the tab a link opens", () => { + it("opens the checkups on the bare path", () => { + expect(openTab("")).toBe("vorsorge"); + }); + + it("opens the visits for a link to one visit", () => { + expect(openTab("visit=v1")).toBe("visits"); + }); + + it("opens the tab `view` names", () => { + expect(openTab("view=visits")).toBe("visits"); + expect(openTab("view=procedures")).toBe("procedures"); + }); + + it("ignores a `view` it does not know", () => { + expect(openTab("view=elsewhere")).toBe("vorsorge"); + }); +}); diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index e97672d7c..b4bc35a05 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -15,6 +15,7 @@ import { VisitsSection } from "@/components/encounters/visits-section"; import { ProceduresSection } from "@/components/encounters/procedures-section"; import { BodySitesSection } from "@/components/body-sites/body-sites-section"; import type { BodySiteSide } from "@/hooks/use-body-sites"; +import { parseCheckupsView } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -66,17 +67,29 @@ export default function CheckupsPageClient() { linkedSideParam === "BOTH" ? linkedSideParam : null; - const [view, setView] = useState( - linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", + // A link to one tab (`?view=visits`, from a line naming several + // appointments). Built by `src/lib/encounters/visit-links.ts`, never by hand. + const linkedView = parseCheckupsView(searchParams?.get("view")); + const [view, setView] = useState( + linkedVisit + ? "visits" + : linkedSite + ? "body-sites" + : (linkedView ?? "vorsorge"), ); // A link to a visit can arrive while the page is already open (the search // opens one from wherever the person is): it moves to the visits tab then - // too, rather than only on the first render. + // too, rather than only on the first render. A link to a tab does the same. const [observedVisit, setObservedVisit] = useState(linkedVisit); if (linkedVisit !== observedVisit) { setObservedVisit(linkedVisit); if (linkedVisit) setView("visits"); } + const [observedView, setObservedView] = useState(linkedView); + if (linkedView !== observedView) { + setObservedView(linkedView); + if (linkedView && !linkedVisit) setView(linkedView); + } const refresh = useCallback( () => diff --git a/src/components/daily/__tests__/priority-card.test.tsx b/src/components/daily/__tests__/priority-card.test.tsx index c3a6a5df0..1aff76415 100644 --- a/src/components/daily/__tests__/priority-card.test.tsx +++ b/src/components/daily/__tests__/priority-card.test.tsx @@ -161,6 +161,37 @@ describe("", () => { expect(html).toContain("View check-ups"); }); + // An appointment keeps the check-up label on the wire for the shipped iOS + // app; the web names what the link opens. The check-up item above is the + // negative control. + it("names an appointment's action for the visit it opens", () => { + const action = (href: string) => ({ + labelKey: "daily.action.viewCheckups", + intent: "checkup.view", + href, + }); + const one = render( + , + ); + expect(one).toContain("View appointment"); + expect(one).not.toContain("View check-ups"); + expect(one).toContain('href="/checkups?visit=v1"'); + const several = render( + , + ); + expect(several).toContain("View appointments"); + }); + describe("dismiss affordance — observational kinds only", () => { const DISMISSIBLE: PriorityItemKind[] = [ "milestone", diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index 6f47ec2c1..5d1d5962d 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -36,6 +36,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { TileHeader } from "@/components/insights/tile-header"; import { ProseBlocks } from "@/components/insights/prose-blocks"; import { Button, buttonVariants } from "@/components/ui/button"; +import { VISITS_PATH } from "@/lib/encounters/visit-links"; import { useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import { @@ -205,7 +206,7 @@ export function PriorityCard({ ) : onAction ? ( , + action: Pick, +): string { + if (item.kind !== "upcoming_visit" || !action.href) return action.labelKey; + return action.href === VISITS_PATH + ? "daily.action.viewAppointments" + : "daily.action.viewAppointment"; +} + function ActionLink({ href, label }: { href: string; label: string }) { return ( ` to its existing destination * by construction; S2 invents no new backend action. * 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/documents/document-record-links.tsx b/src/components/documents/document-record-links.tsx index 93b4c4509..c3cbd6827 100644 --- a/src/components/documents/document-record-links.tsx +++ b/src/components/documents/document-record-links.tsx @@ -50,6 +50,7 @@ import { queryKeys } from "@/lib/query-keys"; import type { EncounterDTO, EncounterListDTO } from "@/lib/encounters/dto"; import type { EncounterKind } from "@/generated/prisma/client"; import type { InboundDocumentDetailDto } from "@/lib/validations/inbound-documents"; +import { visitPath } from "@/lib/encounters/visit-links"; type Translate = ReturnType["t"]; @@ -109,7 +110,7 @@ export async function fetchEncounterPage( /** Where a visit opens: its sheet on the checkups page. */ function visitHref(encounterId: string): string { - return `/checkups?visit=${encodeURIComponent(encounterId)}`; + return visitPath(encounterId); } /** Where a dose opens: its sheet on the vaccinations page. */ 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 ? (