mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
fix(ui): break-words on coach prose, hero/wellness skeleton parity, priority-card pending state
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.
This commit is contained in:
1 parent
d2ad8e03e0
commit
66fe69b493
15 files changed
+138
-19
No files matched your search
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
<div className="flex flex-col gap-4 md:gap-5">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-3">
|
||||
<div className="flex flex-col gap-3 md:gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
{/* Lead read (two lines) + top signal + briefing link. */}
|
||||
<Skeleton className="h-6 w-3/4 max-w-full" />
|
||||
<Skeleton className="h-4 w-56 max-w-full" />
|
||||
@@ -36,10 +36,14 @@ export function TodayHeroSkeleton() {
|
||||
<Skeleton className="size-[168px] rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Worth-a-look rail — two placeholder cards. */}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
{/* Worth-a-look rail — heading bar + two placeholder cards, matching
|
||||
the loaded rail's uppercase heading row and `xl:grid-cols-3` step. */}
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
<Skeleton className="h-24 w-full rounded-xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -255,7 +255,8 @@ export function DocumentSummaryPanel({
|
||||
<p
|
||||
className={cn(
|
||||
"text-foreground text-sm",
|
||||
output === "text" && "font-mono text-xs whitespace-pre-wrap",
|
||||
output === "text" &&
|
||||
"font-mono text-xs whitespace-pre-wrap break-words",
|
||||
)}
|
||||
>
|
||||
{body}
|
||||
|
||||
@@ -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 ? (
|
||||
<TypingDots label={t("insights.coach.thinking")} />
|
||||
) : safeError ? (
|
||||
<span className="text-warning/90">{safeError}</span>
|
||||
<span className="text-warning">{safeError}</span>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{safeError && content && (
|
||||
<p className="text-warning/90 text-xs">{safeError}</p>
|
||||
<p className="text-warning text-xs">{safeError}</p>
|
||||
)}
|
||||
{/* v1.22 (W5) — accompanying chart(s). The token was already
|
||||
stripped from the prose by <StreamedProse>; here it mounts the
|
||||
|
||||
@@ -65,15 +65,20 @@ describe("<WellnessScores>", () => {
|
||||
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(
|
||||
<WellnessScores
|
||||
read={readFrom({ READINESS: ok({ score: 80, band: "green" }) })}
|
||||
isLoading
|
||||
/>,
|
||||
);
|
||||
// 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", () => {
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
data-slot="wellness-score-tile-skeleton"
|
||||
aria-hidden="true"
|
||||
className="bg-card border-border flex flex-col gap-4 rounded-xl border p-4 md:p-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-5 w-5 rounded" />
|
||||
<Skeleton className="h-4 w-20" />
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<Skeleton className="size-[120px] rounded-full" />
|
||||
<Skeleton className="h-3 w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<section
|
||||
data-slot="wellness-scores"
|
||||
aria-label={t("insights.derived.scores.sectionTitle")}
|
||||
className={cn("space-y-3", className)}
|
||||
>
|
||||
<SectionHeading
|
||||
icon={Activity}
|
||||
title={t("insights.derived.scores.sectionTitle")}
|
||||
subtitle={t("insights.derived.scores.sectionSubtitle")}
|
||||
/>
|
||||
<div
|
||||
data-slot="wellness-scores-grid"
|
||||
aria-busy="true"
|
||||
aria-live="polite"
|
||||
aria-label={t("insights.derived.scores.loadingLabel")}
|
||||
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
|
||||
>
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<WellnessScoreTileSkeleton key={`wellness-skeleton-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user