mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #1330 from FloMaetschke/feature/injection-site-in-intake-history
This commit is contained in:
commit
d1ab195b43
13 files changed
+163
-43
No files matched your search
@@ -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
|
||||
|
||||
@@ -1895,6 +1895,7 @@
|
||||
"intakeHistoryColScheduled": "Geplant",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Quelle",
|
||||
"intakeHistoryInjectionSite": "Injektionsstelle: {site}",
|
||||
"intakeHistoryStatusTaken": "Eingenommen",
|
||||
"intakeHistoryStatusSkipped": "Übersprungen",
|
||||
"intakeHistorySortByTaken": "Nach Einnahme-Zeitpunkt sortieren",
|
||||
|
||||
@@ -1895,6 +1895,7 @@
|
||||
"intakeHistoryColScheduled": "Scheduled",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Source",
|
||||
"intakeHistoryInjectionSite": "Injection site: {site}",
|
||||
"intakeHistoryStatusTaken": "Taken",
|
||||
"intakeHistoryStatusSkipped": "Skipped",
|
||||
"intakeHistorySortByTaken": "Sort by taken time",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1895,6 +1895,7 @@
|
||||
"intakeHistoryColScheduled": "예정",
|
||||
"intakeHistoryColStatus": "상태",
|
||||
"intakeHistoryColSource": "출처",
|
||||
"intakeHistoryInjectionSite": "주사 부위: {site}",
|
||||
"intakeHistoryStatusTaken": "복용함",
|
||||
"intakeHistoryStatusSkipped": "건너뜀",
|
||||
"intakeHistorySortByTaken": "복용 시각순 정렬",
|
||||
|
||||
@@ -1895,6 +1895,7 @@
|
||||
"intakeHistoryColScheduled": "Gepland",
|
||||
"intakeHistoryColStatus": "Status",
|
||||
"intakeHistoryColSource": "Bron",
|
||||
"intakeHistoryInjectionSite": "Injectieplaats: {site}",
|
||||
"intakeHistoryStatusTaken": "Ingenomen",
|
||||
"intakeHistoryStatusSkipped": "Overgeslagen",
|
||||
"intakeHistorySortByTaken": "Sorteren op innametijd",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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("<IntakeHistoryListV2> — sort indicators", () => {
|
||||
expect(html).not.toContain(">Next<");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<IntakeHistoryListV2> — 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(<IntakeHistoryListV2 medicationId="med-1" />, 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(
|
||||
<IntakeHistoryListV2 medicationId="med-1" />,
|
||||
client,
|
||||
"de",
|
||||
);
|
||||
expect(html).toContain("Linker Oberschenkel");
|
||||
expect(html).toContain("Injektionsstelle: Linker Oberschenkel");
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isTaken ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-success/15 text-success gap-1 text-xs"
|
||||
>
|
||||
<Check aria-hidden="true" className="h-3 w-3" />
|
||||
{t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
) : isSkipped ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-1 text-xs"
|
||||
>
|
||||
<SkipForward
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{t("medications.intakeHistoryStatusSkipped")}
|
||||
</Badge>
|
||||
<IntakeStatusBadge
|
||||
taken={isTaken}
|
||||
skipped={isSkipped}
|
||||
/>
|
||||
{event.injectionSite ? (
|
||||
<InjectionSiteBadge
|
||||
site={event.injectionSite}
|
||||
className="ml-1.5"
|
||||
/>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -506,28 +507,14 @@ export function IntakeHistoryListV2({
|
||||
{formatters.dateTime(event.scheduledFor)}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{isTaken ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-success/15 text-success gap-1 text-xs"
|
||||
>
|
||||
<Check
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
) : isSkipped ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-muted-foreground gap-1 text-xs"
|
||||
>
|
||||
<SkipForward
|
||||
aria-hidden="true"
|
||||
className="h-3 w-3"
|
||||
/>
|
||||
{t("medications.intakeHistoryStatusSkipped")}
|
||||
</Badge>
|
||||
<IntakeStatusBadge
|
||||
taken={isTaken}
|
||||
skipped={isSkipped}
|
||||
/>
|
||||
{event.injectionSite ? (
|
||||
<InjectionSiteBadge
|
||||
site={event.injectionSite}
|
||||
/>
|
||||
) : null}
|
||||
<Badge
|
||||
variant="outline"
|
||||
@@ -686,3 +673,68 @@ function SortHeaderButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-muted-foreground gap-1 text-xs font-normal ${className ?? ""}`.trim()}
|
||||
aria-label={t("medications.intakeHistoryInjectionSite", {
|
||||
site: siteName,
|
||||
})}
|
||||
data-slot="intake-injection-site"
|
||||
>
|
||||
<MapPin aria-hidden="true" className="h-3 w-3" />
|
||||
{siteName}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-success/15 text-success gap-1 text-xs"
|
||||
>
|
||||
<Check aria-hidden="true" className="h-3 w-3" />
|
||||
{t("medications.intakeHistoryStatusTaken")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (skipped) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-muted-foreground gap-1 text-xs">
|
||||
<SkipForward aria-hidden="true" className="h-3 w-3" />
|
||||
{t("medications.intakeHistoryStatusSkipped")}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in new issue
Block a user