mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
be7df4bc17
commit
5e721e0791
3 files changed
+99
-53
No files matched your search
+6
-1
@@ -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",
|
||||
|
||||
+6
-1
@@ -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",
|
||||
|
||||
+87
-51
@@ -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<string, unknown> = {};
|
||||
if (heightCm) body.heightCm = parseFloat(heightCm);
|
||||
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
|
||||
if (gender) body.gender = gender;
|
||||
try {
|
||||
const body: Record<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown> = {};
|
||||
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"
|
||||
/>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addMedication}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addMedication}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
{meds.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li key={i} className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5">
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
<>
|
||||
<ul className="space-y-1 text-sm">
|
||||
{meds.map((m, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="bg-muted/50 flex items-center justify-between rounded px-3 py-1.5"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<span>
|
||||
{m.name} {m.dose && `(${m.dose})`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-destructive text-xs"
|
||||
onClick={() => setMeds(meds.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.medScheduleHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -226,8 +258,12 @@ export default function OnboardingPage() {
|
||||
{t("onboarding.notificationsDescription")}
|
||||
</p>
|
||||
<div className="bg-muted/50 space-y-2 rounded-lg p-4 text-sm">
|
||||
<p>📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}</p>
|
||||
<p>🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}</p>
|
||||
<p>
|
||||
📱 <strong>Telegram</strong> — {t("onboarding.telegramHint")}
|
||||
</p>
|
||||
<p>
|
||||
🔔 <strong>Web Push</strong> — {t("onboarding.webPushHint")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("onboarding.notificationsLater")}
|
||||
|
||||
Reference in new issue
Block a user