From 5e721e0791c68df474bdcdfee82a0a9192ee4e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 26 Apr 2026 22:34:20 +0200 Subject: [PATCH] fix(onboarding): surface errors and supply default schedule (#87) Issue #87 (kevinpapst): medications added during onboarding were never visible afterwards. Root cause: the createMedication schema requires schedules.min(1) ("Mindestens ein Zeitfenster"), but the onboarding wizard sent schedules:[]. The 422 was silently swallowed and the user was redirected to the dashboard as if everything had worked. This patch is the minimal-impact fix. The proper fix lives in Phase 3.5 of the audit plan, which replaces the wizard entirely with a dashboard-first empty-state flow. Until then: - finishOnboarding() wraps each call in a try/catch, checks response.ok explicitly, and surfaces a sonner toast on failure. setSaving(false) on error so the user can retry instead of being stuck on a spinner. - Each medication is sent with a default schedule (08:00-09:00) so it is actually persisted. A new medScheduleHint i18n key is shown under the meds list while the user is in the wizard, so the default schedule is not surprising. - Errors are translated per stage (errorProfile, errorMedication with {name}, errorTargets) plus an errorGeneric fallback. en.json and de.json kept symmetric. Co-Authored-By: Claude Opus 4.7 (1M context) --- messages/de.json | 7 +- messages/en.json | 7 +- src/app/onboarding/page.tsx | 138 +++++++++++++++++++++++------------- 3 files changed, 99 insertions(+), 53 deletions(-) diff --git a/messages/de.json b/messages/de.json index c471b7159..4791cd810 100644 --- a/messages/de.json +++ b/messages/de.json @@ -819,7 +819,12 @@ "targetsDescription": "Setze optionale Zielwerte für Blutdruck und Gewicht.", "targetSys": "Systolisch (mmHg)", "targetDia": "Diastolisch (mmHg)", - "targetWeight": "Zielgewicht (kg)" + "targetWeight": "Zielgewicht (kg)", + "errorProfile": "Dein Profil konnte nicht gespeichert werden. Bitte erneut versuchen.", + "errorMedication": "Das Medikament „{name}\" konnte nicht angelegt werden. Bitte erneut versuchen oder später unter „Medikamente\" hinzufügen.", + "errorTargets": "Deine Zielwerte konnten nicht gespeichert werden. Bitte erneut versuchen.", + "errorGeneric": "Beim Abschluss des Setups ist etwas schiefgelaufen. Bitte erneut versuchen.", + "medScheduleHint": "Wir legen ein Standard-Erinnerungsfenster an (08:00–09:00 täglich). Du kannst es jederzeit unter „Medikamente\" anpassen." }, "settings": { "title": "Einstellungen", diff --git a/messages/en.json b/messages/en.json index b1d992b0d..8ff2f5b95 100644 --- a/messages/en.json +++ b/messages/en.json @@ -819,7 +819,12 @@ "targetsDescription": "Set optional target values for blood pressure and weight.", "targetSys": "Systolic (mmHg)", "targetDia": "Diastolic (mmHg)", - "targetWeight": "Target weight (kg)" + "targetWeight": "Target weight (kg)", + "errorProfile": "Could not save your profile. Please try again.", + "errorMedication": "Could not add the medication \"{name}\". Please try again or add it later in Medications.", + "errorTargets": "Could not save your target values. Please try again.", + "errorGeneric": "Something went wrong while finishing setup. Please try again.", + "medScheduleHint": "We'll add a default reminder window (08:00–09:00 daily). You can adjust it anytime in Medications." }, "settings": { "title": "Settings", diff --git a/src/app/onboarding/page.tsx b/src/app/onboarding/page.tsx index c7871d3a5..32ee666a3 100644 --- a/src/app/onboarding/page.tsx +++ b/src/app/onboarding/page.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -39,45 +40,63 @@ export default function OnboardingPage() { async function finishOnboarding() { setSaving(true); - const body: Record = {}; - if (heightCm) body.heightCm = parseFloat(heightCm); - if (dateOfBirth) body.dateOfBirth = dateOfBirth; - if (gender) body.gender = gender; + try { + const body: Record = {}; + if (heightCm) body.heightCm = parseFloat(heightCm); + if (dateOfBirth) body.dateOfBirth = dateOfBirth; + if (gender) body.gender = gender; - await fetch("/api/onboarding/complete", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }); - - // Create medications if any - for (const med of meds) { - await fetch("/api/medications", { + const profileRes = await fetch("/api/onboarding/complete", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - name: med.name, - dose: med.dose, - schedules: [], - }), + body: JSON.stringify(body), }); - } + if (!profileRes.ok) { + throw new Error(t("onboarding.errorProfile")); + } - // Save targets if set - if (targetSys || targetDia || targetWeight) { - const targets: Record = {}; - if (targetSys) targets.bpSysTarget = parseInt(targetSys); - if (targetDia) targets.bpDiaTarget = parseInt(targetDia); - if (targetWeight) targets.weightTarget = parseFloat(targetWeight); - await fetch("/api/insights/targets", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(targets), - }); - } + // Create medications. Server validation requires at least one schedule, + // so we attach a sensible default — the user is informed via medScheduleHint + // and can refine it in Medications later. Phase 3.5 of the audit replaces + // this entire wizard with an empty-state-driven flow. + for (const med of meds) { + const medRes = await fetch("/api/medications", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: med.name, + dose: med.dose, + schedules: [{ windowStart: "08:00", windowEnd: "09:00" }], + }), + }); + if (!medRes.ok) { + throw new Error(t("onboarding.errorMedication", { name: med.name })); + } + } - await queryClient.invalidateQueries({ queryKey: ["auth"] }); - router.replace("/"); + if (targetSys || targetDia || targetWeight) { + const targets: Record = {}; + if (targetSys) targets.bpSysTarget = parseInt(targetSys); + if (targetDia) targets.bpDiaTarget = parseInt(targetDia); + if (targetWeight) targets.weightTarget = parseFloat(targetWeight); + const targetsRes = await fetch("/api/insights/targets", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(targets), + }); + if (!targetsRes.ok) { + throw new Error(t("onboarding.errorTargets")); + } + } + + await queryClient.invalidateQueries({ queryKey: ["auth"] }); + router.replace("/"); + } catch (err) { + const message = + err instanceof Error ? err.message : t("onboarding.errorGeneric"); + toast.error(message); + setSaving(false); + } } function addMedication() { @@ -191,27 +210,40 @@ export default function OnboardingPage() { placeholder={t("onboarding.medDosePlaceholder")} className="w-24" /> - {meds.length > 0 && ( -
    - {meds.map((m, i) => ( -
  • - - {m.name} {m.dose && `(${m.dose})`} - - -
  • - ))} -
+ + {m.name} {m.dose && `(${m.dose})`} + + + + ))} + +

+ {t("onboarding.medScheduleHint")} +

+ )} )} @@ -226,8 +258,12 @@ export default function OnboardingPage() { {t("onboarding.notificationsDescription")}

-

📱 Telegram — {t("onboarding.telegramHint")}

-

🔔 Web Push — {t("onboarding.webPushHint")}

+

+ 📱 Telegram — {t("onboarding.telegramHint")} +

+

+ 🔔 Web Push — {t("onboarding.webPushHint")} +

{t("onboarding.notificationsLater")}