fix(ui): workout header stacking, chart-shell inset parity, silent retarget failure

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-18 03:52:32 +02:00
1 parent 5532a42ff6
commit 07f161bf25
13 files changed
+75 -42

No files matched your search

+2 -1
View File
@@ -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}.",
+2 -1
View File
@@ -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}.",
+2 -1
View File
@@ -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}.",
+2 -1
View File
@@ -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}.",
+2 -1
View File
@@ -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}.",
+2 -1
View File
@@ -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}.",
+1 -1
View File
@@ -228,7 +228,7 @@ export default function InsightsPulsPage() {
<Link
href="/insights/cardio-fitness"
data-slot="vo2-cardio-link"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none"
className="bg-card hover:bg-accent/40 focus-visible:ring-ring/50 block space-y-1.5 rounded-xl border p-4 transition-colors focus-visible:ring-2 focus-visible:outline-none md:p-6"
>
<TileHeader
icon={Gauge}
@@ -84,7 +84,7 @@ export function GlucoseAdvancedDisclosure({
{t("insights.bloodGlucose.clinical.advanced.intro")}
</p>
<dl className="grid grid-cols-3 gap-3">
<dl className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<AdvancedStat
label={t("insights.bloodGlucose.clinical.advanced.jIndex.label")}
help={t("insights.bloodGlucose.clinical.advanced.jIndex.help")}
+1 -1
View File
@@ -257,7 +257,7 @@ export function HeroStrip({
// it the shadow bled through the sticky section nav below
// (the nav uses bg-background/80 + backdrop-blur, which the
// shadow leaked through).
"relative isolate overflow-hidden rounded-xl px-4 py-4 sm:px-6 sm:py-6",
"relative isolate overflow-hidden rounded-xl px-4 py-4 md:px-6 md:py-6",
)}
>
{/*
@@ -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}
>
<TileHeader
@@ -234,7 +235,7 @@ export function IntradayPulseChart({
) : null}
{isLoading ? (
<div className="bg-muted/40 h-44 animate-pulse rounded-lg motion-reduce:animate-none" />
<Skeleton className="h-[180px] w-full rounded-lg" />
) : isError ? (
<QueryErrorCard
onRetry={() => refetch()}
@@ -33,6 +33,13 @@ export function CaffeineCard() {
apiGet<NutrientDailySeries>(
`/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) {
+26 -19
View File
@@ -124,26 +124,33 @@ export function WorkoutDetailHeader({ workout }: WorkoutDetailHeaderProps) {
return (
<header
data-slot="workout-detail-header"
className="flex items-start gap-4"
// v1.30 mobile audit (W-1) — the three columns (icon + title block +
// duration) held at every width, so at 360px the middle column got
// ~120px and the long-format date line wrapped 4-5 lines beside a
// text-2xl duration. Stack below sm: duration drops under the title
// block as its own row instead of squeezing beside it.
className="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4"
>
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
<div className="flex min-w-0 items-start gap-4">
{renderSportIconBadge(workout.sportType)}
<div className="min-w-0 flex-1 space-y-1">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{workout.source}
</p>
<h2 className="truncate text-lg font-semibold sm:text-xl">
{sportName}
</h2>
<p className="text-muted-foreground text-sm">
{formatDateRange(
workout.startedAt,
workout.endedAt,
locale,
timeFormat,
)}
</p>
</div>
</div>
<div className="flex flex-col items-end text-right">
<div className="flex items-center gap-2 sm:flex-col sm:items-end sm:gap-0 sm:text-right">
<span className="text-2xl font-semibold tabular-nums">
{formatDuration(workout.durationSec)}
</span>
@@ -180,7 +187,7 @@ function StatTile({ icon, label, value, hint }: StatTileProps) {
</span>
<span className="text-base font-semibold tabular-nums">{value}</span>
{hint ? (
<span className="text-muted-foreground text-[11px]">{hint}</span>
<span className="text-muted-foreground text-xs">{hint}</span>
) : null}
</div>
);
@@ -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
// `<Card>` as a bare 220px `ResponsiveContainer` with no chrome of its own.
// The default `<ChartSkeleton>` 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: () => <ChartSkeleton /> },
{ ssr: false, loading: () => <Skeleton className="h-[220px] w-full" /> },
);
// 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 ? (
<span className="text-muted-foreground text-xs">
<Badge variant="outline" className="text-xs font-normal">
{t("medications.efficacy.primaryBadge")}
</span>
</Badge>
) : undefined
}
/>
@@ -294,13 +304,15 @@ function TargetCard({
<BeforeAfterCard target={target} minWeeks={dto.minWeeksPerSide} />
{target.levelShift?.present && target.levelShift.nearStart ? (
{target.levelShift?.present &&
target.levelShift.nearStart &&
target.levelShift.at ? (
<p
className="text-muted-foreground text-xs"
data-slot="medication-wirkung-levelshift"
>
{t("medications.efficacy.levelShift", {
date: formatShort(target.levelShift.at),
date: fmt.date(target.levelShift.at),
})}
</p>
) : 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({
</div>
);
}
function formatShort(iso?: string): string {
if (!iso) return "";
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? "" : d.toISOString().slice(0, 10);
}