Merge pull request #525 from MBombeck/release/v1.30.1

release/v1.30.1
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-18 04:08:44 +02:00
commit 513e5309ac
96 files changed
+1625 -691

No files matched your search

+11
View File
@@ -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.
+1 -1
View File
@@ -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.
+9 -6
View File
@@ -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",
+10 -7
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}.",
@@ -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",
+6 -3
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}.",
@@ -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",
+6 -3
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}.",
@@ -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",
+6 -3
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}.",
@@ -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",
+6 -3
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}.",
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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(),
}),
});
+21 -25
View File
@@ -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<string>("");
const [confirmId, setConfirmId] = useState<string | null>(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() {
<div
data-slot="coach-conversations-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
@@ -212,7 +218,6 @@ function CoachConversationsBody() {
</h2>
<ul className="flex flex-col gap-1">
{group.conversations.map((c) => {
const isConfirming = confirmId === c.id;
return (
<li
key={c.id}
@@ -260,18 +265,9 @@ function CoachConversationsBody() {
variant="ghost"
size="icon-lg"
onClick={() => 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"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
+1 -1
View File
@@ -230,7 +230,7 @@ function CoachPlansBody() {
<div
data-slot="coach-plans-list"
className={cn(
"-mx-1 flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-1",
"-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1",
COACH_SCROLLBAR,
)}
>
+21 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { Fragment, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Fragment, useCallback, type ReactNode } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { SlidersHorizontal, TrendingUp } from "lucide-react";
@@ -11,6 +11,8 @@ import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useInsightsLayoutQuery } from "@/hooks/use-insights-layout";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import {
orderedVisibleSectionIds,
type InsightsSectionId,
@@ -263,6 +265,20 @@ export default function InsightsPage() {
const { isAuthenticated, user } = useAuth();
const mounted = useMounted();
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.30.1 M12 — the overview fans out into ~6 independent queries
// (comprehensive / advisor / analytics / snapshot / derived batch /
// layout) mounted by different hooks below, so there's no single
// `refetch()` that covers the page. Refetching every currently-mounted
// ("active") query is the same blanket approach the audit's fallback
// suggests for PWA-resume staleness, and it's the right shape here
// regardless of resume vs. an explicit pull.
const refreshInsights = useCallback(
() => queryClient.refetchQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshInsights });
// v1.4.33 IW9 — scroll-to-top on route mount centralised in the
// shared `useScrollResetOnRoute()` hook. The mother page + the
@@ -371,6 +387,7 @@ export default function InsightsPage() {
icon={<TrendingUp className="size-6" />}
title={t("insights.emptyTitle")}
description={t("insights.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/measurements">
@@ -530,6 +547,7 @@ export default function InsightsPage() {
// zero-gap). Each section owns its OWN `SectionHeading` + `space-y-3` to its
// card, so the rhythm is even regardless of which sections are present.
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<HeroStrip
briefing={briefingPayload}
updatedAt={heroStripUpdatedAt}
@@ -568,6 +586,7 @@ export default function InsightsPage() {
icon={<SlidersHorizontal className="size-6" />}
title={t("insights.editMode.emptyTitle")}
description={t("insights.editMode.emptyDescription")}
ctaSize="lg"
action={
<Button size="sm" asChild>
<Link href="/settings/layout/insights">
+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}
+9
View File
@@ -177,6 +177,15 @@ export default function LabsPage() {
queryKey: queryKeys.labResults(),
});
}}
// v1.30.1 H3 — "Save & add another" keeps the sheet open; the
// form clears the reading itself, this callback just refreshes
// the list underneath so the just-saved row is there once the
// user is done and closes the sheet.
onSavedKeepOpen={() => {
queryClient.invalidateQueries({
queryKey: queryKeys.labResults(),
});
}}
onCancel={() => setDialogOpen(false)}
/>
</ResponsiveSheet>
+20 -2
View File
@@ -1,12 +1,18 @@
"use client";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
invalidateKeys,
medicationDependentKeys,
queryKeys,
} from "@/lib/query-keys";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import { MedicationWizardDialog } from "@/components/medications/wizard/medication-wizard-dialog";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
@@ -221,6 +227,17 @@ export default function MedicationsPage() {
refetchOnMount: "always",
});
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/measurements.
// Medications is one of the sharpest PWA-resume-staleness surfaces (a
// background-refreshed take/skip from a notification, or a cross-device
// write, wouldn't otherwise show up until the 5-min staleTime lapses or a
// full remount re-triggers `refetchOnMount: "always"` above).
const refreshMedications = useCallback(
() => invalidateKeys(queryClient, medicationDependentKeys),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshMedications });
// v1.29.x — once the highlighted card is in the DOM, scroll it into view
// and clear the highlight state after a few seconds so the ring is a
// one-time "you tapped here" cue, not a permanent tint (mirrors the
@@ -363,6 +380,7 @@ export default function MedicationsPage() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
<h1
+1 -1
View File
@@ -99,7 +99,7 @@ export default async function OnboardingStepPage({ params }: PageProps) {
{completed ? (
<section
aria-labelledby="onboarding-welcomeback-title"
className="space-y-5"
className="space-y-4"
>
<h1
id="onboarding-welcomeback-title"
+20 -2
View File
@@ -1,9 +1,11 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import React, { Suspense, useCallback, useMemo, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useMounted } from "@/hooks/use-mounted";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
import {
Activity,
Bone,
@@ -190,8 +192,23 @@ export default function DashboardPageClient() {
const mounted = useMounted();
const { t } = useTranslations();
const fmt = useFormatters();
const queryClient = useQueryClient();
const [quickEntryDialog, setQuickEntryDialog] =
useState<QuickEntryDialog>(null);
// v1.30.1 M12 — pull-to-refresh parity for the dashboard, named as one
// of the sharpest PWA-resume-staleness surfaces in the audit (it
// already opts back into `refetchOnWindowFocus`, but a PWA resumed
// from the home screen/app-switcher, not a browser tab focus event,
// is the more common mobile path and that never fires a focus event).
// The dashboard fans out into a dozen-plus independent per-tile/chart
// queries, so — like `/insights` — refetching every currently-active
// query is the only tractable "refresh everything visible" shape.
const refreshDashboard = useCallback(
() => queryClient.refetchQueries({ type: "active" }),
[queryClient],
);
const pull = usePullToRefresh({ onRefresh: refreshDashboard });
// v1.4.29 M5 — the three inline dashboard queries default to a
// 0-ms `staleTime`, so a tab-focus-and-return triggered a refetch
// storm. None of these are real-time data; minute-scale staleness
@@ -785,6 +802,7 @@ export default function DashboardPageClient() {
return (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<DashboardHeader onQuickEntry={setQuickEntryDialog} />
{/* S2 — the Today hero, promoted above the tile strip (MARC sign-off
@@ -562,7 +562,7 @@ export function InviteTokensSection() {
}}
className="space-y-4"
>
<div className="space-y-5">
<div className="space-y-4">
<fieldset>
<legend className="text-sm font-medium">
{t("admin.invites.ttlLegend")}
+37
View File
@@ -632,6 +632,32 @@ export function HealthChart({
? overlayPrefs.prefs.comparisonBaseline
: compareBaseline;
// v1.30.1 M2 — hydrate the range tab from the persisted prefs once
// they arrive, mirroring the overlay toggles above. A plain
// `useState(30)` initializer can't see the persisted value because
// the dashboard-layout query resolves after mount; `rangePoints` also
// drives several derived memos below by identity, so it stays local
// state (unlike the toggles, which read `overlayPrefs.prefs` live)
// and is synced in exactly once. `windowOverride` charts (mini mode)
// are pinned and never adopt a persisted range. Adjusted during
// render (React's documented "adjust state during render" escape
// hatch, not an effect — the lint rule that bans ref reads during
// render is why this is a second piece of state rather than a ref)
// so the very first render that sees the resolved prefs already
// reflects them; `rangeHydrated` guards against re-running past the
// first hit, so a later cache refetch can't stomp a range the user
// already picked this session.
const [rangeHydrated, setRangeHydrated] = useState(false);
if (
!windowOverride &&
chartKey &&
!rangeHydrated &&
typeof overlayPrefs.prefs.rangePoints === "number"
) {
setRangeHydrated(true);
setRangePoints(overlayPrefs.prefs.rangePoints);
}
// v1.4.19 A2 — viewport-aware tick density. Recharts' default is one
// tick per data point; the helper caps that at 4-10 visible labels
// depending on viewport width so the X-axis reads consistently across
@@ -1674,7 +1700,17 @@ export function HealthChart({
// v1.12.8 — a range-tab change re-slices `chartData`; the
// visible-range stats memo recomputes off the new slice and
// the stat strip follows automatically.
setRangeHydrated(true);
setRangePoints(r.points);
// v1.30.1 M2 — persist the pick per chart, same model
// as the overlay toggles, so the range survives a
// remount instead of resetting to 30 d every visit.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
@@ -1789,6 +1825,7 @@ export function HealthChart({
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
accessibilityLayer
>
<CartesianGrid
strokeDasharray="3 3"
+32 -1
View File
@@ -323,6 +323,25 @@ export function MoodChart({
const effectiveCompareBaseline =
!mini && chartKey ? overlayPrefs.prefs.comparisonBaseline : compareBaseline;
// v1.30.1 M2 — hydrate the range tab from the persisted prefs once
// they arrive; see health-chart.tsx for the full rationale (the
// dashboard-layout query resolves after mount, and `rangePoints`
// stays local state because several memos depend on it by identity).
// Adjusted during render, not an effect — React's documented "adjust
// state during render" pattern (a second state flag rather than a
// ref, since the lint rule bans ref reads during render).
const [rangeHydrated, setRangeHydrated] = useState(false);
if (
!mini &&
!windowOverride &&
chartKey &&
!rangeHydrated &&
typeof overlayPrefs.prefs.rangePoints === "number"
) {
setRangeHydrated(true);
setRangePoints(overlayPrefs.prefs.rangePoints);
}
// v1.4.19 A2 — viewport-aware tick density helper.
const viewportWidth = useViewportWidth();
@@ -714,6 +733,7 @@ export function MoodChart({
<ComposedChart
data={chartDataWithCompare ?? chartData}
margin={{ top: 10, right: 8, bottom: 8, left: 8 }}
accessibilityLayer
>
{/* v1.4.25 W3 — the mood chart's YAxis is pinned to
five mood-score ticks ([1,2,3,4,5]), which Recharts
@@ -1047,7 +1067,18 @@ export function MoodChart({
aria-pressed={rangePoints === r.points}
size="sm"
className="min-h-11 px-2 text-xs sm:px-3"
onClick={() => setRangePoints(r.points)}
onClick={() => {
setRangeHydrated(true);
setRangePoints(r.points);
// v1.30.1 M2 — persist the pick per chart, same
// model as the overlay toggles.
if (chartKey && !windowOverride) {
overlayPrefs.setPrefs({
...overlayPrefs.prefs,
rangePoints: r.points,
});
}
}}
title={t(r.titleKey)}
data-slot="chart-range-tab"
>
+1 -1
View File
@@ -262,7 +262,7 @@ export function ClinicianView({
</p>
</header>
<div className="space-y-5">
<div className="space-y-4">
{/* ── Clinical vitals ─────────────────────────────────────── */}
{measurementEntries.length > 0 ? (
<Section title={t("clinicianView.vitals")}>
@@ -131,7 +131,7 @@ export function CustomMetricEntryForm({
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="custom-metric-value">
@@ -149,7 +149,7 @@ export function CustomMetricForm({
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="custom-metric-name">
@@ -266,7 +266,7 @@ export function CustomMetricHistoryList({
title={t("customMetrics.entry.editTitle")}
description={t("customMetrics.entry.editDescription")}
>
<form onSubmit={submitEdit} className="space-y-5">
<form onSubmit={submitEdit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="custom-metric-edit-value">
+14
View File
@@ -104,6 +104,14 @@ export interface PriorityCardProps {
* passes.
*/
onDismiss?: (itemKey: string) => void;
/**
* True while a non-navigation action on THIS card has a mutation in
* flight (e.g. the coach check-in's keep/let-go). Disables the
* button-rendered actions (`aria-busy` + `disabled`) so a slow round trip
* can't be double-tapped into firing the mutation twice; pure `href`
* actions stay live since they're navigation, not a mutation.
*/
actionsPending?: boolean;
className?: string;
}
@@ -114,6 +122,7 @@ export function PriorityCard({
item,
onAction,
onDismiss,
actionsPending,
className,
}: PriorityCardProps) {
const { t } = useTranslations();
@@ -182,6 +191,7 @@ export function PriorityCard({
action={action}
label={t(action.labelKey)}
onAction={onAction}
pending={actionsPending}
/>
))}
</div>
@@ -195,10 +205,12 @@ function ActionButton({
action,
label,
onAction,
pending,
}: {
action: PriorityItemAction;
label: string;
onAction?: (intent: string) => void;
pending?: boolean;
}) {
if (action.href) {
return (
@@ -218,6 +230,8 @@ function ActionButton({
variant="outline"
size="sm"
className={ACTION_SIZE}
disabled={pending}
aria-busy={pending || undefined}
onClick={() => onAction?.(action.intent)}
>
{label}
+11 -7
View File
@@ -23,9 +23,9 @@ export function TodayHeroSkeleton() {
data-slot="today-hero-skeleton"
className="bg-card today-hero-wash border-border relative isolate overflow-hidden rounded-xl border p-4 md:p-6"
>
<div className="flex flex-col gap-4 md:gap-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between md:gap-6">
<div className="min-w-0 flex-1 space-y-3">
<div className="flex flex-col gap-3 md:gap-4">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between md:gap-6">
<div className="min-w-0 flex-1 space-y-2">
{/* Lead read (two lines) + top signal + briefing link. */}
<Skeleton className="h-6 w-3/4 max-w-full" />
<Skeleton className="h-4 w-56 max-w-full" />
@@ -36,10 +36,14 @@ export function TodayHeroSkeleton() {
<Skeleton className="size-[168px] rounded-full" />
</div>
</div>
{/* Worth-a-look rail — two placeholder cards. */}
<div className="grid gap-3 sm:grid-cols-2">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
{/* Worth-a-look rail — heading bar + two placeholder cards, matching
the loaded rail's uppercase heading row and `xl:grid-cols-3` step. */}
<div className="space-y-2">
<Skeleton className="h-3 w-24" />
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-24 w-full rounded-xl" />
</div>
</div>
</div>
</div>
+4
View File
@@ -223,6 +223,10 @@ export function TodayHero({ digest }: { digest: DailyDigest }) {
item={item}
onAction={handleAction}
onDismiss={(itemKey) => dismissItem.mutate(itemKey)}
actionsPending={
item.kind === "coach_checkin" &&
(keep.isPending || letGo.isPending)
}
/>
))}
</div>
@@ -30,6 +30,7 @@ import {
} from "@/lib/medications/window-status";
import { useAuth } from "@/hooks/use-auth";
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
import { runUndoIntake } from "@/components/medications/use-medication-intake";
/**
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
@@ -271,11 +272,18 @@ export function MedicationIntakeQuickAdd({
const doseDeviates =
trimmedDose.length > 0 &&
trimmedDose !== (selectedMedication?.dose ?? "").trim();
await apiPost(`/api/medications/${medicationId}/intake`, {
takenAt: timestamp,
skipped: false,
...(doseDeviates && { doseTaken: trimmedDose }),
});
// v1.30.1 M7 — the route returns the created event
// (`apiSuccess(event, 201)`), same shape the card's take/skip path
// already reads; the id drives the new Undo action below.
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medicationId}/intake`,
{
takenAt: timestamp,
skipped: false,
...(doseDeviates && { doseTaken: trimmedDose }),
},
);
const eventId = created?.id;
await invalidateKeys(queryClient, medicationDependentKeys);
// Fan-out to every inline compliance chart key (one per medication)
@@ -284,7 +292,26 @@ export function MedicationIntakeQuickAdd({
queryKey: queryKeys.complianceChartInline(),
});
toast.success(t("common.saved"));
toast.success(
t("common.saved"),
eventId && selectedMedication
? {
action: {
label: t("medications.intakeUndo"),
onClick: () =>
void runUndoIntake({
medication: {
id: medicationId,
name: selectedMedication.name,
},
eventId,
t,
queryClient,
}),
},
}
: undefined,
);
onSuccess?.();
} catch (err) {
setError(
@@ -84,6 +84,7 @@ export function RecentWorkoutsTile() {
icon={Activity}
size="sm"
title={t("dashboard.recentWorkouts.title")}
titleAs="h2"
right={
<Link
href="/insights/workouts"
@@ -255,7 +255,8 @@ export function DocumentSummaryPanel({
<p
className={cn(
"text-foreground text-sm",
output === "text" && "font-mono text-xs whitespace-pre-wrap",
output === "text" &&
"font-mono text-xs break-words whitespace-pre-wrap",
)}
>
{body}
+31 -7
View File
@@ -75,11 +75,23 @@ export function DocumentBulkBar({
</p>
<div className="ml-auto flex flex-wrap items-center justify-end gap-1.5">
{/* v1.30 mobile audit (DO-1) — five labelled buttons wrapped into a
2–3-row floating slab at ~360 px. Below `sm` every verb collapses
to icon-only (`aria-label` carries the accessible name; the
visible label returns at `sm+`) — the `document-detail-sheet`
footer's established icon-collapse pattern. */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.setKind")}
>
<Tag className="size-4" aria-hidden />
{t("documents.bulk.setKind")}
<span className="hidden sm:inline">
{t("documents.bulk.setKind")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -98,9 +110,16 @@ export function DocumentBulkBar({
{episodes.length > 0 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={busy}>
<Button
variant="outline"
size="sm"
disabled={busy}
aria-label={t("documents.bulk.linkCondition")}
>
<FolderPlus className="size-4" aria-hidden />
{t("documents.bulk.linkCondition")}
<span className="hidden sm:inline">
{t("documents.bulk.linkCondition")}
</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@@ -122,9 +141,10 @@ export function DocumentBulkBar({
disabled={busy}
onClick={onShare}
data-slot="document-bulk-share"
aria-label={t("documents.bulk.share")}
>
<Share2 className="size-4" aria-hidden />
{t("documents.bulk.share")}
<span className="hidden sm:inline">{t("documents.bulk.share")}</span>
</Button>
{/* Solid destructive (matching the detail sheet's Delete) — the
@@ -135,9 +155,10 @@ export function DocumentBulkBar({
size="sm"
disabled={busy}
onClick={onDelete}
aria-label={t("documents.bulk.delete")}
>
<Trash2 className="size-4" aria-hidden />
{t("documents.bulk.delete")}
<span className="hidden sm:inline">{t("documents.bulk.delete")}</span>
</Button>
<Button
@@ -145,9 +166,12 @@ export function DocumentBulkBar({
size="sm"
className="text-muted-foreground"
onClick={onClear}
aria-label={t("documents.selection.clear")}
>
<X className="size-3.5" aria-hidden />
{t("documents.selection.clear")}
<span className="hidden sm:inline">
{t("documents.selection.clear")}
</span>
</Button>
</div>
</div>
@@ -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 (
<div className="space-y-6">
<PullToRefreshIndicator {...pull} />
<PageHeader
title={t("documents.title")}
description={t("documents.subtitle")}
@@ -77,7 +77,7 @@ function CorrelationBody({ value }: { value: IllnessCorrelationValue }) {
}
return (
<div className="space-y-5">
<div className="space-y-4">
{value.redFlags.length > 0 ? (
<div className="border-border bg-muted/40 rounded-lg border p-3">
<p className="text-foreground text-sm font-medium">
@@ -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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent
data-slot="coach-attachment-picker"
className="flex max-h-[85vh] flex-col sm:max-w-lg"
>
<DialogHeader>
<DialogTitle>{t("insights.coach.attach.pickerTitle")}</DialogTitle>
<DialogDescription>
{t("insights.coach.attach.pickerDescription")}
</DialogDescription>
</DialogHeader>
<ResponsiveSheet
open={open}
onOpenChange={handleOpenChange}
title={t("insights.coach.attach.pickerTitle")}
description={t("insights.coach.attach.pickerDescription")}
contentWidth="lg"
footer={
<>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
data-slot="coach-attachment-picker-cancel"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={selected.length === 0}
data-slot="coach-attachment-picker-confirm"
>
{t("insights.coach.attach.pickerConfirm", {
count: selected.length,
})}
</Button>
</>
}
>
<div data-slot="coach-attachment-picker" className="flex flex-col gap-3">
<div className="relative">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
@@ -163,97 +174,74 @@ export function AttachmentPicker({
{t("insights.coach.attach.pickerCap", { max: remainingSlots })}
</p>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
) : (
<ul className="space-y-1.5">
{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 (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
{list.isPending && open ? (
<div className="space-y-2">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-12 w-full rounded-lg" />
))}
</div>
) : list.isError ? (
<p role="alert" className="text-destructive text-sm">
{t("documents.list.loadError")}
</p>
) : documents.length === 0 ? (
<p className="text-muted-foreground py-6 text-center text-sm">
{t("insights.coach.attach.pickerEmpty")}
</p>
) : (
<ul className="max-h-[50vh] space-y-1.5 overflow-y-auto overscroll-contain">
{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 (
<li key={doc.id}>
<button
type="button"
aria-pressed={isSelected}
disabled={disabled}
onClick={() => selectable && toggle(doc.id)}
data-slot="coach-attachment-picker-item"
data-selected={isSelected ? "true" : undefined}
className={cn(
"border-border hover:bg-muted/50 flex w-full items-center gap-3 rounded-lg border p-3 text-left",
"focus-visible:ring-ring/50 focus-visible:ring-[3px] focus-visible:outline-none",
isSelected && "border-primary/40 bg-primary/5",
disabled &&
"cursor-not-allowed opacity-60 hover:bg-transparent",
)}
>
<Icon
className="text-foreground size-5 shrink-0"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{title}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
})}
</ul>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
data-slot="coach-attachment-picker-cancel"
>
{t("common.cancel")}
</Button>
<Button
type="button"
onClick={handleConfirm}
disabled={selected.length === 0}
data-slot="coach-attachment-picker-confirm"
>
{t("insights.coach.attach.pickerConfirm", {
count: selected.length,
{!selectable ? (
<span className="text-muted-foreground block text-xs">
{t("insights.coach.attach.pickerNotIndexed")}
</span>
) : null}
</span>
<Check
className={cn(
"text-primary size-4 shrink-0",
isSelected ? "opacity-100" : "opacity-0",
)}
aria-hidden="true"
/>
</button>
</li>
);
})}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</ul>
)}
</div>
</ResponsiveSheet>
);
}
@@ -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"
>
<X className="size-3" aria-hidden="true" />
</Button>
@@ -634,7 +634,7 @@ function ChatBubbleImpl({
className={cn(
"border-border/60 bg-muted/40 text-foreground",
"rounded-xl rounded-tl-sm border px-3.5 py-2.5",
"text-sm leading-relaxed whitespace-pre-wrap",
"text-sm leading-relaxed break-words whitespace-pre-wrap",
)}
>
{/* v1.4.25 W5b — strip stray Metric/enum leak tokens from the
@@ -649,14 +649,14 @@ function ChatBubbleImpl({
) : inProgress ? (
<TypingDots label={t("insights.coach.thinking")} />
) : safeError ? (
<span className="text-warning/90">{safeError}</span>
<span className="text-warning">{safeError}</span>
) : (
""
)}
</div>
)}
{safeError && content && (
<p className="text-warning/90 text-xs">{safeError}</p>
<p className="text-warning text-xs">{safeError}</p>
)}
{/* v1.22 (W5) — accompanying chart(s). The token was already
stripped from the prose by <StreamedProse>; here it mounts the
@@ -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`
@@ -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<string>("");
const [confirmId, setConfirmId] = useState<string | null>(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 (
<div
key={c.id}
@@ -187,26 +195,18 @@ export function HistoryRail({
</button>
{/* 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. */}
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleDeleteRequest(c.id)}
aria-label={
isConfirming
? t("insights.coach.historyDeleteConfirm")
: t("insights.coach.historyDeleteAria")
}
aria-label={t("insights.coach.historyDeleteAria")}
data-slot="coach-history-delete"
data-confirming={isConfirming ? "true" : undefined}
className={cn(
"size-11 shrink-0",
isConfirming && "text-destructive hover:text-destructive",
)}
className="size-11 shrink-0"
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
@@ -157,7 +157,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
<div data-slot="coach-sources-window" className="flex flex-col gap-1">
<label
htmlFor="coach-sources-window-select"
className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase"
className="text-muted-foreground text-xs font-medium tracking-wide uppercase"
>
{t("insights.coach.windowLabel")}
</label>
@@ -215,7 +215,7 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
>
{t(`insights.coach.cluster.${cluster}.label`)}
</label>
<span className="text-muted-foreground text-[11px] leading-snug">
<span className="text-muted-foreground text-xs leading-snug">
{t("insights.coach.sourcesMemberCount", {
count: memberCount,
})}
@@ -242,10 +242,13 @@ export function SourcesRail({ className, activeScopeLabel }: SourcesRailProps) {
})}
</ul>
{/* 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. */}
</div>
);
}
@@ -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<Set<string>>(
() => new Set(),
);
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
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
@@ -65,15 +65,20 @@ describe("<WellnessScores>", () => {
expect(html).toBe("");
});
it("renders nothing while loading (the page reserves the row)", () => {
it("renders a skeleton row while loading, not a pop-in later", () => {
const html = render(
<WellnessScores
read={readFrom({ READINESS: ok({ score: 80, band: "green" }) })}
isLoading
/>,
);
// While loading no tiles resolve, so the strip stays un-mounted.
expect(html).toBe("");
// The section heading + a row of tile-shaped skeletons render
// immediately, so the strip reserves its final height instead of
// vanishing (`return null`) and popping in once the batch resolves.
expect(html).toContain('data-slot="wellness-scores"');
expect(html).toContain('data-slot="wellness-scores-grid"');
expect(html).toContain('data-slot="wellness-score-tile-skeleton"');
expect(html).not.toContain('data-slot="wellness-score-tile"');
});
it("renders a ring tile with an icon + heading on the gentle wellness surface", () => {
@@ -146,6 +146,7 @@ export function SparklineDeltaTile({
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
className="min-w-0"
titleClassName="min-w-0 truncate whitespace-nowrap"
right={
@@ -262,7 +262,12 @@ function BaselineTile({
data-state="provisional"
className="bg-card border-border flex h-full w-full min-w-0 flex-col gap-3 rounded-xl border p-4 md:p-6"
>
<TileHeader icon={Icon} title={label} titleClassName="truncate" />
<TileHeader
icon={Icon}
title={label}
titleAs="h2"
titleClassName="truncate"
/>
<p
className="text-muted-foreground text-sm"
data-slot="vitals-tile-building"
@@ -457,6 +462,7 @@ function HrvBalanceTile({ read, isLoading }: TileProps) {
<TileHeader
icon={HeartPulse}
title={t("measurements.typeHeartRateVariability")}
titleAs="h2"
titleClassName="truncate"
/>
<p className="text-muted-foreground text-sm">
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useTranslations } from "@/lib/i18n/context";
import { SectionHeading } from "@/components/insights/section-heading";
import { ScoreRing } from "./score-ring";
@@ -168,6 +169,32 @@ function RingTile({
);
}
/**
* Loading placeholder mirroring `RingTile`'s footprint (icon+label row, the
* `size="sm"` 120 px ring, band word) so the strip reserves its final height
* while the shared derived batch loads — matching the `VitalsTileSkeleton`
* pattern instead of the strip vanishing (`return null`) and popping in once
* data resolves. Decorative — `aria-hidden`.
*/
function WellnessScoreTileSkeleton() {
return (
<div
data-slot="wellness-score-tile-skeleton"
aria-hidden="true"
className="bg-card border-border flex flex-col gap-4 rounded-xl border p-4 md:p-6"
>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-5 rounded" />
<Skeleton className="h-4 w-20" />
</div>
<div className="flex flex-col items-center gap-1.5">
<Skeleton className="size-[120px] rounded-full" />
<Skeleton className="h-3 w-12" />
</div>
</div>
);
}
export function WellnessScores({
read,
isLoading,
@@ -198,6 +225,39 @@ export function WellnessScores({
}
});
// While the shared derived batch loads, every per-score gate below is
// `!isLoading && …`, so `tiles` stays empty and the strip used to
// `return null` — a late pop-in that pushed the briefing + everything
// below it down on every cold visit. Render the section heading + a row
// of tile-shaped skeletons instead, the same shape `VitalsDashboard`
// already uses for its grid.
if (isLoading) {
return (
<section
data-slot="wellness-scores"
aria-label={t("insights.derived.scores.sectionTitle")}
className={cn("space-y-3", className)}
>
<SectionHeading
icon={Activity}
title={t("insights.derived.scores.sectionTitle")}
subtitle={t("insights.derived.scores.sectionSubtitle")}
/>
<div
data-slot="wellness-scores-grid"
aria-busy="true"
aria-live="polite"
aria-label={t("insights.derived.scores.loadingLabel")}
className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5"
>
{Array.from({ length: 5 }).map((_, i) => (
<WellnessScoreTileSkeleton key={`wellness-skeleton-${i}`} />
))}
</div>
</section>
);
}
// A failed shared batch must read as an error, not as "no scores" — the
// strip is top-of-page, so the only Retry lives here (mirrors the Vitals
// error card styling; one Retry recovers both strips).
@@ -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")}
@@ -147,7 +147,7 @@ export function GlucoseClinicalPanel() {
</span>
</div>
</CardHeader>
<CardContent className="space-y-5">
<CardContent className="space-y-4">
{/* Time in range */}
{dist ? (
<section className="space-y-2" data-slot="glucose-tir-section">
@@ -343,7 +343,7 @@ export function HealthScoreCard({
{isProvisional && (
<span
data-slot="health-score-card-provisional-badge"
className="border-muted-foreground/30 text-muted-foreground inline-flex items-center rounded-full border px-1.5 py-0.5 text-[9px] font-medium tracking-wide uppercase"
className="border-muted-foreground/30 text-muted-foreground inline-flex items-center rounded-full border px-1.5 py-0.5 text-xs font-medium tracking-wide uppercase"
title={t("insights.healthScore.provenance.provisional", {
count: presentCount,
total: totalCount,
+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",
)}
>
{/*
@@ -834,7 +834,7 @@ function InsightsTabStripImpl({
data-slot="insights-tab-strip-group-popover"
className="w-56 p-2 text-sm"
>
<p className="text-muted-foreground px-2 py-1 text-[11px] font-semibold tracking-wide uppercase">
<p className="text-muted-foreground px-2 py-1 text-xs font-semibold tracking-wide uppercase">
{t(tab.headerKey)}
</p>
<ul className="space-y-1" role="list">
@@ -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()}
@@ -219,7 +219,7 @@ function SeriesBlock({
data-stat={cell.key}
className="space-y-0.5"
>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{cell.label}
</p>
<p className="text-base font-semibold tabular-nums">
@@ -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) {
+1 -1
View File
@@ -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.
@@ -75,7 +75,7 @@ export function SuggestedPrompts({
>
<span
data-slot="insights-suggested-prompts-label"
className="text-muted-foreground mr-1 text-[11px] font-medium tracking-wide uppercase"
className="text-muted-foreground mr-1 text-xs font-medium tracking-wide uppercase"
>
{t("insights.suggestedPrompts.label")}
</span>
+5 -3
View File
@@ -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";
}
+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>
);
+1 -1
View File
@@ -163,7 +163,7 @@ export function BiomarkerForm({
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
{!existing ? (
<div className="space-y-1.5">
<Label htmlFor="biomarker-seed">
@@ -230,6 +230,13 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
// v1.30.1 H3 — "Save & add another": same invalidation as `afterAdd`,
// minus the close. Useful here too — logging several past readings for
// this one biomarker back-to-back without re-opening the sheet each time.
function afterAddKeepOpen() {
queryClient.invalidateQueries({ queryKey: queryKeys.labResults() });
}
function afterEditMarker() {
setEditOpen(false);
queryClient.invalidateQueries({
@@ -482,6 +489,7 @@ export function LabBiomarkerDetail({ biomarkerId }: { biomarkerId: string }) {
lockedBiomarkerId={biomarkerId}
footerSlot={addFooterEl}
onSuccess={afterAdd}
onSavedKeepOpen={afterAddKeepOpen}
onCancel={() => setAddOpen(false)}
/>
</ResponsiveSheet>
+84 -9
View File
@@ -1,6 +1,6 @@
"use client";
import { useId, useState } from "react";
import { useId, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
@@ -55,6 +55,17 @@ interface LabFormProps {
lockedBiomarkerId?: string;
onSuccess?: (created: LabResultDto) => void;
onCancel?: () => void;
/**
* v1.30.1 (H3 QoL fix) — "Save & add another": the sheet stays open, the
* reading is cleared for the next entry, and `takenAt` is PRESERVED (a
* real lab report is 10-20 analytes sharing one blood-draw date, usually
* in the past — re-picking that date for every row was the pain point).
* Optional and additive: the second button only renders when the caller
* wires this up, so a consumer that never passes it keeps the single-Save
* form unchanged. The caller's only job is invalidating the list read —
* the form itself, not the caller, owns the "stay open + reset" behaviour.
*/
onSavedKeepOpen?: (created: LabResultDto) => void;
/**
* When mounted inside a `<ResponsiveSheet>` the caller passes the sheet's
* footer slot element here. The Cancel / Save action row is portalled into
@@ -79,6 +90,7 @@ export function LabForm({
lockedBiomarkerId,
onSuccess,
onCancel,
onSavedKeepOpen,
footerSlot,
}: LabFormProps) {
const { t } = useTranslations();
@@ -103,12 +115,19 @@ export function LabForm({
const [valueText, setValueText] = useState("");
const [takenAt, setTakenAt] = useState(defaultTakenAtValue);
const [note, setNote] = useState("");
const [submitting, setSubmitting] = useState(false);
// v1.30.1 H3 — which submit button is in flight, so the OTHER one doesn't
// also show a spinner. `!== null` replaces the old plain `submitting`
// boolean for every disabled check below.
const [pendingAction, setPendingAction] = useState<
"save" | "saveAndAddAnother" | null
>(null);
const submitting = pendingAction !== null;
const [error, setError] = useState<string | null>(null);
const [defineOpen, setDefineOpen] = useState(false);
const [defineFooterEl, setDefineFooterEl] = useState<HTMLDivElement | null>(
null,
);
const biomarkerTriggerRef = useRef<HTMLButtonElement | null>(null);
const allMarkers = catalog?.biomarkers ?? [];
const selected = allMarkers.find((m) => m.id === biomarkerId);
@@ -132,10 +151,20 @@ export function LabForm({
setBiomarkerId(created.id);
}
async function handleSubmit(e: React.FormEvent) {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
// v1.30.1 H3 — both Save and Save & add another are `type="submit"`
// buttons tied to this one `<form>` via the HTML `form` attribute (they
// render portalled into the sheet's footer, outside the `<form>` tag
// itself); `submitter` distinguishes which one fired this submit.
const submitter = (e.nativeEvent as SubmitEvent)
.submitter as HTMLButtonElement | null;
const keepOpen =
onSavedKeepOpen != null &&
submitter?.dataset.action === "save-and-add-another";
if (!biomarkerId) {
setError(t("labs.form.pickBiomarkerError"));
return;
@@ -159,7 +188,7 @@ export function LabForm({
}
}
setSubmitting(true);
setPendingAction(keepOpen ? "saveAndAddAnother" : "save");
try {
const created = await apiPost<LabResultDto>("/api/labs", {
biomarkerId,
@@ -170,13 +199,38 @@ export function LabForm({
...(note.trim() ? { note: note.trim() } : {}),
});
toast.success(t("labs.form.savedToast"));
onSuccess?.(created);
if (keepOpen) {
// A real lab report shares one blood-draw date across every
// analyte — clear the reading but deliberately KEEP `takenAt` so
// the next row doesn't need it re-set away from "now" again.
// The biomarker resets too (unless locked to one detail page)
// since the whole point is entering the NEXT analyte.
if (!lockedBiomarkerId) setBiomarkerId("");
setValue("");
setValueText("");
setNote("");
setError(null);
onSavedKeepOpen?.(created);
// Return focus to wherever the next entry starts so the flow
// stays keyboard/screen-reader friendly across repeated saves.
if (!lockedBiomarkerId) {
biomarkerTriggerRef.current?.focus();
} else {
document
.getElementById(
resultType === "numeric" ? "lab-value" : "lab-valueText",
)
?.focus();
}
} else {
onSuccess?.(created);
}
} catch (err) {
const message =
err instanceof ApiError ? err.message : t("labs.form.saveError");
setError(message);
} finally {
setSubmitting(false);
setPendingAction(null);
}
}
@@ -200,8 +254,25 @@ export function LabForm({
{t("common.cancel")}
</Button>
) : null}
{/* v1.30.1 H3 — additive: only renders when the caller wires up
`onSavedKeepOpen`. `data-action` is how `handleSubmit` tells this
button apart from the plain Save below via `event.submitter`. */}
{onSavedKeepOpen ? (
<Button
type="submit"
form={formId}
variant="outline"
data-action="save-and-add-another"
disabled={submitting}
>
{pendingAction === "saveAndAddAnother" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.saveAndAddAnother")}
</Button>
) : null}
<Button type="submit" form={formId} disabled={submitting}>
{submitting ? (
{pendingAction === "save" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("labs.form.save")}
@@ -211,7 +282,7 @@ export function LabForm({
return (
<>
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="lab-biomarker">{t("labs.form.biomarker")}</Label>
<Select
@@ -219,7 +290,11 @@ export function LabForm({
onValueChange={handleSelect}
disabled={!!lockedBiomarkerId || catalogLoading}
>
<SelectTrigger id="lab-biomarker" className="w-full">
<SelectTrigger
id="lab-biomarker"
ref={biomarkerTriggerRef}
className="w-full"
>
<SelectValue placeholder={t("labs.form.biomarkerPlaceholder")} />
</SelectTrigger>
<SelectContent>
+1 -1
View File
@@ -356,7 +356,7 @@ export function LabHistoryList({ readings }: { readings: LabResultDto[] }) {
title={t("labs.editTitle")}
description={t("labs.editDescription")}
>
<form onSubmit={submitEdit} className="space-y-5">
<form onSubmit={submitEdit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="lab-edit-value">
+67 -3
View File
@@ -7,6 +7,17 @@ import { MeasurementForm } from "@/components/measurements/measurement-form";
import { MoodForm } from "@/components/mood/mood-form";
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { sheetBodyHasUnsavedInput } from "@/components/dashboard/quick-entry-sheets";
import { useTranslations } from "@/lib/i18n/context";
/**
@@ -26,6 +37,12 @@ import { useTranslations } from "@/lib/i18n/context";
* Nothing here orphans a route: the same surfaces remain reachable
* from their own pages (`/measurements`, `/mood`, dashboard) and from
* the More hub. The picker is an additional fast path, not a removal.
*
* v1.30.1 — the form sheet shares the dashboard quick-entry sheets'
* confirm-before-discard guard (`sheetBodyHasUnsavedInput()`), so a
* swipe-down / backdrop tap / Escape on a half-filled form from the
* primary mobile capture path asks before dropping typed input,
* instead of closing unconditionally.
*/
type CaptureKind = "measurement" | "medication" | "mood";
@@ -41,6 +58,10 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
const { t } = useTranslations();
const [kind, setKind] = useState<CaptureKind | null>(null);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
// dismiss attempt here instead of closing outright when the form
// body carries unsaved input.
const [confirmDiscardOpen, setConfirmDiscardOpen] = useState(false);
function chooseKind(next: CaptureKind) {
// Close the chooser first, then open the form sheet so only one
@@ -53,6 +74,18 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
setKind(null);
}
// Intercept a form-sheet dismiss: close immediately when the form is
// clean, otherwise keep the sheet open and ask before discarding —
// same contract as `QuickEntrySheets.handleQuickEntryOpenChange`.
function handleFormOpenChange(next: boolean) {
if (next) return;
if (sheetBodyHasUnsavedInput()) {
setConfirmDiscardOpen(true);
return;
}
closeForm();
}
const options: ReadonlyArray<{
kind: CaptureKind;
label: string;
@@ -127,9 +160,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
{/* The chosen capture surface, reusing the existing form. */}
<ResponsiveSheet
open={kind !== null}
onOpenChange={(next) => {
if (!next) closeForm();
}}
onOpenChange={handleFormOpenChange}
title={formTitle}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
@@ -155,6 +186,39 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
/>
)}
</ResponsiveSheet>
{/* v1.30.1 — confirm before discarding a partly-filled capture
form when the sheet is dismissed by an overlay tap, Escape,
or a mobile swipe-down. Copy shared with the dashboard
quick-entry sheets' identical guard. */}
<AlertDialog
open={confirmDiscardOpen}
onOpenChange={setConfirmDiscardOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("dashboard.quickEntryDiscard.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("dashboard.quickEntryDiscard.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("dashboard.quickEntryDiscard.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setConfirmDiscardOpen(false);
closeForm();
}}
>
{t("dashboard.quickEntryDiscard.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -31,6 +31,10 @@ import {
} from "@/lib/query-keys";
import { ApiError, apiPost } from "@/lib/api/api-fetch";
import { MEASUREMENT_NOTES_MAX_LENGTH } from "@/lib/validations/measurement";
import {
getLastUsedMeasurementType,
setLastUsedMeasurementType,
} from "@/lib/measurements/last-used-type";
const MAX_COMMENT_LENGTH = MEASUREMENT_NOTES_MAX_LENGTH;
@@ -234,7 +238,16 @@ export function MeasurementForm({
? "BLOOD_PRESSURE"
: defaultType;
const [type, setType] = useState(normalizedDefault || "BLOOD_PRESSURE");
// v1.30.1 M3 — an explicit deep-link default always wins; absent one,
// seed from the last type the user actually saved rather than always
// landing on BLOOD_PRESSURE. Lazy initializer so the localStorage read
// happens once, at mount, not on every render.
const [type, setType] = useState(
() =>
normalizedDefault ||
getLastUsedMeasurementType(MEASUREMENT_FORM_TYPE_VALUES) ||
"BLOOD_PRESSURE",
);
const [value, setValue] = useState("");
const [sysBp, setSysBp] = useState("");
const [diaBp, setDiaBp] = useState("");
@@ -323,6 +336,10 @@ export function MeasurementForm({
});
}
// v1.30.1 M3 — remember this type as the next mount's smart
// default (deep links still override it).
setLastUsedMeasurementType(type);
// Reset form
setValue("");
setSysBp("");
@@ -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);
}
@@ -48,7 +48,10 @@ import { apiGet } from "@/lib/api/api-fetch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
const CURVE_COLOR = "var(--dracula-purple)";
// --chart-1 is the light/dark-aware alias for this hue (dracula-purple in
// dark mode); raw --dracula-purple has no light-theme override and mis-
// renders on the light card.
const CURVE_COLOR = "var(--chart-1)";
interface DoseChange {
id: string;
@@ -62,7 +62,10 @@ import { parseDoseMg } from "@/lib/medications/dose-string";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { MedicationDetailSection } from "@/components/medications/medication-detail-section";
const CHART_COLOR = "var(--dracula-purple)";
// --chart-1 is the light/dark-aware alias for this hue (dracula-purple in
// dark mode); raw --dracula-purple has no light-theme override and mis-
// renders on the light card.
const CHART_COLOR = "var(--chart-1)";
const CHART_FILL_OPACITY = 0.18;
const HOURS_PER_DAY = 24;
/** 21 days back, 0 days forward — three weekly cycles of history with
@@ -15,12 +15,14 @@ import {
} from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { ListRow } from "@/components/ui/list-row";
import {
DropdownMenu,
DropdownMenuContent,
@@ -160,6 +162,12 @@ export function IntakeHistoryListV2({
const { isAuthenticated } = useAuth();
const { t } = useTranslations();
const formatters = useFormatters();
// v1.30 mobile audit (M-2) — the desktop table carries two full datetime
// columns plus status + source + kebab, forcing horizontal scroll on
// every phone. SSR-safe gate (mirrors `measurement-list.tsx`'s H3 split):
// resolves to the desktop table on the server + first paint, then flips
// to the stacked list after hydration.
const isMobile = useIsMobile("md");
// v1.5.5 F-1 C-2 — the kebab + checkbox columns are gated by the
// caller-passed callbacks. The list never assumes a wrapper; it
@@ -265,182 +273,260 @@ export function IntakeHistoryListV2({
/>
) : (
<>
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead
className="w-8"
data-slot="intake-history-select-col"
/>
)}
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByScheduled")}
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>
{t("medications.intakeHistoryColStatus")}
</TableHead>
<TableHead>
{t("medications.intakeHistoryColSource")}
</TableHead>
{showRowActions && (
<TableHead
className="w-8"
data-slot="intake-history-actions-col"
/>
)}
</TableRow>
</TableHeader>
<TableBody>
{/* v1.30 mobile audit (M-2) — the two-datetime-column table is
contained (Table self-wraps in overflow-x-auto) but forces
sideways scroll of a primary history surface on every phone.
Below `md` it swaps for a stacked card list (the
`measurement-list.tsx` pattern); only the active layout
mounts, mirroring that component's SSR-safe gate. */}
{!isMobile && (
<Table>
<TableHeader>
<TableRow>
{showSelection && (
<TableHead
className="w-8"
data-slot="intake-history-select-col"
/>
)}
<TableHead>
<button
type="button"
onClick={() => toggleSort("takenAt")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t("medications.intakeHistorySortByTaken")}
>
{t("medications.intakeHistoryColTaken")}
{sortIndicator("takenAt")}
</button>
</TableHead>
<TableHead>
<button
type="button"
onClick={() => toggleSort("scheduledFor")}
className="focus-visible:ring-ring inline-flex min-h-11 items-center rounded-md font-medium hover:underline focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none sm:min-h-9"
aria-label={t(
"medications.intakeHistorySortByScheduled",
)}
>
{t("medications.intakeHistoryColScheduled")}
{sortIndicator("scheduledFor")}
</button>
</TableHead>
<TableHead>
{t("medications.intakeHistoryColStatus")}
</TableHead>
<TableHead>
{t("medications.intakeHistoryColSource")}
</TableHead>
{showRowActions && (
<TableHead
className="w-8"
data-slot="intake-history-actions-col"
/>
)}
</TableRow>
</TableHeader>
<TableBody>
{events.map((event) => {
// v1.4.37 W3 — `status=completed` server filter
// guarantees that every row is either a taken or a
// skipped event. We derive the branch from the data
// itself rather than trusting a single `skipped`
// flag so a malformed row (e.g. `skipped:false` with
// `takenAt:null`, the v1.4.36 regression) never
// sneaks past as "Eingenommen".
const isTaken = !event.skipped && !!event.takenAt;
const isSkipped = event.skipped;
const isSelected =
selection?.selected.has(event.id) ?? false;
return (
<TableRow key={event.id}>
{showSelection && (
<TableCell className="w-8">
<Checkbox
checked={isSelected}
onCheckedChange={() =>
selection?.onToggle(event.id)
}
aria-label={t(
"medications.detail.intake.selection.rowToggleLabel",
)}
data-slot="intake-history-row-select"
/>
</TableCell>
)}
<TableCell className="text-sm">
{/* v1.7.0 — the date column is always populated:
taken rows show `takenAt`, skipped rows fall
back to `scheduledFor` with a muted
"(planned)" suffix so the chronological
order stays clean and no `—` row floats. */}
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs">
({t("medications.detail.history.plannedSuffix")}
)
</span>
</>
)}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusSkipped")}
</Badge>
) : null}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
</Badge>
</TableCell>
{showRowActions && (
<TableCell className="w-8 text-right">
<IntakeRowActions
event={event}
onEditIntake={onEditIntake}
onDeleteIntake={onDeleteIntake}
t={t}
/>
</TableCell>
)}
</TableRow>
);
})}
</TableBody>
</Table>
)}
{isMobile && (
<div className="space-y-2" data-slot="intake-history-mobile">
{events.map((event) => {
// v1.4.37 W3 — `status=completed` server filter
// guarantees that every row is either a taken or a
// skipped event. We derive the branch from the data
// itself rather than trusting a single `skipped`
// flag so a malformed row (e.g. `skipped:false` with
// `takenAt:null`, the v1.4.36 regression) never
// sneaks past as "Eingenommen".
const isTaken = !event.skipped && !!event.takenAt;
const isSkipped = event.skipped;
const isSelected = selection?.selected.has(event.id) ?? false;
return (
<TableRow key={event.id}>
{showSelection && (
<TableCell className="w-8">
<Checkbox
checked={isSelected}
onCheckedChange={() =>
selection?.onToggle(event.id)
}
aria-label={t(
"medications.detail.intake.selection.rowToggleLabel",
)}
data-slot="intake-history-row-select"
/>
</TableCell>
)}
<TableCell className="text-sm">
{/* v1.7.0 — the date column is always populated:
taken rows show `takenAt`, skipped rows fall
back to `scheduledFor` with a muted
"(planned)" suffix so the chronological
order stays clean and no `—` row floats. */}
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs">
({t("medications.detail.history.plannedSuffix")})
</span>
</>
)}
</TableCell>
<TableCell className="text-sm">
{formatters.dateTime(event.scheduledFor)}
</TableCell>
<TableCell>
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check aria-hidden="true" className="h-3 w-3" />
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusSkipped")}
</Badge>
) : null}
</TableCell>
<TableCell>
<Badge
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
{t("medications.intakeSourceVia", {
origin: sourceLabels[event.source] ?? event.source,
})}
</Badge>
</TableCell>
{showRowActions && (
<TableCell className="w-8 text-right">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
<ListRow
key={event.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-primary/60 data-[state=selected]:bg-primary/5"
>
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-start gap-2">
{showSelection && (
<div className="flex size-11 shrink-0 items-center justify-center">
<Checkbox
checked={isSelected}
onCheckedChange={() =>
selection?.onToggle(event.id)
}
aria-label={t(
"medications.detail.intake.rowActions.openMenu",
"medications.detail.intake.selection.rowToggleLabel",
)}
data-slot="intake-history-row-kebab"
data-slot="intake-history-row-select"
className="relative after:absolute after:-inset-3.5"
/>
</div>
)}
<div className="min-w-0 space-y-1">
<p className="text-sm font-medium">
{event.takenAt ? (
formatters.dateTime(event.takenAt)
) : (
<>
{formatters.dateTime(event.scheduledFor)}{" "}
<span className="text-muted-foreground text-xs font-normal">
(
{t(
"medications.detail.history.plannedSuffix",
)}
)
</span>
</>
)}
</p>
<p className="text-muted-foreground text-xs">
{t("medications.intakeHistoryColScheduled")}:{" "}
{formatters.dateTime(event.scheduledFor)}
</p>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{isTaken ? (
<Badge
variant="secondary"
className="bg-success/15 text-success gap-1 text-xs"
>
<Check
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusTaken")}
</Badge>
) : isSkipped ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs"
>
<SkipForward
aria-hidden="true"
className="h-3 w-3"
/>
{t("medications.intakeHistoryStatusSkipped")}
</Badge>
) : null}
<Badge
variant="outline"
className="text-muted-foreground text-xs font-normal"
>
<MoreHorizontal
aria-hidden="true"
className="h-4 w-4"
/>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEditIntake && (
<DropdownMenuItem
onSelect={() => onEditIntake(event)}
data-slot="intake-history-row-edit"
>
{t(
"medications.detail.intake.rowActions.edit",
)}
</DropdownMenuItem>
)}
{onDeleteIntake && (
<DropdownMenuItem
onSelect={() => onDeleteIntake(event.id)}
data-slot="intake-history-row-delete"
className="text-destructive focus:text-destructive"
>
{t(
"medications.detail.intake.rowActions.delete",
)}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
)}
</TableRow>
{t("medications.intakeSourceVia", {
origin:
sourceLabels[event.source] ?? event.source,
})}
</Badge>
</div>
</div>
</div>
{showRowActions && (
<IntakeRowActions
event={event}
onEditIntake={onEditIntake}
onDeleteIntake={onDeleteIntake}
t={t}
/>
)}
</div>
</ListRow>
);
})}
</TableBody>
</Table>
</div>
)}
{totalPages > 1 && (
<div className="text-muted-foreground mt-3 flex items-center justify-between text-xs">
@@ -481,3 +567,58 @@ export function IntakeHistoryListV2({
</Card>
);
}
/**
* Per-row Bearbeiten/Löschen kebab, shared by the desktop table row and the
* mobile stacked-card row. v1.30 mobile audit (M-1) — was a hard `h-8 w-8`
* (32 px), sub-floor in an edit flow and inconsistent with the sort headers
* in this same file and the dose-history ledger's kebab; now `size-11
* sm:size-9` like the rest of the app's icon-button floor.
*/
function IntakeRowActions({
event,
onEditIntake,
onDeleteIntake,
t,
}: {
event: IntakeEvent;
onEditIntake?: (event: IntakeEvent) => void;
onDeleteIntake?: (eventId: string) => void;
t: ReturnType<typeof useTranslations>["t"];
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11 sm:size-9"
aria-label={t("medications.detail.intake.rowActions.openMenu")}
data-slot="intake-history-row-kebab"
>
<MoreHorizontal aria-hidden="true" className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{onEditIntake && (
<DropdownMenuItem
onSelect={() => onEditIntake(event)}
data-slot="intake-history-row-edit"
>
{t("medications.detail.intake.rowActions.edit")}
</DropdownMenuItem>
)}
{onDeleteIntake && (
<DropdownMenuItem
onSelect={() => onDeleteIntake(event.id)}
data-slot="intake-history-row-delete"
className="text-destructive focus:text-destructive"
>
{t("medications.detail.intake.rowActions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -33,7 +33,10 @@ import { NativeSelect } from "@/components/ui/native-select";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { formatDose } from "@/lib/medications/format-dose";
import { runLogIntake } from "@/components/medications/use-medication-intake";
import {
runLogIntake,
runUndoIntake,
} from "@/components/medications/use-medication-intake";
interface LogIntakeSchedule {
windowStart: string;
@@ -194,6 +197,15 @@ export function LogIntakeDialog({
...(doseDeviates && { doseTaken: trimmedDose }),
t,
queryClient,
// v1.30.1 M7 — a backdated/manual intake now gets the same
// Undo affordance the card's take/skip path already has.
undoIntake: (eventId) =>
runUndoIntake({
medication: { id: med.id, name: med.name },
eventId,
t,
queryClient,
}),
});
if (ok) onOpenChange(false);
} finally {
@@ -28,7 +28,7 @@
* common case. Dates convert to instants at the browser's local
* midnight, matching the user's wall clock like every schedule input.
*/
import { useMemo, useState } from "react";
import { useId, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ChevronDown,
@@ -51,14 +51,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
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 { DateField } from "@/components/ui/date-field";
import { Label } from "@/components/ui/label";
import { TimesOfDayChips } from "@/components/medications/scheduling/times-of-day-chips";
@@ -367,7 +360,7 @@ function TimelineRow({
children: React.ReactNode;
}) {
return (
<li className="relative pb-4 pl-5 last:pb-0" data-slot={dataSlot}>
<li className="relative pb-4 pl-6 last:pb-0" data-slot={dataSlot}>
<span
aria-hidden="true"
className={cn(
@@ -421,6 +414,12 @@ function EraDialog({
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
// v1.30 mobile audit (M-4) — the footer's submit Button lives outside the
// form's DOM subtree once the sheet swaps to `ResponsiveSheet` (the
// Sheet/Dialog primitive owns the footer slot), so it associates via the
// native `form` attribute rather than nesting inside `<form>` — the same
// pattern `log-intake-dialog.tsx` uses.
const formId = useId();
const [times, setTimes] = useState<string[]>(() =>
target ? eraTimes(target.entries) : [],
);
@@ -504,116 +503,116 @@ function EraDialog({
}
return (
<Dialog
<ResponsiveSheet
open={open}
onOpenChange={(next) => {
onOpenChange(next);
if (!next) reset();
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>
{target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")}
</DialogTitle>
<DialogDescription>
{target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")}
</DialogDescription>
</DialogHeader>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground text-xs"
data-slot="zeitplan-history-archived-hint"
title={
target
? t("medications.detail.zeitplan.history.editEraTitle")
: t("medications.detail.zeitplan.history.addEraTitle")
}
description={
target
? t("medications.detail.zeitplan.history.editEraDescription")
: t("medications.detail.zeitplan.history.addEraDescription")
}
footer={
<>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
{t("medications.detail.zeitplan.history.cancel")}
</Button>
<Button
type="submit"
form={formId}
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-slot="zeitplan-history-submit"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<History className="size-4" aria-hidden="true" />
)}
{t("medications.detail.zeitplan.history.submit")}
</Button>
</>
}
>
{target?.source === "ARCHIVED" && (
<p
className="text-muted-foreground mb-3 text-xs"
data-slot="zeitplan-history-archived-hint"
>
{t("medications.detail.zeitplan.history.archivedEditHint")}
</p>
)}
<form
id={formId}
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submit();
}}
className="space-y-4"
>
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("medications.detail.zeitplan.history.timesLabel")}
</p>
<TimesOfDayChips value={times} onChange={setTimes} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateField
id="zeitplan-history-from"
value={fromDate}
onChange={setFromDate}
data-testid="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateField
id="zeitplan-history-until"
value={untilDate}
onChange={setUntilDate}
data-testid="zeitplan-history-until"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.untilHint")}
</p>
{validationKey !== null && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
{validationKey === "timesMissing" &&
t("medications.detail.zeitplan.history.validationTimes")}
{validationKey === "rangeMissing" &&
t("medications.detail.zeitplan.history.validationRangeMissing")}
{validationKey === "rangeOrder" &&
t("medications.detail.zeitplan.history.validationRangeOrder")}
</p>
)}
<form
onSubmit={(e) => {
e.preventDefault();
if (create.isPending) return;
submit();
}}
className="space-y-4"
>
<div className="space-y-1.5">
<p className="text-sm font-medium">
{t("medications.detail.zeitplan.history.timesLabel")}
</p>
<TimesOfDayChips value={times} onChange={setTimes} />
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-from">
{t("medications.detail.zeitplan.history.fromLabel")}
</Label>
<DateField
id="zeitplan-history-from"
value={fromDate}
onChange={setFromDate}
data-testid="zeitplan-history-from"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="zeitplan-history-until">
{t("medications.detail.zeitplan.history.untilLabel")}
</Label>
<DateField
id="zeitplan-history-until"
value={untilDate}
onChange={setUntilDate}
data-testid="zeitplan-history-until"
/>
</div>
</div>
<p className="text-muted-foreground text-xs">
{t("medications.detail.zeitplan.history.untilHint")}
</p>
{validationKey !== null && (
<p
className="text-destructive text-sm"
role="alert"
data-slot="zeitplan-history-validation"
>
{validationKey === "timesMissing" &&
t("medications.detail.zeitplan.history.validationTimes")}
{validationKey === "rangeMissing" &&
t("medications.detail.zeitplan.history.validationRangeMissing")}
{validationKey === "rangeOrder" &&
t("medications.detail.zeitplan.history.validationRangeOrder")}
</p>
)}
<DialogFooter>
<Button
type="button"
variant="ghost"
onClick={() => onOpenChange(false)}
>
{t("medications.detail.zeitplan.history.cancel")}
</Button>
<Button
type="submit"
disabled={create.isPending}
aria-busy={create.isPending || undefined}
data-slot="zeitplan-history-submit"
>
{create.isPending ? (
<Loader2
className="size-4 animate-spin motion-reduce:animate-none"
aria-hidden="true"
/>
) : (
<History className="size-4" aria-hidden="true" />
)}
{t("medications.detail.zeitplan.history.submit")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</form>
</ResponsiveSheet>
);
}
@@ -450,7 +450,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
aria-checked={selected}
onClick={() => setEntry(candidate)}
{...getEntryRadioProps(index)}
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
className={`flex min-h-11 items-center justify-center rounded-full border px-2.5 py-1 text-xs transition-colors sm:min-h-9 ${
selected
? "border-primary bg-primary text-primary-foreground"
: "border-border bg-background text-foreground hover:bg-muted"
@@ -215,6 +215,12 @@ export async function runLogIntake(deps: {
doseTaken?: string;
t: Translator;
queryClient: QueryClient;
/**
* v1.30.1 M7 — reverse the just-logged intake. Optional so existing
* unit tests that don't care about Undo can omit it; the toast simply
* carries no action when it's absent.
*/
undoIntake?: (eventId: string) => void | Promise<void>;
}): Promise<boolean> {
const {
medication,
@@ -224,22 +230,42 @@ export async function runLogIntake(deps: {
doseTaken,
t,
queryClient,
undoIntake,
} = deps;
try {
const body: Record<string, unknown> = { skipped };
if (!skipped) body.takenAt = takenAt;
if (scheduledFor) body.scheduledFor = scheduledFor;
if (!skipped && doseTaken) body.doseTaken = doseTaken;
await apiPost(`/api/medications/${medication.id}/intake`, body);
await invalidateMedicationReads(queryClient);
toast.success(
t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
),
// v1.30.1 M7 — the route returns the created event
// (`apiSuccess(event, 201)`, same as the card's `runRecordIntake`);
// its id drives the Undo action below.
const created = await apiPost<{ id?: string } | undefined>(
`/api/medications/${medication.id}/intake`,
body,
);
const eventId = created?.id;
await invalidateMedicationReads(queryClient);
const message = t(
skipped
? "medications.intakeToastSkipped"
: "medications.intakeToastTaken",
{ name: medication.name },
);
// v1.30.1 M7 — only pass a second `toast.success` argument when there's
// a real Undo action to attach; keeps the no-undo call signature
// identical to the pre-fix behaviour (existing unit tests assert the
// single-argument call).
if (eventId && undoIntake) {
toast.success(message, {
action: {
label: t("medications.intakeUndo"),
onClick: () => void undoIntake(eventId),
},
});
} else {
toast.success(message);
}
return true;
} catch {
toast.error(t("medications.intakeToastFailed", { name: medication.name }));
@@ -123,7 +123,7 @@ export function CheckInWizard({
return (
<section
className="flex flex-col gap-5"
className="flex flex-col gap-4"
data-slot="mental-health-check-in"
aria-busy={isPending || undefined}
>
@@ -161,7 +161,7 @@ export function CheckInWizard({
</header>
<Card className="gap-0 p-0">
<CardContent className="flex flex-col gap-5 p-4 sm:p-6">
<CardContent className="flex flex-col gap-4 p-4 md:p-6">
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-xs">
{t("mentalHealth.progress", {
@@ -224,7 +224,7 @@ export function CheckInWizard({
</div>
</CardContent>
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 sm:p-6">
<div className="border-border/70 flex items-center justify-between gap-2 border-t p-4 md:p-6">
<Button
type="button"
variant="ghost"
+1 -1
View File
@@ -141,7 +141,7 @@ export function AllergyForm({
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5 sm:col-span-2">
<Label htmlFor="allergy-substance">
@@ -131,7 +131,7 @@ export function FamilyHistoryForm({
);
return (
<form id={formId} onSubmit={handleSubmit} className="space-y-5">
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="family-relationship">
+1 -1
View File
@@ -165,7 +165,7 @@ export function AboutMeSection({
as="section"
aria-labelledby="settings-ai-about-me-title"
data-testid="settings-about-me-card"
className="space-y-5"
className="space-y-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<SettingsCardHeader
+14 -13
View File
@@ -19,6 +19,7 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useAuth } from "@/hooks/use-auth";
@@ -124,13 +125,13 @@ function ApiEndpointsCard() {
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
<p className="text-muted-foreground text-xs tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
@@ -432,12 +433,12 @@ function ApiTokensCard() {
narrow viewport. The revoke action stays full-width to
clear the 44pt tap-target floor. */}
{activeTokens.length === 0 ? (
<p
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm md:hidden"
<EmptyState
className="md:hidden"
size="compact"
data-testid="settings-api-tokens-active-empty"
>
{t("settings.noActiveTokens")}
</p>
title={t("settings.noActiveTokens")}
/>
) : (
<ul
className="space-y-2 md:hidden"
@@ -465,19 +466,19 @@ function ApiTokensCard() {
</Badge>
)}
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
@@ -599,19 +600,19 @@ function ApiTokensCard() {
<p className="text-sm font-medium break-all">
{tok.name}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
@@ -34,6 +34,7 @@ import {
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -211,14 +212,14 @@ export function CoachMemorySection({
)}
{!query.isError && facts.length === 0 ? (
<p
<EmptyState
data-testid="settings-coach-memory-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachMemory.empty")}
</p>
variant="plain"
size="compact"
title={t("settings.ai.coachMemory.empty")}
/>
) : (
<div className="space-y-5">
<div className="space-y-4">
{grouped.map((group) => (
<div key={group.category} className="space-y-2">
<h3
@@ -116,7 +116,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
{isLoading ? (
<div
data-slot="coach-prefs-skeleton"
className="mt-4 flex flex-col gap-5 pl-7"
className="mt-4 flex flex-col gap-4 pl-7"
>
<div className="flex flex-col gap-2">
<Skeleton className="h-3 w-20" />
@@ -133,8 +133,8 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
</div>
</div>
) : (
<div className="mt-4 flex flex-col gap-5 pl-7">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div className="mt-4 flex flex-col gap-4 pl-7">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{/* Tone */}
<div className="flex flex-col gap-2">
<Label htmlFor="coach-prefs-tone" className="text-xs font-medium">
@@ -212,7 +212,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
them. */}
<p
data-slot="coach-prefs-sources-pointer"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-[11px] leading-relaxed"
className="text-muted-foreground border-border/60 rounded-md border border-dashed px-3 py-2 text-xs leading-relaxed"
>
{t("insights.coach.settingsSourcesPointer")}{" "}
<Link
@@ -228,7 +228,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
<Label className="text-xs font-medium">
{t("insights.coach.settingsExcludeLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("insights.coach.settingsExcludeHint")}
</p>
<ul
@@ -265,7 +265,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
<Label className="text-xs font-medium">
{t("insights.coach.settingsContextLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("insights.coach.settingsContextHint")}
</p>
<ul
@@ -307,7 +307,7 @@ export function CoachPrefsSection({ isAuthenticated }: CoachPrefsSectionProps) {
>
{t("coach.settings.reminderSuggestionsLabel")}
</Label>
<p className="text-muted-foreground text-[11px] leading-relaxed">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("coach.settings.reminderSuggestionsDescription")}
</p>
</div>
@@ -21,6 +21,7 @@ import { useMemo } from "react";
import { BellRing, Check, Loader2, Trash2, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { formatDateOrRelative } from "@/lib/format";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -177,14 +178,14 @@ export function CoachRemindersSection({
)}
{!query.isError && reminders.length === 0 ? (
<p
<EmptyState
data-testid="settings-coach-reminders-empty"
className="text-muted-foreground text-sm"
>
{t("settings.ai.coachReminders.empty")}
</p>
variant="plain"
size="compact"
title={t("settings.ai.coachReminders.empty")}
/>
) : (
<div className="space-y-5">
<div className="space-y-4">
{due.length > 0 && (
<div className="space-y-2">
<h3 className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
@@ -716,7 +716,7 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.47 W4 — column header spacer additionally reserves the
width of the new drag-handle icon (w-7) so Tile / Chart
alignment with the row switches below stays pixel-perfect. */}
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-xs font-medium tracking-wide uppercase">
<span className="w-7" aria-hidden="true" />
<span className="flex-1" aria-hidden="true" />
<span className="w-12 text-center">
+1 -1
View File
@@ -147,7 +147,7 @@ function ExportCardShell({
title={title}
description={description}
status={
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-[11px] font-medium tracking-wide uppercase">
<span className="border-border text-muted-foreground rounded-full border px-2 py-0.5 text-xs font-medium tracking-wide uppercase">
{format}
</span>
}
@@ -43,6 +43,7 @@ import {
} from "@/lib/insights/sub-page-metric";
import { SUB_PAGE_TABS } from "@/components/insights/insights-tab-strip";
import { apiPut } from "@/lib/api/api-fetch";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
@@ -240,12 +241,12 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
<Loader2 className="mr-2 inline h-3.5 w-3.5 animate-spin align-text-bottom motion-reduce:animate-none" />
</p>
) : tilesByGroup.length === 0 ? (
<p
className="text-muted-foreground text-sm"
<EmptyState
variant="plain"
size="compact"
data-slot="insights-pill-order-empty"
>
{t("insights.pillOrder.empty")}
</p>
title={t("insights.pillOrder.empty")}
/>
) : (
<div className="max-h-[60vh] space-y-3 overflow-y-auto overscroll-contain">
{tilesByGroup.map(({ group, rows }) => (
@@ -254,7 +255,7 @@ export function InsightsPillOrderSection({ id }: { id?: string }) {
data-slot="insights-pill-order-group"
data-group={group}
>
<p className="text-muted-foreground px-1 py-1 text-[11px] font-semibold tracking-wide uppercase">
<p className="text-muted-foreground px-1 py-1 text-xs font-semibold tracking-wide uppercase">
{t(MANAGER_GROUP_HEADER_KEYS[group])}
</p>
<DndContext
+6 -6
View File
@@ -128,13 +128,13 @@ function McpConnectionsCard() {
{t("settings.mcp.connectionConnected")}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{conn.scope}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.mcp.connectionLastUsed")}:
</span>{" "}
@@ -353,7 +353,7 @@ function McpTokensCard() {
aria-label={t("settings.mcp.writeScopeToggleLabel")}
/>
</div>
<p className="text-muted-foreground text-[11px] leading-relaxed">
<p className="text-muted-foreground text-xs leading-relaxed">
{t("settings.mcp.writeScopeNote")}
</p>
</div>
@@ -447,19 +447,19 @@ function McpTokensCard() {
</Badge>
)}
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTablePermissions")}:
</span>{" "}
{tok.permissions.join(", ")}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableCreated")}:
</span>{" "}
{formatDate(tok.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.tokenTableLastUsed")}:
</span>{" "}
@@ -21,6 +21,7 @@ import { Leaf } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
@@ -85,9 +86,11 @@ export function NutrientIntakeCard() {
<Skeleton className="h-9 w-full rounded-md" />
</div>
) : rows.length === 0 ? (
<p className="text-muted-foreground pl-7 text-sm">
{t("settings.sections.sources.nutrients.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.nutrients.empty")}
/>
) : (
<ul className="divide-border divide-y rounded-md border" role="list">
{rows.map((row) => (
@@ -13,6 +13,7 @@ import { useQuery } from "@tanstack/react-query";
import { ChevronDown, Loader2, ShieldCheck } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
@@ -112,9 +113,11 @@ export function SecurityActivityCard({
)}
{!isLoading && !isError && events.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.activityEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.security.activityEmpty")}
/>
)}
{events.length > 0 && (
@@ -25,6 +25,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
@@ -138,9 +139,11 @@ export function SecurityKeysCard({ keys }: { keys: WebauthnKeyInfo[] }) {
<div className="mt-4">
{keys.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t("settings.security.keys.empty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.security.keys.empty")}
/>
) : (
<ul className="space-y-2" data-testid="security-keys-list">
{keys.map((key) => (
@@ -15,6 +15,7 @@ import { ChevronDown, Loader2, MonitorSmartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SettingsCard } from "@/components/settings/settings-card";
import { EmptyState } from "@/components/ui/empty-state";
import { useTranslations, useFormatters } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiDelete } from "@/lib/api/api-fetch";
@@ -126,9 +127,11 @@ export function SecuritySessionsCard({
)}
{!isLoading && !isError && sessions.length === 0 && (
<p className="text-muted-foreground text-sm">
{t("settings.security.sessionsEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.security.sessionsEmpty")}
/>
)}
{sessions.length > 0 && (
@@ -320,7 +320,7 @@ export function ShareLinkCreateForm({
setRangeDays(Math.max(1, Number(e.target.value) || 1))
}
/>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.rangeHint")}
</p>
</div>
@@ -338,7 +338,7 @@ export function ShareLinkCreateForm({
}
aria-invalid={expiryDays < 1 || expiryDays > MAX_DAYS}
/>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.expiryHint", { max: MAX_DAYS })}
</p>
</div>
@@ -353,7 +353,7 @@ export function ShareLinkCreateForm({
<Label htmlFor="share-fhir" className="text-sm font-medium">
{t("settings.sharing.fhirApi")}
</Label>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.fhirApiHint")}
</p>
</div>
@@ -396,7 +396,7 @@ export function ShareLinkCreateForm({
<Label className="text-sm font-medium">
{t("settings.sharing.attachTitle")}
</Label>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.attachCount", {
count: selectedDocs.length,
max: MAX_DOCUMENTS,
@@ -445,11 +445,11 @@ export function ShareLinkCreateForm({
</ul>
) : null}
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.attachFrozen")}
</p>
{selectedDocs.length > 0 ? (
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.exifNote")}
</p>
) : null}
@@ -499,7 +499,7 @@ export function ShareLinkCreateForm({
})}
</p>
)}
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.shownOnce")}
</p>
@@ -537,7 +537,7 @@ export function ShareLinkCreateForm({
{/* The link (no passphrase). On its own it cannot open the record. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
<p className="text-foreground text-xs font-medium">
{t("settings.sharing.linkLabel")}
</p>
<div className="flex items-center gap-2">
@@ -556,7 +556,7 @@ export function ShareLinkCreateForm({
</Button>
</div>
{copied === "link" && (
<p className="text-success text-[11px]">
<p className="text-success text-xs">
{t("settings.sharing.copied")}
</p>
)}
@@ -565,7 +565,7 @@ export function ShareLinkCreateForm({
{/* The passphrase — the second factor. Shown once; only its hash is
stored, so it cannot be recovered. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
<p className="text-foreground text-xs font-medium">
{t("settings.sharing.passphraseLabel")}
</p>
<div className="flex items-center gap-2">
@@ -587,11 +587,11 @@ export function ShareLinkCreateForm({
</Button>
</div>
{copied === "passphrase" && (
<p className="text-success text-[11px]">
<p className="text-success text-xs">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
{t("settings.sharing.passphraseHint")}
</p>
</div>
+9 -9
View File
@@ -38,6 +38,7 @@ import {
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import {
@@ -107,12 +108,11 @@ function ShareLinksCard() {
{t("settings.sharing.activeTitle")}
</h3>
{activeLinks.length === 0 ? (
<p
className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-center text-sm"
<EmptyState
size="compact"
data-testid="share-active-empty"
>
{t("settings.sharing.noActive")}
</p>
title={t("settings.sharing.noActive")}
/>
) : (
<ul className="space-y-2" data-testid="share-active-list">
{activeLinks.map((link) => (
@@ -158,19 +158,19 @@ function ShareLinksCard() {
)}
</div>
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.created")}:
</span>{" "}
{formatDate(link.createdAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.expires")}:
</span>{" "}
{formatDateTime(link.expiresAt)}
</p>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
@@ -246,7 +246,7 @@ function ShareLinksCard() {
: t("settings.sharing.statusExpired")}
</Badge>
</div>
<p className="text-muted-foreground text-[11px]">
<p className="text-muted-foreground text-xs">
<span className="font-medium">
{t("settings.sharing.accessCount")}:
</span>{" "}
+6 -3
View File
@@ -42,6 +42,7 @@ import {
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { EmptyState } from "@/components/ui/empty-state";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { NutrientIntakeCard } from "@/components/settings/nutrient-intake-card";
@@ -412,9 +413,11 @@ export function SourcesSection() {
{t("settings.sections.sources.perMetricHelp")}
</p>
{overriddenMetrics.length === 0 ? (
<p className="text-muted-foreground text-xs italic">
{t("settings.sections.sources.perMetricEmpty")}
</p>
<EmptyState
variant="plain"
size="compact"
title={t("settings.sections.sources.perMetricEmpty")}
/>
) : (
<ul className="space-y-2">
{overriddenMetrics.map((metric) => (
@@ -225,6 +225,32 @@ function MetricRow({
String(override?.max ?? effective?.default?.greenMax ?? bounds.max),
);
// v1.30.1 M9 — the row is keyed on the stable `metric`, so a per-row
// reset (or "reset all") that lands a NEW `override` (typically
// `null`) never re-runs the `useState` initializers above: the
// component instance survives the refetch. Pre-fix the switch and
// min/max fields kept showing the stale override after the server
// confirmed it was cleared, while the "Überschrieben" badge (driven
// straight off the `hasOverride` prop, not state) vanished — a
// contradictory row until a hard reload. Re-derive an identity string
// from the override prop and resync local state during render
// whenever it actually changes; typing in the fields never changes
// `overrideIdentity`, so this never fights a live edit.
const overrideIdentity = override
? `${override.min}:${override.max}`
: "unset";
const [syncedIdentity, setSyncedIdentity] = useState(overrideIdentity);
if (overrideIdentity !== syncedIdentity) {
setSyncedIdentity(overrideIdentity);
setOverrideMode(hasOverride);
setMinStr(
String(override?.min ?? effective?.default?.greenMin ?? bounds.min),
);
setMaxStr(
String(override?.max ?? effective?.default?.greenMax ?? bounds.max),
);
}
const minNum = parseFloat(minStr);
const maxNum = parseFloat(maxStr);
const valid =
+29 -4
View File
@@ -4,15 +4,20 @@
* Today rail — "dismiss / mark seen" for the OBSERVATIONAL `PriorityItem`
* kinds (`milestone`, `ecg_new_recording`, `tension_window`). Posts the
* item's own `itemKey` to `POST /api/daily/digest/dismiss` (server persists
* it so the dismissal survives reload / a second device), then invalidates
* the daily digest so the dismissed card leaves the rail immediately — the
* same invalidate-on-mutate pattern `useCoachCheckinAction` uses. Keys route
* it so the dismissal survives reload / a second device). Keys route
* through the centralised factory — no bare arrays.
*
* The dismiss is optimistic (`onMutate` strikes the item from the cached
* digest immediately): a second tap on the same card has nothing left to
* hit, since the card itself leaves the rail before the round trip lands.
* `onError` rolls the cache back to the pre-tap snapshot; `onSettled`
* re-syncs from the server either way.
*/
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import type { DailyDigest } from "@/lib/daily/digest";
export function usePriorityItemDismiss() {
const queryClient = useQueryClient();
@@ -22,7 +27,27 @@ export function usePriorityItemDismiss() {
await apiPost("/api/daily/digest/dismiss", { itemKey });
return itemKey;
},
onSuccess: () => {
onMutate: async (itemKey) => {
await queryClient.cancelQueries({ queryKey: queryKeys.dailyDigest() });
const previous = queryClient.getQueryData<DailyDigest>(
queryKeys.dailyDigest(),
);
if (previous) {
queryClient.setQueryData<DailyDigest>(queryKeys.dailyDigest(), {
...previous,
worthALook: previous.worthALook.filter(
(item) => item.itemKey !== itemKey,
),
});
}
return { previous };
},
onError: (_err, _itemKey, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKeys.dailyDigest(), context.previous);
}
},
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.dailyDigest() });
},
});
+16 -2
View File
@@ -60,8 +60,22 @@ export function usePullToRefresh({
let busy = false;
let lastDistance = 0;
const atTop = () =>
(document.scrollingElement?.scrollTop ?? window.scrollY) <= 0;
// v1.30.1 M12 — the authenticated shell's actual scroll container is
// `<main id="main-content">` (`AuthShell` sets it `overflow-y-auto`
// inside a height-locked `h-dvh` layout); `document.scrollingElement`
// never moves there; it stays permanently at 0. Checking the document
// root meant "at top" was always true regardless of how far the user
// had actually scrolled a long list — a downward drag mid-scroll could
// arm the pull gesture instead of just scrolling the list. Falls back
// to the document root for the few body-scrolled surfaces (public
// pages) that render outside the shell's `#main-content`.
const atTop = () => {
const main = document.getElementById("main-content");
const scrollTop = main
? main.scrollTop
: (document.scrollingElement?.scrollTop ?? window.scrollY);
return scrollTop <= 0;
};
const reset = () => {
startY = null;
+23
View File
@@ -306,11 +306,24 @@ export const CHART_OVERLAY_KEYS = [
] as const;
export type ChartOverlayKey = (typeof CHART_OVERLAY_KEYS)[number];
/**
* v1.30.1 — the range tabs (`7 / 30 / 90 / All`) every chart card
* exposes. `0` means "All". Shared here so both the client hook and
* the server route validate against the same closed set instead of
* each chart component re-declaring it.
*/
export const CHART_RANGE_POINTS = [0, 7, 30, 90] as const;
export type ChartRangePoints = (typeof CHART_RANGE_POINTS)[number];
export interface ChartOverlayPrefs {
showTrendIndicator: boolean;
showTrendArrow: boolean;
showTargetRange: boolean;
comparisonBaseline: ComparisonBaseline;
// v1.30.1 — persisted range-tab selection. Undefined = never saved
// for this chart yet, so the chart falls back to its own 30 d
// default rather than forcing every unopened chart to look "saved".
rangePoints?: ChartRangePoints;
}
export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
@@ -320,6 +333,13 @@ export const DEFAULT_CHART_OVERLAY_PREFS: ChartOverlayPrefs = {
comparisonBaseline: "none",
};
function isChartRangePoints(value: unknown): value is ChartRangePoints {
return (
typeof value === "number" &&
(CHART_RANGE_POINTS as readonly number[]).includes(value)
);
}
export type ChartOverlayPrefsMap = Partial<
Record<ChartOverlayKey, ChartOverlayPrefs>
>;
@@ -338,6 +358,9 @@ function coerceChartOverlayPrefs(value: unknown): ChartOverlayPrefs {
comparisonBaseline: isComparisonBaseline(candidate.comparisonBaseline)
? candidate.comparisonBaseline
: "none",
rangePoints: isChartRangePoints(candidate.rangePoints)
? candidate.rangePoints
: undefined,
};
}
+43
View File
@@ -0,0 +1,43 @@
/**
* v1.30.1 (M3 QoL fix) — remember the last manually-saved measurement
* type so the add form doesn't default to BLOOD_PRESSURE for a daily
* weight/glucose logger. Client-only convenience persisted in
* `localStorage`, mirroring the `mood/recent-tags.ts` MRU pattern: it
* never touches the API, and a cleared/disabled store just degrades
* to the pre-existing BLOOD_PRESSURE default.
*
* A `defaultType` deep link (`?add=<TYPE>`, the Vorsorge card, an
* Insights empty-state CTA) always wins over this — callers only
* consult `getLastUsedMeasurementType()` when no explicit default was
* supplied.
*/
const STORAGE_KEY = "healthlog:measurement:last-used-type";
/**
* Read the last-saved type, validated against the caller-supplied set
* of currently selectable form values so a type retired from the
* catalog (or a corrupt/foreign value) never gets selected silently.
*/
export function getLastUsedMeasurementType(
validValues: readonly string[],
): string | null {
if (typeof window === "undefined") return null;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw && validValues.includes(raw)) return raw;
} catch {
/* storage disabled — fall through to no memory */
}
return null;
}
/** Record a successfully-saved type as the new "last used" value. */
export function setLastUsedMeasurementType(type: string): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, type);
} catch {
/* storage full / disabled — nothing to remember */
}
}