From 66fe69b493ad9d36d3c607e1d776acc31a05cbbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 03:00:15 +0200 Subject: [PATCH 01/16] fix(ui): break-words on coach prose, hero/wellness skeleton parity, priority-card pending state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coach chat bubbles and the document OCR text panel wrapped long unbroken tokens (URLs, IDs) past their column width on narrow viewports — add break-words. Drop the /90 alpha on the coach error text so it stays on the AA-retuned status token. Today hero's loading skeleton had drifted from the v1.29.1 tightened hero (gap/items-start/space-y/rail-heading/xl:grid-cols-3), causing a visible shift on every dashboard load; mirror the loaded classes 1:1. The wellness-scores strip returned null while its shared batch loaded, so it popped in late and pushed the page below it down — render the section heading + tile-shaped skeletons instead, matching the vitals dashboard's loading pattern. Priority-card actions (coach check-in keep/let-go) had no pending state, so a slow round trip could be double-tapped into firing the mutation twice; disable the action row while in flight. Priority-item dismiss is now optimistic (removes the card from the rail on tap, rolls back on error) instead of waiting on invalidate-after-success. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/pl.json | 1 + src/components/daily/priority-card.tsx | 14 +++++ src/components/daily/today-hero-skeleton.tsx | 18 +++--- src/components/daily/today-hero.tsx | 4 ++ .../documents/document-ai-panels.tsx | 3 +- .../insights/coach-panel/chat-bubble.tsx | 6 +- .../__tests__/wellness-scores.test.tsx | 11 +++- .../insights/derived/wellness-scores.tsx | 60 +++++++++++++++++++ src/components/insights/prose-blocks.tsx | 2 +- src/hooks/use-priority-item-dismiss.ts | 33 ++++++++-- 15 files changed, 138 insertions(+), 19 deletions(-) diff --git a/messages/de.json b/messages/de.json index 6a3bb382f..e245834e0 100644 --- a/messages/de.json +++ b/messages/de.json @@ -3548,6 +3548,7 @@ "sectionTitle": "Deine Gesundheitswerte", "sectionSubtitle": "Zusammengesetzte Werte für Erholung, Stress und Belastung aus deinen verbundenen Geräten", "loadError": "Deine Wellness-Werte konnten gerade nicht geladen werden.", + "loadingLabel": "Deine Gesundheitswerte werden geladen", "recovery": "Erholung", "stress": "Stress", "strain": "Belastung" diff --git a/messages/en.json b/messages/en.json index 2edd6c69f..0ff68d971 100644 --- a/messages/en.json +++ b/messages/en.json @@ -3548,6 +3548,7 @@ "sectionTitle": "Your health scores", "sectionSubtitle": "Composite recovery, stress and strain read from your connected devices", "loadError": "Could not load your wellness scores right now.", + "loadingLabel": "Loading your health scores", "recovery": "Recovery", "stress": "Stress", "strain": "Strain" diff --git a/messages/es.json b/messages/es.json index fdc46ff71..19dfcd437 100644 --- a/messages/es.json +++ b/messages/es.json @@ -3548,6 +3548,7 @@ "sectionTitle": "Tus indicadores de salud", "sectionSubtitle": "Valores combinados de recuperación, estrés y esfuerzo leídos de tus dispositivos conectados", "loadError": "No se pudieron cargar tus puntuaciones de bienestar en este momento.", + "loadingLabel": "Cargando tus indicadores de salud", "recovery": "Recuperación", "stress": "Estrés", "strain": "Esfuerzo" diff --git a/messages/fr.json b/messages/fr.json index a476e572d..ef2e1927e 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -3548,6 +3548,7 @@ "sectionTitle": "Tes indicateurs de santé", "sectionSubtitle": "Valeurs combinées de récupération, de stress et d'effort issues de tes appareils connectés", "loadError": "Impossible de charger vos scores de bien-être pour le moment.", + "loadingLabel": "Chargement de tes indicateurs de santé", "recovery": "Récupération", "stress": "Stress", "strain": "Effort" diff --git a/messages/it.json b/messages/it.json index 074ac3ad0..7e453fc2c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -3548,6 +3548,7 @@ "sectionTitle": "I tuoi indicatori di salute", "sectionSubtitle": "Valori combinati di recupero, stress e sforzo letti dai tuoi dispositivi connessi", "loadError": "Al momento non è stato possibile caricare i tuoi punteggi di benessere.", + "loadingLabel": "Caricamento dei tuoi indicatori di salute", "recovery": "Recupero", "stress": "Stress", "strain": "Sforzo" diff --git a/messages/pl.json b/messages/pl.json index 07a0c8c46..15458dbe6 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -3548,6 +3548,7 @@ "sectionTitle": "Twoje wskaźniki zdrowia", "sectionSubtitle": "Złożone wartości regeneracji, stresu i obciążenia odczytane z Twoich połączonych urządzeń", "loadError": "Nie udało się teraz wczytać Twoich wyników dobrostanu.", + "loadingLabel": "Wczytywanie Twoich wskaźników zdrowia", "recovery": "Regeneracja", "stress": "Stres", "strain": "Obciążenie" diff --git a/src/components/daily/priority-card.tsx b/src/components/daily/priority-card.tsx index fab425ef5..c264b9795 100644 --- a/src/components/daily/priority-card.tsx +++ b/src/components/daily/priority-card.tsx @@ -104,6 +104,14 @@ export interface PriorityCardProps { * passes. */ onDismiss?: (itemKey: string) => void; + /** + * True while a non-navigation action on THIS card has a mutation in + * flight (e.g. the coach check-in's keep/let-go). Disables the + * button-rendered actions (`aria-busy` + `disabled`) so a slow round trip + * can't be double-tapped into firing the mutation twice; pure `href` + * actions stay live since they're navigation, not a mutation. + */ + actionsPending?: boolean; className?: string; } @@ -114,6 +122,7 @@ export function PriorityCard({ item, onAction, onDismiss, + actionsPending, className, }: PriorityCardProps) { const { t } = useTranslations(); @@ -182,6 +191,7 @@ export function PriorityCard({ action={action} label={t(action.labelKey)} onAction={onAction} + pending={actionsPending} /> ))} @@ -195,10 +205,12 @@ function ActionButton({ action, label, onAction, + pending, }: { action: PriorityItemAction; label: string; onAction?: (intent: string) => void; + pending?: boolean; }) { if (action.href) { return ( @@ -218,6 +230,8 @@ function ActionButton({ variant="outline" size="sm" className={ACTION_SIZE} + disabled={pending} + aria-busy={pending || undefined} onClick={() => onAction?.(action.intent)} > {label} diff --git a/src/components/daily/today-hero-skeleton.tsx b/src/components/daily/today-hero-skeleton.tsx index 1abae7136..5f1d11527 100644 --- a/src/components/daily/today-hero-skeleton.tsx +++ b/src/components/daily/today-hero-skeleton.tsx @@ -23,9 +23,9 @@ export function TodayHeroSkeleton() { data-slot="today-hero-skeleton" className="bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6" > -
-
-
+
+
+
{/* Lead read (two lines) + top signal + briefing link. */} @@ -36,10 +36,14 @@ export function TodayHeroSkeleton() {
- {/* Worth-a-look rail — two placeholder cards. */} -
- - + {/* Worth-a-look rail — heading bar + two placeholder cards, matching + the loaded rail's uppercase heading row and `xl:grid-cols-3` step. */} +
+ +
+ + +
diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index c9bebb8cb..2f089bdad 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -223,6 +223,10 @@ export function TodayHero({ digest }: { digest: DailyDigest }) { item={item} onAction={handleAction} onDismiss={(itemKey) => dismissItem.mutate(itemKey)} + actionsPending={ + item.kind === "coach_checkin" && + (keep.isPending || letGo.isPending) + } /> ))}
diff --git a/src/components/documents/document-ai-panels.tsx b/src/components/documents/document-ai-panels.tsx index 8f6ab97f9..1a6a55e36 100644 --- a/src/components/documents/document-ai-panels.tsx +++ b/src/components/documents/document-ai-panels.tsx @@ -255,7 +255,8 @@ export function DocumentSummaryPanel({

{body} diff --git a/src/components/insights/coach-panel/chat-bubble.tsx b/src/components/insights/coach-panel/chat-bubble.tsx index 9d821ceae..da7f7d9a1 100644 --- a/src/components/insights/coach-panel/chat-bubble.tsx +++ b/src/components/insights/coach-panel/chat-bubble.tsx @@ -634,7 +634,7 @@ function ChatBubbleImpl({ className={cn( "border-border/60 bg-muted/40 text-foreground", "rounded-xl rounded-tl-sm border px-3.5 py-2.5", - "text-sm leading-relaxed whitespace-pre-wrap", + "text-sm leading-relaxed whitespace-pre-wrap break-words", )} > {/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the @@ -649,14 +649,14 @@ function ChatBubbleImpl({ ) : inProgress ? ( ) : safeError ? ( - {safeError} + {safeError} ) : ( "" )}

)} {safeError && content && ( -

{safeError}

+

{safeError}

)} {/* v1.22 (W5) — accompanying chart(s). The token was already stripped from the prose by ; here it mounts the diff --git a/src/components/insights/derived/__tests__/wellness-scores.test.tsx b/src/components/insights/derived/__tests__/wellness-scores.test.tsx index b8c04c856..e96cae1f5 100644 --- a/src/components/insights/derived/__tests__/wellness-scores.test.tsx +++ b/src/components/insights/derived/__tests__/wellness-scores.test.tsx @@ -65,15 +65,20 @@ describe("", () => { expect(html).toBe(""); }); - it("renders nothing while loading (the page reserves the row)", () => { + it("renders a skeleton row while loading, not a pop-in later", () => { const html = render( , ); - // While loading no tiles resolve, so the strip stays un-mounted. - expect(html).toBe(""); + // The section heading + a row of tile-shaped skeletons render + // immediately, so the strip reserves its final height instead of + // vanishing (`return null`) and popping in once the batch resolves. + expect(html).toContain('data-slot="wellness-scores"'); + expect(html).toContain('data-slot="wellness-scores-grid"'); + expect(html).toContain('data-slot="wellness-score-tile-skeleton"'); + expect(html).not.toContain('data-slot="wellness-score-tile"'); }); it("renders a ring tile with an icon + heading on the gentle wellness surface", () => { diff --git a/src/components/insights/derived/wellness-scores.tsx b/src/components/insights/derived/wellness-scores.tsx index 7d9af50f6..697eb93c9 100644 --- a/src/components/insights/derived/wellness-scores.tsx +++ b/src/components/insights/derived/wellness-scores.tsx @@ -12,6 +12,7 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; import { useTranslations } from "@/lib/i18n/context"; import { SectionHeading } from "@/components/insights/section-heading"; import { ScoreRing } from "./score-ring"; @@ -168,6 +169,32 @@ function RingTile({ ); } +/** + * Loading placeholder mirroring `RingTile`'s footprint (icon+label row, the + * `size="sm"` 120 px ring, band word) so the strip reserves its final height + * while the shared derived batch loads — matching the `VitalsTileSkeleton` + * pattern instead of the strip vanishing (`return null`) and popping in once + * data resolves. Decorative — `aria-hidden`. + */ +function WellnessScoreTileSkeleton() { + return ( + + ); +} + export function WellnessScores({ read, isLoading, @@ -198,6 +225,39 @@ export function WellnessScores({ } }); + // While the shared derived batch loads, every per-score gate below is + // `!isLoading && …`, so `tiles` stays empty and the strip used to + // `return null` — a late pop-in that pushed the briefing + everything + // below it down on every cold visit. Render the section heading + a row + // of tile-shaped skeletons instead, the same shape `VitalsDashboard` + // already uses for its grid. + if (isLoading) { + return ( +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ ); + } + // A failed shared batch must read as an error, not as "no scores" — the // strip is top-of-page, so the only Retry lives here (mirrors the Vitals // error card styling; one Retry recovers both strips). diff --git a/src/components/insights/prose-blocks.tsx b/src/components/insights/prose-blocks.tsx index b16d1aba2..e362462e1 100644 --- a/src/components/insights/prose-blocks.tsx +++ b/src/components/insights/prose-blocks.tsx @@ -38,7 +38,7 @@ import { InlineLearnLink } from "@/components/ui/learn-more-link"; /** Default paragraph styling shared by every prose surface + the Coach * streaming tail, so the streamed and settled renders line up exactly. */ export const PROSE_PARAGRAPH_CLASS = - "leading-relaxed [&:not(:first-child)]:mt-3"; + "leading-relaxed break-words [&:not(:first-child)]:mt-3"; /** * Split a trusted-as-text string into real paragraphs on blank lines. diff --git a/src/hooks/use-priority-item-dismiss.ts b/src/hooks/use-priority-item-dismiss.ts index 1c715257c..8ff8f46c3 100644 --- a/src/hooks/use-priority-item-dismiss.ts +++ b/src/hooks/use-priority-item-dismiss.ts @@ -4,15 +4,20 @@ * Today rail — "dismiss / mark seen" for the OBSERVATIONAL `PriorityItem` * kinds (`milestone`, `ecg_new_recording`, `tension_window`). Posts the * item's own `itemKey` to `POST /api/daily/digest/dismiss` (server persists - * it so the dismissal survives reload / a second device), then invalidates - * the daily digest so the dismissed card leaves the rail immediately — the - * same invalidate-on-mutate pattern `useCoachCheckinAction` uses. Keys route + * it so the dismissal survives reload / a second device). Keys route * through the centralised factory — no bare arrays. + * + * The dismiss is optimistic (`onMutate` strikes the item from the cached + * digest immediately): a second tap on the same card has nothing left to + * hit, since the card itself leaves the rail before the round trip lands. + * `onError` rolls the cache back to the pre-tap snapshot; `onSettled` + * re-syncs from the server either way. */ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { apiPost } from "@/lib/api/api-fetch"; import { queryKeys } from "@/lib/query-keys"; +import type { DailyDigest } from "@/lib/daily/digest"; export function usePriorityItemDismiss() { const queryClient = useQueryClient(); @@ -22,7 +27,27 @@ export function usePriorityItemDismiss() { await apiPost("/api/daily/digest/dismiss", { itemKey }); return itemKey; }, - onSuccess: () => { + onMutate: async (itemKey) => { + await queryClient.cancelQueries({ queryKey: queryKeys.dailyDigest() }); + const previous = queryClient.getQueryData( + queryKeys.dailyDigest(), + ); + if (previous) { + queryClient.setQueryData(queryKeys.dailyDigest(), { + ...previous, + worthALook: previous.worthALook.filter( + (item) => item.itemKey !== itemKey, + ), + }); + } + return { previous }; + }, + onError: (_err, _itemKey, context) => { + if (context?.previous) { + queryClient.setQueryData(queryKeys.dailyDigest(), context.previous); + } + }, + onSettled: () => { void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() }); }, }); 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 02/16] 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={ @@ -98,9 +110,16 @@ export function DocumentBulkBar({ {episodes.length > 0 ? ( - @@ -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")}
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 ( - - + + + + } + > +
- - {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 ( -
  • - -
  • - ); - })} -
- )} -
- - - - + + ); })} - - - -
+ + )} + + ); } 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" >