diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f6dac65..fbb655259 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [1.30.1] — 2026-07-18 + +Polish and small fixes. + +- **Mobile.** Coach and document text wraps instead of overflowing; the intake history is a card list on small screens instead of a sideways-scrolling table; tap targets meet the minimum size; two dialogs became bottom sheets; the workout detail header stacks cleanly. +- **Feedback.** Loading skeletons match the content that replaces them (no more layout jump), and the Today priority-card actions show a pending state and can’t fire twice on a double-tap. +- **Remembered choices.** A chart’s time range and the measurement type you last logged are remembered; a coach chat deletes with an Undo; a backdated dose can be undone; lab panels get “Save & add another”; pull-to-refresh works on more pages (and a scroll-position bug that made it fire anywhere is fixed). +- **Consistency, accessibility, wording.** Spacing and contrast tidied, more empty states use the shared component, headings and focus rings improved, a few German phrasings corrected, the daily line reads more calmly, and several explanations gained their guideline citation. + +No migration. No breaking changes. + ## [1.30.0] — 2026-07-18 — Quality pass A broad correctness, performance, and polish release, plus two new features. Nothing new is required of you; existing data is untouched. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2172699f1..b3bfdcd2a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.0 + version: 1.30.1 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 6a3bb382f..13fe46be9 100644 --- a/messages/de.json +++ b/messages/de.json @@ -141,7 +141,7 @@ "aiSummaryDisclaimer": "Dieser Abschnitt wurde automatisch von einem KI-Modell erzeugt und ist nicht klinisch validiert. Er ersetzt keine ärztliche Beurteilung.", "packageReadme": "HealthLog Gesundheitsakte\n\nDieses Paket enthält:\n- report.pdf: den Gesundheitsbericht für die ärztliche Vorlage.\n- bundle.json: dieselben Daten als HL7-FHIR-R4-Dokument (Bundle), ePA-kompatibel.\n\nErstellt mit HealthLog.", "wellnessTitle": "Wellness-Zusammenfassung", - "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus deinen erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", + "wellnessDisclaimer": "Dies sind beschreibende Wellness-Werte, die aus den erfassten Signalen berechnet werden (0–100). Sie sind keine klinischen Beurteilungen oder Diagnosen.", "wellnessRecovery": "Erholung", "wellnessStress": "Stress", "wellnessStrain": "Belastung", @@ -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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Unterhaltungen durchsuchen…", "historyEmpty": "Deine Unterhaltungen erscheinen hier. Stell eine Frage, um zu starten.", "historyDeleteAria": "Unterhaltung löschen", - "historyDeleteConfirm": "Diese Unterhaltung löschen? Das lässt sich nicht rückgängig machen.", + "historyDeleted": "Unterhaltung gelöscht", "sourcesTitle": "Worauf ich zugreife", "sourcesFresh": "Aktuell", "sourcesStale": "Veraltet", @@ -3548,6 +3549,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" @@ -5294,7 +5296,7 @@ "statusRevoked": "Widerrufen", "statusExpired": "Abgelaufen", "inactiveTitle": "Widerrufen und abgelaufen ({count})", - "shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.", + "shownOnce": "Nur einmal sichtbar. Erfasse QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.", "qrLabel": "QR-Code", "qrAlt": "QR-Code mit Freigabelink und Passphrase", "linkLabel": "Link", @@ -6854,7 +6856,7 @@ "title": "Dokumente", "untitled": "Unbenanntes Dokument", "download": "Herunterladen", - "attachmentHint": "Diese Datei wird auf dein Gerät heruntergeladen (keine Vorschau in der Seite).", + "attachmentHint": "Diese Datei wird auf Ihr Gerät heruntergeladen (keine Vorschau in der Seite).", "exifNote": "Kamera-Metadaten (EXIF/GPS) wurden aus geteilten JPEG-, PNG- und WebP-Bildern entfernt." } }, @@ -7395,6 +7397,7 @@ "notePlaceholder": "Optionaler Kontext zu diesem Wert", "requiredError": "Bitte einen Wert eingeben.", "save": "Speichern", + "saveAndAddAnother": "Speichern & nächsten Wert", "savedToast": "Wert gespeichert", "saveError": "Wert konnte nicht gespeichert werden", "biomarker": "Biomarker", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Dein Gesundheitsscore heute liegt bei {score}.", - "allClear": "Heute braucht nichts deine Aufmerksamkeit — alles verläuft normal." + "allClear": "Heute braucht nichts deine Aufmerksamkeit." }, "push": { "title": "Dein Morgenüberblick", diff --git a/messages/en.json b/messages/en.json index 2edd6c69f..82501a4bb 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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Search conversations…", "historyEmpty": "Your conversations will appear here. Ask anything to start.", "historyDeleteAria": "Delete conversation", - "historyDeleteConfirm": "Delete this conversation? This cannot be undone.", + "historyDeleted": "Conversation deleted", "sourcesTitle": "What I can see", "sourcesFresh": "Fresh", "sourcesStale": "Stale", @@ -2661,11 +2662,11 @@ "fatFreeMassTitle": "Fat-free mass", "fatFreeMassBody": "Fat-free mass is everything in your body that is not fat — muscle, bone, organs, and water. Holding or building it while losing weight usually signals healthier body composition.", "fatMassTitle": "Fat mass", - "fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk.", + "fatMassBody": "Fat mass is the total weight of fat in your body. Some fat is essential for hormones and energy storage, but too much — especially around the abdomen — raises long-term health risk (WHO 2024).", "muscleMassTitle": "Muscle mass", "muscleMassBody": "Muscle mass is the estimated weight of the muscle in your body. More muscle supports strength, metabolism, and mobility, and it tends to need active effort to preserve as you age.", "visceralFatTitle": "Visceral fat", - "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes, so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.", + "visceralFatBody": "Visceral fat is the fat stored deep in the abdomen around your organs. Unlike fat under the skin, a high level is closely linked to heart disease and type 2 diabetes (AHA, Circulation 2021), so lower is generally better. The scale shows a unitless rating (about 1–12 is the healthy range, 13 and up elevated); this is a separate scale from the imaging cm² thresholds clinicians use and is not interchangeable with them.", "leanBodyMassTitle": "Lean body mass", "leanBodyMassBody": "Lean body mass is your total weight minus body fat — muscle, bone, organs, and water combined. Tracking it alongside weight shows whether changes come from muscle or from fat.", "flightsClimbedTitle": "Flights climbed", @@ -2695,7 +2696,7 @@ "audioEventsTitle": "Loud sound events", "audioEventsBody": "Loud sound events flag moments when noise around you reached a level that can harm hearing with enough exposure. Frequent events are a cue to seek quieter surroundings or hearing protection.", "daylightTitle": "Time in daylight", - "daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood, and time outdoors is linked to eye health.", + "daylightBody": "Time in daylight is how long you spend in outdoor light each day. Regular daylight supports a healthy sleep-wake rhythm and mood (CDC 2024), and time outdoors is linked to eye health.", "bloodGlucoseTitle": "Blood glucose", "bloodGlucoseBody": "Blood glucose is the amount of sugar in your blood, the body's main energy source. It rises after eating and settles between meals. As general guidance for adults without diabetes, a fasting reading under 100 mg/dL is normal, 100–125 is prediabetes, and 126 or above falls in the diabetes range (ADA 2024). A single high reading does not diagnose — a clinician confirms with a repeat test, and people managing diabetes use different, clinician-set targets.", "skinTemperatureTitle": "Skin temperature", @@ -2707,7 +2708,7 @@ "wristTemperatureTitle": "Wrist temperature", "wristTemperatureBody": "Wrist temperature is measured while you sleep and reported as the difference from your personal baseline, not an absolute value. A steady reading is normal, while a sustained rise can hint at illness, intense training, or a menstrual-cycle change.", "fallsTitle": "Falls", - "fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults.", + "fallsBody": "This counts the hard falls your wearable detects. Zero is the goal; a rising count is a mobility-risk signal worth raising with a clinician, especially for older adults (CDC STEADI 2024).", "sixMinuteWalkTitle": "Six-minute walk distance", "sixMinuteWalkBody": "This is an estimate of how far you could walk in six minutes — a long-standing clinical test of mobility and cardiopulmonary endurance. The wearable figure is a proxy, not the supervised clinical test; a steady or rising trend is the useful read.", "stairAscentSpeedTitle": "Stair ascent speed", @@ -3548,6 +3549,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" @@ -7395,6 +7397,7 @@ "notePlaceholder": "Optional context for this result", "requiredError": "Enter a value.", "save": "Save", + "saveAndAddAnother": "Save & add another", "savedToast": "Result saved", "saveError": "Could not save the result", "biomarker": "Biomarker", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Your health score today is {score}.", - "allClear": "Nothing needs your attention today — everything's tracking normally." + "allClear": "Nothing needs your attention today." }, "push": { "title": "Your morning briefing", diff --git a/messages/es.json b/messages/es.json index fdc46ff71..fc6cbd9c5 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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Buscar en las conversaciones…", "historyEmpty": "Tus conversaciones aparecen aquí. Lanza una pregunta para empezar.", "historyDeleteAria": "Eliminar conversación", - "historyDeleteConfirm": "¿Eliminar esta conversación? No se puede deshacer.", + "historyDeleted": "Conversación eliminada", "sourcesTitle": "A qué accedo", "sourcesFresh": "Actuales", "sourcesStale": "Desactualizados", @@ -3548,6 +3549,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" @@ -7395,6 +7397,7 @@ "notePlaceholder": "Contexto opcional para este resultado", "requiredError": "Introduce un valor.", "save": "Guardar", + "saveAndAddAnother": "Guardar y añadir otro", "savedToast": "Resultado guardado", "saveError": "No se pudo guardar el resultado", "biomarker": "Biomarcador", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Tu puntuación de salud hoy es {score}.", - "allClear": "Hoy nada requiere tu atención: todo evoluciona con normalidad." + "allClear": "Hoy nada necesita tu atención." }, "push": { "title": "Tu resumen matinal", diff --git a/messages/fr.json b/messages/fr.json index a476e572d..e5839646e 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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Rechercher dans les conversations…", "historyEmpty": "Tes conversations s’affichent ici. Lance une question pour commencer.", "historyDeleteAria": "Supprimer la conversation", - "historyDeleteConfirm": "Supprimer cette conversation ? Action irréversible.", + "historyDeleted": "Conversation supprimée", "sourcesTitle": "Ce à quoi j’accède", "sourcesFresh": "À jour", "sourcesStale": "Obsolètes", @@ -3548,6 +3549,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" @@ -7395,6 +7397,7 @@ "notePlaceholder": "Contexte facultatif pour ce résultat", "requiredError": "Saisissez une valeur.", "save": "Enregistrer", + "saveAndAddAnother": "Enregistrer et ajouter un autre", "savedToast": "Résultat enregistré", "saveError": "Impossible d'enregistrer le résultat", "biomarker": "Biomarqueur", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Ton score de santé aujourd'hui est de {score}.", - "allClear": "Rien ne requiert ton attention aujourd'hui — tout évolue normalement." + "allClear": "Rien ne requiert ton attention aujourd'hui." }, "push": { "title": "Votre résumé du matin", diff --git a/messages/it.json b/messages/it.json index 074ac3ad0..744b0e761 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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Cerca nelle conversazioni…", "historyEmpty": "Le tue conversazioni compaiono qui. Fai una domanda per iniziare.", "historyDeleteAria": "Elimina conversazione", - "historyDeleteConfirm": "Eliminare questa conversazione? Non si può annullare.", + "historyDeleted": "Conversazione eliminata", "sourcesTitle": "A cosa accedo", "sourcesFresh": "Aggiornate", "sourcesStale": "Datate", @@ -3548,6 +3549,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" @@ -7395,6 +7397,7 @@ "notePlaceholder": "Contesto facoltativo per questo risultato", "requiredError": "Inserisci un valore.", "save": "Salva", + "saveAndAddAnother": "Salva e aggiungi un altro", "savedToast": "Risultato salvato", "saveError": "Impossibile salvare il risultato", "biomarker": "Biomarcatore", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Il tuo punteggio di salute oggi è {score}.", - "allClear": "Oggi nulla richiede la tua attenzione — tutto procede normalmente." + "allClear": "Oggi nulla richiede la tua attenzione." }, "push": { "title": "Il tuo riepilogo mattutino", diff --git a/messages/pl.json b/messages/pl.json index 07a0c8c46..5e5e0bb6c 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}.", @@ -2261,7 +2262,7 @@ "historySearchPlaceholder": "Szukaj w rozmowach…", "historyEmpty": "Twoje rozmowy pojawią się tutaj. Zadaj pytanie, aby zacząć.", "historyDeleteAria": "Usuń rozmowę", - "historyDeleteConfirm": "Usunąć tę rozmowę? Tego nie da się cofnąć.", + "historyDeleted": "Rozmowa usunięta", "sourcesTitle": "Do czego mam dostęp", "sourcesFresh": "Aktualne", "sourcesStale": "Nieaktualne", @@ -3548,6 +3549,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" @@ -7395,6 +7397,7 @@ "notePlaceholder": "Opcjonalny kontekst tego wyniku", "requiredError": "Podaj wartość.", "save": "Zapisz", + "saveAndAddAnother": "Zapisz i dodaj kolejny", "savedToast": "Wynik zapisany", "saveError": "Nie udało się zapisać wyniku", "biomarker": "Biomarker", @@ -8639,7 +8642,7 @@ "daily": { "line": { "score": "Twój dzisiejszy wynik zdrowia to {score}.", - "allClear": "Dziś nic nie wymaga Twojej uwagi — wszystko przebiega normalnie." + "allClear": "Dziś nic nie wymaga Twojej uwagi." }, "push": { "title": "Twoje poranne podsumowanie", diff --git a/package.json b/package.json index 4efe4e7e5..b5ac1467b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.0", + "version": "1.30.1", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index a5ab32619..edd2f79b5 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.0"; + /* @sw-version-fallback */ "v1.30.1"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/dashboard/chart-overlay-prefs/route.ts b/src/app/api/dashboard/chart-overlay-prefs/route.ts index 1a84a9cd1..ac9d4cd07 100644 --- a/src/app/api/dashboard/chart-overlay-prefs/route.ts +++ b/src/app/api/dashboard/chart-overlay-prefs/route.ts @@ -44,6 +44,12 @@ const prefsSchema = z.object({ comparisonBaseline: z .enum(["none", "lastMonth", "lastYear"]) .default("none"), + // v1.30.1 — persisted range-tab selection (M2 QoL fix). Optional so + // a client on an older cached bundle that never sends it doesn't + // 422. Literal set mirrors `CHART_RANGE_POINTS` in dashboard-layout. + rangePoints: z + .union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)]) + .optional(), }), }); diff --git a/src/app/coach/conversations/page.tsx b/src/app/coach/conversations/page.tsx index a80a3d776..2201a3fcc 100644 --- a/src/app/coach/conversations/page.tsx +++ b/src/app/coach/conversations/page.tsx @@ -10,6 +10,7 @@ import { Target, Trash2, } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -18,7 +19,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card"; import { COACH_SCROLLBAR } from "@/components/insights/coach-panel/message-thread"; import { useCoachConversations, - useDeleteCoachConversation, + useDeleteCoachConversationWithUndo, } from "@/components/insights/coach-panel/use-coach"; import type { CoachConversationDTO } from "@/lib/ai/coach/types"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; @@ -102,16 +103,17 @@ function CoachConversationsBody() { const router = useRouter(); const { conversations, isLoading, isError, refetch } = useCoachConversations(); - const deleteMutation = useDeleteCoachConversation(); + const { pendingDeleteIds, requestDelete, undoDelete } = + useDeleteCoachConversationWithUndo(); const [filter, setFilter] = useState(""); - const [confirmId, setConfirmId] = useState(null); // Mirror the rail's title substring filter. const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); - if (!q) return conversations; - return conversations.filter((c) => c.title.toLowerCase().includes(q)); - }, [conversations, filter]); + const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id)); + if (!q) return visible; + return visible.filter((c) => c.title.toLowerCase().includes(q)); + }, [conversations, filter, pendingDeleteIds]); const groups = useMemo(() => groupByRecency(filtered), [filtered]); @@ -120,13 +122,17 @@ function CoachConversationsBody() { router.push(`/coach?c=${id}`); } + // v1.30.1 M5 — one tap deletes (with an Undo toast); see + // `use-coach.ts`'s `useDeleteCoachConversationWithUndo` for the + // rationale — this mirrors the rail's identical fix. function handleDeleteRequest(id: string) { - if (confirmId === id) { - deleteMutation.mutate(id); - setConfirmId(null); - } else { - setConfirmId(id); - } + requestDelete(id); + toast.success(t("insights.coach.historyDeleted"), { + action: { + label: t("common.undo"), + onClick: () => undoDelete(id), + }, + }); } return ( @@ -171,7 +177,7 @@ function CoachConversationsBody() {
@@ -212,7 +218,6 @@ function CoachConversationsBody() {
    {group.conversations.map((c) => { - const isConfirming = confirmId === c.id; return (
  • handleDeleteRequest(c.id)} - aria-label={ - isConfirming - ? t("insights.coach.historyDeleteConfirm") - : t("insights.coach.historyDeleteAria") - } + aria-label={t("insights.coach.historyDeleteAria")} data-slot="coach-conversations-delete" - data-confirming={isConfirming ? "true" : undefined} - className={cn( - "shrink-0", - isConfirming && - "text-destructive hover:text-destructive", - )} + className="shrink-0" >
diff --git a/src/components/documents/documents-view.tsx b/src/components/documents/documents-view.tsx index d9d61b9ef..a1227eaf8 100644 --- a/src/components/documents/documents-view.tsx +++ b/src/components/documents/documents-view.tsx @@ -38,6 +38,8 @@ import { PageHeader } from "@/components/ui/page-header"; import { QueryErrorCard } from "@/components/ui/query-error-card"; import { Skeleton } from "@/components/ui/skeleton"; import { useAuth } from "@/hooks/use-auth"; +import { usePullToRefresh } from "@/hooks/use-pull-to-refresh"; +import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator"; import { apiGet, apiPost } from "@/lib/api/api-fetch"; import { useTranslations } from "@/lib/i18n/context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; @@ -233,6 +235,18 @@ export function DocumentsView() { [list.data], ); + // v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/ + // measurements/medications. The vault is one of the sharpest PWA-resume- + // staleness surfaces named in the audit: a document indexed server-side + // while the tab was backgrounded (auto-index / AI summary) otherwise + // stays invisible until the bounded processing poll above happens to + // catch it or the user navigates away and back. + const refreshDocuments = useCallback( + () => Promise.all([list.refetch(), usage.refetch()]), + [list, usage], + ); + const pull = usePullToRefresh({ onRefresh: refreshDocuments }); + const upload = useDocumentUpload( usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined, ); @@ -595,6 +609,7 @@ export function DocumentsView() { return (
+ +
{value.redFlags.length > 0 ? (

diff --git a/src/components/insights/coach-panel/attachment-picker.tsx b/src/components/insights/coach-panel/attachment-picker.tsx index 5752523ec..286b9a037 100644 --- a/src/components/insights/coach-panel/attachment-picker.tsx +++ b/src/components/insights/coach-panel/attachment-picker.tsx @@ -5,14 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { Check, Search } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { apiGet } from "@/lib/api/api-fetch"; @@ -128,18 +121,36 @@ export function AttachmentPicker({ } return ( -

- - - {t("insights.coach.attach.pickerTitle")} - - {t("insights.coach.attach.pickerDescription")} - - - + + + + + } + > +
-
- {list.isPending && open ? ( -
- {Array.from({ length: 4 }, (_, i) => ( - - ))} -
- ) : list.isError ? ( -

- {t("documents.list.loadError")} -

- ) : documents.length === 0 ? ( -

- {t("insights.coach.attach.pickerEmpty")} -

- ) : ( -
    - {documents.map((doc) => { - const isSelected = selected.includes(doc.id); - const selectable = doc.hasContentIndex; - const disabled = !selectable || (!isSelected && capReached); - const title = - doc.title ?? doc.filename ?? t("documents.card.untitled"); - const Icon = DOCUMENT_KIND_ICONS[doc.kind]; - return ( -
  • - -
  • - ); - })} -
- )} -
- - - - + + ); })} - - - -
+ + )} +
+ ); } diff --git a/src/components/insights/coach-panel/attachment-pills.tsx b/src/components/insights/coach-panel/attachment-pills.tsx index e4f548c1e..5c6cd5066 100644 --- a/src/components/insights/coach-panel/attachment-pills.tsx +++ b/src/components/insights/coach-panel/attachment-pills.tsx @@ -85,7 +85,12 @@ export function AttachmentPills({ title: item.title, })} title={t("insights.coach.attach.pillRemove", { title: item.title })} - className="text-muted-foreground hover:text-foreground size-5 shrink-0 rounded-full" + // v1.30 mobile audit (C-4) — the visible glyph stays a compact + // size-5 pill control, but the actual hit area extends to the + // 44px floor via an invisible `after` hit-slop (the same + // `measurement-list.tsx` checkbox trick) rather than growing the + // pill's visual footprint in the composer's attach row. + className="text-muted-foreground hover:text-foreground relative size-5 shrink-0 rounded-full after:absolute after:-inset-3" >
)} {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/coach-panel/coach-input.tsx b/src/components/insights/coach-panel/coach-input.tsx index 358810d47..477937b5d 100644 --- a/src/components/insights/coach-panel/coach-input.tsx +++ b/src/components/insights/coach-panel/coach-input.tsx @@ -49,8 +49,13 @@ import { useTranslations } from "@/lib/i18n/context"; * fire two requests in parallel. * * v1.4.22 B4: the disclaimer ("Coach replies are generated …") moved - * out of the composer and into the sources rail footer, so the - * composer stays focused on the input affordance. + * out of the composer and into the sources rail footer. DISC-01 + * (v1.18.6) later dropped that per-page copy too, in favour of the + * one-time onboarding acknowledgment; the only in-surface "not medical + * advice" text left in this namespace is `errorNoProvider`, shown only + * when no provider is connected. The real non-diagnostic boundary is + * the guard stack (system prompt, prose-grounding check, outbound + * refusal), not a rendered disclaimer line. * * v1.18.7 (W-coach C-UI): voice dictation returns to the web composer. * The earlier placeholder mic was dropped in v1.4.25 because it did @@ -646,7 +651,7 @@ export function CoachInput({ className={cn( "border-border/60 bg-muted/40 group rounded-2xl border", "shadow-sm transition-colors", - "focus-within:border-primary/50 focus-within:ring-primary/15 focus-within:bg-background focus-within:ring-2", + "focus-within:border-primary/50 focus-within:ring-primary/50 focus-within:bg-background focus-within:ring-2", // Both surfaces share ONE baseline row: the drawer flanks the // textarea with mic (left) + send (right); the page leads with a // `+` actions menu, then the textarea, then mic + send. `items-end` diff --git a/src/components/insights/coach-panel/history-rail.tsx b/src/components/insights/coach-panel/history-rail.tsx index 67e4929a9..10da1062e 100644 --- a/src/components/insights/coach-panel/history-rail.tsx +++ b/src/components/insights/coach-panel/history-rail.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { MessagesSquare, Paperclip, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -10,7 +11,10 @@ import { useTranslations } from "@/lib/i18n/context"; import { formatRelativeTime } from "@/lib/i18n/relative-time"; import { COACH_SCROLLBAR } from "./message-thread"; -import { useCoachConversations, useDeleteCoachConversation } from "./use-coach"; +import { + useCoachConversations, + useDeleteCoachConversationWithUndo, +} from "./use-coach"; /** * v1.4.20 phase B2b — conversation history rail. @@ -52,26 +56,31 @@ export function HistoryRail({ }: HistoryRailProps) { const { t } = useTranslations(); const { conversations, isLoading } = useCoachConversations(); - const deleteMutation = useDeleteCoachConversation(); + const { pendingDeleteIds, requestDelete, undoDelete } = + useDeleteCoachConversationWithUndo(); const [filter, setFilter] = useState(""); - const [confirmId, setConfirmId] = useState(null); const filtered = useMemo(() => { const q = filter.trim().toLowerCase(); - if (!q) return conversations; - return conversations.filter((c) => c.title.toLowerCase().includes(q)); - }, [conversations, filter]); + const visible = conversations.filter((c) => !pendingDeleteIds.has(c.id)); + if (!q) return visible; + return visible.filter((c) => c.title.toLowerCase().includes(q)); + }, [conversations, filter, pendingDeleteIds]); + // v1.30.1 M5 — a single tap hides the row and schedules the real + // delete; the toast's Undo action cancels it within the grace + // window. Replaces the old arm-then-tap-again confirm, which never + // disarmed. If the user just deleted the active thread, the drawer + // is listening to `onSelect` for navigation; we leave it to the + // parent to clear `activeId` when the conversation disappears. function handleDeleteRequest(id: string) { - if (confirmId === id) { - deleteMutation.mutate(id); - setConfirmId(null); - // If the user just deleted the active thread, the drawer is - // listening to `onSelect` for navigation; we leave it to the - // parent to clear `activeId` when the conversation disappears. - } else { - setConfirmId(id); - } + requestDelete(id); + toast.success(t("insights.coach.historyDeleted"), { + action: { + label: t("common.undo"), + onClick: () => undoDelete(id), + }, + }); } return ( @@ -141,7 +150,6 @@ export function HistoryRail({ ) : ( filtered.map((c) => { const isActive = c.id === activeId; - const isConfirming = confirmId === c.id; return (
{/* v1.4.27 R3d MB2 — drop the hover-only `opacity-0` reveal: touch surfaces have no hover, so the delete - affordance was invisible on mobile. The button now + affordance was invisible on mobile. The button sits at 44 px always-visible per the WCAG 2.5.5 - floor; confirming state stays styled via the - `data-confirming` attribute. */} + floor. v1.30.1 M5 — one tap deletes (with an Undo + toast); the old arm/confirm double-tap is gone. */} diff --git a/src/components/insights/coach-panel/sources-rail.tsx b/src/components/insights/coach-panel/sources-rail.tsx index e674b8e5d..3f286df51 100644 --- a/src/components/insights/coach-panel/sources-rail.tsx +++ b/src/components/insights/coach-panel/sources-rail.tsx @@ -157,7 +157,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
@@ -215,7 +215,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { > {t(`insights.coach.cluster.${cluster}.label`)} - + {t("insights.coach.sourcesMemberCount", { count: memberCount, })} @@ -242,10 +242,13 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) { })} {/* v1.12.0 — the clinical-decisions disclaimer used to render - here AND at the bottom of the message thread. Both are now - consolidated into a single line directly above the composer - (always visible, every viewport), so the rail no longer - carries its own copy. */} + here AND at the bottom of the message thread. DISC-01 + (v1.18.6) dropped both per-page copies in favour of the + one-time onboarding acknowledgment + (`onboarding.disclaimer.acknowledge`); the guard stack + (system prompt, prose-grounding check, outbound refusal) + carries the real non-diagnostic boundary. The rail renders + no disclaimer of its own. */}
); } diff --git a/src/components/insights/coach-panel/use-coach.ts b/src/components/insights/coach-panel/use-coach.ts index 481ae8664..911f96a06 100644 --- a/src/components/insights/coach-panel/use-coach.ts +++ b/src/components/insights/coach-panel/use-coach.ts @@ -128,6 +128,87 @@ export function useDeleteCoachConversation() { }); } +// v1.30.1 M5 — delayed-commit delete window, in milliseconds. Long +// enough to read + tap Undo, short enough that the eventual DELETE +// isn't a surprise days later. +const CONVERSATION_DELETE_UNDO_MS = 6000; + +/** + * v1.30.1 M5 — replaces the old "tap once to arm, tap the same row + * again to delete" confirm, which never disarmed (a row armed minutes + * earlier deleted on a later stray tap) and had no way back once fired. + * Mirrors the Documents delete-with-undo pattern instead: a single tap + * hides the row immediately and schedules the real + * `DELETE /api/insights/chat/[id]` after `CONVERSATION_DELETE_UNDO_MS`; + * calling `undoDelete` within that window cancels the network call and + * un-hides the row. `pendingDeleteIds` is a client-only hide filter — + * consumers subtract it from whatever list they render. + * + * Centralised here (rather than duplicated in the rail + the standalone + * `/coach/conversations` page, which previously carried byte-identical + * arm/confirm logic) so both surfaces share one implementation. + */ +export function useDeleteCoachConversationWithUndo() { + const deleteMutation = useDeleteCoachConversation(); + const [pendingDeleteIds, setPendingDeleteIds] = useState>( + () => new Set(), + ); + const timers = useRef(new Map>()); + + useEffect(() => { + // A closed drawer / unmounted page must not silently lose a + // scheduled delete: fire any still-pending ones immediately rather + // than leaking the timer (or worse, never deleting at all). + const timerMap = timers.current; + return () => { + for (const [id, timer] of timerMap) { + clearTimeout(timer); + deleteMutation.mutate(id); + } + timerMap.clear(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- run once on unmount only + }, []); + + const requestDelete = useCallback( + (id: string) => { + setPendingDeleteIds((prev) => { + const next = new Set(prev); + next.add(id); + return next; + }); + const timer = setTimeout(() => { + timers.current.delete(id); + setPendingDeleteIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + deleteMutation.mutate(id); + }, CONVERSATION_DELETE_UNDO_MS); + timers.current.set(id, timer); + }, + [deleteMutation], + ); + + const undoDelete = useCallback((id: string) => { + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + setPendingDeleteIds((prev) => { + if (!prev.has(id)) return prev; + const next = new Set(prev); + next.delete(id); + return next; + }); + }, []); + + return { pendingDeleteIds, requestDelete, undoDelete }; +} + /** * v1.29.x (S7) — attach a stored document to an EXISTING fenced (or about-to-be- * fenced) conversation. On success invalidates the conversation detail + rail so 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/sparkline-delta-tile.tsx b/src/components/insights/derived/sparkline-delta-tile.tsx index c6241b52f..a5d0b5013 100644 --- a/src/components/insights/derived/sparkline-delta-tile.tsx +++ b/src/components/insights/derived/sparkline-delta-tile.tsx @@ -146,6 +146,7 @@ export function SparklineDeltaTile({ - +

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/glucose/glucose-advanced-disclosure.tsx b/src/components/insights/glucose/glucose-advanced-disclosure.tsx index fd725cf9f..1e3ab6165 100644 --- a/src/components/insights/glucose/glucose-advanced-disclosure.tsx +++ b/src/components/insights/glucose/glucose-advanced-disclosure.tsx @@ -84,7 +84,7 @@ export function GlucoseAdvancedDisclosure({ {t("insights.bloodGlucose.clinical.advanced.intro")}

-
+
- + {/* Time in range */} {dist ? (
diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 1ecff0abd..796ce76c1 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -343,7 +343,7 @@ export function HealthScoreCard({ {isProvisional && ( {/* diff --git a/src/components/insights/insights-tab-strip.tsx b/src/components/insights/insights-tab-strip.tsx index d7f4b6f61..75e9c1ff4 100644 --- a/src/components/insights/insights-tab-strip.tsx +++ b/src/components/insights/insights-tab-strip.tsx @@ -834,7 +834,7 @@ function InsightsTabStripImpl({ data-slot="insights-tab-strip-group-popover" className="w-56 p-2 text-sm" > -

+

{t(tab.headerKey)}

    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/metric-stat-strip.tsx b/src/components/insights/metric-stat-strip.tsx index c8085074b..ab25b0c48 100644 --- a/src/components/insights/metric-stat-strip.tsx +++ b/src/components/insights/metric-stat-strip.tsx @@ -219,7 +219,7 @@ function SeriesBlock({ data-stat={cell.key} className="space-y-0.5" > -

    +

    {cell.label}

    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/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/components/insights/suggested-prompts.tsx b/src/components/insights/suggested-prompts.tsx index a372dc1e3..a421e5cfe 100644 --- a/src/components/insights/suggested-prompts.tsx +++ b/src/components/insights/suggested-prompts.tsx @@ -75,7 +75,7 @@ export function SuggestedPrompts({ > {t("insights.suggestedPrompts.label")} diff --git a/src/components/insights/tile-header.tsx b/src/components/insights/tile-header.tsx index 3ceb76aad..a5fdf21f8 100644 --- a/src/components/insights/tile-header.tsx +++ b/src/components/insights/tile-header.tsx @@ -54,9 +54,11 @@ interface TileHeaderProps { /** * Render the title as a real heading element (e.g. `"h2"`) instead of the * default presentational `CardTitle` div, keeping the identical CardTitle - * styling. Use only where the tile owns the ONLY heading on its subpage - * branch (the recovery blocks) so the `h1 → h2` outline stays nested with no - * skipped level. Default (unset) keeps every other tile a plain CardTitle. + * styling. Use on tiles that sit directly under the page's `h1` — a single + * owning block (the recovery blocks) or a grid of sibling tiles (dashboard + * tiles, insights overview grids) both nest correctly as `h1 → h2, h2, …`; + * just don't nest a `titleAs="h2"` tile inside another heading's own + * subsection. Default (unset) keeps every other tile a plain CardTitle. */ titleAs?: "h2"; } 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/labs/biomarker-form.tsx b/src/components/labs/biomarker-form.tsx index 1d88d2a29..7a9de5c3e 100644 --- a/src/components/labs/biomarker-form.tsx +++ b/src/components/labs/biomarker-form.tsx @@ -163,7 +163,7 @@ export function BiomarkerForm({ ); return ( - + {!existing ? (