diff --git a/CHANGELOG.md b/CHANGELOG.md index a4d2035d2..9d4f41cbb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [Unreleased] + +### Added + +- **The intake history shows where an injection went.** A dose logged + with an injection site already stored it, but the per-medication + history only showed when it was taken and where the entry came from. + Each injected dose now carries a chip with the site next to its status, + in the table and on the mobile cards, so the rotation can be read back + from the history. Doses without a site look as before. + ## [1.46.6] — 2026-11-02 ### Added diff --git a/messages/de.json b/messages/de.json index 3c914dfc9..ab3f02171 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Geplant", "intakeHistoryColStatus": "Status", "intakeHistoryColSource": "Quelle", + "intakeHistoryInjectionSite": "Injektionsstelle: {site}", "intakeHistoryStatusTaken": "Eingenommen", "intakeHistoryStatusSkipped": "Übersprungen", "intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren", diff --git a/messages/en.json b/messages/en.json index aea2d6a89..bf1c24d0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Scheduled", "intakeHistoryColStatus": "Status", "intakeHistoryColSource": "Source", + "intakeHistoryInjectionSite": "Injection site: {site}", "intakeHistoryStatusTaken": "Taken", "intakeHistoryStatusSkipped": "Skipped", "intakeHistorySortByTaken": "Sort by taken time", diff --git a/messages/es.json b/messages/es.json index a729ed506..3034349a6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Programada", "intakeHistoryColStatus": "Estado", "intakeHistoryColSource": "Fuente", + "intakeHistoryInjectionSite": "Lugar de inyección: {site}", "intakeHistoryStatusTaken": "Tomada", "intakeHistoryStatusSkipped": "Omitida", "intakeHistorySortByTaken": "Ordenar por hora de toma", diff --git a/messages/fr.json b/messages/fr.json index 130a2dcec..a9ceb0b1b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Programmée", "intakeHistoryColStatus": "Statut", "intakeHistoryColSource": "Source", + "intakeHistoryInjectionSite": "Site d'injection : {site}", "intakeHistoryStatusTaken": "Prise", "intakeHistoryStatusSkipped": "Ignorée", "intakeHistorySortByTaken": "Trier par heure de prise", diff --git a/messages/it.json b/messages/it.json index 7c417dc4c..9a709062a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Programmata", "intakeHistoryColStatus": "Stato", "intakeHistoryColSource": "Sorgente", + "intakeHistoryInjectionSite": "Sito di iniezione: {site}", "intakeHistoryStatusTaken": "Assunta", "intakeHistoryStatusSkipped": "Saltata", "intakeHistorySortByTaken": "Ordina per ora di assunzione", diff --git a/messages/ko.json b/messages/ko.json index 6687f8fd6..85ed9abcf 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "예정", "intakeHistoryColStatus": "상태", "intakeHistoryColSource": "출처", + "intakeHistoryInjectionSite": "주사 부위: {site}", "intakeHistoryStatusTaken": "복용함", "intakeHistoryStatusSkipped": "건너뜀", "intakeHistorySortByTaken": "복용 시각순 정렬", diff --git a/messages/nl.json b/messages/nl.json index 17aefb7de..717186d43 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Gepland", "intakeHistoryColStatus": "Status", "intakeHistoryColSource": "Bron", + "intakeHistoryInjectionSite": "Injectieplaats: {site}", "intakeHistoryStatusTaken": "Ingenomen", "intakeHistoryStatusSkipped": "Overgeslagen", "intakeHistorySortByTaken": "Sorteren op innametijd", diff --git a/messages/pl.json b/messages/pl.json index 5132eeb8a..a51837763 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Zaplanowany", "intakeHistoryColStatus": "Status", "intakeHistoryColSource": "Źródło", + "intakeHistoryInjectionSite": "Miejsce wstrzyknięcia: {site}", "intakeHistoryStatusTaken": "Przyjęty", "intakeHistoryStatusSkipped": "Pominięty", "intakeHistorySortByTaken": "Sortuj wg czasu przyjęcia", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index d8d87e84b..812dea2a8 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -1895,6 +1895,7 @@ "intakeHistoryColScheduled": "Programada", "intakeHistoryColStatus": "Status", "intakeHistoryColSource": "Fonte", + "intakeHistoryInjectionSite": "Local da injeção: {site}", "intakeHistoryStatusTaken": "Tomada", "intakeHistoryStatusSkipped": "Pulada", "intakeHistorySortByTaken": "Ordenar por horário da tomada", diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json index e4bb6649d..537c6b9a6 100644 --- a/src/__tests__/baselines/source-function-lines.json +++ b/src/__tests__/baselines/source-function-lines.json @@ -1,7 +1,7 @@ { "$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.", "count": 345, - "total": 111918, + "total": 111894, "entries": { "app/achievements/page.tsx :: AchievementsPage": 206, "app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275, @@ -156,7 +156,7 @@ "components/medications/glp1-medication-card.tsx :: Glp1MedicationCard": 405, "components/medications/injection-site-picker.tsx :: InjectionSitePicker": 287, "components/medications/intake-history-editable.tsx :: IntakeHistoryEditable": 224, - "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 427, + "components/medications/intake-history-list-v2.tsx :: IntakeHistoryListV2": 403, "components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208, "components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267, "components/medications/medication-card.tsx :: MedicationCard": 413, diff --git a/src/components/medications/__tests__/intake-history-list-v2.test.tsx b/src/components/medications/__tests__/intake-history-list-v2.test.tsx index a5594d94a..6838df977 100644 --- a/src/components/medications/__tests__/intake-history-list-v2.test.tsx +++ b/src/components/medications/__tests__/intake-history-list-v2.test.tsx @@ -46,6 +46,7 @@ type IntakeRow = { takenAt: string | null; skipped: boolean; source: "WEB" | "API" | "REMINDER" | "IMPORT"; + injectionSite?: string | null; createdAt: string; }; @@ -347,3 +348,50 @@ describe(" — sort indicators", () => { expect(html).not.toContain(">Next<"); }); }); + +describe(" — injection site", () => { + const rows: IntakeRow[] = [ + { + id: "evt-site", + medicationId: "med-1", + scheduledFor: "2026-05-15T08:00:00.000Z", + takenAt: "2026-05-15T08:02:00.000Z", + skipped: false, + source: "WEB", + injectionSite: "THIGH_LEFT", + createdAt: "2026-05-15T08:02:00.000Z", + }, + { + id: "evt-nosite", + medicationId: "med-1", + scheduledFor: "2026-05-14T08:00:00.000Z", + takenAt: "2026-05-14T08:02:00.000Z", + skipped: false, + source: "WEB", + injectionSite: null, + createdAt: "2026-05-14T08:02:00.000Z", + }, + ]; + + it("renders the localised site next to the status of an injected dose", () => { + const client = makeClient(); + seedIntake(client, "med-1", rows); + const html = render(, client); + expect(html).toContain("Left thigh"); + expect(html).toContain('aria-label="Injection site: Left thigh"'); + // One chip for the injected dose, none for the dose without a site. + expect(html.match(/data-slot="intake-injection-site"/g)).toHaveLength(1); + }); + + it("speaks German when the locale is de", () => { + const client = makeClient(); + seedIntake(client, "med-1", rows); + const html = render( + , + client, + "de", + ); + expect(html).toContain("Linker Oberschenkel"); + expect(html).toContain("Injektionsstelle: Linker Oberschenkel"); + }); +}); diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx index 61e437df4..c42afa8af 100644 --- a/src/components/medications/intake-history-list-v2.tsx +++ b/src/components/medications/intake-history-list-v2.tsx @@ -14,7 +14,12 @@ import { Check, SkipForward, MoreHorizontal, + MapPin, } from "lucide-react"; +import { + describeInjectionSite, + type InjectionSiteKey, +} from "@/lib/medications/injection-sites"; import { useAuth } from "@/hooks/use-auth"; import { useIsMobile } from "@/hooks/use-is-mobile"; @@ -94,6 +99,12 @@ export interface IntakeEvent { source: IntakeSourceValue; /** v1.42.2 — recorded by the server (`autoMarkTaken`), not the person. */ autoRecorded?: boolean; + /** + * Where an injected dose went. Set only for medications with + * `trackInjectionSites` on and only when the person picked a site; the + * list renders it as a badge so the rotation is visible in the history. + */ + injectionSite?: InjectionSiteKey | null; createdAt: string; } @@ -400,25 +411,15 @@ export function IntakeHistoryListV2({ {formatters.dateTime(event.scheduledFor)} - {isTaken ? ( - - - ) : isSkipped ? ( - - + + {event.injectionSite ? ( + ) : null} @@ -506,28 +507,14 @@ export function IntakeHistoryListV2({ {formatters.dateTime(event.scheduledFor)}

- {isTaken ? ( - - - ) : isSkipped ? ( - - + + {event.injectionSite ? ( + ) : null} ); } + +/** + * Injection-site chip for one history row. The visible text is the + * localised site name (same vocabulary as the body-map picker); the + * accessible name prefixes it with "Injection site" so a screen reader + * does not announce a bare "Left thigh" next to the status. + */ +function InjectionSiteBadge({ + site, + className, +}: { + site: InjectionSiteKey; + className?: string; +}) { + const { t } = useTranslations(); + const siteName = t(describeInjectionSite(site)); + return ( + + + ); +} + +/** + * Taken / skipped chip shared by the desktop table and the mobile cards. + * Renders nothing for a row that is neither (the `status:"completed"` + * query keeps those out, but the shape stays defensive). + */ +function IntakeStatusBadge({ + taken, + skipped, +}: { + taken: boolean; + skipped: boolean; +}) { + const { t } = useTranslations(); + if (taken) { + return ( + + + ); + } + if (skipped) { + return ( + + + ); + } + return null; +}