From a977df24a0af96bc774618a4ce2322c06f360bde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 05:55:36 +0200 Subject: [PATCH] fix(ui): catalog loading/error truth, info-popover dedupe, touch sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Metric catalog page painted every metric as "Not tracked yet" (with Connect CTAs) while its four availability probes were still in flight, and stayed there forever on a failed batch — a load failure indistinguishable from an honest absence. Gate rows on the probes' aggregate loading/error state: row skeletons while loading, a QueryErrorCard with retry on failure. Repoint ComplianceInfoTip onto InfoPopover instead of carrying a verbatim structural copy (adds icon/align/data-slot override props to the shared primitive); migrate the cycle log-sheet's FieldInfo off a hover Tooltip onto the same click-opened InfoPopover, since a hover trigger never opens reliably on the sheet's thumb-first touch surface. Lift ViewToggle/SegmentedToggle off their stale 40px tap floor onto the 44px/36px floor the sibling intake and history-rail controls already carry, and correct the docstrings to match. Restore the dropped safe-area-inset-top term in /coach/conversations' height calc so it matches /coach on notched devices. Bring the coach history rail's off-scale spacing and sub-floor timestamp text onto the standard scale. --- 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/app/coach/conversations/page.tsx | 7 +- src/app/insights/catalog/page.tsx | 73 +++++++++++++++++++ src/components/cycle/field-info.tsx | 52 +++++-------- .../insights/coach-panel/history-rail.tsx | 6 +- .../card-parts/compliance-info-tip.tsx | 57 ++++----------- .../medications/detail/segmented-toggle.tsx | 8 +- src/components/ui/info-popover.tsx | 27 +++++-- src/components/ui/view-toggle.tsx | 9 ++- 14 files changed, 152 insertions(+), 93 deletions(-) diff --git a/messages/de.json b/messages/de.json index ae466c900..2bed62c7c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -592,6 +592,7 @@ "statusPresent": "Erfasst", "statusAbsent": "Noch nicht erfasst", "statusModuleOff": "Aus", + "loadError": "Erfassungsstatus konnte nicht geladen werden.", "connectCta": "Quelle verbinden", "viewCta": "Ansehen", "ecg": { diff --git a/messages/en.json b/messages/en.json index ec91a82a7..a0d67346d 100644 --- a/messages/en.json +++ b/messages/en.json @@ -592,6 +592,7 @@ "statusPresent": "Tracked", "statusAbsent": "Not tracked yet", "statusModuleOff": "Off", + "loadError": "Could not load your tracking status.", "connectCta": "Connect a source", "viewCta": "View", "ecg": { diff --git a/messages/es.json b/messages/es.json index e4a444d59..26d1adac0 100644 --- a/messages/es.json +++ b/messages/es.json @@ -592,6 +592,7 @@ "statusPresent": "Registrado", "statusAbsent": "Aún sin registrar", "statusModuleOff": "Desactivado", + "loadError": "No se pudo cargar el estado de seguimiento.", "connectCta": "Conectar una fuente", "viewCta": "Ver", "ecg": { diff --git a/messages/fr.json b/messages/fr.json index f792f1c31..787cc81fb 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -592,6 +592,7 @@ "statusPresent": "Suivi", "statusAbsent": "Pas encore suivi", "statusModuleOff": "Désactivé", + "loadError": "Impossible de charger l'état de suivi.", "connectCta": "Connecter une source", "viewCta": "Voir", "ecg": { diff --git a/messages/it.json b/messages/it.json index 9aa2d1297..061fc45f1 100644 --- a/messages/it.json +++ b/messages/it.json @@ -592,6 +592,7 @@ "statusPresent": "Monitorato", "statusAbsent": "Non ancora monitorato", "statusModuleOff": "Disattivato", + "loadError": "Impossibile caricare lo stato di monitoraggio.", "connectCta": "Collega una fonte", "viewCta": "Visualizza", "ecg": { diff --git a/messages/pl.json b/messages/pl.json index cbc2aa405..1d1dc33b9 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -592,6 +592,7 @@ "statusPresent": "Śledzone", "statusAbsent": "Jeszcze nieśledzone", "statusModuleOff": "Wyłączone", + "loadError": "Nie udało się załadować statusu śledzenia.", "connectCta": "Połącz źródło", "viewCta": "Zobacz", "ecg": { diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index 6c1f7aaa9..884406d09 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -364,8 +364,11 @@ export default function CoachConversationsPage() { data-slot="coach-conversations-page" // Match `/coach`'s full-bleed sizing: cancel the AuthShell padding // and claim the viewport height below the top bar (minus the - // mobile-only BottomNav band). - className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" + // mobile-only BottomNav band). L4 fix + // (`.planning/audits/2026-07-18-qa-ui.md`) — this had dropped the + // `safe-area-inset-top` term `/coach` subtracts, overshooting by + // ~50px on notched devices; restored to match exactly. + className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem)]" > diff --git a/src/app/insights/catalog/page.tsx b/src/app/insights/catalog/page.tsx index 9cda95039..56ce8f971 100644 --- a/src/app/insights/catalog/page.tsx +++ b/src/app/insights/catalog/page.tsx @@ -21,6 +21,8 @@ import { import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { TileHeader } from "@/components/insights/tile-header"; import { SubPageShell } from "@/components/insights/sub-page-shell"; import { useAuth } from "@/hooks/use-auth"; @@ -105,6 +107,29 @@ function StatusBadge({ present }: { present: boolean }) { ); } +/** + * Row placeholder shown while the availability probes are still + * in flight — same anatomy as a loaded `CatalogRow` (name line + a + * trailing action-sized block) so the group cards don't reflow when + * the real status lands. Never renders the "Not tracked yet" copy or + * a CTA — an in-flight probe is not the same thing as a confirmed + * absence (M1, `.planning/audits/2026-07-18-qa-ui.md`). + */ +function CatalogRowSkeleton() { + return ( +
+
+ + +
+ +
+ ); +} + function CatalogRow({ entry, present, @@ -218,6 +243,51 @@ export default function MetricCatalogPage() { return hasMetricData(entry.metric, availability); } + // M1 fix (`.planning/audits/2026-07-18-qa-ui.md`) — every probe backing + // `availability` is async; while any of them is still in flight, + // `hasMetricData()` reads an undefined summary and reports "absent" for + // every metric. Gate the rows on the probes' aggregate loading/error + // state instead of letting a cold load or a failed batch masquerade as + // an honest "Not tracked yet". + const isLoading = Boolean( + isAuthenticated && + (analyticsQuery.isLoading || + comprehensiveQuery.isLoading || + workoutsProbe.isLoading || + ecgQuery.isLoading || + (nutrientsModuleEnabled && nutrientsProbe.isLoading)), + ); + + const isError = Boolean( + analyticsQuery.isError || + comprehensiveQuery.isError || + workoutsProbe.isError || + ecgQuery.isError || + (nutrientsModuleEnabled && nutrientsProbe.isError), + ); + + function retryAll() { + void analyticsQuery.refetch(); + void comprehensiveQuery.refetch(); + workoutsProbe.refetch(); + void ecgQuery.refetch(); + if (nutrientsModuleEnabled) void nutrientsProbe.refetch(); + } + + if (isError) { + return ( + + + + ); + } + return ( ); } + if (isLoading && entry.kind !== "info") { + return ; + } return ( ` trigger with - * an `aria-label`, keyboard- and SR-reachable via Radix) but bumps the hit - * area to ≥44 px for touch, since the cycle sheet is a thumb-first surface. + * understand a term. + * + * L2 (`.planning/audits/2026-07-18-qa-ui.md`) — this used to be a + * self-contained hover `Tooltip`, which never opens reliably on tap; the + * cycle sheet is a thumb-first surface, so it now wraps the click-opened + * `InfoPopover` (the app's one (i) info-affordance since the v1.28.17 + * merge) instead, keeping the `cycle-field-info` data-slot the sheet + * already keys off of. * * Copy stays descriptive-only — never diagnosis or medical advice — matching * the phase-education honesty gate. @@ -31,31 +27,17 @@ export function FieldInfo({ }: { /** Accessible name for the trigger (what the icon explains). */ label: string; - /** The explanation shown in the tooltip. */ + /** The explanation shown in the popover. */ detail: string; className?: string; }) { return ( - - - - - - {detail} - - - + ); } diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index f2a223cbb..fea93dcbb 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -122,7 +122,9 @@ export function HistoryRail({ data-slot="coach-history-rail" // v1.18.1 (W-COACH-UI C1/C3) — a touch more horizontal padding so // the list rows clear the rail edge and the search field breathes. - className={cn("flex h-full min-h-0 flex-col gap-2.5 p-3.5", className)} + // L5 fix (`.planning/audits/2026-07-18-qa-ui.md`) — `gap-2.5 p-3.5` + // was off the `1/1.5/2/3/4/6/8/10` spacing scale. + className={cn("flex h-full min-h-0 flex-col gap-2 p-3", className)} > {/* v1.4.33 — promote the rail label from a `` to a real `

` so the drawer carries a semantic outline on desktop @@ -239,7 +241,7 @@ export function HistoryRail({ ) : null} {c.title} - + {formatRelativeTime(c.updatedAt, t)} diff --git a/src/components/medications/card-parts/compliance-info-tip.tsx b/src/components/medications/card-parts/compliance-info-tip.tsx index efed854c2..967391cf3 100644 --- a/src/components/medications/card-parts/compliance-info-tip.tsx +++ b/src/components/medications/card-parts/compliance-info-tip.tsx @@ -1,15 +1,9 @@ "use client"; -import { useState } from "react"; import { HelpCircle } from "lucide-react"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; +import { InfoPopover } from "@/components/ui/info-popover"; import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; /** * Small `?` affordance that explains what the adherence percentage @@ -19,45 +13,24 @@ import { cn } from "@/lib/utils"; * dashboard compliance card header and the per-medication bars * (`medication-compliance-bars.tsx`). * - * Pattern follows `health-score-delta-explainer.tsx` (icon-only - * trigger, 44 px hit target collapsed back to the row's stride via - * negative margins). A click-opened Popover is used on every viewport - * — unlike a hover Tooltip it stays reachable on touch devices, where - * the medication cards see most of their use. + * Thin wrapper over `InfoPopover` (L1, `.planning/audits/2026-07-18-qa-ui.md`) + * — this used to be a verbatim structural copy of the primitive; it now + * only supplies the `HelpCircle` glyph, `align="start"`, and the + * `compliance-info-*` data-slots the two call sites already key off of. */ export function ComplianceInfoTip({ className }: { className?: string }) { const { t } = useTranslations(); - const [open, setOpen] = useState(false); return ( - - - - - -

- {t("medications.complianceInfo")} -

-
-
+ ); } diff --git a/src/components/medications/detail/segmented-toggle.tsx b/src/components/medications/detail/segmented-toggle.tsx index e5fc1cfda..bac02195d 100644 --- a/src/components/medications/detail/segmented-toggle.tsx +++ b/src/components/medications/detail/segmented-toggle.tsx @@ -7,8 +7,10 @@ import { cn } from "@/lib/utils"; * the icon-only `MedicationViewToggle` chrome (bordered pill group, filled * active segment, ghost inactive) but carries text labels. `aria-pressed` * announces the state per segment; the group is named for screen readers. - * Segments keep the 40-px mobile tap floor, shrinking to the 32-px control - * height on desktop. + * Segments keep the 44-px mobile tap floor, shrinking to the 36-px control + * height on desktop — matching the floor `ViewToggle` and the sibling + * intake / history-rail controls raised in this release window (L3, + * `.planning/audits/2026-07-18-qa-ui.md`). */ export interface SegmentedToggleOption { value: T; @@ -49,7 +51,7 @@ export function SegmentedToggle({ aria-pressed={active} data-slot={`${dataSlot ?? "segment"}-${optionValue}`} className={cn( - "focus-visible:ring-ring inline-flex min-h-10 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-8", + "focus-visible:ring-ring inline-flex min-h-11 items-center justify-center rounded-[5px] px-3 text-xs font-medium transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:min-h-9", active ? "bg-secondary text-secondary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", diff --git a/src/components/ui/info-popover.tsx b/src/components/ui/info-popover.tsx index 549c8036a..ba067fa57 100644 --- a/src/components/ui/info-popover.tsx +++ b/src/components/ui/info-popover.tsx @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; -import { Info } from "lucide-react"; +import { Info, type LucideIcon } from "lucide-react"; import { Popover, @@ -43,6 +43,18 @@ export interface InfoPopoverProps { */ label?: string; className?: string; + /** + * Trigger glyph. Defaults to `Info` (h-3.5 w-3.5); the compliance-tip + * wrapper passes `HelpCircle` at `h-3 w-3` to keep its pre-merge look. + */ + icon?: LucideIcon; + iconClassName?: string; + /** Popover alignment relative to the trigger. Defaults to `"end"`. */ + align?: "start" | "center" | "end"; + /** `data-slot` override for the trigger button. */ + triggerDataSlot?: string; + /** `data-slot` override for the popover body. */ + bodyDataSlot?: string; } export function InfoPopover({ @@ -50,6 +62,11 @@ export function InfoPopover({ link, label, className, + icon: Icon = Info, + iconClassName = "h-3.5 w-3.5", + align = "end", + triggerDataSlot = "info-popover-trigger", + bodyDataSlot = "info-popover-body", }: InfoPopoverProps) { const { t } = useTranslations(); const [open, setOpen] = useState(false); @@ -59,7 +76,7 @@ export function InfoPopover({ diff --git a/src/components/ui/view-toggle.tsx b/src/components/ui/view-toggle.tsx index 88c76eab9..ed5c5aec6 100644 --- a/src/components/ui/view-toggle.tsx +++ b/src/components/ui/view-toggle.tsx @@ -25,9 +25,10 @@ export interface ViewToggleProps { * two-segment control: the active segment carries the filled surface, the * inactive one stays ghost. `aria-pressed` announces the state per segment; * the group is named for screen readers. Segments keep the 44-px mobile tap - * floor (`size-11` via `size-10` + the `sm:size-8` desktop step), shrinking - * to the standard 36-px control height on desktop like a neighbouring Add - * button. + * floor (`size-11`), shrinking to the 36-px control height on desktop + * (`sm:size-9`) like a neighbouring Add button — the same floor the intake + * kebab and history-rail delete controls raised in this release window + * (L3, `.planning/audits/2026-07-18-qa-ui.md`). * * Previously forked between the medications module * (`medications/medication-view-toggle.tsx`) and the Vorsorge/Illness/Labs @@ -62,7 +63,7 @@ export function ViewToggle({ title={label} data-slot={`${dataSlotPrefix}-${value}`} className={cn( - "focus-visible:ring-ring inline-flex size-10 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-8", + "focus-visible:ring-ring inline-flex size-11 items-center justify-center rounded-[5px] transition-colors focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none sm:size-9", active ? "bg-secondary text-secondary-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",