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

+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,
},
};
}