From 96fc7fbda20cdde418c06397f567138d22f85358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 17 May 2026 23:44:18 +0200 Subject: [PATCH] feat(settings): mood-reminder Settings toggle and profile API field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- messages/de.json | 10 ++ messages/en.json | 10 ++ messages/es.json | 10 ++ messages/it.json | 10 ++ messages/pl.json | 10 ++ src/app/api/user/profile/route.ts | 3 + .../settings/mood-reminder-card.tsx | 109 ++++++++++++++++++ .../settings/notifications-section.tsx | 4 + src/lib/auth/profile-update.ts | 6 + 9 files changed, 172 insertions(+) create mode 100644 src/components/settings/mood-reminder-card.tsx diff --git a/messages/de.json b/messages/de.json index 8084f470b..6eb93b9a6 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 84e4fea2d..df99c3f99 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/messages/es.json b/messages/es.json index 65cb0d819..9503a3a0c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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": { diff --git a/messages/it.json b/messages/it.json index 12d69c952..c61b76936 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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": { diff --git a/messages/pl.json b/messages/pl.json index 7afc2bf37..7c485f648 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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": { diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts index bed82717a..60aff0fa9 100644 --- a/src/app/api/user/profile/route.ts +++ b/src/app/api/user/profile/route.ts @@ -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, }); }); diff --git a/src/components/settings/mood-reminder-card.tsx b/src/components/settings/mood-reminder-card.tsx new file mode 100644 index 000000000..2029a9d66 --- /dev/null +++ b/src/components/settings/mood-reminder-card.tsx @@ -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(null); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState(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 ( +
+ + + + {enabled + ? t("notifications.moodReminder.statusOn") + : t("notifications.moodReminder.statusOff")} + + + } + /> + {msg && ( +

+ {msg} +

+ )} +
+ ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 0fc0a950e..2977b0b62 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -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() { )} +
+ +
); } diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index b8d74da65..50b7c59a6 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -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; @@ -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, }, }; }