From 5532a42ff61e501ff93e57580f9dbfdccff4d927 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sat, 18 Jul 2026 03:12:58 +0200
Subject: [PATCH] fix(ui): mobile stacked intake history, icon-collapse bulk
bar, ResponsiveSheet parity, tap-target floor
Intake history stayed a 4-5 column table with two full datetime columns
on mobile, forcing sideways scroll of a primary history surface; add
the stacked card-list split below md (the measurement-list pattern) and
lift the row kebab off a hard 32px box onto the size-11/size-9 floor.
The documents bulk-action bar wrapped five labelled buttons into a
2-3 row floating slab at 360px; collapse to icon-only (aria-label
carries the name) below sm, matching the document-detail-sheet
footer's established pattern.
Two coach/medication modals were still on raw Dialog while every
sibling had moved to ResponsiveSheet: the schedule-era editor and the
document attachment picker. Both now share the bottom-sheet-on-mobile
primitive; the era editor's submit button associates to its form via
the native `form` attribute since the footer slot sits outside the
form's DOM subtree (the log-intake-dialog precedent).
Tap-target floor: side-effect entry chips and the coach attachment-pill
remove control were below the 44px floor; the chips get a min-h-11
flex-center, the pill remove gets an invisible hit-slop instead of
growing the pill's visual footprint. Two insights empty-state CTAs
were missing the existing `ctaSize="lg"` treatment already used by
their sibling empty states.
---
src/app/insights/page.tsx | 2 +
.../documents/document-bulk-bar.tsx | 38 +-
.../coach-panel/attachment-picker.tsx | 207 ++++----
.../insights/coach-panel/attachment-pills.tsx | 7 +-
.../medications/intake-history-list-v2.tsx | 483 ++++++++++++------
.../scheduling/schedule-history-timeline.tsx | 223 ++++----
.../medications/side-effects-section.tsx | 2 +-
7 files changed, 565 insertions(+), 397 deletions(-)
diff --git a/src/app/insights/page.tsx b/src/app/insights/page.tsx
index 473b782df..4d8ea36cd 100644
--- a/src/app/insights/page.tsx
+++ b/src/app/insights/page.tsx
@@ -371,6 +371,7 @@ export default function InsightsPage() {
icon={ }
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
+ ctaSize="lg"
action={
@@ -568,6 +569,7 @@ export default function InsightsPage() {
icon={ }
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
+ ctaSize="lg"
action={
diff --git a/src/components/documents/document-bulk-bar.tsx b/src/components/documents/document-bulk-bar.tsx
index 1d57acbfe..3c677d12c 100644
--- a/src/components/documents/document-bulk-bar.tsx
+++ b/src/components/documents/document-bulk-bar.tsx
@@ -75,11 +75,23 @@ export function DocumentBulkBar({
+ {/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
+ 2–3-row floating slab at ~360 px. Below `sm` every verb collapses
+ to icon-only (`aria-label` carries the accessible name; the
+ visible label returns at `sm+`) — the `document-detail-sheet`
+ footer's established icon-collapse pattern. */}
-
+
- {t("documents.bulk.setKind")}
+
+ {t("documents.bulk.setKind")}
+
@@ -98,9 +110,16 @@ export function DocumentBulkBar({
{episodes.length > 0 ? (
-
+
- {t("documents.bulk.linkCondition")}
+
+ {t("documents.bulk.linkCondition")}
+
@@ -122,9 +141,10 @@ export function DocumentBulkBar({
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
+ aria-label={t("documents.bulk.share")}
>
- {t("documents.bulk.share")}
+ {t("documents.bulk.share")}
{/* Solid destructive (matching the detail sheet's Delete) — the
@@ -135,9 +155,10 @@ export function DocumentBulkBar({
size="sm"
disabled={busy}
onClick={onDelete}
+ aria-label={t("documents.bulk.delete")}
>
- {t("documents.bulk.delete")}
+ {t("documents.bulk.delete")}
- {t("documents.selection.clear")}
+
+ {t("documents.selection.clear")}
+
diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx
index 5752523ec..f5a9de002 100644
--- a/src/components/insights/coach-panel/attachment-picker.tsx
+++ b/src/components/insights/coach-panel/attachment-picker.tsx
@@ -5,14 +5,7 @@ import { useQuery } from "@tanstack/react-query";
import { Check, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
@@ -128,18 +121,39 @@ export function AttachmentPicker({
}
return (
-
-
+ handleOpenChange(false)}
+ data-slot="coach-attachment-picker-cancel"
+ >
+ {t("common.cancel")}
+
+
+ {t("insights.coach.attach.pickerConfirm", {
+ count: selected.length,
+ })}
+
+ >
+ }
+ >
+
-
- {t("insights.coach.attach.pickerTitle")}
-
- {t("insights.coach.attach.pickerDescription")}
-
-
-
-
- {list.isPending && open ? (
-
- {Array.from({ length: 4 }, (_, i) => (
-
- ))}
-
- ) : list.isError ? (
-
- {t("documents.list.loadError")}
-
- ) : documents.length === 0 ? (
-
- {t("insights.coach.attach.pickerEmpty")}
-
- ) : (
-
- {documents.map((doc) => {
- const isSelected = selected.includes(doc.id);
- const selectable = doc.hasContentIndex;
- const disabled = !selectable || (!isSelected && capReached);
- const title =
- doc.title ?? doc.filename ?? t("documents.card.untitled");
- const Icon = DOCUMENT_KIND_ICONS[doc.kind];
- return (
-
- selectable && toggle(doc.id)}
- data-slot="coach-attachment-picker-item"
- data-selected={isSelected ? "true" : undefined}
- className={cn(
- "border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
- "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
- isSelected && "border-primary/40 bg-primary/5",
- disabled &&
- "cursor-not-allowed opacity-60 hover:bg-transparent",
- )}
- >
-
-
-
- {title}
-
- {!selectable ? (
-
- {t("insights.coach.attach.pickerNotIndexed")}
-
- ) : null}
+ {list.isPending && open ? (
+
+ {Array.from({ length: 4 }, (_, i) => (
+
+ ))}
+
+ ) : list.isError ? (
+
+ {t("documents.list.loadError")}
+
+ ) : documents.length === 0 ? (
+
+ {t("insights.coach.attach.pickerEmpty")}
+
+ ) : (
+
+ {documents.map((doc) => {
+ const isSelected = selected.includes(doc.id);
+ const selectable = doc.hasContentIndex;
+ const disabled = !selectable || (!isSelected && capReached);
+ const title =
+ doc.title ?? doc.filename ?? t("documents.card.untitled");
+ const Icon = DOCUMENT_KIND_ICONS[doc.kind];
+ return (
+
+ selectable && toggle(doc.id)}
+ data-slot="coach-attachment-picker-item"
+ data-selected={isSelected ? "true" : undefined}
+ className={cn(
+ "border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
+ "focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
+ isSelected && "border-primary/40 bg-primary/5",
+ disabled &&
+ "cursor-not-allowed opacity-60 hover:bg-transparent",
+ )}
+ >
+
+
+
+ {title}
-
-
-
- );
- })}
-
- )}
-
-
-
- handleOpenChange(false)}
- data-slot="coach-attachment-picker-cancel"
- >
- {t("common.cancel")}
-
-
- {t("insights.coach.attach.pickerConfirm", {
- count: selected.length,
+ {!selectable ? (
+
+ {t("insights.coach.attach.pickerNotIndexed")}
+
+ ) : null}
+
+
+
+
+ );
})}
-
-
-
-
+
+ )}
+
+
);
}
diff --git a/src/components/insights/coach-panel/attachment-pills.tsx b/src/components/insights/coach-panel/attachment-pills.tsx
index e4f548c1e..5c6cd5066 100644
--- a/src/components/insights/coach-panel/attachment-pills.tsx
+++ b/src/components/insights/coach-panel/attachment-pills.tsx
@@ -85,7 +85,12 @@ export function AttachmentPills({
title: item.title,
})}
title={t("insights.coach.attach.pillRemove", { title: item.title })}
- className="text-muted-foreground hover:text-foreground size-5 shrink-0 rounded-full"
+ // v1.30 mobile audit (C-4) — the visible glyph stays a compact
+ // size-5 pill control, but the actual hit area extends to the
+ // 44px floor via an invisible `after` hit-slop (the same
+ // `measurement-list.tsx` checkbox trick) rather than growing the
+ // pill's visual footprint in the composer's attach row.
+ className="text-muted-foreground hover:text-foreground relative size-5 shrink-0 rounded-full after:absolute after:-inset-3"
>
diff --git a/src/components/medications/intake-history-list-v2.tsx b/src/components/medications/intake-history-list-v2.tsx
index ad2d831ca..b1c921526 100644
--- a/src/components/medications/intake-history-list-v2.tsx
+++ b/src/components/medications/intake-history-list-v2.tsx
@@ -15,12 +15,14 @@ import {
} from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
+import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
+import { ListRow } from "@/components/ui/list-row";
import {
DropdownMenu,
DropdownMenuContent,
@@ -160,6 +162,12 @@ export function IntakeHistoryListV2({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const formatters = useFormatters();
+ // v1.30 mobile audit (M-2) — the desktop table carries two full datetime
+ // columns plus status + source + kebab, forcing horizontal scroll on
+ // every phone. SSR-safe gate (mirrors `measurement-list.tsx`'s H3 split):
+ // resolves to the desktop table on the server + first paint, then flips
+ // to the stacked list after hydration.
+ const isMobile = useIsMobile("md");
// v1.5.5 F-1 C-2 — the kebab + checkbox columns are gated by the
// caller-passed callbacks. The list never assumes a wrapper; it
@@ -265,182 +273,266 @@ export function IntakeHistoryListV2({
/>
) : (
<>
-
-
-
- {showSelection && (
-
- )}
-
- toggleSort("takenAt")}
- className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
- aria-label={t("medications.intakeHistorySortByTaken")}
- >
- {t("medications.intakeHistoryColTaken")}
- {sortIndicator("takenAt")}
-
-
-
- toggleSort("scheduledFor")}
- className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
- aria-label={t("medications.intakeHistorySortByScheduled")}
- >
- {t("medications.intakeHistoryColScheduled")}
- {sortIndicator("scheduledFor")}
-
-
-
- {t("medications.intakeHistoryColStatus")}
-
-
- {t("medications.intakeHistoryColSource")}
-
- {showRowActions && (
-
- )}
-
-
-
- {events.map((event) => {
- // v1.4.37 W3 — `status=completed` server filter
- // guarantees that every row is either a taken or a
- // skipped event. We derive the branch from the data
- // itself rather than trusting a single `skipped`
- // flag so a malformed row (e.g. `skipped:false` with
- // `takenAt:null`, the v1.4.36 regression) never
- // sneaks past as "Eingenommen".
- const isTaken = !event.skipped && !!event.takenAt;
- const isSkipped = event.skipped;
- const isSelected = selection?.selected.has(event.id) ?? false;
- return (
-
- {showSelection && (
-
-
- selection?.onToggle(event.id)
- }
- aria-label={t(
- "medications.detail.intake.selection.rowToggleLabel",
- )}
- data-slot="intake-history-row-select"
- />
-
- )}
-
- {/* v1.7.0 — the date column is always populated:
- taken rows show `takenAt`, skipped rows fall
- back to `scheduledFor` with a muted
- "(planned)" suffix so the chronological
- order stays clean and no `—` row floats. */}
- {event.takenAt ? (
- formatters.dateTime(event.takenAt)
- ) : (
- <>
- {formatters.dateTime(event.scheduledFor)}{" "}
-
- ({t("medications.detail.history.plannedSuffix")})
-
- >
+ {/* v1.30 mobile audit (M-2) — the two-datetime-column table is
+ contained (Table self-wraps in overflow-x-auto) but forces
+ sideways scroll of a primary history surface on every phone.
+ Below `md` it swaps for a stacked card list (the
+ `measurement-list.tsx` pattern); only the active layout
+ mounts, mirroring that component's SSR-safe gate. */}
+ {!isMobile && (
+
+
+
+ {showSelection && (
+
+ )}
+
+ toggleSort("takenAt")}
+ className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
+ aria-label={t("medications.intakeHistorySortByTaken")}
+ >
+ {t("medications.intakeHistoryColTaken")}
+ {sortIndicator("takenAt")}
+
+
+
+ toggleSort("scheduledFor")}
+ className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
+ aria-label={t(
+ "medications.intakeHistorySortByScheduled",
)}
-
-
- {formatters.dateTime(event.scheduledFor)}
-
-
- {isTaken ? (
-
-
- {t("medications.intakeHistoryStatusTaken")}
-
- ) : isSkipped ? (
+ >
+ {t("medications.intakeHistoryColScheduled")}
+ {sortIndicator("scheduledFor")}
+
+
+
+ {t("medications.intakeHistoryColStatus")}
+
+
+ {t("medications.intakeHistoryColSource")}
+
+ {showRowActions && (
+
+ )}
+
+
+
+ {events.map((event) => {
+ // v1.4.37 W3 — `status=completed` server filter
+ // guarantees that every row is either a taken or a
+ // skipped event. We derive the branch from the data
+ // itself rather than trusting a single `skipped`
+ // flag so a malformed row (e.g. `skipped:false` with
+ // `takenAt:null`, the v1.4.36 regression) never
+ // sneaks past as "Eingenommen".
+ const isTaken = !event.skipped && !!event.takenAt;
+ const isSkipped = event.skipped;
+ const isSelected =
+ selection?.selected.has(event.id) ?? false;
+ return (
+
+ {showSelection && (
+
+
+ selection?.onToggle(event.id)
+ }
+ aria-label={t(
+ "medications.detail.intake.selection.rowToggleLabel",
+ )}
+ data-slot="intake-history-row-select"
+ />
+
+ )}
+
+ {/* v1.7.0 — the date column is always populated:
+ taken rows show `takenAt`, skipped rows fall
+ back to `scheduledFor` with a muted
+ "(planned)" suffix so the chronological
+ order stays clean and no `—` row floats. */}
+ {event.takenAt ? (
+ formatters.dateTime(event.takenAt)
+ ) : (
+ <>
+ {formatters.dateTime(event.scheduledFor)}{" "}
+
+ (
+ {t(
+ "medications.detail.history.plannedSuffix",
+ )}
+ )
+
+ >
+ )}
+
+
+ {formatters.dateTime(event.scheduledFor)}
+
+
+ {isTaken ? (
+
+
+ {t("medications.intakeHistoryStatusTaken")}
+
+ ) : isSkipped ? (
+
+
+ {t("medications.intakeHistoryStatusSkipped")}
+
+ ) : null}
+
+
-
- {t("medications.intakeHistoryStatusSkipped")}
+ {t("medications.intakeSourceVia", {
+ origin:
+ sourceLabels[event.source] ?? event.source,
+ })}
- ) : null}
-
-
-
- {t("medications.intakeSourceVia", {
- origin: sourceLabels[event.source] ?? event.source,
- })}
-
-
- {showRowActions && (
-
-
-
-
-
-
-
-
- {onEditIntake && (
- onEditIntake(event)}
- data-slot="intake-history-row-edit"
- >
- {t(
- "medications.detail.intake.rowActions.edit",
- )}
-
- )}
- {onDeleteIntake && (
- onDeleteIntake(event.id)}
- data-slot="intake-history-row-delete"
- className="text-destructive focus:text-destructive"
- >
- {t(
- "medications.detail.intake.rowActions.delete",
- )}
-
- )}
-
-
- )}
-
+ {showRowActions && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ {isMobile && (
+
+ {events.map((event) => {
+ const isTaken = !event.skipped && !!event.takenAt;
+ const isSkipped = event.skipped;
+ const isSelected =
+ selection?.selected.has(event.id) ?? false;
+ return (
+
+
+
+ {showSelection && (
+
+
+ selection?.onToggle(event.id)
+ }
+ aria-label={t(
+ "medications.detail.intake.selection.rowToggleLabel",
+ )}
+ data-slot="intake-history-row-select"
+ className="relative after:absolute after:-inset-3.5"
+ />
+
+ )}
+
+
+ {event.takenAt ? (
+ formatters.dateTime(event.takenAt)
+ ) : (
+ <>
+ {formatters.dateTime(event.scheduledFor)}{" "}
+
+ (
+ {t(
+ "medications.detail.history.plannedSuffix",
+ )}
+ )
+
+ >
+ )}
+
+
+ {t("medications.intakeHistoryColScheduled")}:{" "}
+ {formatters.dateTime(event.scheduledFor)}
+
+
+ {isTaken ? (
+
+
+ {t("medications.intakeHistoryStatusTaken")}
+
+ ) : isSkipped ? (
+
+
+ {t(
+ "medications.intakeHistoryStatusSkipped",
+ )}
+
+ ) : null}
+
+ {t("medications.intakeSourceVia", {
+ origin:
+ sourceLabels[event.source] ?? event.source,
+ })}
+
+
+
+
+ {showRowActions && (
+
+ )}
+
+
);
})}
-
-
+
+ )}
{totalPages > 1 && (
@@ -481,3 +573,58 @@ export function IntakeHistoryListV2({
);
}
+
+/**
+ * Per-row Bearbeiten/Löschen kebab, shared by the desktop table row and the
+ * mobile stacked-card row. v1.30 mobile audit (M-1) — was a hard `h-8 w-8`
+ * (32 px), sub-floor in an edit flow and inconsistent with the sort headers
+ * in this same file and the dose-history ledger's kebab; now `size-11
+ * sm:size-9` like the rest of the app's icon-button floor.
+ */
+function IntakeRowActions({
+ event,
+ onEditIntake,
+ onDeleteIntake,
+ t,
+}: {
+ event: IntakeEvent;
+ onEditIntake?: (event: IntakeEvent) => void;
+ onDeleteIntake?: (eventId: string) => void;
+ t: ReturnType
["t"];
+}) {
+ return (
+
+
+
+
+
+
+
+ {onEditIntake && (
+ onEditIntake(event)}
+ data-slot="intake-history-row-edit"
+ >
+ {t("medications.detail.intake.rowActions.edit")}
+
+ )}
+ {onDeleteIntake && (
+ onDeleteIntake(event.id)}
+ data-slot="intake-history-row-delete"
+ className="text-destructive focus:text-destructive"
+ >
+ {t("medications.detail.intake.rowActions.delete")}
+
+ )}
+
+
+ );
+}
diff --git a/src/components/medications/scheduling/schedule-history-timeline.tsx b/src/components/medications/scheduling/schedule-history-timeline.tsx
index 9105d4b14..987c9a3e7 100644
--- a/src/components/medications/scheduling/schedule-history-timeline.tsx
+++ b/src/components/medications/scheduling/schedule-history-timeline.tsx
@@ -28,7 +28,7 @@
* common case. Dates convert to instants at the browser's local
* midnight, matching the user's wall clock like every schedule input.
*/
-import { useMemo, useState } from "react";
+import { useId, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
@@ -51,14 +51,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
+import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
@@ -421,6 +414,12 @@ function EraDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
+ // v1.30 mobile audit (M-4) — the footer's submit Button lives outside the
+ // form's DOM subtree once the sheet swaps to `ResponsiveSheet` (the
+ // Sheet/Dialog primitive owns the footer slot), so it associates via the
+ // native `form` attribute rather than nesting inside `
+
);
}
diff --git a/src/components/medications/side-effects-section.tsx b/src/components/medications/side-effects-section.tsx
index 9b07cf638..ade1b2c43 100644
--- a/src/components/medications/side-effects-section.tsx
+++ b/src/components/medications/side-effects-section.tsx
@@ -450,7 +450,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
aria-checked={selected}
onClick={() => setEntry(candidate)}
{...getEntryRadioProps(index)}
- className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
+ className={`flex min-h-11 items-center justify-center rounded-full border px-2.5 py-1 text-xs transition-colors sm:min-h-9 ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"