mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
1 parent
e1a5c639a5
commit
96fc7fbda2
9 files changed
+172
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user