added in browser notifications for notifications in settings

This commit is contained in:
masongga
2026-05-24 00:14:06 -06:00
parent dfafba0c94
commit 3cb22a4089
10 changed files with 394 additions and 59 deletions

View File

@@ -2,6 +2,7 @@ import { Suspense } from "react";
import { AppAuthGate } from "@/components/layout/app-auth-gate";
import { DailyReminderProvider } from "@/components/reminders/daily-reminder-provider";
import { InAppReminderBanner } from "@/components/reminders/in-app-reminder-banner";
export default function AppLayout({
children,
@@ -12,6 +13,7 @@ export default function AppLayout({
<Suspense fallback={<div className="min-h-svh bg-background" />}>
<AppAuthGate>
<DailyReminderProvider />
<InAppReminderBanner />
{children}
</AppAuthGate>
</Suspense>

View File

@@ -17,14 +17,20 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
clearDailyReminderSentToday,
deliverTestDailyReminder,
getNotificationPermissionLabel,
getNotificationPermissionState,
isNotificationSupported,
requestNotificationPermission,
showTestDailyReminderNotification,
} from "@/lib/daily-reminders";
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
import { getTodayDateKey } from "@/lib/habits-storage";
import {
reminderDeliveryOptions,
usesInAppReminders,
usesSystemReminders,
type ReminderDeliveryMethod,
} from "@/lib/reminder-delivery";
import {
avatarVibeOptions,
defaultProfilePreferences,
@@ -86,7 +92,8 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
if (
"dailyReminderEnabled" in updates ||
"dailyReminderTime" in updates
"dailyReminderTime" in updates ||
"dailyReminderDelivery" in updates
) {
clearDailyReminderSentToday(getTodayDateKey());
}
@@ -102,37 +109,16 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
}
};
const handleDailyReminderToggle = async (checked: boolean) => {
if (checked) {
if (!isNotificationSupported()) {
setError("This browser does not support notifications.");
return;
}
const permission = await requestNotificationPermission();
setNotificationPermission(permission);
if (permission !== "granted") {
setError(
permission === "denied"
? "Notifications are blocked. Enable them in your browser settings to use daily reminders."
: "Notification permission was not granted.",
);
return;
}
const ensureSystemReminderPermission = async (
deliveryMethod: ReminderDeliveryMethod,
) => {
if (!usesSystemReminders(deliveryMethod)) {
return true;
}
setError(null);
await updatePreferences({ dailyReminderEnabled: checked });
};
const handleTestNotification = async () => {
setNotificationMessage(null);
setError(null);
if (!isNotificationSupported()) {
setError("This browser does not support notifications.");
return;
setError("This browser does not support browser (OS) notifications.");
return false;
}
const permission = await requestNotificationPermission();
@@ -141,22 +127,86 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
if (permission !== "granted") {
setError(
permission === "denied"
? "Notifications are blocked. Enable them in your browser settings."
: "Notification permission was not granted.",
? "Browser notifications are blocked. Enable them in your browser settings or choose In-app delivery."
: "Browser notification permission was not granted.",
);
return false;
}
return true;
};
const handleDailyReminderToggle = async (checked: boolean) => {
if (checked) {
const allowed = await ensureSystemReminderPermission(
preferences.dailyReminderDelivery,
);
if (!allowed) {
return;
}
}
setError(null);
await updatePreferences({ dailyReminderEnabled: checked });
};
const handleDeliveryMethodChange = async (
deliveryMethod: ReminderDeliveryMethod,
) => {
if (
preferences.dailyReminderEnabled &&
usesSystemReminders(deliveryMethod)
) {
const allowed = await ensureSystemReminderPermission(deliveryMethod);
if (!allowed) {
return;
}
}
setError(null);
await updatePreferences({ dailyReminderDelivery: deliveryMethod });
};
const handleTestNotification = async () => {
setNotificationMessage(null);
setError(null);
const deliveryMethod = preferences.dailyReminderDelivery;
if (usesSystemReminders(deliveryMethod)) {
const allowed = await ensureSystemReminderPermission(deliveryMethod);
if (!allowed) {
return;
}
}
const status = await getDailyReminderStatus();
const shown = deliverTestDailyReminder(status, deliveryMethod);
if (!shown) {
setError("Could not show a test reminder.");
return;
}
if (deliveryMethod === "in_app") {
setNotificationMessage(
"In-app test reminder sent. Look for the banner above the bottom navigation.",
);
return;
}
const status = await getDailyReminderStatus();
const shown = showTestDailyReminderNotification(status);
if (!shown) {
setError("Could not show a test notification.");
if (deliveryMethod === "system") {
setNotificationMessage(
"Browser test notification sent. Check your browser or OS notification center.",
);
return;
}
setNotificationMessage(
"Test notification sent. Check the corner of your screen or Windows notification center.",
"Test reminder sent in-app and through your browser notification center.",
);
};
@@ -239,19 +289,44 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
<Label htmlFor="daily-reminder">Daily reminder</Label>
<Checkbox
id="daily-reminder"
disabled={isSaving || !isNotificationSupported()}
disabled={isSaving}
checked={preferences.dailyReminderEnabled}
onCheckedChange={(checked) =>
void handleDailyReminderToggle(checked === true)
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<span className="text-sm">Browser permission</span>
<Badge variant="secondary">
{getNotificationPermissionLabel(notificationPermission)}
</Badge>
<div className="space-y-2">
<Label>Reminder delivery</Label>
<div className="space-y-2">
{reminderDeliveryOptions.map((option) => (
<button
key={option.value}
type="button"
disabled={isSaving}
onClick={() => void handleDeliveryMethodChange(option.value)}
className={`w-full rounded-lg border px-3 py-3 text-left transition-colors ${
preferences.dailyReminderDelivery === option.value
? "border-primary bg-primary/10"
: "hover:bg-muted/40"
}`}
>
<p className="text-sm font-medium">{option.label}</p>
<p className="mt-1 text-xs text-muted-foreground">
{option.description}
</p>
</button>
))}
</div>
</div>
{usesSystemReminders(preferences.dailyReminderDelivery) ? (
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<span className="text-sm">Browser permission</span>
<Badge variant="secondary">
{getNotificationPermissionLabel(notificationPermission)}
</Badge>
</div>
) : null}
<div className="space-y-2">
<Label htmlFor="reminder-time">Daily reminder time</Label>
<Input
@@ -265,21 +340,19 @@ export function ProfilePreferencesForm({ email }: ProfilePreferencesFormProps) {
/>
</div>
<p className="text-xs text-muted-foreground">
Reminders use your browser&apos;s local time and notify you once
per day when the quiz or habits are still incomplete. Keep this app
open in a tab for reminders to fire. On Windows, check Focus Assist
if notifications are hidden.
In-app reminders appear inside Habit Pet while a tab is open.
Browser (OS) reminders use the Web Notifications API and may appear
in your system notification center. Reminders fire once per day at
your chosen local time.
</p>
<Button
type="button"
variant="outline"
className="w-full"
disabled={
isSaving || notificationPermission !== "granted"
}
disabled={isSaving}
onClick={() => void handleTestNotification()}
>
Send test notification
Send test reminder
</Button>
{notificationMessage ? (
<p className="text-xs text-emerald-600">{notificationMessage}</p>

View File

@@ -5,17 +5,18 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { HABIT_PET_DATA_UPDATED_EVENT } from "@/lib/app-events";
import { getDailyReminderStatus } from "@/lib/daily-reminder-status";
import {
deliverDailyReminder,
getNotificationPermissionState,
getReminderCheckIntervalMs,
hasReachedReminderTime,
markDailyReminderSentToday,
showDailyReminderNotification,
wasDailyReminderSentToday,
} from "@/lib/daily-reminders";
import {
getProfilePreferences,
type ProfilePreferences,
} from "@/lib/profile-preferences-storage";
import { usesInAppReminders, usesSystemReminders } from "@/lib/reminder-delivery";
export function DailyReminderProvider() {
const [preferences, setPreferences] = useState<ProfilePreferences | null>(
@@ -47,7 +48,11 @@ export function DailyReminderProvider() {
return;
}
if (getNotificationPermissionState() !== "granted") {
if (
usesSystemReminders(activePreferences.dailyReminderDelivery) &&
!usesInAppReminders(activePreferences.dailyReminderDelivery) &&
getNotificationPermissionState() !== "granted"
) {
return;
}
@@ -62,7 +67,10 @@ export function DailyReminderProvider() {
}
if (status.needsReminder) {
showDailyReminderNotification(status);
deliverDailyReminder(
status,
activePreferences.dailyReminderDelivery,
);
}
markDailyReminderSentToday(status.dateKey);

View File

@@ -0,0 +1,83 @@
"use client";
import { Bell, X } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import {
IN_APP_REMINDER_EVENT,
type InAppReminderPayload,
} from "@/lib/in-app-reminders";
export function InAppReminderBanner() {
const router = useRouter();
const [reminder, setReminder] = useState<InAppReminderPayload | null>(null);
useEffect(() => {
const handleReminder = (event: Event) => {
const customEvent = event as CustomEvent<InAppReminderPayload>;
setReminder(customEvent.detail);
};
window.addEventListener(IN_APP_REMINDER_EVENT, handleReminder);
return () => {
window.removeEventListener(IN_APP_REMINDER_EVENT, handleReminder);
};
}, []);
if (!reminder) {
return null;
}
return (
<div
role="status"
aria-live="polite"
className="fixed inset-x-0 bottom-16 z-50 px-4"
>
<div className="mx-auto flex max-w-lg items-start gap-3 rounded-xl border border-primary/30 bg-card p-4 shadow-lg">
<div className="rounded-full bg-primary/10 p-2">
<Bell aria-hidden="true" className="h-4 w-4 text-primary" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div>
<p className="font-medium leading-tight">{reminder.title}</p>
<p className="mt-1 text-sm text-muted-foreground">{reminder.body}</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
onClick={() => {
setReminder(null);
router.push(reminder.targetUrl);
}}
>
Open Pet tab
</Button>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setReminder(null)}
>
Dismiss
</Button>
</div>
</div>
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
aria-label="Dismiss reminder"
onClick={() => setReminder(null)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
);
}

View File

@@ -123,6 +123,9 @@ function parseProfilePreferences(
dailyReminderTime:
parsed.dailyReminderTime ??
defaultProfilePreferences.dailyReminderTime,
dailyReminderDelivery:
parsed.dailyReminderDelivery ??
defaultProfilePreferences.dailyReminderDelivery,
};
}
} catch {

View File

@@ -45,6 +45,7 @@ function parseStoredPreferences(
avatarVibe: "Calm",
dailyReminderEnabled: false,
dailyReminderTime: "20:00",
dailyReminderDelivery: "in_app",
};
}
@@ -60,6 +61,7 @@ function parseStoredPreferences(
avatarVibe: "Calm",
dailyReminderEnabled: false,
dailyReminderTime: "20:00",
dailyReminderDelivery: "in_app",
};
}
@@ -68,5 +70,6 @@ function parseStoredPreferences(
avatarVibe: "Calm",
dailyReminderEnabled: false,
dailyReminderTime: "20:00",
dailyReminderDelivery: "in_app",
};
}

View File

@@ -1,5 +1,11 @@
import type { DailyReminderStatus } from "@/lib/daily-reminder-status";
import { showInAppDailyReminder } from "@/lib/in-app-reminders";
import { routes } from "@/lib/routes";
import {
type ReminderDeliveryMethod,
usesInAppReminders,
usesSystemReminders,
} from "@/lib/reminder-delivery";
const REMINDER_SENT_STORAGE_PREFIX = "habit-pet-daily-reminder-sent";
const REMINDER_CHECK_INTERVAL_MS = 15_000;
@@ -135,7 +141,7 @@ export function buildDailyReminderNotification(status: DailyReminderStatus) {
};
}
export function showDailyReminderNotification(
export function showSystemDailyReminderNotification(
status: DailyReminderStatus,
) {
if (!isNotificationSupported() || Notification.permission !== "granted") {
@@ -160,7 +166,7 @@ export function showDailyReminderNotification(
return true;
}
export function showTestDailyReminderNotification(
export function showTestSystemDailyReminderNotification(
status: DailyReminderStatus,
) {
if (!isNotificationSupported() || Notification.permission !== "granted") {
@@ -169,7 +175,7 @@ export function showTestDailyReminderNotification(
const { body, targetUrl } = buildDailyReminderNotification(status);
const notification = new Notification("Test: Habit Pet reminder", {
body: `This is a test notification. ${body}`,
body: `This is a browser test notification. ${body}`,
tag: `habit-pet-daily-reminder-test-${Date.now()}`,
});
@@ -185,6 +191,61 @@ export function showTestDailyReminderNotification(
return true;
}
export function deliverDailyReminder(
status: DailyReminderStatus,
deliveryMethod: ReminderDeliveryMethod,
) {
let delivered = false;
if (usesInAppReminders(deliveryMethod)) {
const { title, body, targetUrl } = buildDailyReminderNotification(status);
delivered =
showInAppDailyReminder({ title, body, targetUrl }) || delivered;
}
if (usesSystemReminders(deliveryMethod)) {
delivered =
showSystemDailyReminderNotification(status) || delivered;
}
return delivered;
}
export function deliverTestDailyReminder(
status: DailyReminderStatus,
deliveryMethod: ReminderDeliveryMethod,
) {
let delivered = false;
if (usesInAppReminders(deliveryMethod)) {
const { title, body, targetUrl } = buildDailyReminderNotification(status);
delivered =
showInAppDailyReminder({
title: `Test: ${title}`,
body: `This is an in-app test reminder. ${body}`,
targetUrl,
isTest: true,
}) || delivered;
}
if (usesSystemReminders(deliveryMethod)) {
delivered =
showTestSystemDailyReminderNotification(status) || delivered;
}
return delivered;
}
/** @deprecated Use showSystemDailyReminderNotification */
export function showDailyReminderNotification(status: DailyReminderStatus) {
return showSystemDailyReminderNotification(status);
}
/** @deprecated Use showTestSystemDailyReminderNotification */
export function showTestDailyReminderNotification(status: DailyReminderStatus) {
return showTestSystemDailyReminderNotification(status);
}
export function getReminderCheckIntervalMs() {
return REMINDER_CHECK_INTERVAL_MS;
}

38
lib/in-app-reminders.ts Normal file
View File

@@ -0,0 +1,38 @@
import { routes } from "@/lib/routes";
export const IN_APP_REMINDER_EVENT = "habit-pet-in-app-reminder";
export type InAppReminderPayload = {
title: string;
body: string;
targetUrl: string;
isTest?: boolean;
};
export function dispatchInAppReminder(payload: InAppReminderPayload) {
if (typeof window === "undefined") {
return false;
}
window.dispatchEvent(
new CustomEvent<InAppReminderPayload>(IN_APP_REMINDER_EVENT, {
detail: payload,
}),
);
return true;
}
export function showInAppDailyReminder(input: {
title: string;
body: string;
targetUrl?: string;
isTest?: boolean;
}) {
return dispatchInAppReminder({
title: input.title,
body: input.body,
targetUrl: input.targetUrl ?? routes.avatar,
isTest: input.isTest,
});
}

View File

@@ -1,4 +1,8 @@
import { notifyHabitPetDataUpdated } from "@/lib/app-events";
import {
normalizeReminderDeliveryMethod,
type ReminderDeliveryMethod,
} from "@/lib/reminder-delivery";
import { createClient } from "@/lib/supabase/client";
export type ProfilePreferences = {
@@ -6,6 +10,7 @@ export type ProfilePreferences = {
avatarVibe: string;
dailyReminderEnabled: boolean;
dailyReminderTime: string;
dailyReminderDelivery: ReminderDeliveryMethod;
};
type StoredProfilePreferences = {
@@ -13,6 +18,7 @@ type StoredProfilePreferences = {
avatarVibe?: string;
dailyReminderEnabled?: boolean;
dailyReminderTime?: string;
dailyReminderDelivery?: string;
};
type ProfileRow = {
@@ -34,6 +40,7 @@ export const defaultProfilePreferences: ProfilePreferences = {
avatarVibe: "Calm",
dailyReminderEnabled: false,
dailyReminderTime: "20:00",
dailyReminderDelivery: "in_app",
};
function isFocusTopic(value: string) {
@@ -71,6 +78,9 @@ function normalizePreferences(
defaultProfilePreferences.dailyReminderEnabled,
dailyReminderTime:
partial.dailyReminderTime ?? defaultProfilePreferences.dailyReminderTime,
dailyReminderDelivery: normalizeReminderDeliveryMethod(
partial.dailyReminderDelivery,
),
};
}
@@ -79,7 +89,7 @@ function parseStoredPreferencesJson(raw: string): Partial<ProfilePreferences> {
const parsed = JSON.parse(raw) as StoredProfilePreferences;
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return parsed;
return parsed as Partial<ProfilePreferences>;
}
} catch {
return { focusTopic: raw };
@@ -112,6 +122,7 @@ function serializeFocusTopicsField(preferences: ProfilePreferences) {
avatarVibe: preferences.avatarVibe,
dailyReminderEnabled: preferences.dailyReminderEnabled,
dailyReminderTime: preferences.dailyReminderTime,
dailyReminderDelivery: preferences.dailyReminderDelivery,
};
return JSON.stringify(payload);

53
lib/reminder-delivery.ts Normal file
View File

@@ -0,0 +1,53 @@
export const reminderDeliveryOptions = [
{
value: "in_app",
label: "In-app",
description:
"Show reminders inside Habit Pet when you have the site open in a tab.",
},
{
value: "system",
label: "Browser (OS)",
description:
"Use the Web Notifications API. Your browser may show these in the Windows or macOS notification center.",
},
{
value: "both",
label: "Both",
description: "In-app banners plus browser notifications when permitted.",
},
] as const;
export type ReminderDeliveryMethod =
(typeof reminderDeliveryOptions)[number]["value"];
export function isReminderDeliveryMethod(
value: string | undefined,
): value is ReminderDeliveryMethod {
return reminderDeliveryOptions.some((option) => option.value === value);
}
export function normalizeReminderDeliveryMethod(
value: string | undefined,
): ReminderDeliveryMethod {
if (value && isReminderDeliveryMethod(value)) {
return value;
}
return "in_app";
}
export function usesInAppReminders(method: ReminderDeliveryMethod) {
return method === "in_app" || method === "both";
}
export function usesSystemReminders(method: ReminderDeliveryMethod) {
return method === "system" || method === "both";
}
export function getReminderDeliveryLabel(method: ReminderDeliveryMethod) {
return (
reminderDeliveryOptions.find((option) => option.value === method)?.label ??
"In-app"
);
}