-
- {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 (
- -
-
-
-
- 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"