mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge branch 'fix/v14311-visits' into release/v1.43.11
This commit is contained in:
commit
8d16799479
53 files changed
+1837
-118
No files matched your search
@@ -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:
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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}개",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
+10
-2
@@ -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",
|
||||
|
||||
@@ -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<string, { count: number; why: string }> = {
|
||||
"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<string, number> = {};
|
||||
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=<id>` 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 });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Which tab a link to `/checkups` opens.
|
||||
*
|
||||
* Visits are the page's second tab. `?visit=<id>` 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<typeof import("@tanstack/react-query")>()),
|
||||
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: () => <div data-section="vorsorge" />,
|
||||
}));
|
||||
vi.mock("@/components/encounters/visits-section", () => ({
|
||||
VisitsSection: () => <div data-section="visits" />,
|
||||
}));
|
||||
vi.mock("@/components/encounters/procedures-section", () => ({
|
||||
ProceduresSection: () => <div data-section="procedures" />,
|
||||
}));
|
||||
vi.mock("@/components/body-sites/body-sites-section", () => ({
|
||||
BodySitesSection: () => <div data-section="body-sites" />,
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import CheckupsPageClient from "../page-client";
|
||||
|
||||
function openTab(query: string): string | null {
|
||||
search = new URLSearchParams(query);
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<CheckupsPageClient />
|
||||
</I18nProvider>,
|
||||
);
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<string>(
|
||||
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(
|
||||
() =>
|
||||
|
||||
@@ -161,6 +161,37 @@ describe("<PriorityCard>", () => {
|
||||
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(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "upcoming_visit",
|
||||
actions: [action("/checkups?visit=v1")],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(one).toContain("View appointment");
|
||||
expect(one).not.toContain("View check-ups");
|
||||
expect(one).toContain('href="/checkups?visit=v1"');
|
||||
const several = render(
|
||||
<PriorityCard
|
||||
item={item({
|
||||
kind: "upcoming_visit",
|
||||
actions: [action("/checkups?view=visits")],
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(several).toContain("View appointments");
|
||||
});
|
||||
|
||||
describe("dismiss affordance — observational kinds only", () => {
|
||||
const DISMISSIBLE: PriorityItemKind[] = [
|
||||
"milestone",
|
||||
|
||||
@@ -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({
|
||||
<ActionLink
|
||||
key={action.intent}
|
||||
href={action.href}
|
||||
label={t(action.labelKey)}
|
||||
label={t(actionLabelKey(item, action))}
|
||||
/>
|
||||
) : onAction ? (
|
||||
<ActionButton
|
||||
@@ -225,6 +226,24 @@ export function PriorityCard({
|
||||
}
|
||||
|
||||
/** A navigation action. Always offered — reading is never delegated away. */
|
||||
/**
|
||||
* The label key a web card shows for an action.
|
||||
*
|
||||
* The server's `labelKey`, except on an appointment: that item still carries
|
||||
* the check-up item's label on the wire ("View check-ups"), because the
|
||||
* shipped iOS app resolves the label and routes the intent it already knows.
|
||||
* Its `href` already opens the visit, so the web names what it opens.
|
||||
*/
|
||||
export function actionLabelKey(
|
||||
item: Pick<PriorityItem, "kind">,
|
||||
action: Pick<PriorityItemAction, "labelKey" | "href">,
|
||||
): 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 (
|
||||
<Link
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
* stands in its place — not an alarming empty card.
|
||||
*
|
||||
* Every `PriorityItem` action carries an `href` (dose.log → /medications,
|
||||
* sync.reconnect → /settings/integrations, checkup.view → /checkups), so
|
||||
* sync.reconnect → /settings/integrations, checkup.view → /checkups, or the
|
||||
* visit itself on an upcoming_visit item), so
|
||||
* `PriorityCard` wires each tap as a `<Link>` to its existing destination
|
||||
* by construction; S2 invents no new backend action.
|
||||
*
|
||||
|
||||
@@ -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<DayNotableWindowResponse>(`/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 ? (
|
||||
<CardDescription className="text-xs" data-slot="since-last-visit-reason">
|
||||
{heading.reason}
|
||||
</CardDescription>
|
||||
) : 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}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<QueryErrorRow
|
||||
@@ -236,13 +259,14 @@ export function SinceLastVisit({
|
||||
icon={Stethoscope}
|
||||
id={`since-last-visit-${next.id}`}
|
||||
titleAs="h3"
|
||||
title={t("day.sinceLastVisit.title", { date: lastLabel })}
|
||||
title={heading.title}
|
||||
right={
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{tCount("day.sinceLastVisit.count", rows.length)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{reason}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<section
|
||||
@@ -297,11 +321,11 @@ export function SinceLastVisit({
|
||||
className="min-h-11 sm:min-h-9"
|
||||
>
|
||||
<Link
|
||||
href={`/settings/gesundheitsakte?reportFrom=${lastDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
href={`/settings/gesundheitsakte?reportFrom=${fromDay}&reportTo=${nextDay < today ? nextDay : today}`}
|
||||
data-slot="since-last-visit-report"
|
||||
>
|
||||
<FileText className="size-4" aria-hidden="true" />
|
||||
{t("day.sinceLastVisit.report", { date: lastLabel })}
|
||||
{t("day.sinceLastVisit.report", { date: fromLabel })}
|
||||
</Link>
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
@@ -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<typeof useTranslations>["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. */
|
||||
|
||||
@@ -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("<VisitsSection>", () => {
|
||||
* 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> 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("<VisitsSection> since the last visit", () => {
|
||||
/<h3 id="since-last-visit-[^"]+" data-slot="card-title" class="[^"]*text-sm/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says why on the default window, where the title alone cannot", () => {
|
||||
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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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, string | number>,
|
||||
) => 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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 ? (
|
||||
<Button
|
||||
@@ -135,18 +138,21 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
|
||||
icon={CalendarClock}
|
||||
title={t("encounters.upcomingHeading")}
|
||||
/>
|
||||
{upcoming.map((encounter, i) => (
|
||||
{upcoming.map((encounter) => (
|
||||
<Fragment key={encounter.id}>
|
||||
<VisitCard
|
||||
encounter={encounter}
|
||||
onOpen={canManageVisits ? openSheet : undefined}
|
||||
/>
|
||||
{/* v1.42 — the next appointment carries what happened
|
||||
since the last one, each date opening its day. The
|
||||
component draws its own card, and none when it has
|
||||
nothing to list. */}
|
||||
{i === 0 && lastDone ? (
|
||||
<SinceLastVisit next={encounter} last={lastDone} />
|
||||
{/* v1.42 — the next booked appointment carries what
|
||||
happened in the window before it, each date opening its
|
||||
day. The component draws its own card, and none when it
|
||||
has nothing to list. */}
|
||||
{encounter.id === prepared?.id && prepared.preparation ? (
|
||||
<SinceLastVisit
|
||||
next={encounter}
|
||||
preparation={prepared.preparation}
|
||||
/>
|
||||
) : null}
|
||||
</Fragment>
|
||||
))}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
|
||||
@@ -95,7 +96,7 @@ export function EpisodeVisitsCard({ episodeId }: { episodeId: string }) {
|
||||
<li key={visit.id}>
|
||||
<ListRow asChild>
|
||||
<Link
|
||||
href="/checkups"
|
||||
href={visitPath(visit.id)}
|
||||
data-encounter-id={visit.id}
|
||||
className="hover:bg-muted/50 focus-visible:ring-ring/50 flex items-center gap-3 focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
|
||||
@@ -64,6 +64,7 @@ import type { IllnessEpisodeDTO } from "@/lib/illness/dto";
|
||||
import type { RelatedLink } from "@/lib/links/related";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
type Translate = ReturnType<typeof useTranslations>["t"];
|
||||
type RelatedKind = RelatedLink["kind"];
|
||||
@@ -311,7 +312,7 @@ function VisitPicker({ selected, onChange, anchor, known }: PickerProps) {
|
||||
label: encounterKindText(t, visit.kind as EncounterKind),
|
||||
meta: visit.practitioner?.name ?? null,
|
||||
occurredAt: visit.occurredAt,
|
||||
href: `/checkups?visit=${encodeURIComponent(visit.id)}`,
|
||||
href: visitPath(visit.id),
|
||||
})),
|
||||
anchor,
|
||||
{
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* The dashboard's next-appointment line opens the appointment, on the visits
|
||||
* tab, and not the checkups list the card otherwise summarises.
|
||||
*
|
||||
* The line linked the bare `/checkups`, which opens the preventive-care tab —
|
||||
* the one list an appointment is never on. The negative control is the card's
|
||||
* own empty state, whose link is to the checkups and stays there.
|
||||
*/
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
const visit = {
|
||||
id: "visit-42",
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
// Inside the card's two-week horizon whenever the suite runs.
|
||||
occurredAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
practitioner: { name: "Practice Example" },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-measurement-reminders", () => ({
|
||||
useMeasurementReminders: () => ({
|
||||
data: [],
|
||||
isError: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
useMeasurementReminderMutations: () => ({
|
||||
satisfy: { mutate: vi.fn(), isPending: false },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-encounters", () => ({
|
||||
useEncounters: () => ({ data: { upcoming: [visit], past: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-record-capabilities", () => ({
|
||||
useRecordCapabilities: () => ({
|
||||
canManage: true,
|
||||
canWriteDomain: () => true,
|
||||
canManageDomain: () => true,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { VorsorgeDashboardCard } from "../vorsorge-dashboard-card";
|
||||
|
||||
function linkHref(html: string, slot: string): string | null {
|
||||
const tag = html.match(new RegExp(`<a[^>]*data-slot="${slot}"[^>]*>`));
|
||||
return tag?.[0].match(/href="([^"]*)"/)?.[1] ?? null;
|
||||
}
|
||||
|
||||
describe("<VorsorgeDashboardCard> — the next appointment", () => {
|
||||
it("opens the visit itself", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<VorsorgeDashboardCard />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(html).toContain("Practice Example");
|
||||
expect(linkHref(html, "vorsorge-dashboard-next-visit")).toBe(
|
||||
"/checkups?visit=visit-42",
|
||||
);
|
||||
// The empty state names the checkups and still opens them.
|
||||
expect(html).toContain('href="/checkups"');
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import { CalendarClock, CheckCircle2, Plus } from "lucide-react";
|
||||
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
import {
|
||||
useFormatters,
|
||||
useTranslations,
|
||||
@@ -240,10 +241,12 @@ export function VorsorgeDashboardCard() {
|
||||
|
||||
{/* Visually distinct from a due checkup: a quiet line under the block
|
||||
rather than a fourth row with an action, because there is nothing
|
||||
to do about an appointment except keep it. */}
|
||||
to do about an appointment except keep it. It opens the visit
|
||||
itself: a bare `/checkups` lands on the checkups tab, which is
|
||||
exactly where the appointment is not. */}
|
||||
{nextAppointment ? (
|
||||
<Link
|
||||
href="/checkups"
|
||||
href={visitPath(nextAppointment.id)}
|
||||
data-slot="vorsorge-dashboard-next-visit"
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 flex items-center gap-2 rounded-md border-t pt-2 text-xs transition-colors focus-visible:ring-[3px] focus-visible:outline-none"
|
||||
>
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
usePractitioners,
|
||||
type Practitioner,
|
||||
} from "@/hooks/use-practitioners";
|
||||
import { VISITS_PATH } from "@/lib/encounters/visit-links";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { PractitionerSheet } from "./practitioner-sheet";
|
||||
|
||||
@@ -107,7 +108,7 @@ export function PractitionerList({ enabled = true }: { enabled?: boolean }) {
|
||||
<PageHeader
|
||||
title={t("practitioners.title")}
|
||||
description={t("practitioners.description")}
|
||||
backLink={{ href: "/checkups", label: t("encounters.sectionTitle") }}
|
||||
backLink={{ href: VISITS_PATH, label: t("encounters.sectionTitle") }}
|
||||
actions={addButton}
|
||||
/>
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("@/lib/api/api-fetch", async () => {
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { EncounterDTO } from "@/lib/encounters/dto";
|
||||
import { ReportVisitOffer } from "../report-visit-offer";
|
||||
import { ReportVisitOffer, visitReportWindow } from "../report-visit-offer";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@@ -67,7 +67,7 @@ function render(upcoming: EncounterDTO[], onUse = () => {}) {
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<ReportVisitOffer onUseVisitDate={onUse} />
|
||||
<ReportVisitOffer onUseVisitWindow={onUse} />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -108,4 +108,57 @@ describe("<ReportVisitOffer>", () => {
|
||||
expect(html).toContain('data-slot="report-visit-offer-apply"');
|
||||
expect(onUse).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The issue behind this: an internist a week ago, a dermatologist next. The
|
||||
// start used to be whatever the panel held, typically the last visit of any
|
||||
// kind; it is now the server's window before this visit, and says why.
|
||||
it("names where the report would start, and why there", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "derm",
|
||||
preparation: {
|
||||
from: "2026-03-02",
|
||||
basis: "specialty",
|
||||
since: {
|
||||
id: "old-derm",
|
||||
occurredAt: "2026-03-02T09:00:00.000Z",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Former skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain('data-slot="report-visit-offer-window"');
|
||||
expect(html).toContain("Since your last Dermatology visit on");
|
||||
expect(html).toContain("Use this window");
|
||||
});
|
||||
|
||||
it("says why on the default window", () => {
|
||||
const html = render([
|
||||
visit({
|
||||
id: "first",
|
||||
preparation: { from: "2026-07-23", basis: "default", since: null },
|
||||
}),
|
||||
]);
|
||||
expect(html).toContain("The last 90 days");
|
||||
expect(html).toContain(
|
||||
"No earlier visit with the same practice, specialty or kind.",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies the server's start and the appointment's day on a press", () => {
|
||||
const at = new Date(Date.now() + 5 * DAY_MS);
|
||||
const window = visitReportWindow(
|
||||
visit({
|
||||
id: "derm",
|
||||
occurredAt: at.toISOString(),
|
||||
preparation: { from: "2026-03-02", basis: "default", since: null },
|
||||
}),
|
||||
);
|
||||
expect(window.from).toBe("2026-03-02");
|
||||
expect(window.to).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
// Without a window from the server, only the end moves.
|
||||
expect(visitReportWindow(visit({ id: "plain" })).from).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -14,15 +14,20 @@
|
||||
* Termin ansteht") and does nothing until pressed. A control that moves on its
|
||||
* own is the thing that makes an export panel untrustworthy.
|
||||
*
|
||||
* Nothing about the stored selection changes: this sets the custom-range end
|
||||
* date and nothing else.
|
||||
* Pressing it sets both ends of the custom range: the end on the appointment's
|
||||
* day, the start where the server put the window before that visit
|
||||
* (`EncounterDTO.preparation`: the last visit with the same practitioner, else
|
||||
* the same specialty, else the same kind, else the last 90 days). The offer
|
||||
* says which, so the start date explains itself, and both dates stay editable.
|
||||
* Nothing about the stored selection changes.
|
||||
*/
|
||||
import { CalendarClock, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { encounterKindText } from "@/components/encounters/encounter-labels";
|
||||
import { preparationText } from "@/components/encounters/preparation-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useEncounters } from "@/hooks/use-encounters";
|
||||
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { detectBrowserTimezone, userDayKey } from "@/lib/tz/format";
|
||||
@@ -40,11 +45,27 @@ function localDay(iso: string): string {
|
||||
return userDayKey(new Date(iso), detectBrowserTimezone());
|
||||
}
|
||||
|
||||
/** The report window an appointment offers, as `yyyy-MM-dd` days. */
|
||||
export interface VisitReportWindow {
|
||||
/** The server's start of the window before the visit; null if it has none. */
|
||||
from: string | null;
|
||||
/** The appointment's own day. */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** What pressing the offer for `visit` applies. */
|
||||
export function visitReportWindow(visit: Encounter): VisitReportWindow {
|
||||
return {
|
||||
from: visit.preparation?.from ?? null,
|
||||
to: localDay(visit.occurredAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function ReportVisitOffer({
|
||||
onUseVisitDate,
|
||||
onUseVisitWindow,
|
||||
}: {
|
||||
/** Sets the custom window's END date. Called only on an explicit press. */
|
||||
onUseVisitDate: (endDay: string) => void;
|
||||
/** Sets the custom window. Called only on an explicit press. */
|
||||
onUseVisitWindow: (window: VisitReportWindow) => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const format = useFormatters();
|
||||
@@ -68,6 +89,9 @@ export function ReportVisitOffer({
|
||||
|
||||
const what =
|
||||
next.practitioner?.name ?? encounterKindText(t, next.kind as EncounterKind);
|
||||
const windowText = next.preparation
|
||||
? preparationText(t, format.dateShortSmartCalendar, next.preparation)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -78,24 +102,38 @@ export function ReportVisitOffer({
|
||||
a phone the sentence takes the full row and the two buttons wrap
|
||||
below it; with a zero basis it was squeezed into a one-word column
|
||||
beside them. */}
|
||||
<p className="text-foreground flex min-w-0 flex-1 basis-full items-center gap-2 text-sm sm:basis-0">
|
||||
<CalendarClock className="size-4 shrink-0" aria-hidden />
|
||||
<span>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</span>
|
||||
</p>
|
||||
<div className="text-foreground flex min-w-0 flex-1 basis-full items-start gap-2 text-sm sm:basis-0">
|
||||
<CalendarClock className="mt-0.5 size-4 shrink-0" aria-hidden />
|
||||
<div className="min-w-0 space-y-0.5">
|
||||
<p>
|
||||
{t("settings.healthRecord.visitOffer", {
|
||||
what,
|
||||
when: format.date(next.occurredAt),
|
||||
})}
|
||||
</p>
|
||||
{/* Where the report would start, and why there: meta, so muted. */}
|
||||
{windowText ? (
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="report-visit-offer-window"
|
||||
>
|
||||
{windowText.title}
|
||||
</p>
|
||||
) : null}
|
||||
{windowText?.reason ? (
|
||||
<p className="text-muted-foreground text-xs">{windowText.reason}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto min-h-11 sm:ml-0 sm:min-h-9"
|
||||
data-slot="report-visit-offer-apply"
|
||||
onClick={() => onUseVisitDate(localDay(next.occurredAt))}
|
||||
onClick={() => onUseVisitWindow(visitReportWindow(next))}
|
||||
>
|
||||
{t("settings.healthRecord.visitOfferApply")}
|
||||
{t("settings.healthRecord.visitOfferApplyWindow")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -322,11 +322,12 @@ export function HealthRecordExportPanel() {
|
||||
</div>
|
||||
|
||||
{/* Never auto-applies: pressing it is what opens the custom window and
|
||||
sets its end date. */}
|
||||
sets both its dates, which stay editable. */}
|
||||
<ReportVisitOffer
|
||||
onUseVisitDate={(endDay) => {
|
||||
onUseVisitWindow={({ from, to }) => {
|
||||
setCustomRange(true);
|
||||
setEndDate(endDay);
|
||||
if (from) setStartDate(from);
|
||||
setEndDate(to);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1506,6 +1506,60 @@ describe("buildDailyDigest — due check-ups and today's visits hold their place
|
||||
.map((i) => i.body),
|
||||
).toEqual(["Dr. Weiss today", "Dentist tomorrow"]);
|
||||
});
|
||||
|
||||
// The tap opens where the appointment lives: the bare `/checkups` it carried
|
||||
// opens the preventive-care tab an appointment is never on. Label and intent
|
||||
// stay the check-up item's on the wire, for the shipped iOS app; the web
|
||||
// card relabels (`priority-card.test.tsx`).
|
||||
it("opens the visit itself, keeping the intent the app routes", () => {
|
||||
const d = buildDailyDigest(input({ upcomingVisits: [visit()] }), t);
|
||||
const item = d.worthALook.find((i) => i.kind === "upcoming_visit");
|
||||
expect(item?.actions).toEqual([
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups?visit=v1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("opens the visits list for a day with several appointments", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
upcomingVisits: [
|
||||
visit(),
|
||||
visit({ id: "v2", practitionerName: "Dentist" }),
|
||||
],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const item = d.worthALook.find((i) => i.kind === "upcoming_visit");
|
||||
expect(item?.actions).toEqual([
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups?view=visits",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves the due check-ups item on the checkups list", () => {
|
||||
const d = buildDailyDigest(
|
||||
input({
|
||||
preventiveDue: [{ label: "Skin check", staysDue: true }],
|
||||
upcomingVisits: [visit()],
|
||||
}),
|
||||
t,
|
||||
);
|
||||
const care = d.worthALook.find((i) => i.kind === "preventive_care");
|
||||
expect(care?.actions).toEqual([
|
||||
{
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDailyDigest — Today overview", () => {
|
||||
|
||||
@@ -577,6 +577,8 @@ describe("appointments", () => {
|
||||
t,
|
||||
);
|
||||
expect(o.today[0].value).toBe("Today 16:15, GP");
|
||||
// The visit itself, never the bare checkups page.
|
||||
expect(o.today[0].href).toBe("/checkups?visit=a");
|
||||
});
|
||||
|
||||
it("says nothing about the day after tomorrow", () => {
|
||||
@@ -600,6 +602,8 @@ describe("appointments", () => {
|
||||
t,
|
||||
);
|
||||
expect(o.today[0].value).toBe("Today 09:00, GP +1");
|
||||
// A line naming two appointments opens the list both are on.
|
||||
expect(o.today[0].href).toBe("/checkups?view=visits");
|
||||
});
|
||||
|
||||
it("does not repeat a visit the rail already shows", () => {
|
||||
|
||||
+14
-2
@@ -24,6 +24,7 @@ import type { DailyBriefing, DailyBriefingSignal } from "@/lib/ai/schema";
|
||||
import type { AiCapabilityState } from "@/lib/ai/capabilities/types";
|
||||
import type { ArrivalKind } from "@/lib/arrivals/types";
|
||||
import { encounterKindLabelKey } from "@/lib/encounters/kind-label";
|
||||
import { visitsPath } from "@/lib/encounters/visit-links";
|
||||
import type { MedsTodayBlock } from "@/lib/dashboard/meds-today";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { ServerTranslator } from "@/lib/i18n/server-translator";
|
||||
@@ -675,9 +676,14 @@ function buildPreventiveCareItem(
|
||||
* appointments alone.
|
||||
*
|
||||
* No module gate: a visit is core, like the checkups page it lives on. No
|
||||
* action beyond opening the page either — there is nothing to do about an
|
||||
* action beyond opening the visit either — there is nothing to do about an
|
||||
* appointment except keep it, and a "mark done" here would duplicate the
|
||||
* visit's own status field with a second place to set it.
|
||||
*
|
||||
* The action opens where the appointment lives: the visit itself, or the
|
||||
* visits list for a day with several. The bare `/checkups` it used to carry
|
||||
* sent every tap on an appointment to the preventive-care list the
|
||||
* appointment is not on.
|
||||
*/
|
||||
function buildUpcomingVisitItems(
|
||||
visits: readonly DailyDigestUpcomingVisit[],
|
||||
@@ -706,9 +712,15 @@ function buildUpcomingVisitItems(
|
||||
status: "info",
|
||||
actions: [
|
||||
{
|
||||
// The wire keeps the check-up item's label and intent: the shipped
|
||||
// iOS app routes `checkup.view` and knows no other token for this
|
||||
// item, so a new intent would leave its tap inert. The web card
|
||||
// relabels it from the kind (`actionLabelKey` in
|
||||
// `priority-card.tsx`); the switch to `visit.view` waits for an
|
||||
// app that understands it.
|
||||
labelKey: "daily.action.viewCheckups",
|
||||
intent: "checkup.view",
|
||||
href: "/checkups",
|
||||
href: visitsPath(group.map((visit) => visit.id)),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -28,6 +28,7 @@ import { isFromToday } from "@/lib/insights/measurement-freshness";
|
||||
import type { ModuleKey } from "@/lib/modules/registry";
|
||||
import type { PriorityItem } from "@/lib/daily/priority-item";
|
||||
import { translateOrNull } from "@/lib/i18n/translate-code";
|
||||
import { visitsPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
type Translate = (
|
||||
key: string,
|
||||
@@ -681,7 +682,8 @@ function appointmentFact(
|
||||
: "daily.todayFact.appointment.tomorrow",
|
||||
{ time: visit.timeLabel, what },
|
||||
),
|
||||
href: "/checkups",
|
||||
// The visit itself, or the visits list when the line counts several.
|
||||
href: visitsPath(sameDay.map((v) => v.id)),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { resolveCycleDay } from "@/lib/cycle/verdict";
|
||||
import { effectiveMoodTz } from "@/lib/mood/date-key";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
/** Everything a section reader needs about the day. */
|
||||
export interface DayFrame {
|
||||
@@ -630,7 +631,7 @@ async function visitsPart(frame: DayFrame): Promise<SectionPart> {
|
||||
meta: row.status === "PLANNED" ? "PLANNED" : row.kind,
|
||||
note: openText(row.outcomeEncrypted, "visit outcome"),
|
||||
docs: docs.get(row.id) ?? [],
|
||||
href: `/checkups?visit=${row.id}`,
|
||||
href: visitPath(row.id),
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -26,6 +26,7 @@ import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||||
import { dateOnlyKey, dayKeyAsUtcMidnight } from "@/lib/tz/date-only";
|
||||
import { daysBetweenDateKeys, shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
import { startOfLocalDayKey } from "@/lib/tz/local-day";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
/** The window when the record holds no visit the caller may see. */
|
||||
export const FALLBACK_WINDOW_DAYS = 90;
|
||||
@@ -275,7 +276,7 @@ async function procedureChanges(w: Window): Promise<DayChange[]> {
|
||||
kind: "procedure",
|
||||
id: row.id,
|
||||
title: openText(row.reasonEncrypted, "procedure reason") ?? "",
|
||||
href: `/checkups?visit=${row.id}`,
|
||||
href: visitPath(row.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
appointmentNextDueAfterFiring,
|
||||
appointmentNotifyHour,
|
||||
appointmentReminderAddress,
|
||||
appointmentReminderVisitPath,
|
||||
} from "@/lib/encounters/appointment-reminder";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
@@ -116,3 +117,31 @@ describe("the address an appointment reminder names", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("where an appointment's push opens", () => {
|
||||
const appointment = (visitUserId: string) => ({
|
||||
userId: "u1",
|
||||
origin: "ENCOUNTER",
|
||||
encounters: [{ id: "visit-1", userId: visitUserId }],
|
||||
});
|
||||
|
||||
it("opens the visit, on the visits tab", () => {
|
||||
expect(appointmentReminderVisitPath(appointment("u1"))).toBe(
|
||||
"/checkups?visit=visit-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("counter-check: a checkup keeps the event's default, the checkups list", () => {
|
||||
expect(
|
||||
appointmentReminderVisitPath({
|
||||
...appointment("u1"),
|
||||
origin: "VORSORGE",
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
appointmentReminderVisitPath({ ...appointment("u1"), encounters: [] }),
|
||||
).toBeNull();
|
||||
// Another account's visit is never a tap target.
|
||||
expect(appointmentReminderVisitPath(appointment("u2"))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import {
|
||||
PREPARATION_DEFAULT_DAYS,
|
||||
resolvePreparation,
|
||||
specialtyKey,
|
||||
type PreparationVisit,
|
||||
} from "../preparation";
|
||||
|
||||
const TZ = "Europe/Berlin";
|
||||
const TODAY = "2026-10-21";
|
||||
|
||||
function visit(
|
||||
id: string,
|
||||
occurredAt: string,
|
||||
over: {
|
||||
kind?: EncounterKind;
|
||||
practitionerId?: string | null;
|
||||
name?: string;
|
||||
specialty?: string | null;
|
||||
} = {},
|
||||
): PreparationVisit {
|
||||
const practitionerId =
|
||||
over.practitionerId === undefined ? null : over.practitionerId;
|
||||
return {
|
||||
id,
|
||||
occurredAt: new Date(occurredAt),
|
||||
kind: over.kind ?? "OTHER",
|
||||
practitionerId,
|
||||
practitioner: practitionerId
|
||||
? { name: over.name ?? practitionerId, specialty: over.specialty ?? null }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const internist = (id: string, at: string) =>
|
||||
visit(id, at, {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-int",
|
||||
name: "Internal practice",
|
||||
specialty: "Internal medicine",
|
||||
});
|
||||
|
||||
// The report from the issue: an internist a week ago, a dermatologist next.
|
||||
const next = visit("next", "2026-10-23T08:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm",
|
||||
name: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
});
|
||||
|
||||
function resolve(target: PreparationVisit, done: PreparationVisit[]) {
|
||||
return resolvePreparation({ next: target, done, tz: TZ, today: TODAY });
|
||||
}
|
||||
|
||||
describe("the window before a booked visit", () => {
|
||||
it("starts at the last visit with the same practitioner", () => {
|
||||
const prep = resolve(next, [
|
||||
internist("int", "2026-10-16T09:00:00.000Z"),
|
||||
visit("derm", "2026-03-02T09:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm",
|
||||
name: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
}),
|
||||
]);
|
||||
expect(prep).toEqual({
|
||||
from: "2026-03-02",
|
||||
basis: "practitioner",
|
||||
since: {
|
||||
id: "derm",
|
||||
occurredAt: "2026-03-02T09:00:00.000Z",
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("else at the last visit in the same specialty, compared without case", () => {
|
||||
const prep = resolve(next, [
|
||||
internist("int", "2026-10-16T09:00:00.000Z"),
|
||||
visit("other-derm", "2025-11-20T09:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm-old",
|
||||
name: "Former skin practice",
|
||||
specialty: " dermatology ",
|
||||
}),
|
||||
]);
|
||||
expect(prep.basis).toBe("specialty");
|
||||
expect(prep.from).toBe("2025-11-20");
|
||||
expect(prep.since?.id).toBe("other-derm");
|
||||
expect(prep.since?.specialty).toBe("dermatology");
|
||||
});
|
||||
|
||||
it("never falls back to the last visit of any kind", () => {
|
||||
// The issue: one week of internist data offered for the dermatologist.
|
||||
const prep = resolve(next, [internist("int", "2026-10-16T09:00:00.000Z")]);
|
||||
expect(prep).toEqual({
|
||||
from: "2026-07-23",
|
||||
basis: "default",
|
||||
since: null,
|
||||
});
|
||||
expect(PREPARATION_DEFAULT_DAYS).toBe(90);
|
||||
});
|
||||
|
||||
it("matches on kind only for a kind that names one line of care", () => {
|
||||
const therapy = visit("next-t", "2026-10-25T08:00:00.000Z", {
|
||||
kind: "THERAPY",
|
||||
});
|
||||
const done = [
|
||||
visit("acute", "2026-10-18T09:00:00.000Z", { kind: "ACUTE" }),
|
||||
visit("t1", "2026-10-11T09:00:00.000Z", { kind: "THERAPY" }),
|
||||
];
|
||||
const prep = resolve(therapy, done);
|
||||
expect(prep.basis).toBe("kind");
|
||||
expect(prep.from).toBe("2026-10-11");
|
||||
|
||||
// A specialist visit with no specialty says nothing about which one.
|
||||
const specialist = visit("next-s", "2026-10-25T08:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
});
|
||||
expect(
|
||||
resolve(specialist, [
|
||||
visit("s1", "2026-10-11T09:00:00.000Z", { kind: "SPECIALIST" }),
|
||||
]).basis,
|
||||
).toBe("default");
|
||||
// Neither does "other", the kind nobody picked.
|
||||
const other = visit("next-o", "2026-10-25T08:00:00.000Z");
|
||||
expect(
|
||||
resolve(other, [visit("o1", "2026-10-11T09:00:00.000Z")]).basis,
|
||||
).toBe("default");
|
||||
});
|
||||
|
||||
it("prefers the practitioner over a newer visit in the same specialty", () => {
|
||||
const prep = resolve(next, [
|
||||
visit("derm-b", "2026-09-01T09:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm-b",
|
||||
specialty: "Dermatology",
|
||||
}),
|
||||
visit("derm-a", "2026-01-15T09:00:00.000Z", {
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: "p-derm",
|
||||
specialty: "Dermatology",
|
||||
}),
|
||||
]);
|
||||
expect(prep.basis).toBe("practitioner");
|
||||
expect(prep.since?.id).toBe("derm-a");
|
||||
});
|
||||
|
||||
it("only counts visits before the booked one", () => {
|
||||
const later = visit("later", "2026-10-24T09:00:00.000Z", {
|
||||
practitionerId: "p-derm",
|
||||
});
|
||||
expect(resolve(next, [later]).basis).toBe("default");
|
||||
});
|
||||
|
||||
it("starts on the matched visit's own day in the record's zone", () => {
|
||||
// 23:30 UTC is the next calendar day in Berlin.
|
||||
const prep = resolve(next, [
|
||||
visit("late", "2026-05-04T23:30:00.000Z", { practitionerId: "p-derm" }),
|
||||
]);
|
||||
expect(prep.from).toBe("2026-05-05");
|
||||
});
|
||||
});
|
||||
|
||||
describe("specialty comparison", () => {
|
||||
it("folds case and spacing, and nothing else", () => {
|
||||
expect(specialtyKey(" Internal Medicine ")).toBe("internal medicine");
|
||||
expect(specialtyKey("")).toBeNull();
|
||||
expect(specialtyKey(" ")).toBeNull();
|
||||
expect(specialtyKey(null)).toBeNull();
|
||||
expect(specialtyKey("Dermatology")).not.toBe(specialtyKey("Skin doctor"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
CHECKUPS_VIEWS,
|
||||
VISITS_PATH,
|
||||
parseCheckupsView,
|
||||
visitPath,
|
||||
visitsPath,
|
||||
} from "../visit-links";
|
||||
|
||||
describe("visit links", () => {
|
||||
it("opens one visit on the visits tab, never the bare checkups page", () => {
|
||||
expect(visitPath("v1")).toBe("/checkups?visit=v1");
|
||||
expect(visitPath("a b&c")).toBe("/checkups?visit=a%20b%26c");
|
||||
});
|
||||
|
||||
it("opens the visit itself for one, the visits list for several", () => {
|
||||
expect(visitsPath(["v1"])).toBe("/checkups?visit=v1");
|
||||
expect(visitsPath(["v1", "v2"])).toBe(VISITS_PATH);
|
||||
expect(visitsPath([])).toBe(VISITS_PATH);
|
||||
expect(VISITS_PATH).toBe("/checkups?view=visits");
|
||||
});
|
||||
|
||||
it("reads exactly the checkups page's tabs from `view`", () => {
|
||||
for (const view of CHECKUPS_VIEWS) {
|
||||
expect(parseCheckupsView(view)).toBe(view);
|
||||
}
|
||||
expect(parseCheckupsView("visit")).toBeNull();
|
||||
expect(parseCheckupsView("")).toBeNull();
|
||||
expect(parseCheckupsView(null)).toBeNull();
|
||||
expect(parseCheckupsView(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -39,6 +39,7 @@
|
||||
* edit of the address reaches a nudge that is already booked.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
import { readPractitionerContact } from "@/lib/practitioners/dto";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
@@ -188,13 +189,15 @@ export function appointmentNextDueAfterFiring(
|
||||
}
|
||||
|
||||
/**
|
||||
* What the reminder tick reads beside an ENCOUNTER row to name the practice
|
||||
* address: the practitioner of the live visit that owns it.
|
||||
* What the reminder tick reads beside an ENCOUNTER row: the live visit that
|
||||
* owns it, for the push's tap target ({@link appointmentReminderVisitPath}),
|
||||
* and its practitioner, to name the practice address.
|
||||
*/
|
||||
export const APPOINTMENT_REMINDER_ADDRESS_INCLUDE = {
|
||||
encounters: {
|
||||
where: { deletedAt: null },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
practitioner: {
|
||||
select: {
|
||||
@@ -245,3 +248,22 @@ export function appointmentReminderAddress(reminder: {
|
||||
"location",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an appointment's push opens: the visit itself.
|
||||
*
|
||||
* The nudge rides the checkup event type, whose default target is the bare
|
||||
* `/checkups` — the preventive-care tab, which never lists an appointment. A
|
||||
* checkup's push keeps that default (null here); an appointment's names its
|
||||
* visit, through the producer override `resolveTapPath` already honours.
|
||||
*/
|
||||
export function appointmentReminderVisitPath(reminder: {
|
||||
userId: string;
|
||||
origin: string;
|
||||
encounters?: ReadonlyArray<{ id: string; userId: string }>;
|
||||
}): string | null {
|
||||
if (reminder.origin !== "ENCOUNTER") return null;
|
||||
const visit = reminder.encounters?.[0];
|
||||
if (!visit || visit.userId !== reminder.userId) return null;
|
||||
return visitPath(visit.id);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import type {
|
||||
MeasurementReminder,
|
||||
Practitioner,
|
||||
} from "@/generated/prisma/client";
|
||||
import type { EncounterPreparationDTO } from "@/lib/encounters/preparation";
|
||||
import type { RestoreSkipSummary } from "@/lib/export/restore-skips";
|
||||
import {
|
||||
toPractitionerDTO,
|
||||
@@ -93,6 +94,14 @@ export interface EncounterDTO {
|
||||
* at it again.
|
||||
*/
|
||||
skipped?: RestoreSkipSummary;
|
||||
/**
|
||||
* The window before a booked visit: the day the visit preparation and the
|
||||
* doctor report offered for it start, the rule that chose it, and the
|
||||
* earlier visit it is the day of (`src/lib/encounters/preparation.ts`).
|
||||
*
|
||||
* On the list read. Null on every row but a booked upcoming one.
|
||||
*/
|
||||
preparation?: EncounterPreparationDTO | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* The read behind `EncounterDTO.preparation`: one query of the record's past
|
||||
* visits for every booked visit on the page, resolved by the rule in
|
||||
* `preparation.ts`.
|
||||
*/
|
||||
import type { EncounterStatus, PrismaClient } from "@/generated/prisma/client";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
import {
|
||||
resolvePreparation,
|
||||
type EncounterPreparationDTO,
|
||||
type PreparationVisit,
|
||||
} from "./preparation";
|
||||
|
||||
/**
|
||||
* How many past visits the rule looks through, newest first. Far more than a
|
||||
* person files in years; a match older than the cap falls to the next rule,
|
||||
* which on an account this busy is never the default.
|
||||
*/
|
||||
export const PREPARATION_HISTORY_CAP = 500;
|
||||
|
||||
type Booked = PreparationVisit & { status: EncounterStatus };
|
||||
|
||||
/** The window before each booked visit in `upcoming`, by visit id. */
|
||||
export async function loadVisitPreparations(
|
||||
db: Pick<PrismaClient, "encounter">,
|
||||
recordId: string,
|
||||
upcoming: readonly Booked[],
|
||||
now: Date,
|
||||
): Promise<Map<string, EncounterPreparationDTO>> {
|
||||
const booked = upcoming.filter((visit) => visit.status === "PLANNED");
|
||||
if (booked.length === 0) return new Map();
|
||||
const [tz, done] = await Promise.all([
|
||||
resolveUserTimezone(recordId),
|
||||
db.encounter.findMany({
|
||||
where: {
|
||||
userId: recordId,
|
||||
deletedAt: null,
|
||||
status: "DONE",
|
||||
occurredAt: { lte: now },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
occurredAt: true,
|
||||
kind: true,
|
||||
practitionerId: true,
|
||||
practitioner: { select: { name: true, specialty: true } },
|
||||
},
|
||||
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
|
||||
take: PREPARATION_HISTORY_CAP,
|
||||
}),
|
||||
]);
|
||||
const today = userDayKey(now, tz);
|
||||
return new Map(
|
||||
booked.map((next) => [
|
||||
next.id,
|
||||
resolvePreparation({ next, done, tz, today }),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Where the window before a booked visit starts, and why.
|
||||
*
|
||||
* The visit preparation ("what happened since the last visit") and the doctor
|
||||
* report offered for an appointment both need a start date. It used to be the
|
||||
* last visit of any kind: a person seeing an internist on one day and a
|
||||
* dermatologist a week later was offered a one-week report for the
|
||||
* dermatologist, covering nothing that visit is about.
|
||||
*
|
||||
* The rule, first match wins, over the visits that took place (DONE) before
|
||||
* the booked one:
|
||||
*
|
||||
* 1. practitioner — the last visit with the same practitioner;
|
||||
* 2. specialty — else the last visit with a practitioner of the same
|
||||
* specialty (the free-text field, compared without case
|
||||
* or extra spaces, so "Dermatology" and "dermatology "
|
||||
* match and "Dermatology" and "Skin doctor" do not);
|
||||
* 3. kind — else the last visit of the same kind, for the kinds that
|
||||
* name one line of care (routine, preventive, therapy);
|
||||
* 4. default — else the last {@link PREPARATION_DEFAULT_DAYS} days.
|
||||
*
|
||||
* The other kinds never match by kind alone. A specialist visit says nothing
|
||||
* about which specialty (that is what rule 2 is for), "other" is the default
|
||||
* nobody picked, and an acute visit, an emergency, a hospital stay or a
|
||||
* procedure is an event rather than a recurring line of care, so the last one
|
||||
* is no better a start than the default.
|
||||
*
|
||||
* The window starts on the matched visit's own day: a dose changed at that
|
||||
* visit belongs to what the next one should know.
|
||||
*
|
||||
* Resolved on the server, published on the visit list, never recomputed by a
|
||||
* client. A person can still change both dates on the report.
|
||||
*
|
||||
* Pure and client-safe; the read lives in `preparation-read.ts`.
|
||||
*/
|
||||
import type { EncounterKind } from "@/generated/prisma/client";
|
||||
import { shiftDateKey, userDayKey } from "@/lib/tz/format";
|
||||
|
||||
/** The window when no earlier visit matches the booked one. */
|
||||
export const PREPARATION_DEFAULT_DAYS = 90;
|
||||
|
||||
/** The kinds a visit is matched on when neither practitioner nor specialty is. */
|
||||
export const PREPARATION_KIND_MATCHES = [
|
||||
"ROUTINE",
|
||||
"PREVENTIVE",
|
||||
"THERAPY",
|
||||
] as const satisfies readonly EncounterKind[];
|
||||
|
||||
export const PREPARATION_BASES = [
|
||||
"practitioner",
|
||||
"specialty",
|
||||
"kind",
|
||||
"default",
|
||||
] as const;
|
||||
|
||||
export type PreparationBasis = (typeof PREPARATION_BASES)[number];
|
||||
|
||||
/** The earlier visit the window starts at. */
|
||||
export interface PreparationAnchorDTO {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
kind: EncounterKind;
|
||||
practitionerName: string | null;
|
||||
specialty: string | null;
|
||||
}
|
||||
|
||||
/** The window before one booked visit, resolved. */
|
||||
export interface EncounterPreparationDTO {
|
||||
/** First day of the window, `YYYY-MM-DD` on the record's own calendar. */
|
||||
from: string;
|
||||
/** Which rule chose `from`. */
|
||||
basis: PreparationBasis;
|
||||
/** The visit `from` is the day of; null on `default`. */
|
||||
since: PreparationAnchorDTO | null;
|
||||
}
|
||||
|
||||
/** The fields the rule reads, off a booked visit or an earlier one. */
|
||||
export interface PreparationVisit {
|
||||
id: string;
|
||||
occurredAt: Date;
|
||||
kind: EncounterKind;
|
||||
practitionerId: string | null;
|
||||
practitioner: { name: string; specialty: string | null } | null;
|
||||
}
|
||||
|
||||
/** A specialty as compared: no case, no outer or doubled spaces. */
|
||||
export function specialtyKey(value: string | null | undefined): string | null {
|
||||
const key = value
|
||||
?.normalize("NFKC")
|
||||
.trim()
|
||||
.replace(/\s+/g, " ")
|
||||
.toLocaleLowerCase();
|
||||
return key ? key : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The window before `next`.
|
||||
*
|
||||
* `done` is the record's visits that took place, newest first. Only those
|
||||
* before `next` are considered; `today` (the record's local day) anchors the
|
||||
* default window.
|
||||
*/
|
||||
export function resolvePreparation(args: {
|
||||
next: PreparationVisit;
|
||||
done: readonly PreparationVisit[];
|
||||
tz: string;
|
||||
today: string;
|
||||
}): EncounterPreparationDTO {
|
||||
const { next, tz, today } = args;
|
||||
const earlier = args.done.filter(
|
||||
(visit) =>
|
||||
visit.id !== next.id &&
|
||||
visit.occurredAt.getTime() < next.occurredAt.getTime(),
|
||||
);
|
||||
const specialty = specialtyKey(next.practitioner?.specialty);
|
||||
const kindMatches = (
|
||||
PREPARATION_KIND_MATCHES as readonly EncounterKind[]
|
||||
).includes(next.kind);
|
||||
|
||||
const rules: Array<[PreparationBasis, (visit: PreparationVisit) => boolean]> =
|
||||
[
|
||||
[
|
||||
"practitioner",
|
||||
(visit) =>
|
||||
next.practitionerId !== null &&
|
||||
visit.practitionerId === next.practitionerId,
|
||||
],
|
||||
[
|
||||
"specialty",
|
||||
(visit) =>
|
||||
specialty !== null &&
|
||||
specialtyKey(visit.practitioner?.specialty) === specialty,
|
||||
],
|
||||
["kind", (visit) => kindMatches && visit.kind === next.kind],
|
||||
];
|
||||
|
||||
for (const [basis, matches] of rules) {
|
||||
const hit = earlier.find(matches);
|
||||
if (!hit) continue;
|
||||
return {
|
||||
from: userDayKey(hit.occurredAt, tz),
|
||||
basis,
|
||||
since: {
|
||||
id: hit.id,
|
||||
occurredAt: hit.occurredAt.toISOString(),
|
||||
kind: hit.kind,
|
||||
practitionerName: hit.practitioner?.name ?? null,
|
||||
specialty: hit.practitioner?.specialty?.trim() || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
from: shiftDateKey(today, -PREPARATION_DEFAULT_DAYS),
|
||||
basis: "default",
|
||||
since: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Where a visit lives in the app, spelled once.
|
||||
*
|
||||
* Visits have no page of their own: they are the second tab of `/checkups`,
|
||||
* beside the preventive-care list that opens by default. A bare `/checkups`
|
||||
* therefore lands on the checkups, which is the wrong half of the page for an
|
||||
* appointment, and every surface that hand-spelled that path for a visit (the
|
||||
* dashboard's next-appointment line, the Today rail, the Today overview, the
|
||||
* appointment's push) sent the person to the list the appointment is not on.
|
||||
*
|
||||
* Every link to one visit or to the visits list is built here. The checkups
|
||||
* page reads both parameters: `visit` opens that visit on the visits tab
|
||||
* (`src/hooks/use-deep-linked-record.ts`), `view` picks a tab.
|
||||
* `visit-link-guard.test.ts` refuses a hand-spelled copy elsewhere.
|
||||
*
|
||||
* Pure and client-safe: no server imports.
|
||||
*/
|
||||
|
||||
/** The tabs of the checkups page, as the `view` parameter names them. */
|
||||
export const CHECKUPS_VIEWS = [
|
||||
"vorsorge",
|
||||
"visits",
|
||||
"procedures",
|
||||
"body-sites",
|
||||
] as const;
|
||||
|
||||
export type CheckupsView = (typeof CHECKUPS_VIEWS)[number];
|
||||
|
||||
/** The tab a `view` parameter names, or null for anything else. */
|
||||
export function parseCheckupsView(
|
||||
value: string | null | undefined,
|
||||
): CheckupsView | null {
|
||||
return (CHECKUPS_VIEWS as readonly string[]).includes(value ?? "")
|
||||
? (value as CheckupsView)
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The visits list: the checkups page opened on its visits tab. */
|
||||
export const VISITS_PATH = "/checkups?view=visits";
|
||||
|
||||
/** One visit: the visits tab, scrolled to the visit and opened. */
|
||||
export function visitPath(id: string): string {
|
||||
return `/checkups?visit=${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a group of visits leads: the visit itself when there is one, the
|
||||
* visits list when several share one line (a day with two appointments).
|
||||
*/
|
||||
export function visitsPath(ids: readonly string[]): string {
|
||||
return ids.length === 1 ? visitPath(ids[0]) : VISITS_PATH;
|
||||
}
|
||||
@@ -141,6 +141,8 @@ interface FakeReminder {
|
||||
lastSatisfiedAt: Date | null;
|
||||
/** v1.39.2 — the last delivered nudge; absent on most fixtures. */
|
||||
lastNotifiedAt?: Date | null;
|
||||
/** The visit an appointment's row belongs to; absent on a checkup. */
|
||||
encounters?: Array<{ id: string; userId: string; practitioner: null }>;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
user: {
|
||||
@@ -355,6 +357,30 @@ describe("runMeasurementReminderTick", () => {
|
||||
expect(payload.metadata?.reminderId).toBe("appt-1");
|
||||
});
|
||||
|
||||
it("opens the appointment's visit, not the checkups list", async () => {
|
||||
// The event's default target is the bare `/checkups`, whose first tab is
|
||||
// the preventive-care list an appointment is never on.
|
||||
const { prisma } = makePrisma({
|
||||
reminders: [
|
||||
reminder({
|
||||
origin: "ENCOUNTER",
|
||||
measurementType: null,
|
||||
id: "appt-1",
|
||||
encounters: [{ id: "visit-9", userId: "u1", practitioner: null }],
|
||||
}),
|
||||
],
|
||||
measurementMatch: null,
|
||||
labMatch: null,
|
||||
});
|
||||
const dispatch = vi.fn<DispatchFn>(async () => OK);
|
||||
|
||||
await runMeasurementReminderTick(prisma as never, NINE_LOCAL, { dispatch });
|
||||
|
||||
expect(dispatch.mock.calls[0][0].metadata?.url).toBe(
|
||||
"/checkups?visit=visit-9",
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves a checkup nudge actionable", async () => {
|
||||
// The negative control. Without it the assertion above would still pass
|
||||
// against a producer that suppressed every reminder's affordance.
|
||||
@@ -368,6 +394,8 @@ describe("runMeasurementReminderTick", () => {
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
expect(dispatch.mock.calls[0][0].suppressActions).toBeUndefined();
|
||||
// And keeps the event's default target, the checkups list.
|
||||
expect(dispatch.mock.calls[0][0].metadata?.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it("free-text reminder never queries Measurement (matches on LabResult instead)", async () => {
|
||||
|
||||
@@ -63,6 +63,7 @@ import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||||
import {
|
||||
APPOINTMENT_REMINDER_ADDRESS_INCLUDE,
|
||||
appointmentReminderAddress,
|
||||
appointmentReminderVisitPath,
|
||||
} from "@/lib/encounters/appointment-reminder";
|
||||
|
||||
/**
|
||||
@@ -479,6 +480,7 @@ export async function runMeasurementReminderTick(
|
||||
// An appointment's address lives encrypted on its practitioner and is
|
||||
// resolved here; a checkup's is its own free-text column.
|
||||
const address = appointmentReminderAddress(reminder);
|
||||
const visitUrl = appointmentReminderVisitPath(reminder);
|
||||
const { title, body } = buildMeasurementReminderPayload(
|
||||
await resolveJobLocale(reminder.user.locale),
|
||||
reminder.label,
|
||||
@@ -510,6 +512,9 @@ export async function runMeasurementReminderTick(
|
||||
metadata: {
|
||||
scheduledAt: now.toISOString(),
|
||||
reminderId: reminder.id,
|
||||
// An appointment opens its visit; a checkup keeps the event's
|
||||
// default, the checkups list.
|
||||
...(visitUrl ? { url: visitUrl } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import type {
|
||||
RELATED_KINDS,
|
||||
RELATED_VIA,
|
||||
} from "@/lib/validations/relation-links";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
export type RelatedKind = (typeof RELATED_KINDS)[number];
|
||||
export type RelatedVia = (typeof RELATED_VIA)[number];
|
||||
@@ -191,7 +192,7 @@ async function hydrate(
|
||||
row.practitioner?.name ??
|
||||
row.kind,
|
||||
date: iso(row.occurredAt),
|
||||
href: `/checkups?visit=${encodeURIComponent(row.id)}`,
|
||||
href: visitPath(row.id),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -73,6 +73,7 @@ import {
|
||||
queryWords,
|
||||
type SearchCandidate,
|
||||
} from "@/lib/search/match";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
// ── Bounds ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -227,7 +228,7 @@ export function recordUrl(
|
||||
const rid = encodeURIComponent(id);
|
||||
switch (kind) {
|
||||
case "visit":
|
||||
return `${origin}/checkups?visit=${rid}`;
|
||||
return `${origin}${visitPath(id)}`;
|
||||
case "condition":
|
||||
return `${origin}/illness/${rid}`;
|
||||
case "document":
|
||||
|
||||
@@ -225,9 +225,11 @@ describe("the visit row (v1.39 C2)", () => {
|
||||
inputs({ onboarding: onboardingState({}, { visit: "within-a-month" }) }),
|
||||
);
|
||||
expect(asked.map((i) => i.id)).toContain("visit");
|
||||
// The visits tab: the bare page opens on the checkups, where a visit is
|
||||
// neither listed nor added.
|
||||
expect(asked.find((i) => i.id === "visit")).toMatchObject({
|
||||
done: false,
|
||||
href: "/checkups",
|
||||
href: "/checkups?view=visits",
|
||||
});
|
||||
const later = buildChecklist(
|
||||
inputs({ onboarding: onboardingState({}, { visit: "later" }) }),
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
* status + dismissed-ids set) and renders against the result.
|
||||
*/
|
||||
|
||||
import { VISITS_PATH } from "@/lib/encounters/visit-links";
|
||||
|
||||
import {
|
||||
hasEnteredOnboardingFlow,
|
||||
isOnboardingSettled,
|
||||
@@ -227,7 +229,8 @@ export function buildChecklist(inputs: ChecklistInputs): ChecklistItem[] {
|
||||
items.push({
|
||||
id: "visit",
|
||||
done: inputs.upcomingVisitCount >= 1,
|
||||
href: "/checkups",
|
||||
// The visits tab, where a visit is added; the bare page opens checkups.
|
||||
href: VISITS_PATH,
|
||||
dismissed: inputs.dismissedIds.has("visit"),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
practitionerListQuerySchema,
|
||||
} from "@/lib/validations/practitioners";
|
||||
import { bodySiteQuerySchema } from "@/lib/validations/body-sites";
|
||||
import { PREPARATION_BASES } from "@/lib/encounters/preparation";
|
||||
import {
|
||||
illnessLifecycleEnum,
|
||||
illnessTypeEnum,
|
||||
@@ -169,6 +170,40 @@ const skipReport = z
|
||||
"What the write could not do, named. `links: 0` with an empty list is the ordinary answer. Anything else is a side effect the caller's grant did not reach — today only `checkupClosure`, the closing of a preventive-care 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.",
|
||||
});
|
||||
|
||||
const preparationAnchor = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
occurredAt: z.string(),
|
||||
kind: encounterKindEnum,
|
||||
practitionerName: z.string().nullable(),
|
||||
specialty: z.string().nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterPreparationAnchor",
|
||||
description:
|
||||
"The earlier visit the window starts at: its id, instant and kind, and the practitioner's name and specialty as they read now.",
|
||||
});
|
||||
|
||||
const encounterPreparation = z
|
||||
.object({
|
||||
from: z
|
||||
.string()
|
||||
.describe(
|
||||
"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: z
|
||||
.enum(PREPARATION_BASES)
|
||||
.describe(
|
||||
"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: preparationAnchor.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "EncounterPreparation",
|
||||
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.",
|
||||
});
|
||||
|
||||
const encounter = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -183,6 +218,12 @@ const encounter = z
|
||||
reminderNextDueAt: z.string().nullable(),
|
||||
links: encounterLinks.optional(),
|
||||
skipped: skipReport.optional(),
|
||||
preparation: encounterPreparation
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"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.",
|
||||
),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ import { surfaceModule } from "@/lib/modules/surface";
|
||||
import type { ShareDomain } from "@/lib/sharing/scope";
|
||||
import { dateOnlyKey } from "@/lib/tz/date-only";
|
||||
import { userDayKey } from "@/lib/tz/format";
|
||||
import { visitPath } from "@/lib/encounters/visit-links";
|
||||
|
||||
/**
|
||||
* The sharing domain each lane reads, or `null` for a lane only the record's
|
||||
@@ -423,7 +424,7 @@ async function visitsLane(frame: LaneFrame): Promise<LaneRead> {
|
||||
r.practitioner?.name ??
|
||||
"",
|
||||
sub: r.kind,
|
||||
href: `/checkups?visit=${r.id}`,
|
||||
href: visitPath(r.id),
|
||||
}),
|
||||
),
|
||||
standing: [],
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
TimelineReadinessResponse,
|
||||
} from "@/lib/day/contract";
|
||||
import { readLocalDaysWithReadings } from "@/lib/day/daily-stats";
|
||||
import { VISITS_PATH } from "@/lib/encounters/visit-links";
|
||||
import { listLiveMeasurementTypes } from "@/lib/measurements/live-types";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { TRACKED_INTAKE_EVENT_WHERE } from "@/lib/medications/intake-tracking";
|
||||
@@ -377,7 +378,7 @@ async function visitsLane(frame: Frame) {
|
||||
{ detail: { key: "visits", params: { count: done, procedures } } },
|
||||
);
|
||||
}
|
||||
const visitGap = gap("visitsEmpty", 0, "/checkups");
|
||||
const visitGap = gap("visitsEmpty", 0, VISITS_PATH);
|
||||
return planned > 0
|
||||
? lane("thin", planned, null, {
|
||||
detail: { key: "visitsPlanned", params: { count: planned } },
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* The window before a booked visit, through the real list route and real
|
||||
* Postgres.
|
||||
*
|
||||
* The unit test pins the rule over hand-built rows. What only the database can
|
||||
* answer is the read: that it looks at visits that took place (DONE, not
|
||||
* cancelled, not booked), that it ignores the list's own filters and paging,
|
||||
* that a soft-deleted visit is not an anchor, and that the practitioner's
|
||||
* specialty reaches the rule through the join.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { GET as listEncounters } from "@/app/api/encounters/route";
|
||||
import type { EncounterListDTO } from "@/lib/encounters/dto";
|
||||
import { shiftDateKey } from "@/lib/tz/format";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const OWNER_ID = "prep-owner";
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function daysFromNow(days: number): Date {
|
||||
return new Date(Date.now() + days * DAY_MS);
|
||||
}
|
||||
|
||||
/** The local day of an instant on the owner's calendar. */
|
||||
function berlinDay(at: Date): string {
|
||||
return at.toLocaleDateString("sv-SE", { timeZone: "Europe/Berlin" });
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: OWNER_ID,
|
||||
username: "prep-owner",
|
||||
email: "prep-owner@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
expiresAt: daysFromNow(7),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
async function practitioner(name: string, specialty: string | null) {
|
||||
return getPrismaClient().practitioner.create({
|
||||
data: { userId: OWNER_ID, name, specialty },
|
||||
});
|
||||
}
|
||||
|
||||
async function encounter(data: {
|
||||
occurredAt: Date;
|
||||
status?: "PLANNED" | "DONE" | "CANCELLED" | "NO_SHOW";
|
||||
kind?: "ROUTINE" | "SPECIALIST" | "THERAPY" | "OTHER";
|
||||
practitionerId?: string | null;
|
||||
deletedAt?: Date | null;
|
||||
}) {
|
||||
return getPrismaClient().encounter.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
occurredAt: data.occurredAt,
|
||||
status: data.status ?? "DONE",
|
||||
kind: data.kind ?? "OTHER",
|
||||
practitionerId: data.practitionerId ?? null,
|
||||
deletedAt: data.deletedAt ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function list(query = ""): Promise<EncounterListDTO> {
|
||||
const res = await listEncounters(
|
||||
new Request(`http://localhost/api/encounters${query}`) as never,
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
return ((await res.json()) as { data: EncounterListDTO }).data;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seed();
|
||||
});
|
||||
|
||||
describe("GET /api/encounters — the window before a booked visit", () => {
|
||||
it("starts at the same practitioner's last visit, not the last visit of any kind", async () => {
|
||||
const derm = await practitioner("Skin practice", "Dermatology");
|
||||
const internist = await practitioner(
|
||||
"Internal practice",
|
||||
"Internal medicine",
|
||||
);
|
||||
const dermVisit = await encounter({
|
||||
occurredAt: daysFromNow(-200),
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
// The newer visit elsewhere the old rule anchored on.
|
||||
await encounter({
|
||||
occurredAt: daysFromNow(-5),
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: internist.id,
|
||||
});
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(2),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
|
||||
const body = await list();
|
||||
const booked = body.upcoming.find((v) => v.id === next.id);
|
||||
expect(booked?.preparation).toEqual({
|
||||
from: berlinDay(dermVisit.occurredAt),
|
||||
basis: "practitioner",
|
||||
since: {
|
||||
id: dermVisit.id,
|
||||
occurredAt: dermVisit.occurredAt.toISOString(),
|
||||
kind: "SPECIALIST",
|
||||
practitionerName: "Skin practice",
|
||||
specialty: "Dermatology",
|
||||
},
|
||||
});
|
||||
// Only a booked visit carries one.
|
||||
expect(body.past.every((v) => v.preparation === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a specialty across practitioners, through the join", async () => {
|
||||
const oldDerm = await practitioner("Former skin practice", "dermatology ");
|
||||
const newDerm = await practitioner("Skin practice", "Dermatology");
|
||||
const anchor = await encounter({
|
||||
occurredAt: daysFromNow(-300),
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: oldDerm.id,
|
||||
});
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(3),
|
||||
status: "PLANNED",
|
||||
kind: "SPECIALIST",
|
||||
practitionerId: newDerm.id,
|
||||
});
|
||||
|
||||
const booked = (await list()).upcoming.find((v) => v.id === next.id);
|
||||
expect(booked?.preparation?.basis).toBe("specialty");
|
||||
expect(booked?.preparation?.since?.id).toBe(anchor.id);
|
||||
});
|
||||
|
||||
it("anchors only on a visit that took place and still exists", async () => {
|
||||
const derm = await practitioner("Skin practice", "Dermatology");
|
||||
await encounter({
|
||||
occurredAt: daysFromNow(-20),
|
||||
status: "CANCELLED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
await encounter({
|
||||
occurredAt: daysFromNow(-30),
|
||||
status: "NO_SHOW",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
await encounter({
|
||||
occurredAt: daysFromNow(-40),
|
||||
practitionerId: derm.id,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
// Another booked visit with the same practice is not one that happened.
|
||||
await encounter({
|
||||
occurredAt: daysFromNow(1),
|
||||
status: "PLANNED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(9),
|
||||
status: "PLANNED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
|
||||
const booked = (await list()).upcoming.find((v) => v.id === next.id);
|
||||
expect(booked?.preparation).toEqual({
|
||||
from: shiftDateKey(berlinDay(new Date()), -90),
|
||||
basis: "default",
|
||||
since: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("reads the whole history, whatever the list was filtered to", async () => {
|
||||
const derm = await practitioner("Skin practice", "Dermatology");
|
||||
const anchor = await encounter({
|
||||
occurredAt: daysFromNow(-400),
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
const next = await encounter({
|
||||
occurredAt: daysFromNow(4),
|
||||
status: "PLANNED",
|
||||
practitionerId: derm.id,
|
||||
});
|
||||
|
||||
const from = daysFromNow(-1).toISOString();
|
||||
const body = await list(`?from=${encodeURIComponent(from)}`);
|
||||
expect(body.past).toEqual([]);
|
||||
expect(
|
||||
body.upcoming.find((v) => v.id === next.id)?.preparation?.since?.id,
|
||||
).toBe(anchor.id);
|
||||
});
|
||||
|
||||
it("leaves a cancelled upcoming visit without a window", async () => {
|
||||
const cancelled = await encounter({
|
||||
occurredAt: daysFromNow(5),
|
||||
status: "CANCELLED",
|
||||
});
|
||||
const booked = (await list()).upcoming.find((v) => v.id === cancelled.id);
|
||||
expect(booked?.preparation).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user