From 07f161bf25fca438bcbbabb9d61bdf39e3e17ef0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 18 Jul 2026 03:26:33 +0200 Subject: [PATCH] fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WorkoutDetailHeader held three columns at every width, wrapping the long-format date to 4-5 lines beside a text-2xl duration at 360px; stack below sm with duration moving under the title block. Same pass drops a stray text-[11px] muted hint below the size floor. The intraday pulse chart and the VO2/cardio-fitness link card carried a static p-4 shell with no md:p-6 step, and the intraday loading state was a raw pulse div instead of the Skeleton primitive under-reserving the chart height — both now match the card inset contract used elsewhere. Same one-line fix on the insights hero shell (sm-stepped inset instead of the standard md-stepped one) and the glucose advanced panel's 3-up grid at a 320px fold. The medication efficacy tab's retarget control failed saves silently — no toast, no inline alert, an unhandled rejection escaping past the disabled-button reset. Surface a toast on failure. Also swaps the level-shift date off a raw toISOString slice onto the locale formatter, and the primary-target tag off bare muted text onto the Badge component the rest of the app uses for this kind of marker. The caffeine card collapsed its 280px skeleton to nothing on every nutrients-page visit for an account with no logged caffeine; a 60s staleTime (already the convention on the sibling insights queries) keeps the flash to once per session. The efficacy chart's dynamic- import fallback was the full card-chrome ChartSkeleton nested inside its own Card, snapping ~100px when the real bare chart mounted — swap for a plain Skeleton sized to the actual chart. --- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- src/app/insights/pulse/page.tsx | 2 +- .../glucose/glucose-advanced-disclosure.tsx | 2 +- src/components/insights/hero-strip.tsx | 2 +- .../insights/intraday-pulse-chart.tsx | 5 ++- .../insights/nutrients/caffeine-card.tsx | 7 +++ src/components/insights/workout-detail.tsx | 45 +++++++++++-------- .../detail/efficacy/efficacy-tab.tsx | 36 ++++++++++----- 13 files changed, 75 insertions(+), 42 deletions(-) diff --git a/messages/de.json b/messages/de.json index e245834e0..0db2acf0a 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1809,7 +1809,8 @@ "label": "Nicht der richtige Messwert?", "placeholder": "Verfolgen anhand…", "apply": "Übernehmen", - "reset": "Auf Standard zurücksetzen" + "reset": "Auf Standard zurücksetzen", + "error": "Diese Änderung konnte nicht gespeichert werden. Bitte erneut versuchen." }, "beforeAfter": { "summary": "Ø {before} davor → Ø {after} seitdem – eine Veränderung von {delta}.", diff --git a/messages/en.json b/messages/en.json index 0ff68d971..85ae8a4ea 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1809,7 +1809,8 @@ "label": "Not the right measurement?", "placeholder": "Track against…", "apply": "Apply", - "reset": "Reset to default" + "reset": "Reset to default", + "error": "Could not save that change. Try again." }, "beforeAfter": { "summary": "Ø {before} before → Ø {after} since — a change of {delta}.", diff --git a/messages/es.json b/messages/es.json index 19dfcd437..1f8693a3c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -1809,7 +1809,8 @@ "label": "¿No es la medición correcta?", "placeholder": "Seguir según…", "apply": "Aplicar", - "reset": "Restablecer al valor predeterminado" + "reset": "Restablecer al valor predeterminado", + "error": "No se pudo guardar ese cambio. Inténtalo de nuevo." }, "beforeAfter": { "summary": "Ø {before} antes → Ø {after} desde entonces: un cambio de {delta}.", diff --git a/messages/fr.json b/messages/fr.json index ef2e1927e..943c80cc9 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1809,7 +1809,8 @@ "label": "Ce n'est pas la bonne mesure ?", "placeholder": "Suivre selon…", "apply": "Appliquer", - "reset": "Réinitialiser par défaut" + "reset": "Réinitialiser par défaut", + "error": "Impossible d'enregistrer cette modification. Réessaie." }, "beforeAfter": { "summary": "Ø {before} avant → Ø {after} depuis — un écart de {delta}.", diff --git a/messages/it.json b/messages/it.json index 7e453fc2c..6e947266a 100644 --- a/messages/it.json +++ b/messages/it.json @@ -1809,7 +1809,8 @@ "label": "Non è la misurazione giusta?", "placeholder": "Monitora in base a…", "apply": "Applica", - "reset": "Ripristina predefinito" + "reset": "Ripristina predefinito", + "error": "Impossibile salvare questa modifica. Riprova." }, "beforeAfter": { "summary": "Ø {before} prima → Ø {after} da allora — una variazione di {delta}.", diff --git a/messages/pl.json b/messages/pl.json index 15458dbe6..e5fa6918b 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -1809,7 +1809,8 @@ "label": "To nie ten pomiar?", "placeholder": "Śledź według…", "apply": "Zastosuj", - "reset": "Przywróć domyślne" + "reset": "Przywróć domyślne", + "error": "Nie udało się zapisać tej zmiany. Spróbuj ponownie." }, "beforeAfter": { "summary": "Ø {before} przedtem → Ø {after} od tego czasu — zmiana o {delta}.", diff --git a/src/app/insights/pulse/page.tsx b/src/app/insights/pulse/page.tsx index 4ab7decbc..90367402a 100644 --- a/src/app/insights/pulse/page.tsx +++ b/src/app/insights/pulse/page.tsx @@ -228,7 +228,7 @@ export default function InsightsPulsPage() { -
+
{/* diff --git a/src/components/insights/intraday-pulse-chart.tsx b/src/components/insights/intraday-pulse-chart.tsx index e9a29f0d1..4d2c8a69e 100644 --- a/src/components/insights/intraday-pulse-chart.tsx +++ b/src/components/insights/intraday-pulse-chart.tsx @@ -23,6 +23,7 @@ import { userDayKey, shiftDateKey } from "@/lib/tz/format"; import { TileHeader } from "@/components/insights/tile-header"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; +import { Skeleton } from "@/components/ui/skeleton"; import { BUCKET_MINUTES, type IntradayHrBucket, @@ -185,7 +186,7 @@ export function IntradayPulseChart({ // hue, per the plan's §3.6 hue-family call); the chart inside stays // untouched. The same hue marks the `tension_window` rail card, so // the two S11 surfaces read as one family. - className="bg-card metric-accent space-y-1.5 rounded-xl border p-4" + className="bg-card metric-accent space-y-1.5 rounded-xl border p-4 md:p-6" style={{ "--tile-hue": "var(--tile-stress)" } as React.CSSProperties} > + ) : isError ? ( refetch()} diff --git a/src/components/insights/nutrients/caffeine-card.tsx b/src/components/insights/nutrients/caffeine-card.tsx index e89733df1..49e799711 100644 --- a/src/components/insights/nutrients/caffeine-card.tsx +++ b/src/components/insights/nutrients/caffeine-card.tsx @@ -33,6 +33,13 @@ export function CaffeineCard() { apiGet( `/api/nutrients/daily?nutrient=caffeine&days=${WINDOW_DAYS}`, ), + // v1.30 UI audit (M7) — this card unmounts entirely on a no-data window + // (`hasData` gate below), so a self-hoster who never logs caffeine saw + // the 280px skeleton collapse on every `/insights/nutrients` visit. A + // 60s `staleTime` (the "route swing within a minute is a free cache + // hit" convention used across the insights queries) keeps the flash to + // once per session rather than once per mount. + staleTime: 60_000, }); if (isLoading) { diff --git a/src/components/insights/workout-detail.tsx b/src/components/insights/workout-detail.tsx index 6bc216c64..74e8169c0 100644 --- a/src/components/insights/workout-detail.tsx +++ b/src/components/insights/workout-detail.tsx @@ -124,26 +124,33 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) { return (
- {renderSportIconBadge(workout.sportType)} -
-

- {workout.source} -

-

- {sportName} -

-

- {formatDateRange( - workout.startedAt, - workout.endedAt, - locale, - timeFormat, - )} -

+
+ {renderSportIconBadge(workout.sportType)} +
+

+ {workout.source} +

+

+ {sportName} +

+

+ {formatDateRange( + workout.startedAt, + workout.endedAt, + locale, + timeFormat, + )} +

+
-
+
{formatDuration(workout.durationSec)} @@ -180,7 +187,7 @@ function StatTile({ icon, label, value, hint }: StatTileProps) { {value} {hint ? ( - {hint} + {hint} ) : null}
); diff --git a/src/components/medications/detail/efficacy/efficacy-tab.tsx b/src/components/medications/detail/efficacy/efficacy-tab.tsx index f5ded3a59..8d0ac882b 100644 --- a/src/components/medications/detail/efficacy/efficacy-tab.tsx +++ b/src/components/medications/detail/efficacy/efficacy-tab.tsx @@ -13,12 +13,14 @@ import { useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { Activity, TrendingUp } from "lucide-react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { MedicationDetailSection } from "@/components/medications/medication-detail-section"; import { SegmentedToggle } from "@/components/medications/detail/segmented-toggle"; import { TileHeader } from "@/components/insights/tile-header"; import { Glp1PlateauNote } from "@/components/insights/glp1-plateau-note"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { @@ -29,9 +31,10 @@ import { SelectValue, } from "@/components/ui/select"; import { ChartSkeleton } from "@/components/charts/chart-skeleton"; +import { Skeleton } from "@/components/ui/skeleton"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; -import { useTranslations } from "@/lib/i18n/context"; +import { useTranslations, useFormatters } from "@/lib/i18n/context"; import { useAuth } from "@/hooks/use-auth"; import { DEFAULT_TIMEZONE } from "@/lib/tz/format"; import type { @@ -39,12 +42,18 @@ import type { EfficacyTargetView, } from "@/lib/medications/efficacy/build-efficacy"; +// v1.30 UI audit (M8) — `EfficacyChart` mounts inside `TargetCard`'s own +// `` as a bare 220px `ResponsiveContainer` with no chrome of its own. +// The default `` fallback is a rounded-xl bordered card with +// faux header + a 240/280px band — a card nested in a card, then a ~100px +// height snap when the real chart mounts. A chrome-less skeleton sized to +// the actual chart avoids both. const EfficacyChart = dynamic( () => import("@/components/charts/chart-runtime").then((mod) => ({ default: mod.EfficacyChart, })), - { ssr: false, loading: () => }, + { ssr: false, loading: () => }, ); // v1.28.20 — the estimated active-substance curve (Wirkstoffverlauf) also @@ -223,6 +232,7 @@ function TargetCard({ timezone: string; }) { const { t } = useTranslations(); + const fmt = useFormatters(); const [scaleMode, setScaleMode] = useState<"absolute" | "percent">( "absolute", ); @@ -236,9 +246,9 @@ function TargetCard({ title={target.label} right={ target.primary ? ( - + {t("medications.efficacy.primaryBadge")} - + ) : undefined } /> @@ -294,13 +304,15 @@ function TargetCard({ - {target.levelShift?.present && target.levelShift.nearStart ? ( + {target.levelShift?.present && + target.levelShift.nearStart && + target.levelShift.at ? (

{t("medications.efficacy.levelShift", { - date: formatShort(target.levelShift.at), + date: fmt.date(target.levelShift.at), })}

) : null} @@ -414,6 +426,12 @@ function RetargetControl({ await apiPut(`/api/medications/${medicationId}/efficacy/target`, body); onChanged(); if (clear) setValue(""); + } catch { + // v1.30 UI audit (M9) — the save previously failed silently: the + // buttons re-enabled with no toast, no inline alert, and the + // rejection escaped as an unhandled promise. The user could not + // tell the override was not saved. + toast.error(t("medications.efficacy.retarget.error")); } finally { setBusy(false); } @@ -465,9 +483,3 @@ function RetargetControl({
); } - -function formatShort(iso?: string): string { - if (!iso) return ""; - const d = new Date(iso); - return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10); -}