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:
Marc-André Bombeck committed 2026-07-18 03:52:32 +02:00
1 parent d2ad8e03e0
commit 66fe69b493
15 files changed
+138 -19

No files matched your search

+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+1
View File
@@ -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"
+14
View File
@@ -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}
+11 -7
View File
@@ -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>
+4
View File
@@ -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).
+1 -1
View File
@@ -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.
+29 -4
View File
@@ -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<DailyDigest>(
queryKeys.dailyDigest(),
);
if (previous) {
queryClient.setQueryData<DailyDigest>(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() });
},
});