chore: remove three components nothing renders

personal-record-badge.tsx, intake-history-editable.tsx and
intake-history-preview.tsx had no importer outside their own tests and
the preview, so they rendered nowhere while four guard tests kept them
listed as live surfaces. They go with their tests, their guard entries
and the strings only they used.

GET /api/personal-records stays: the native app reads it. The
resolved-field guard now records its benchmark band as a native-only
field instead of naming the deleted badge as its reader.
This commit is contained in:
Marc-André Bombeck committed 2026-11-11 20:03:00 +01:00
1 parent af673a63da
commit 5449933c4f
22 files changed
+14 -957

No files matched your search

@@ -153,7 +153,6 @@
"components/medications/dose-strength-curve.tsx :: DoseStrengthCurve": 266,
"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": 403,
"components/medications/intake-import-dialog.tsx :: IntakeImportDialog": 208,
"components/medications/log-intake-dialog.tsx :: LogIntakeDialog": 267,
@@ -869,7 +869,6 @@
"components/insights/__tests__/metric-stat-strip.test.tsx": 3,
"components/insights/__tests__/metric-target-summary-unit-preference.test.tsx": 1,
"components/insights/__tests__/metric-target-summary.test.tsx": 8,
"components/insights/__tests__/personal-record-badge.test.tsx": 1,
"components/insights/__tests__/rhythm-events-card.test.tsx": 1,
"components/insights/__tests__/sleep-stage-stacked-bar.test.tsx": 5,
"components/insights/__tests__/sub-page-shell.test.tsx": 4,
@@ -1031,7 +1030,6 @@
"components/insights/nutrients/micronutrients-card.tsx": 3,
"components/insights/nutrients/types.ts": 4,
"components/insights/period-narrative-card.tsx": 1,
"components/insights/personal-record-badge.tsx": 2,
"components/insights/prose-blocks.tsx": 1,
"components/insights/recovery/recovery-metric-block.tsx": 2,
"components/insights/recovery/recovery-section.tsx": 3,
@@ -1127,7 +1125,6 @@
"components/medications/__tests__/intake-edit-dialog-seed.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2-row-actions.test.tsx": 1,
"components/medications/__tests__/intake-history-list-v2.test.tsx": 4,
"components/medications/__tests__/intake-history-preview-bulk-toolbar.test.tsx": 2,
"components/medications/__tests__/inventory-section-actions.test.tsx": 1,
"components/medications/__tests__/inventory-section-summary.test.tsx": 2,
"components/medications/__tests__/log-injection-site-dialog.test.tsx": 1,
@@ -1163,7 +1160,6 @@
"components/medications/glp1-medication-card.tsx": 36,
"components/medications/injection-site-picker.tsx": 2,
"components/medications/intake-edit-dialog.tsx": 2,
"components/medications/intake-history-editable.tsx": 2,
"components/medications/intake-history-list-v2.tsx": 13,
"components/medications/intake-import-dialog.tsx": 1,
"components/medications/log-injection-site-dialog.tsx": 2,
@@ -1200,7 +1196,6 @@
"components/medications/sections/courses-section.tsx": 1,
"components/medications/sections/data-portability-row.tsx": 1,
"components/medications/sections/destructive-zone-section.tsx": 5,
"components/medications/sections/intake-history-preview.tsx": 3,
"components/medications/sections/intake-tracking-section.tsx": 1,
"components/medications/sections/inventory-dialogs.tsx": 3,
"components/medications/sections/inventory-section.tsx": 8,
@@ -234,7 +234,6 @@ describe("daily-reads refetch guard", () => {
const routedThroughHelper = [
"components/medications/take-all-due.ts",
"components/medications/intake-edit-dialog.tsx",
"components/medications/intake-history-editable.tsx",
"components/medications/intake-import-dialog.tsx",
"components/medications/sections/destructive-zone-section.tsx",
"components/medications/use-medication-intake.ts",
@@ -528,13 +528,6 @@ const REGISTRY: DestructiveEntry[] = [
recovery: "permanent",
confirm: ["AlertDialog"],
},
{
file: "components/medications/intake-history-editable.tsx",
destroys: "one intake event, or every selected intake event",
triggers: ["components/medications/intake-history-list-v2.tsx"],
recovery: "tombstoned-no-restore",
confirm: ["AlertDialog"],
},
{
file: "components/medications/dose-history/use-dose-history-ledger.ts",
destroys: "one intake event",
-49
View File
@@ -15,7 +15,6 @@
* - insights.emptyState.* (B4 — per-metric empty states)
* - notifications.admin.* (B5 — admin notification messages)
* - notifications.user.* (B5 — user-facing Telegram test body)
* - personalRecords + workouts (BL-P4-8 — PR + Workout strings)
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
@@ -46,18 +45,6 @@ for (const locale of LOCALES) {
FLAT_BY_LOCALE.set(locale, flat);
}
function keysForPrefix(prefix: string): Set<string> {
// Source of truth: EN. The locale-integrity test ensures every
// locale carries the same key shape, so EN's view is canonical.
const enFlat = FLAT_BY_LOCALE.get("en");
if (!enFlat) throw new Error("EN bundle did not load");
const set = new Set<string>();
for (const k of enFlat.keys()) {
if (k === prefix || k.startsWith(`${prefix}.`)) set.add(k);
}
return set;
}
const REQUIRED_KEY_SETS: Array<{
label: string;
required: string[];
@@ -143,39 +130,3 @@ describe("v1.4.27 B6 i18n drift-guard — required keys present in every locale"
});
}
});
describe("v1.4.27 B6 i18n drift-guard — PR + Workout strings stay in lockstep", () => {
// BL-P4-8: PR and Workout strings were the trigger for the
// drift-guard ask. Personal-record badge / tooltip live under
// `insights.personalRecord.*`. Workout strings are not yet in the
// bundles — the v1.4.23 batch ingest landed the API, the UI
// surface ships in v1.4.28 / v1.5. The drift-guard keeps the
// namespace anchor live so the workout copy work in v1.4.28 lands
// with parity from the first commit.
const prKeys = [...keysForPrefix("insights.personalRecord")];
it("insights.personalRecord namespace has at least one key (sanity)", () => {
expect(
prKeys.length,
"insights.personalRecord.* keys exist in en.json",
).toBeGreaterThan(0);
});
it.each(LOCALES)(
"messages/%s.json carries every insights.personalRecord.* key with a non-empty value",
(locale) => {
const flat = FLAT_BY_LOCALE.get(locale)!;
const missing: string[] = [];
const empty: string[] = [];
for (const key of prKeys) {
const v = flat.get(key);
if (v === undefined) missing.push(key);
else if (v.trim() === "") empty.push(key);
}
expect(
{ missing, empty },
`Locale ${locale} drifted from the personal-record key set.`,
).toEqual({ missing: [], empty: [] });
},
);
});
@@ -444,12 +444,10 @@ const V143_FIELDS: ResolvedFieldEntry[] = [
{
field: "GET /api/personal-records benchmark",
props: ["awards.ts#benchmark"],
readers: [
{
file: "src/components/insights/personal-record-badge.tsx",
pattern: /fresh\.benchmark/,
},
],
iosOnly: {
reason:
"the band was added for the native records screen (ios #116 item 5); no web surface reads `/api/personal-records` since the never-mounted badge that did was deleted",
},
},
{
field: "GET /api/mood/insights patterns",
@@ -284,9 +284,6 @@ const PINNED_AFFORDANCES: Record<
"toast.success": 4,
},
"src/components/medications/intake-edit-dialog.tsx": { "toast.success": 1 },
"src/components/medications/intake-history-editable.tsx": {
"toast.success": 2,
},
"src/components/medications/intake-history-list-v2.tsx": {
"text-success": 1,
},
@@ -1,109 +0,0 @@
/**
* v1.4.25 — `<PersonalRecordBadge>` rendering.
*
* The badge renders a small "PR" pill when the user achieved an
* all-time best for the metric in the last 30 days. Tests cover:
* 1. Renders the pill when a fresh PR exists.
* 2. Stays silent when no PR exists for the metric.
* 3. Stays silent when the only PR is older than 30 days.
* 4. Surfaces metric-type and PR-value via data-attrs / tooltip.
* 5. EN + DE locale parity.
*/
import { describe, it, expect, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "@/lib/i18n/context";
// React-Query is mocked the same way the other insights tests do it —
// we control the `data` field per-test so the SSR tree is deterministic.
type Row = {
id: string;
metricType: string;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
};
let mockRows: Row[] = [];
vi.mock("@tanstack/react-query", () => ({
// `dataUpdatedAt` mirrors what TanStack Query produces after a
// successful refetch — `Date.now()` is fine inside the test
// (the React-purity rule is component-scoped).
useQuery: () => ({
data: mockRows,
isLoading: false,
dataUpdatedAt: Date.now(),
}),
}));
import { PersonalRecordBadge } from "../personal-record-badge";
function render(node: React.ReactNode, locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>{node}</I18nProvider>,
);
}
function freshPR(over: Partial<Row> = {}): Row {
return {
id: "pr-1",
metricType: "ACTIVITY_STEPS",
metricSlot: null,
value: 18432,
unit: "count",
achievedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
...over,
};
}
describe("<PersonalRecordBadge>", () => {
it("renders the PR pill when an all-time best lands in the last 30 days", () => {
mockRows = [freshPR()];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).toMatch(/data-slot="insights-pr-badge"/);
expect(html).toMatch(/data-metric-type="ACTIVITY_STEPS"/);
expect(html).toContain("PR");
});
it("renders nothing when no PR exists for the metric", () => {
mockRows = [];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("stays silent when the PR is older than 30 days", () => {
const stale = new Date(Date.now() - 45 * 24 * 60 * 60 * 1000).toISOString();
mockRows = [freshPR({ achievedAt: stale })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("ignores workout-slot rows on the plain-metric badge", () => {
mockRows = [freshPR({ metricSlot: "longest_run_duration" })];
const html = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
);
expect(html).not.toMatch(/data-slot="insights-pr-badge"/);
});
it("uses the same 'PR' literal across EN and DE locales", () => {
mockRows = [freshPR()];
const en = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"en",
);
const de = render(
<PersonalRecordBadge metricType="ACTIVITY_STEPS" withTooltip={false} />,
"de",
);
expect(en).toContain("PR");
expect(de).toContain("PR");
});
});
@@ -1,171 +0,0 @@
"use client";
/**
* v1.4.25 — Personal-record "PR" pill.
*
* Renders a small, text-only "PR" tag next to a metric label when the
* given metric reached an all-time best in the last 30 days. Pulls
* directly from `GET /api/personal-records?metricType=<type>` so the
* server's `PersonalRecord` rows are the single source of truth — no
* client-side recomputation, no risk of drift against the doctor
* report's PR list.
*
* Design choices:
* - Text-only (no celebratory emoji / no animation). The badge is
* informational and the same Recharts-restrained aesthetic the
* rest of the trend tiles use.
* - Tooltip is opt-in via the `withTooltip` prop. The dashboard's
* tile strip already runs a `TooltipProvider`, so the badge can
* drop straight in. Standalone uses (correlation cards, doctor
* report) pass `withTooltip={false}` to keep the DOM minimal.
* - Query is per-metric — 14 PR-trackable metrics × ~1 KB response =
* small enough that 14 idle queries on the dashboard cost less
* than one Recharts re-render. TanStack Query coalesces.
*/
import { useQuery } from "@tanstack/react-query";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { MeasurementType } from "@/generated/prisma/client";
import { cn } from "@/lib/utils";
import { ScorePill } from "@/components/ui/score-pill";
interface PersonalRecordRow {
id: string;
metricType: MeasurementType;
metricSlot: string | null;
value: number;
unit: string;
achievedAt: string;
/**
* v1.43 — the cited band for the age the record was set at, resolved on
* the server; null (or absent on an older server) when none applies.
*/
benchmark?: {
low: number;
high: number;
unit: string;
source: string;
sourceLabel: string;
} | null;
}
interface PersonalRecordBadgeProps {
metricType: MeasurementType;
/** Render a wrapping Tooltip with the PR value + date. Default true. */
withTooltip?: boolean;
/** Optional className passthrough so callers can position the pill. */
className?: string;
}
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
/**
* Pure freshness check kept out of the component body so the React
* purity linter doesn't trip on the `Date.now()` reference; callers
* pass `now` from the surrounding query refetch boundary where one
* stale-time tick = one new clock reading.
*/
function isPRWithin30Days(achievedAtIso: string, now: number): boolean {
const ts = new Date(achievedAtIso).getTime();
return Number.isFinite(ts) && now - ts <= THIRTY_DAYS_MS;
}
export function PersonalRecordBadge({
metricType,
withTooltip = true,
className,
}: PersonalRecordBadgeProps) {
const { t } = useTranslations();
const fmt = useFormatters();
const { data, dataUpdatedAt } = useQuery({
// Per-metric queryKey so two tiles for two different metrics never
// share a cache slot (HealthLog convention — see
// `feedback_react_query_key_collision`).
queryKey: queryKeys.personalRecordsByMetric(metricType),
queryFn: async () => {
try {
const data = await apiGet<PersonalRecordRow[] | null>(
`/api/personal-records?metricType=${encodeURIComponent(metricType)}`,
);
return data ?? [];
} catch {
// The badge is decorative — degrade to "no records", as before.
return [] as PersonalRecordRow[];
}
},
// Per-tile data: stale-while-revalidate cadence matches the
// dashboard's existing trend queries. 5 minutes is enough to
// absorb the worker's 30-minute fallback cadence without
// surfacing stale state for long.
staleTime: 5 * 60 * 1000,
});
// `dataUpdatedAt` is TanStack Query's per-query refetch timestamp
// — a deterministic clock reading that React's purity linter
// accepts because it's hook-derived. Each refetch advances it,
// shifting the freshness window forward by exactly the stale time.
// On the very first render (before the query has resolved)
// `dataUpdatedAt` is 0, so the freshness filter rejects everything
// until the response lands — desired behaviour for the SSR path.
const fresh = (data ?? []).find(
(row) =>
row.metricSlot === null &&
isPRWithin30Days(row.achievedAt, dataUpdatedAt),
);
if (!fresh) return null;
const pill = (
<ScorePill
tone="success"
numeric
data-slot="insights-pr-badge"
data-metric-type={metricType}
className={cn("tracking-wide uppercase", className)}
aria-label={t("insights.personalRecord.badge")}
>
{t("insights.personalRecord.badge")}
</ScorePill>
);
if (!withTooltip) return pill;
const formattedDate = fmt.date(new Date(fresh.achievedAt));
const tooltipText = t("insights.personalRecord.tooltip", {
value: fmt.number(fresh.value, 1),
unit: fresh.unit,
date: formattedDate,
});
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent
className="bg-muted border-border text-foreground"
data-slot="insights-pr-badge-tooltip"
>
<span className="text-xs">{tooltipText}</span>
{fresh.benchmark ? (
<span
className="text-muted-foreground block text-xs"
data-slot="insights-pr-badge-benchmark"
>
{t("insights.personalRecord.benchmark", {
low: fmt.number(fresh.benchmark.low, 1),
high: fmt.number(fresh.benchmark.high, 1),
unit: fresh.benchmark.unit,
source: fresh.benchmark.sourceLabel,
})}
</span>
) : null}
</TooltipContent>
</Tooltip>
);
}
@@ -1,108 +0,0 @@
/**
* v1.5.5 — `<IntakeHistoryPreview>` smoke tests.
*
* The preview mounts in SSR with an empty selection so the bulk-
* delete toolbar stays hidden by default. Selection / dialog flips
* are interactive surfaces and are exercised by the e2e suite.
*
* The kebab + selection wiring is covered by
* `intake-history-list-v2-row-actions.test.tsx`; this file pins the
* wrapper-level contract (no toolbar by default, wired through the
* v1.5.5 endpoints).
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { IntakeHistoryPreview } from "@/components/medications/sections/intake-history-preview";
import { queryKeys } from "@/lib/query-keys";
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "test-user", username: "tester", role: "USER" },
isAuthenticated: true,
isLoading: false,
}),
}));
function makeClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: Number.POSITIVE_INFINITY,
refetchOnMount: false,
},
},
});
}
function seed(client: QueryClient) {
client.setQueryData(
queryKeys.medicationIntakeList("med-1", {
sortBy: "takenAt",
sortDir: "desc",
limit: 14,
offset: 0,
status: "completed",
}),
{
events: [
{
id: "evt-1",
medicationId: "med-1",
scheduledFor: "2026-05-15T08:00:00.000Z",
takenAt: "2026-05-15T08:02:00.000Z",
skipped: false,
source: "WEB",
createdAt: "2026-05-15T08:02:00.000Z",
},
],
meta: { total: 1, limit: 14, offset: 0 },
},
);
}
function render(node: React.ReactNode, client: QueryClient) {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<QueryClientProvider client={client}>{node}</QueryClientProvider>
</I18nProvider>,
);
}
describe("<IntakeHistoryPreview> — F-1 C-2", () => {
it("hides the bulk-delete toolbar when nothing is selected (initial render)", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
expect(html).not.toContain("intake-history-bulk-delete-toolbar");
});
it("renders the row-action kebab and selection checkbox on every row", () => {
const client = makeClient();
seed(client);
const html = render(
<IntakeHistoryPreview
medicationId="med-1"
importOpen={false}
onImportOpenChange={() => {}}
/>,
client,
);
// Per-row affordances feature 15 + 16 — the row carries both.
expect(html).toContain("intake-history-row-kebab");
expect(html).toContain("intake-history-row-select");
// The import header CTA stays alongside the new affordances.
expect(html).toContain("intake-history-import");
});
});
@@ -1,283 +0,0 @@
"use client";
/**
* v1.7.0 — shared editable intake-history surface.
*
* Extracted from `<IntakeHistoryPreview>` so the detail-page preview
* (14-row, `takenAt` sort) and the full-history view (25-row,
* `scheduledFor` sort) share one state machine: multi-select
* bulk-delete, per-row edit (`<IntakeEditDialog>`) and per-row delete
* (`<AlertDialog>`). The component owns the table + every editing
* dialog; it does NOT own the CSV-import dialog (that stays lifted to
* the hosting page so it mounts exactly once).
*
* Both surfaces render the same `<IntakeHistoryListV2>`; the wrapper
* just changes `pageSize` + `defaultSortBy`. The full-history route
* defaults to `scheduledFor desc` so skipped rows (`takenAt: null`)
* never float to the top of the table (O-1).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Trash2 } from "lucide-react";
import { toast } from "sonner";
import { showErrorToast } from "@/lib/errors/error-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { IntakeEditDialog } from "@/components/medications/intake-edit-dialog";
import {
IntakeHistoryListV2,
type IntakeEvent,
type IntakeHistorySortKey,
} from "@/components/medications/intake-history-list-v2";
import { useTranslations } from "@/lib/i18n/context";
import { invalidateMedicationReads } from "@/lib/query-keys";
import { apiDelete, apiPost } from "@/lib/api/api-fetch";
export interface IntakeHistoryEditableProps {
medicationId: string;
/** Override the default page size — preview 14, full history 25. */
pageSize?: number;
/**
* Initial sort column. The full-history view passes `"scheduledFor"`
* so the chronological order stays clean even for skipped rows
* (`takenAt: null`); the preview keeps the legacy `"takenAt"`.
*/
defaultSortBy?: IntakeHistorySortKey;
}
export function IntakeHistoryEditable({
medicationId,
pageSize,
defaultSortBy,
}: IntakeHistoryEditableProps) {
const { t } = useTranslations();
// v1.36.x — bulk-deleting recorded doses is the owner's alone.
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
const queryClient = useQueryClient();
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editingEvent, setEditingEvent] = useState<IntakeEvent | null>(null);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [bulkBusy, setBulkBusy] = useState(false);
const [rowDeleteBusy, setRowDeleteBusy] = useState(false);
function onToggle(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
function clearSelection() {
setSelected(new Set());
}
async function confirmBulkDelete() {
if (bulkBusy || selected.size === 0) return;
setBulkBusy(true);
const ids = Array.from(selected);
try {
await apiPost(`/api/medications/${medicationId}/intake/bulk-delete`, {
eventIds: ids,
});
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.bulkDelete.toast"));
setBulkConfirmOpen(false);
clearSelection();
} catch (failure) {
showErrorToast(t("medications.detail.intake.bulkDelete.failed"), {
cause: failure,
});
} finally {
setBulkBusy(false);
}
}
async function confirmRowDelete() {
if (rowDeleteBusy || !pendingDeleteId) return;
setRowDeleteBusy(true);
const id = pendingDeleteId;
try {
await apiDelete(`/api/medications/${medicationId}/intake/${id}`);
await invalidateMedicationReads(queryClient);
toast.success(t("medications.detail.intake.deleteRow.toast"));
setPendingDeleteId(null);
// Drop the deleted row from the pending selection so the
// bulk-toolbar count stays honest.
setSelected((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
} catch (failure) {
showErrorToast(t("medications.detail.intake.deleteRow.failed"), {
cause: failure,
});
} finally {
setRowDeleteBusy(false);
}
}
return (
<>
<div className="space-y-3" data-slot="intake-history-editable-body">
{canManageMedications && selected.size > 0 && (
<div
className="border-border bg-muted/40 flex flex-wrap items-center justify-between gap-2 rounded-md border p-2"
role="region"
aria-label={t(
"medications.detail.intake.bulkDelete.selectionCount",
{ count: selected.size },
)}
data-slot="intake-history-bulk-delete-toolbar"
>
<span className="text-foreground text-sm font-medium">
{t("medications.detail.intake.bulkDelete.selectionCount", {
count: selected.size,
})}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={clearSelection}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-cancel"
>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => setBulkConfirmOpen(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-bulk-delete"
>
<Trash2 aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.bulkDelete.deleteButton")}
</Button>
</div>
</div>
)}
<IntakeHistoryListV2
medicationId={medicationId}
pageSize={pageSize}
defaultSortBy={defaultSortBy}
onEditIntake={setEditingEvent}
onDeleteIntake={setPendingDeleteId}
selection={{
mode: "multi",
selected,
onToggle,
}}
/>
</div>
<IntakeEditDialog
medicationId={medicationId}
event={
editingEvent
? {
id: editingEvent.id,
takenAt: editingEvent.takenAt,
skipped: editingEvent.skipped,
scheduledFor: editingEvent.scheduledFor,
}
: null
}
onClose={() => setEditingEvent(null)}
/>
<AlertDialog
open={bulkConfirmOpen}
onOpenChange={(open) => {
if (!open) setBulkConfirmOpen(false);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.bulkDelete.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.bulkDelete.confirmBody", {
count: selected.size,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void confirmBulkDelete();
}}
disabled={bulkBusy}
aria-busy={bulkBusy || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.bulkDelete.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!pendingDeleteId}
onOpenChange={(open) => {
if (!open) setPendingDeleteId(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("medications.detail.intake.deleteRow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("medications.detail.intake.deleteRow.confirmBody")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rowDeleteBusy}>
{t("medications.detail.intake.bulkDelete.cancelButton")}
</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void confirmRowDelete();
}}
disabled={rowDeleteBusy}
aria-busy={rowDeleteBusy || undefined}
className="bg-destructive dark:bg-destructive/60 hover:bg-destructive/90 text-white"
>
{t("medications.detail.intake.deleteRow.confirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -1,74 +0,0 @@
"use client";
/**
* v1.5.5 D-3 §9.5 — Intake-history preview.
*
* Section chrome via `<MedicationDetailSection>`. Header CTA carries a
* CSV-import trigger. The body re-uses the shared
* `<IntakeHistoryEditable>` (extracted in v1.7.0) so the table + sort +
* pagination + multi-select bulk-delete + per-row edit/delete stay
* identical between this preview and the full-history view.
*
* v1.7.0 — the footer "View full history →" link is gone; the detail
* header's History button now routes directly to the full view, so the
* redundant link is removed (R-medui §4.3).
*/
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { Upload } from "lucide-react";
import { Button } from "@/components/ui/button";
import { IntakeHistoryEditable } from "@/components/medications/intake-history-editable";
import { IntakeImportDialog } from "@/components/medications/intake-import-dialog";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
import { useTranslations } from "@/lib/i18n/context";
export interface IntakeHistoryPreviewProps {
medicationId: string;
importOpen: boolean;
onImportOpenChange: (open: boolean) => void;
}
export function IntakeHistoryPreview({
medicationId,
importOpen,
onImportOpenChange,
}: IntakeHistoryPreviewProps) {
const { t } = useTranslations();
// v1.36.x — importing dose history is not a delegated verb.
const { canManageDomain } = useRecordCapabilities();
const canManageMedications = canManageDomain("medications");
return (
<>
<MedicationDetailSection
titleId="medication-detail-intake-history-heading"
title={t("medications.detail.intake.title")}
dataSlot="medication-detail-intake-history-section"
headerExtras={
canManageMedications ? (
<Button
variant="outline"
size="sm"
onClick={() => onImportOpenChange(true)}
className="min-h-11 sm:min-h-9"
data-slot="intake-history-import"
>
<Upload aria-hidden="true" className="h-4 w-4" />
{t("medications.detail.intake.importButton")}
</Button>
) : null
}
>
<IntakeHistoryEditable medicationId={medicationId} pageSize={14} />
</MedicationDetailSection>
{importOpen && (
<IntakeImportDialog
medicationId={medicationId}
onClose={() => onImportOpenChange(false)}
/>
)}
</>
);
}