mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
feat(medications): promote the detail page to a tabbed shell
Make /medications/[id] the canonical full-page home for one medication: a Radix tab strip (Übersicht · Zeitplan · Erinnerung · Bestand · Verlauf · Injektion · Erweitert) with ?tab= URL state, a read-only hero, and a "Vollständig bearbeiten" jump into the wizard for structural edits. Everyday levers move inline into the tabs. Übersicht surfaces the adherence bars, Erinnerung owns notifications + reminder grace, Bestand reads the pen/vial inventory (all medications, calm empty state), Verlauf renders the editable intake history, and Injektion (injectable routes only) carries the existing GLP-1 charts + side-effect log. The ledger, the per-dose window editor and the injection body-map land in the following waves. The legacy /medications/[id]/history route now redirects to ?tab=verlauf for bookmarks and deep links. Tab labels are i18n across six locales.
This commit is contained in:
1 parent
6398cdece5
commit
ac39b9ca34
12 files changed
+986
-324
No files matched your search
@@ -1,153 +1,40 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.0 — full intake-history view.
|
||||
* v1.15.18 — legacy intake-history route.
|
||||
*
|
||||
* Reached directly from the detail-page header's History button. This
|
||||
* surface is intake-history ONLY: the estimated drug-level curve, the
|
||||
* side-effect logbook, the schedule ("Rhythmus") editor and the
|
||||
* titration ladder no longer dominate here. The only editable units
|
||||
* are individual intakes (edit / delete / bulk-delete via the shared
|
||||
* `<IntakeHistoryEditable>`); CSV import is present but de-emphasised
|
||||
* as a ghost action in the header.
|
||||
*
|
||||
* Sort defaults to `scheduledFor desc` so the order reads
|
||||
* today → yesterday → … and skipped rows (`takenAt: null`) never float
|
||||
* to the top (O-1).
|
||||
*
|
||||
* The estimated active-ingredient curve stays available as a
|
||||
* default-CLOSED disclosure at the bottom for GLP-1 medications (O-2);
|
||||
* it is genuine history context but opt-in, not the default read.
|
||||
* The full intake history folded into the medication detail page's
|
||||
* Verlauf tab. This route stays only as a deep-link redirect so existing
|
||||
* bookmarks and any iOS deep-link keep landing on the right surface.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, use } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { use, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowLeft, Loader2, Upload } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
|
||||
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
|
||||
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
export default function IntakeHistoryPage({
|
||||
export default function IntakeHistoryRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = use(params);
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
}
|
||||
}, [authLoading, isAuthenticated, router]);
|
||||
|
||||
const { data: medication, isLoading: medLoading } = useQuery({
|
||||
queryKey: queryKeys.medicationDetail(id),
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/medications/${id}`);
|
||||
if (!res.ok) throw new Error("Failed to fetch");
|
||||
const json = await res.json();
|
||||
return json.data as {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
treatmentClass?: string;
|
||||
notificationsEnabled?: boolean;
|
||||
};
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
if (authLoading || medLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isGlp1 = medication?.treatmentClass === "GLP1";
|
||||
router.replace(`/medications/${id}?tab=verlauf`);
|
||||
}, [id, router]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Back to the medication detail page — history is a drill-down
|
||||
OF the medication, not a sibling of the list. */}
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href={`/medications/${id}`}>
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.detail.history.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{medication && (
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{medication.name}
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">{medication.dose}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("medications.detail.history.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
{/* De-emphasised import — ghost / muted, not a prominent CTA. */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="text-muted-foreground min-h-11 sm:min-h-9"
|
||||
data-slot="history-import-trigger"
|
||||
>
|
||||
<Upload aria-hidden="true" className="h-4 w-4" />
|
||||
{t("medications.detail.intake.importButton")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{medication && (
|
||||
<IntakeHistoryEditable
|
||||
medicationId={id}
|
||||
pageSize={25}
|
||||
defaultSortBy="scheduledFor"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Estimated drug-level curve (the modelled active-ingredient
|
||||
level) + dose-strength (titration) curve for GLP-1 medications.
|
||||
Visible by default — the drug-level estimate is the one the
|
||||
medication concerns, so it leads; the dose-strength curve plots
|
||||
the user's own logged dose-change history below it. */}
|
||||
{medication && isGlp1 && (
|
||||
<div className="space-y-6" data-slot="history-drug-level-section">
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{importOpen && (
|
||||
<IntakeImportDialog
|
||||
medicationId={id}
|
||||
onClose={() => setImportOpen(false)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className="flex h-64 items-center justify-center"
|
||||
role="status"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* v1.7.2 W3 — medication detail page as the history view.
|
||||
* v1.15.18 — medication detail page as the full-page tabbed shell.
|
||||
*
|
||||
* Supersedes the v1.5.6 composition that carried an Edit / History /
|
||||
* Advanced action row and a "Rhythmus" cadence block. Editing and
|
||||
* advanced settings are now reached from the medications-list card kebab
|
||||
* only; the detail page is purely history-centric:
|
||||
*
|
||||
* back link → compact, NON-editable header (name / dose / status +
|
||||
* plain-language cadence line) → intake-history table → GLP-1
|
||||
* drug-level / titration as a default-CLOSED disclosure.
|
||||
*
|
||||
* The side-effect logbook stays inline for GLP-1 medications (genuine
|
||||
* history context). CSV import rides the intake-history section header.
|
||||
*
|
||||
* Reads:
|
||||
*
|
||||
* - `medicationDetail(id)` for the medication snapshot.
|
||||
* - `medicationIntakeList(id, …)` for the header total.
|
||||
*
|
||||
* Mutations all cascade through `medicationDependentKeys`.
|
||||
* Supersedes the v1.7.2 history-centric composition. The page now owns
|
||||
* auth + the medication read and hands the snapshot to
|
||||
* `<MedicationDetailTabs>`, which carries the tab strip (Übersicht ·
|
||||
* Zeitplan · Erinnerung · Bestand · Verlauf · Injektion* · Erweitert),
|
||||
* `?tab=` URL state, the read-only hero and the "Vollständig bearbeiten"
|
||||
* jump into the wizard. The former modal advanced-settings sheet and the
|
||||
* separate `/history` route both fold into tabs here.
|
||||
*/
|
||||
|
||||
import { use, useEffect, useMemo, useState } from "react";
|
||||
import { use, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -32,75 +21,13 @@ import { ArrowLeft, Loader2 } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
|
||||
import { DoseStrengthCurve } from "@/components/medications/dose-strength-curve";
|
||||
import { MedicationDetailSummary } from "@/components/medications/medication-detail-summary";
|
||||
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
|
||||
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
|
||||
import type { MedicationPayload } from "@/components/medications/wizard/wizard-payload";
|
||||
|
||||
interface ScheduleSnapshot {
|
||||
id: string;
|
||||
windowStart: string;
|
||||
windowEnd: string;
|
||||
label: string | null;
|
||||
dose: string | null;
|
||||
daysOfWeek: string | null;
|
||||
timesOfDay?: string[];
|
||||
rrule?: string | null;
|
||||
rollingIntervalDays?: number | null;
|
||||
reminderGraceMinutes?: number | null;
|
||||
}
|
||||
|
||||
interface MedicationDetailSnapshot {
|
||||
id: string;
|
||||
name: string;
|
||||
dose: string;
|
||||
category: string;
|
||||
treatmentClass?: string;
|
||||
deliveryForm?: string;
|
||||
dosesPerUnit?: number | null;
|
||||
active: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
pausedAt: string | null;
|
||||
startsOn?: string | null;
|
||||
endsOn?: string | null;
|
||||
oneShot?: boolean;
|
||||
schedules: ScheduleSnapshot[];
|
||||
}
|
||||
|
||||
function snapshotToWizardPayload(
|
||||
med: MedicationDetailSnapshot,
|
||||
): MedicationPayload {
|
||||
return {
|
||||
id: med.id,
|
||||
name: med.name,
|
||||
dose: med.dose,
|
||||
category: med.category,
|
||||
treatmentClass: med.treatmentClass,
|
||||
deliveryForm: med.deliveryForm,
|
||||
dosesPerUnit: med.dosesPerUnit ?? null,
|
||||
notificationsEnabled: med.notificationsEnabled,
|
||||
startsOn: med.startsOn ? new Date(med.startsOn) : null,
|
||||
endsOn: med.endsOn ? new Date(med.endsOn) : null,
|
||||
oneShot: med.oneShot ?? false,
|
||||
schedules: med.schedules.map((s) => ({
|
||||
id: s.id,
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? null,
|
||||
dose: s.dose ?? null,
|
||||
...parseScheduleRecurrence(s.daysOfWeek),
|
||||
timesOfDay: s.timesOfDay,
|
||||
rrule: s.rrule ?? null,
|
||||
rollingIntervalDays: s.rollingIntervalDays ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
import {
|
||||
MedicationDetailTabs,
|
||||
type MedicationDetailSnapshot,
|
||||
} from "@/components/medications/detail/MedicationDetailTabs";
|
||||
|
||||
export default function MedicationDetailPage({
|
||||
params,
|
||||
@@ -112,8 +39,6 @@ export default function MedicationDetailPage({
|
||||
const router = useRouter();
|
||||
const { t } = useTranslations();
|
||||
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authLoading && !isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
@@ -139,13 +64,6 @@ export default function MedicationDetailPage({
|
||||
retry: false,
|
||||
});
|
||||
|
||||
// v1.7.2 W3 — derive the wizard payload purely to feed the read-only
|
||||
// cadence summary line (the editor itself lives on the card kebab).
|
||||
const wizardPayload = useMemo<MedicationPayload | null>(
|
||||
() => (medication ? snapshotToWizardPayload(medication) : null),
|
||||
[medication],
|
||||
);
|
||||
|
||||
if (authLoading || isLoading) {
|
||||
return (
|
||||
<div
|
||||
@@ -178,70 +96,12 @@ export default function MedicationDetailPage({
|
||||
data-slot="medication-detail-error-card"
|
||||
>
|
||||
<p className="text-destructive text-sm">
|
||||
{t("medications.loadFailed")}
|
||||
{t("medications.detail.shell.loadFailed")}
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const oneShot = medication.oneShot === true;
|
||||
// `wizardPayload` is non-null here — the early returns above bail
|
||||
// before this point whenever `medication` is undefined.
|
||||
const payload = wizardPayload as MedicationPayload;
|
||||
const isGlp1 = !oneShot && medication.treatmentClass === "GLP1";
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-slot="medication-detail-page">
|
||||
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
|
||||
<Link href="/medications">
|
||||
<ArrowLeft aria-hidden="true" className="mr-1 size-4" />
|
||||
{t("medications.back")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{/* v1.7.2 W3 — compact, NON-editable header. Editing and advanced
|
||||
settings are reached from the medications-list card kebab only;
|
||||
the detail page is history-centric. The header is a read-only
|
||||
summary: name / dose / status + plain-language cadence line. */}
|
||||
<MedicationDetailSummary
|
||||
name={medication.name}
|
||||
dose={medication.dose}
|
||||
active={medication.active}
|
||||
endsOn={medication.endsOn}
|
||||
payload={payload}
|
||||
oneShot={oneShot}
|
||||
startsOn={medication.startsOn}
|
||||
/>
|
||||
|
||||
{/* Intake history table — the primary surface. */}
|
||||
<IntakeHistoryPreview
|
||||
medicationId={id}
|
||||
importOpen={importOpen}
|
||||
onImportOpenChange={setImportOpen}
|
||||
/>
|
||||
|
||||
{/* v1.6.0 — side-effect logbook stays inline for GLP-1 medications
|
||||
(genuine history context). */}
|
||||
{isGlp1 && <SideEffectsSection medicationId={id} />}
|
||||
|
||||
{/* GLP-1 estimated drug-level curve (the modelled active-ingredient
|
||||
level) + dose-strength (titration) curve. Visible by default for
|
||||
GLP-1 medications — the drug-level estimate is the one the
|
||||
medication concerns, so it leads; the dose-strength curve plots
|
||||
the user's own logged dose-change history below it. */}
|
||||
{isGlp1 && (
|
||||
<div className="space-y-6" data-slot="detail-drug-level-section">
|
||||
<DrugLevelChart
|
||||
medication={{
|
||||
id: medication.id,
|
||||
name: medication.name,
|
||||
dose: medication.dose,
|
||||
}}
|
||||
/>
|
||||
<DoseStrengthCurve medicationId={id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <MedicationDetailTabs medication={medication} />;
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
/**
|
||||
* v1.7.2 W3 — the `/medications/[id]` detail page IS the history view.
|
||||
* v1.15.18 — the `/medications/[id]` detail page is the tabbed shell.
|
||||
*
|
||||
* Editing and advanced settings are reached from the medications-list
|
||||
* card kebab only; the detail page is purely history-centric. Source-
|
||||
* level guards (the page is a client component with `use()` + hooks, so
|
||||
* a full render is heavier than the contract warrants):
|
||||
* - no `TodaysDoseCard` import or render (lives only on the list page);
|
||||
* - no `isToday` / `todayEvent` derivations (dead once the card is
|
||||
* gone);
|
||||
* - no `landingIntent` / `wizardIntent` / `openWizardWithIntent`
|
||||
* plumbing;
|
||||
* - the page no longer hosts the wizard, the `AdvancedSettingsSheet`,
|
||||
* the `MedicationDetailHeader` action row, or an editable cadence
|
||||
* ("Rhythmus") block — those moved to the card kebab. It renders the
|
||||
* read-only `MedicationDetailSummary` + the intake-history table.
|
||||
* The page owns auth + the medication read and delegates the rest to
|
||||
* `<MedicationDetailTabs>` (the tab strip, `?tab=` URL state, the hero
|
||||
* and the wizard / advanced affordances). Source-level guards (the page
|
||||
* is a client component with `use()` + hooks, so a full render is
|
||||
* heavier than the contract warrants):
|
||||
* - it renders `MedicationDetailTabs` and nothing tab-specific itself;
|
||||
* - it carries no `TodaysDoseCard` (lives only on the list page);
|
||||
* - it hosts neither the wizard nor the modal advanced sheet directly
|
||||
* (those live inside the tab component).
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
@@ -25,35 +21,18 @@ const source = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("medication detail page is the history view (W3)", () => {
|
||||
describe("medication detail page is the tabbed shell (v1.15.18)", () => {
|
||||
it("delegates to MedicationDetailTabs", () => {
|
||||
expect(source).toContain("MedicationDetailTabs");
|
||||
});
|
||||
|
||||
it("does not import or render TodaysDoseCard", () => {
|
||||
expect(source).not.toContain("TodaysDoseCard");
|
||||
expect(source).not.toContain("todays-dose-card");
|
||||
});
|
||||
|
||||
it("drops the dead isToday / todayEvent derivations", () => {
|
||||
expect(source).not.toContain("isToday");
|
||||
expect(source).not.toContain("todayEvent");
|
||||
});
|
||||
|
||||
it("drops the landingIntent plumbing", () => {
|
||||
expect(source).not.toContain("landingIntent");
|
||||
expect(source).not.toContain("wizardIntent");
|
||||
expect(source).not.toContain("openWizardWithIntent");
|
||||
});
|
||||
|
||||
it("no longer hosts the wizard, advanced sheet, detail-header, or cadence row", () => {
|
||||
// v1.7.2 W3 — editing + advanced moved to the medications-list card
|
||||
// kebab; the detail page carries none of those affordances.
|
||||
it("does not host the wizard or the modal advanced sheet directly", () => {
|
||||
expect(source).not.toContain("AdvancedSettingsSheet");
|
||||
expect(source).not.toContain("MedicationWizardDialog");
|
||||
expect(source).not.toContain("MedicationDetailHeader");
|
||||
expect(source).not.toContain("CadenceSummaryRow");
|
||||
expect(source).not.toContain("hideEdit");
|
||||
});
|
||||
|
||||
it("renders the read-only summary header + the intake-history table", () => {
|
||||
expect(source).toContain("MedicationDetailSummary");
|
||||
expect(source).toContain("IntakeHistoryPreview");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user