mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
fix(ui): catalog loading/error truth, info-popover dedupe, touch sweep
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.
This commit is contained in:
1 parent
1d559d7ed0
commit
a977df24a0
14 files changed
+152
-93
No files matched your search
@@ -592,6 +592,7 @@
|
||||
"statusPresent": "Erfasst",
|
||||
"statusAbsent": "Noch nicht erfasst",
|
||||
"statusModuleOff": "Aus",
|
||||
"loadError": "Erfassungsstatus konnte nicht geladen werden.",
|
||||
"connectCta": "Quelle verbinden",
|
||||
"viewCta": "Ansehen",
|
||||
"ecg": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)]"
|
||||
>
|
||||
<CoachConversationsBody />
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="catalog-row-skeleton"
|
||||
className="flex items-start justify-between gap-3 py-2"
|
||||
>
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20 shrink-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<SubPageShell
|
||||
title={t("metricCatalog.title")}
|
||||
description={t("metricCatalog.description")}
|
||||
>
|
||||
<QueryErrorCard
|
||||
description={t("metricCatalog.loadError")}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
</SubPageShell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SubPageShell
|
||||
title={t("metricCatalog.title")}
|
||||
@@ -278,6 +348,9 @@ export default function MetricCatalogPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isLoading && entry.kind !== "info") {
|
||||
return <CatalogRowSkeleton key={entry.id} />;
|
||||
}
|
||||
return (
|
||||
<CatalogRow
|
||||
key={entry.id}
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Info } from "lucide-react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { InfoPopover } from "@/components/ui/info-popover";
|
||||
|
||||
/**
|
||||
* v1.18.1 — the cycle log-sheet field-explainer affordance.
|
||||
@@ -16,10 +8,14 @@ import { cn } from "@/lib/utils";
|
||||
* A small "?" / info icon that puts a factual, descriptive-only one-liner
|
||||
* (what a clinical field means — BBT, cervical mucus, an LH surge) right at
|
||||
* the point of capture, so a first-time user never has to leave the sheet to
|
||||
* understand a term. Mirrors the mood surface's `MoodExplainerIcon` precedent
|
||||
* (self-contained `TooltipProvider`, a real focusable `<button>` 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 (
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
aria-label={label}
|
||||
data-slot="cycle-field-info"
|
||||
className={cn(
|
||||
// size-6 icon inside an 11-unit (44 px) hit box so touch targets
|
||||
// clear the WCAG 2.5.8 / Apple HIG minimum without enlarging the
|
||||
// visible glyph.
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring -m-2.5 inline-flex size-11 shrink-0 items-center justify-center rounded-full p-2.5 transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="size-4" aria-hidden="true" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[16rem] text-xs leading-snug">
|
||||
{detail}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<InfoPopover
|
||||
content={detail}
|
||||
label={label}
|
||||
align="start"
|
||||
triggerDataSlot="cycle-field-info"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 `<span>` to a real
|
||||
`<h3>` so the drawer carries a semantic outline on desktop
|
||||
@@ -239,7 +241,7 @@ export function HistoryRail({
|
||||
) : null}
|
||||
<span className="truncate">{c.title}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground block text-[11px]">
|
||||
<span className="text-muted-foreground block text-xs">
|
||||
{formatRelativeTime(c.updatedAt, t)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="compliance-info-trigger"
|
||||
aria-label={t("medications.complianceInfoLabel")}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground focus-visible:ring-ring/50",
|
||||
"inline-flex min-h-11 min-w-11 shrink-0 items-center justify-center rounded-full",
|
||||
"-mx-2 -my-3",
|
||||
"transition-colors focus-visible:ring-2 focus-visible:outline-none",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="compliance-info-body"
|
||||
align="start"
|
||||
sideOffset={6}
|
||||
className="max-w-xs"
|
||||
>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
{t("medications.complianceInfo")}
|
||||
</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<InfoPopover
|
||||
content={t("medications.complianceInfo")}
|
||||
label={t("medications.complianceInfoLabel")}
|
||||
icon={HelpCircle}
|
||||
iconClassName="h-3 w-3"
|
||||
align="start"
|
||||
triggerDataSlot="compliance-info-trigger"
|
||||
bodyDataSlot="compliance-info-body"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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<T extends string> {
|
||||
value: T;
|
||||
@@ -49,7 +51,7 @@ export function SegmentedToggle<T extends string>({
|
||||
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",
|
||||
|
||||
@@ -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({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="info-popover-trigger"
|
||||
data-slot={triggerDataSlot}
|
||||
aria-label={label ?? t("common.moreInfo")}
|
||||
aria-expanded={open}
|
||||
className={cn(
|
||||
@@ -70,12 +87,12 @@ export function InfoPopover({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Info className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
<Icon className={iconClassName} aria-hidden="true" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
data-slot="info-popover-body"
|
||||
align="end"
|
||||
data-slot={bodyDataSlot}
|
||||
align={align}
|
||||
sideOffset={6}
|
||||
className="max-w-xs space-y-1.5"
|
||||
>
|
||||
|
||||
@@ -25,9 +25,10 @@ export interface ViewToggleProps<T extends string> {
|
||||
* 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<T extends string>({
|
||||
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",
|
||||
|
||||
Reference in new issue
Block a user