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() });
},
});