mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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() });
|
||||
},
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user