feat(settings): mood-reminder Settings toggle and profile API field

v0.5.4 PR #190 shipped the server cron, ledger, and APNs scaffolding
for the daily mood reminder but no surface for the user to opt in,
so the feature was dead code regardless of the locale, dispatcher,
or APNs fixes. This commit adds:

- `MoodReminderCard` in `/settings/notifications` — a single Switch
  row bound to `users.mood_reminder_enabled`, optimistic on toggle,
  with a localised toast on save success and an error message on
  failure.
- `applyProfileUpdate` accepts and persists `moodReminderEnabled`
  through the existing `/api/auth/profile` PUT and `/api/user/profile`
  PATCH paths so both web and iOS clients share the same validation
  + audit envelope.
- `GET /api/user/profile` now surfaces the current value so the
  Switch can render the right initial state without a separate
  endpoint.
- Five-locale strings (de/en/es/fr/it/pl) under
  `notifications.moodReminder.*` for the card title, description,
  Switch aria label, on/off status text, and toast / error copy.
This commit is contained in:
Marc-André Bombeck committed 2026-05-17 23:44:18 +02:00
1 parent e1a5c639a5
commit 96fc7fbda2
9 files changed
+172

No files matched your search

+10
View File
@@ -2790,6 +2790,16 @@
},
"user": {
"telegramTestBody": "HealthLog: Verbindung hergestellt. Telegram-Benachrichtigungen sind aktiv."
},
"moodReminder": {
"title": "Tägliche Stimmungs-Erinnerung",
"description": "Sanfter Anstoß um 22:00 Uhr, falls du heute noch keine Stimmung erfasst hast. Greift nur auf bereits eingerichtete Kanäle (Telegram, ntfy, Web Push, Apple Push) zurück und schweigt, sobald du heute geloggt hast.",
"toggleAria": "Tägliche Stimmungs-Erinnerung umschalten",
"statusOn": "Aktiv um 22:00 deiner Zeit",
"statusOff": "Aus",
"enabledToast": "Stimmungs-Erinnerung aktiviert.",
"disabledToast": "Stimmungs-Erinnerung deaktiviert.",
"saveError": "Speichern fehlgeschlagen. Bitte später erneut versuchen."
}
},
"i18n": {
+10
View File
@@ -2790,6 +2790,16 @@
},
"user": {
"telegramTestBody": "HealthLog: connection successful. Telegram notifications are active."
},
"moodReminder": {
"title": "Daily mood reminder",
"description": "Gentle 22:00 nudge when you haven't logged a mood for the day. Routes only through channels you've already configured (Telegram, ntfy, Web Push, Apple Push) and stays silent once you've logged today.",
"toggleAria": "Toggle daily mood reminder",
"statusOn": "On at 22:00 your time",
"statusOff": "Off",
"enabledToast": "Mood reminder enabled.",
"disabledToast": "Mood reminder disabled.",
"saveError": "Save failed. Please try again."
}
},
"i18n": {
+10
View File
@@ -2790,6 +2790,16 @@
},
"user": {
"telegramTestBody": "HealthLog: conexión correcta. Las notificaciones de Telegram están activas."
},
"moodReminder": {
"title": "Recordatorio diario de ánimo",
"description": "Aviso suave a las 22:00 si aún no has registrado el ánimo del día. Se envía solo por canales ya configurados (Telegram, ntfy, Web Push, Apple Push) y permanece en silencio una vez que has registrado el ánimo hoy.",
"toggleAria": "Alternar recordatorio diario de ánimo",
"statusOn": "Activo a las 22:00 hora local",
"statusOff": "Desactivado",
"enabledToast": "Recordatorio de ánimo activado.",
"disabledToast": "Recordatorio de ánimo desactivado.",
"saveError": "No se pudo guardar. Inténtalo de nuevo."
}
},
"i18n": {
+10
View File
@@ -2790,6 +2790,16 @@
},
"user": {
"telegramTestBody": "HealthLog: connessione riuscita. Le notifiche Telegram sono attive."
},
"moodReminder": {
"title": "Promemoria quotidiano dell’umore",
"description": "Promemoria leggero alle 22:00 se non hai ancora registrato l’umore di oggi. Usa solo i canali già configurati (Telegram, ntfy, Web Push, Apple Push) e tace una volta registrato l’umore di oggi.",
"toggleAria": "Attiva o disattiva il promemoria quotidiano dell’umore",
"statusOn": "Attivo alle 22:00 ora locale",
"statusOff": "Disattivato",
"enabledToast": "Promemoria dell’umore attivato.",
"disabledToast": "Promemoria dell’umore disattivato.",
"saveError": "Salvataggio non riuscito. Riprova."
}
},
"i18n": {
+10
View File
@@ -2790,6 +2790,16 @@
},
"user": {
"telegramTestBody": "HealthLog: połączenie udane. Powiadomienia Telegram są aktywne."
},
"moodReminder": {
"title": "Codzienne przypomnienie o nastroju",
"description": "Delikatne przypomnienie o 22:00, jeśli dziś jeszcze nie zapisano nastroju. Wysyłane tylko skonfigurowanymi już kanałami (Telegram, ntfy, Web Push, Apple Push) i milknie, gdy dziś zapisano nastrój.",
"toggleAria": "Włącz lub wyłącz codzienne przypomnienie o nastroju",
"statusOn": "Aktywne o 22:00 czasu lokalnego",
"statusOff": "Wyłączone",
"enabledToast": "Przypomnienie o nastroju włączone.",
"disabledToast": "Przypomnienie o nastroju wyłączone.",
"saveError": "Zapis nieudany. Spróbuj ponownie."
}
},
"i18n": {
+3
View File
@@ -35,6 +35,7 @@ export const GET = apiHandler(async () => {
heightCm: true,
locale: true,
timezone: true,
moodReminderEnabled: true,
},
});
@@ -47,6 +48,7 @@ export const GET = apiHandler(async () => {
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
moodReminderEnabled: dbUser?.moodReminderEnabled ?? false,
});
});
@@ -72,5 +74,6 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
moodReminderEnabled: result.user.moodReminderEnabled,
});
});
@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SmilePlus } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useTranslations } from "@/lib/i18n/context";
interface ProfileShape {
moodReminderEnabled: boolean;
}
export function MoodReminderCard({
isAuthenticated,
}: {
isAuthenticated: boolean;
}) {
const { t } = useTranslations();
const queryClient = useQueryClient();
const [optimistic, setOptimistic] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
const { data: profile } = useQuery({
queryKey: ["user", "profile"],
queryFn: async () => {
const res = await fetch("/api/user/profile");
if (!res.ok) throw new Error("Failed to load profile");
return (await res.json()).data as ProfileShape;
},
enabled: isAuthenticated,
});
const enabled = optimistic ?? profile?.moodReminderEnabled ?? false;
async function handleToggle(next: boolean) {
setOptimistic(next);
setSaving(true);
setMsg(null);
setMsgType(null);
const res = await fetch("/api/user/profile", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ moodReminderEnabled: next }),
});
if (res.ok) {
setMsg(
next
? t("notifications.moodReminder.enabledToast")
: t("notifications.moodReminder.disabledToast"),
);
setMsgType("success");
await queryClient.invalidateQueries({ queryKey: ["user", "profile"] });
setOptimistic(null);
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
}
setSaving(false);
}
return (
<section
aria-labelledby="settings-mood-reminder-title"
className="bg-card rounded-lg border p-4 sm:p-6"
>
<SettingsCardHeader
icon={SmilePlus}
title={t("notifications.moodReminder.title")}
titleId="settings-mood-reminder-title"
description={t("notifications.moodReminder.description")}
status={
<label className="flex min-h-11 items-center gap-3">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
disabled={!isAuthenticated || saving}
aria-label={t("notifications.moodReminder.toggleAria")}
/>
<span className="text-muted-foreground text-xs">
{enabled
? t("notifications.moodReminder.statusOn")
: t("notifications.moodReminder.statusOff")}
</span>
</label>
}
/>
{msg && (
<p
role="status"
aria-live="polite"
className={
msgType === "error"
? "text-destructive mt-3 text-sm"
: "text-muted-foreground mt-3 text-sm"
}
>
{msg}
</p>
)}
</section>
);
}
@@ -6,6 +6,7 @@ import { ChevronRight } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { MoodReminderCard } from "@/components/settings/mood-reminder-card";
import { NotificationStatusCard } from "@/components/settings/notification-status-card";
import { NtfyCard } from "@/components/settings/ntfy-card";
import { TelegramCard } from "@/components/settings/telegram-card";
@@ -105,6 +106,9 @@ export function NotificationsSection() {
<WebPushCard />
</div>
)}
<div id="mood-reminder" className="scroll-mt-28">
<MoodReminderCard isAuthenticated={isAuthenticated} />
</div>
</section>
);
}
+6
View File
@@ -18,6 +18,7 @@ const extendedProfileSchema = profileSchema.extend({
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone")
.optional(),
moodReminderEnabled: z.boolean().optional(),
});
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
@@ -35,6 +36,7 @@ export interface ApplyProfileResult {
gender: string | null;
timezone: string;
locale: string | null;
moodReminderEnabled: boolean;
};
}
@@ -91,6 +93,9 @@ export async function applyProfileUpdate(
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
if (data.moodReminderEnabled !== undefined) {
updates.moodReminderEnabled = data.moodReminderEnabled;
}
// v1.4.18 — capture the prior locale so we can emit a separate
// `settings.locale.update` audit row when the locale actually
@@ -138,6 +143,7 @@ export async function applyProfileUpdate(
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
moodReminderEnabled: updatedUser.moodReminderEnabled,
},
};
}