diff --git a/messages/de.json b/messages/de.json index c9d7aef88..44ba5ee3b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Letzte Workouts", "viewAll": "Alle anzeigen", - "loading": "Workouts werden geladen…", "empty": { "title": "Noch keine Workouts", "cta": "Öffne die Apple-Health-App auf dem iPhone, damit Workouts hier erscheinen." @@ -3879,6 +3878,8 @@ "caption": "Dein Puls über den Tag, gemittelt in 10-Minuten-Schritten.", "hourlyNote": "Stündlicher Durchschnitt für diesen Tag — aktuelle Tage zeigen das volle 10-Minuten-Detail.", "empty": "Für diesen Tag noch nicht genug Pulswerte, um den Tagesverlauf zu zeigen.", + "coverageOne": "Basierend auf 1 Messung über {hours}h.", + "coverageMany": "Basierend auf {count} Messungen über {hours}h.", "baseline": "Ruhe", "today": "Heute", "previousDay": "Vorheriger Tag", diff --git a/messages/en.json b/messages/en.json index c0ca0352a..6346aa9bb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Recent workouts", "viewAll": "View all", - "loading": "Loading workouts…", "empty": { "title": "No workouts yet", "cta": "Open Apple Health on your iPhone to sync workouts here." @@ -3879,6 +3878,8 @@ "caption": "Your heart rate through the day, averaged in 10-minute steps.", "hourlyNote": "Showing this day's hourly average — recent days show the full 10-minute detail.", "empty": "Not enough heart-rate readings for this day to show its shape yet.", + "coverageOne": "Based on 1 reading across {hours}h.", + "coverageMany": "Based on {count} readings across {hours}h.", "baseline": "Resting", "today": "Today", "previousDay": "Previous day", diff --git a/messages/es.json b/messages/es.json index c30794199..36f54cb24 100644 --- a/messages/es.json +++ b/messages/es.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entrenamientos recientes", "viewAll": "Ver todos", - "loading": "Cargando entrenamientos…", "empty": { "title": "Aún no hay entrenamientos", "cta": "Abre la app Apple Salud en el iPhone para sincronizar los entrenamientos." @@ -3879,6 +3878,8 @@ "caption": "Tu frecuencia cardíaca a lo largo del día, promediada en tramos de 10 minutos.", "hourlyNote": "Mostrando el promedio por hora de este día — los días recientes muestran el detalle completo de 10 minutos.", "empty": "Aún no hay suficientes lecturas para este día para mostrar su forma.", + "coverageOne": "Basado en 1 lectura a lo largo de {hours}h.", + "coverageMany": "Basado en {count} lecturas a lo largo de {hours}h.", "baseline": "Reposo", "today": "Hoy", "previousDay": "Día anterior", diff --git a/messages/fr.json b/messages/fr.json index 33b44e7a3..14420754a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Entraînements récents", "viewAll": "Tout voir", - "loading": "Chargement des entraînements…", "empty": { "title": "Aucun entraînement", "cta": "Ouvre l'app Apple Santé sur ton iPhone pour synchroniser les entraînements." @@ -3879,6 +3878,8 @@ "caption": "Votre fréquence cardiaque au fil de la journée, moyennée par tranches de 10 minutes.", "hourlyNote": "Moyenne horaire affichée pour cette journée — les jours récents affichent le détail complet par tranches de 10 minutes.", "empty": "Pas encore assez de mesures pour cette journée pour en afficher le rythme.", + "coverageOne": "Basé sur 1 mesure sur {hours}h.", + "coverageMany": "Basé sur {count} mesures sur {hours}h.", "baseline": "Repos", "today": "Aujourd'hui", "previousDay": "Jour précédent", diff --git a/messages/it.json b/messages/it.json index 79b40580f..639145a51 100644 --- a/messages/it.json +++ b/messages/it.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Allenamenti recenti", "viewAll": "Vedi tutti", - "loading": "Caricamento allenamenti…", "empty": { "title": "Nessun allenamento", "cta": "Apri l'app Salute di Apple sull'iPhone per sincronizzare gli allenamenti." @@ -3879,6 +3878,8 @@ "caption": "La tua frequenza cardiaca durante il giorno, mediata in intervalli di 10 minuti.", "hourlyNote": "Media oraria per questo giorno — i giorni recenti mostrano il dettaglio completo a 10 minuti.", "empty": "Non ci sono ancora abbastanza misurazioni per questo giorno per mostrarne l'andamento.", + "coverageOne": "Basato su 1 misurazione in {hours}h.", + "coverageMany": "Basato su {count} misurazioni in {hours}h.", "baseline": "Riposo", "today": "Oggi", "previousDay": "Giorno precedente", diff --git a/messages/pl.json b/messages/pl.json index cc319642e..996d46c39 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -405,7 +405,6 @@ "recentWorkouts": { "title": "Ostatnie treningi", "viewAll": "Zobacz wszystkie", - "loading": "Wczytywanie treningów…", "empty": { "title": "Brak treningów", "cta": "Otwórz aplikację Apple Zdrowie na iPhonie, aby zsynchronizować treningi." @@ -3879,6 +3878,8 @@ "caption": "Twoje tętno w ciągu dnia, uśrednione w 10-minutowych odcinkach.", "hourlyNote": "Pokazano średnią godzinową dla tego dnia — ostatnie dni pokazują pełny, 10-minutowy szczegół.", "empty": "Za mało pomiarów z tego dnia, aby pokazać jego przebieg.", + "coverageOne": "Na podstawie 1 pomiaru w ciągu {hours}h.", + "coverageMany": "Na podstawie {count} pomiarów w ciągu {hours}h.", "baseline": "Spoczynek", "today": "Dziś", "previousDay": "Poprzedni dzień", diff --git a/src/app/page-client.tsx b/src/app/page-client.tsx index 2f99a5eec..a23f4a47a 100644 --- a/src/app/page-client.tsx +++ b/src/app/page-client.tsx @@ -35,6 +35,7 @@ import dynamic from "next/dynamic"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { ChartErrorBoundary } from "@/components/charts/chart-error-state"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; import { HealthChartDynamic } from "@/components/charts/health-chart-dynamic"; @@ -790,8 +791,11 @@ export default function DashboardPageClient() { decision 1). Renders the S1 daily digest: the day's read, the honest score/freshness face, and the worth-a-look rail. Shimmer skeleton while the cached digest loads; a genuinely empty account - degrades to nothing (the hero itself returns null). The dense - tile grid + charts below are untouched. */} + degrades to nothing (the hero itself returns null). A FAILED fetch + is a distinct state from that honest empty degrade — it renders + `` with a retry action rather than silently + falling through to nothing. The dense tile grid + charts below + are untouched. */} {insightsEnabled && // `!mounted` pins the SSR pass AND the first hydration render to the // skeleton (v1.16.4 pattern): the digest query rehydrates from the @@ -800,6 +804,8 @@ export default function DashboardPageClient() { // skeleton — a text-content hydration mismatch (React #418). (!mounted || digestQuery.isLoading ? ( + ) : digestQuery.isError ? ( + digestQuery.refetch()} /> ) : digestQuery.data ? ( ) : null)} diff --git a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx index 750d97bab..cfea873bc 100644 --- a/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx +++ b/src/components/dashboard/__tests__/recent-workouts-tile.test.tsx @@ -32,11 +32,15 @@ let mockResult: { isLoading: boolean; isEmpty: boolean; error: Error | null; + isError: boolean; + refetch: () => void; } = { data: undefined, isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; vi.mock("@/hooks/use-workouts", async () => { @@ -66,15 +70,26 @@ beforeEach(() => { isLoading: false, isEmpty: false, error: null, + isError: false, + refetch: () => {}, }; }); describe("", () => { - it("renders the loading placeholder while the query is in flight", () => { + it("renders row-shaped skeletons while the query is in flight", () => { mockResult.isLoading = true; const html = render(); expect(html).toContain('data-slot="recent-workouts-loading"'); - expect(html).toContain("Loading workouts"); + expect(html).toContain('data-slot="skeleton"'); + }); + + it("renders a retry card instead of the empty state on a failed fetch", () => { + mockResult.isError = true; + const html = render(); + expect(html).toContain('data-slot="query-error-card"'); + // A failed fetch must never read as the honest "no workouts yet" + // empty state — that would misreport an outage as an empty account. + expect(html).not.toContain('data-slot="recent-workouts-empty"'); }); it("renders the empty-state with the Apple-Health onboarding cue", () => { diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx index db30b451e..6caaa66f0 100644 --- a/src/components/dashboard/recent-workouts-tile.tsx +++ b/src/components/dashboard/recent-workouts-tile.tsx @@ -17,6 +17,8 @@ import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts"; import { cn } from "@/lib/utils"; import { TileHeader } from "@/components/insights/tile-header"; import { getDateTimeFormat } from "@/lib/intl/formatter-cache"; +import { Skeleton } from "@/components/ui/skeleton"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; /** * v1.4.32 — ``. @@ -99,7 +101,7 @@ function renderRow(workout: WorkoutListEntry, sportName: string) { export function RecentWorkoutsTile() { const { t, locale } = useTranslations(); - const { data, isLoading } = useWorkouts({ limit: 3 }); + const { data, isLoading, isError, refetch } = useWorkouts({ limit: 3 }); const workouts = data?.workouts ?? []; return ( @@ -131,12 +133,24 @@ export function RecentWorkoutsTile() { // v1.4.43 W11-L6 — reserve roughly the loaded tile height // (3 workouts × ~3 rem rows + spacing) so the surrounding // dashboard layout doesn't reflow once the data lands. -

- {t("dashboard.recentWorkouts.loading")} -

+ // v1.29.x — row-shaped skeletons instead of a "loading…" text + // line, matching the sport-icon + label + day + duration shape + // the loaded rows render. +
+ {[0, 1, 2].map((i) => ( +
+ + + + +
+ ))} +
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : workouts.length === 0 ? (

{t("dashboard.recentWorkouts.empty.title")}

diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index 563d3edbd..8ecfcca1f 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -22,10 +22,12 @@ import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; -import type { - IntradayHrBucket, - IntradayResolution, - TensionWindow, +import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { + BUCKET_MINUTES, + type IntradayHrBucket, + type IntradayResolution, + type TensionWindow, } from "@/lib/analytics/intraday-pulse"; /** Slim DTO the intraday route returns (type-only — no server code bundled). */ @@ -57,13 +59,23 @@ const AXIS_TICKS = [0, 360, 720, 1080, 1440]; * and non-diagnostic — "possible tension", never a stress score. Empty / no- * baseline days render an honest one-liner rather than an empty grid. * + * The area never interpolates across a gap of missing buckets — `chartData` + * is a full-day grid with `null` for every absent bucket and the `Area` + * renders with `connectNulls={false}`, so a handful of spot readings shows + * as isolated points rather than a fabricated smooth curve. Whenever that + * produces a visible break, a small "based on N readings across M hours" + * line discloses the coverage so the shape doesn't read as more continuous + * than it is. + * * v1.29.x — a day navigator (`TileHeader`'s `right` slot, per UI-STANDARDS * §11) pages the same route backward through prior days; "next" is disabled * at today, so the view can never step into the future. A day outside the * dense retention window (`DENSE_INTRADAY_RETENTION_DAYS`) reads back at the * coarser hourly grain instead of an empty chart — `resolution` on the DTO * tells the caption which, and the tension read is always absent on an - * hourly day (it needs per-sample resolution to stay honest). + * hourly day (it needs per-sample resolution to stay honest). A failed fetch + * renders `` with a retry action instead of falling through + * to the empty-day copy. */ export function IntradayPulseChart({ userTimezone, @@ -91,7 +103,7 @@ export function IntradayPulseChart({ setSelectedDateKey(next === todayKey ? null : next); }; - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.insightsPulseIntraday(dateKey), queryFn: () => apiGet(`/api/insights/pulse/intraday?date=${dateKey}`), @@ -100,15 +112,51 @@ export function IntradayPulseChart({ refetchOnWindowFocus: false, }); - const chartData = useMemo( - () => - (data?.series ?? []).map((b) => ({ - minute: b.startMinute, - label: minuteLabel(b.startMinute), - bpm: b.mean, - })), - [data?.series], - ); + const bucketMinutes = data?.bucketMinutes ?? BUCKET_MINUTES; + + // A handful of spot readings scattered across the day must never read as + // a smooth, continuous curve — that fabricates a shape the data doesn't + // support. Building one grid point per possible bucket (present buckets + // carry their mean, absent ones are `null`) lets `connectNulls={false}` + // below break the area over any missing bucket instead of interpolating + // across it, so only genuinely adjacent readings connect. + const chartData = useMemo(() => { + const series = data?.series; + if (!series || series.length === 0) return []; + const byMinute = new Map(series.map((b) => [b.startMinute, b.mean])); + const points: Array<{ minute: number; label: string; bpm: number | null }> = + []; + for (let minute = 0; minute < 1440; minute += bucketMinutes) { + points.push({ + minute, + label: minuteLabel(minute), + bpm: byMinute.get(minute) ?? null, + }); + } + return points; + }, [data?.series, bucketMinutes]); + + // The coverage disclosure only fires when the chart actually contains a + // break — a fully contiguous day already reads honestly without it. Whole + // hours (not fractional) keep the copy simple across locales. + const coverage = useMemo(() => { + const series = data?.series; + if (!series || series.length < 2) return null; + const sorted = [...series].sort((a, b) => a.startMinute - b.startMinute); + let hasGap = false; + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].startMinute - sorted[i - 1].startMinute > bucketMinutes) { + hasGap = true; + break; + } + } + if (!hasGap) return null; + const readingCount = sorted.reduce((sum, b) => sum + b.count, 0); + const first = sorted[0].startMinute; + const last = sorted[sorted.length - 1].startMinute + bucketMinutes; + const hours = Math.max(1, Math.round((last - first) / 60)); + return { readingCount, hours }; + }, [data?.series, bucketMinutes]); const caption = data?.tension ? t(`insights.intradayPulse.tension.${data.tension.partOfDay}`) @@ -116,6 +164,15 @@ export function IntradayPulseChart({ ? t("insights.intradayPulse.hourlyNote") : t("insights.intradayPulse.caption"); + const coverageNote = coverage + ? coverage.readingCount === 1 + ? t("insights.intradayPulse.coverageOne", { hours: coverage.hours }) + : t("insights.intradayPulse.coverageMany", { + count: coverage.readingCount, + hours: coverage.hours, + }) + : null; + const dayLabel = isToday ? t("insights.intradayPulse.today") : fmt.dateShort(new Date(`${dateKey}T12:00:00.000Z`)); @@ -167,9 +224,22 @@ export function IntradayPulseChart({ } />

{caption}

+ {coverageNote ? ( +

+ {coverageNote} +

+ ) : null} {isLoading ? (
+ ) : isError ? ( + refetch()} + className="border-0 bg-transparent shadow-none" + /> ) : chartData.length === 0 ? (
{t("insights.intradayPulse.empty")} @@ -254,15 +324,22 @@ export function IntradayPulseChart({ }} /> ) : null} + {/* `connectNulls={false}` (the default, spelled out here for + intent) is the honesty fix: `chartData` carries one point + per possible bucket, `null` where no reading exists, so a + gap of missing buckets breaks the line/fill instead of + interpolating a shape across hours of silence. `dot` + keeps an isolated reading visible as a point even where + it has no neighbour to connect to. */} diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index f3b1bede9..f5ded3a59 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -20,6 +20,7 @@ import { TileHeader } from "@/components/insights/tile-header"; import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Select, SelectContent, @@ -85,7 +86,7 @@ export function EfficacyTab({ const timezone = user?.timezone || DEFAULT_TIMEZONE; const queryClient = useQueryClient(); - const { data, isLoading } = useQuery({ + const { data, isLoading, isError, refetch } = useQuery({ queryKey: queryKeys.medicationEfficacy(medicationId), queryFn: () => apiGet( @@ -95,7 +96,7 @@ export function EfficacyTab({ staleTime: 60_000, }); - if (isLoading || !data) { + if (isLoading) { return ( + refetch()} /> + + ); + } + // The tab is hidden upstream when ineligible; this is a defensive fallback. if (!data.eligible) { return ( diff --git a/src/hooks/use-workouts.ts b/src/hooks/use-workouts.ts index 30cbe4937..82a490a42 100644 --- a/src/hooks/use-workouts.ts +++ b/src/hooks/use-workouts.ts @@ -82,6 +82,10 @@ export interface UseWorkoutsResult { isLoading: boolean; isEmpty: boolean; error: Error | null; + /** True once the query has exhausted retries and settled on a failure. */ + isError: boolean; + /** Re-runs the query — the retry action for a failed fetch. */ + refetch: () => void; } export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { @@ -109,6 +113,8 @@ export function useWorkouts(opts: UseWorkoutsOptions = {}): UseWorkoutsResult { isLoading: query.isLoading, isEmpty, error: query.error as Error | null, + isError: query.isError, + refetch: () => void query.refetch(), }; }