diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index a4665af..a17f949 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -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({ }> + {children} diff --git a/components/profile/profile-preferences-form.tsx b/components/profile/profile-preferences-form.tsx index 255b536..f91547e 100644 --- a/components/profile/profile-preferences-form.tsx +++ b/components/profile/profile-preferences-form.tsx @@ -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) { void handleDailyReminderToggle(checked === true) } /> -
- Browser permission - - {getNotificationPermissionLabel(notificationPermission)} - +
+ +
+ {reminderDeliveryOptions.map((option) => ( + + ))} +
+ {usesSystemReminders(preferences.dailyReminderDelivery) ? ( +
+ Browser permission + + {getNotificationPermissionLabel(notificationPermission)} + +
+ ) : null}

- Reminders use your browser'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.

{notificationMessage ? (

{notificationMessage}

diff --git a/components/reminders/daily-reminder-provider.tsx b/components/reminders/daily-reminder-provider.tsx index e2d3a3d..a6767ae 100644 --- a/components/reminders/daily-reminder-provider.tsx +++ b/components/reminders/daily-reminder-provider.tsx @@ -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( @@ -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); diff --git a/components/reminders/in-app-reminder-banner.tsx b/components/reminders/in-app-reminder-banner.tsx new file mode 100644 index 0000000..93b27b8 --- /dev/null +++ b/components/reminders/in-app-reminder-banner.tsx @@ -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(null); + + useEffect(() => { + const handleReminder = (event: Event) => { + const customEvent = event as CustomEvent; + setReminder(customEvent.detail); + }; + + window.addEventListener(IN_APP_REMINDER_EVENT, handleReminder); + + return () => { + window.removeEventListener(IN_APP_REMINDER_EVENT, handleReminder); + }; + }, []); + + if (!reminder) { + return null; + } + + return ( +
+
+
+
+
+
+

{reminder.title}

+

{reminder.body}

+
+
+ + +
+
+ +
+
+ ); +} diff --git a/lib/ai-daily-tasks-server.ts b/lib/ai-daily-tasks-server.ts index 4c912f7..491df24 100644 --- a/lib/ai-daily-tasks-server.ts +++ b/lib/ai-daily-tasks-server.ts @@ -123,6 +123,9 @@ function parseProfilePreferences( dailyReminderTime: parsed.dailyReminderTime ?? defaultProfilePreferences.dailyReminderTime, + dailyReminderDelivery: + parsed.dailyReminderDelivery ?? + defaultProfilePreferences.dailyReminderDelivery, }; } } catch { diff --git a/lib/ai-task-metadata.ts b/lib/ai-task-metadata.ts index e782079..87e2a9a 100644 --- a/lib/ai-task-metadata.ts +++ b/lib/ai-task-metadata.ts @@ -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", }; } diff --git a/lib/daily-reminders.ts b/lib/daily-reminders.ts index bf2226d..8462a86 100644 --- a/lib/daily-reminders.ts +++ b/lib/daily-reminders.ts @@ -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; } diff --git a/lib/in-app-reminders.ts b/lib/in-app-reminders.ts new file mode 100644 index 0000000..668cdf5 --- /dev/null +++ b/lib/in-app-reminders.ts @@ -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(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, + }); +} diff --git a/lib/profile-preferences-storage.ts b/lib/profile-preferences-storage.ts index 39f95ae..10afe06 100644 --- a/lib/profile-preferences-storage.ts +++ b/lib/profile-preferences-storage.ts @@ -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 { const parsed = JSON.parse(raw) as StoredProfilePreferences; if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { - return parsed; + return parsed as Partial; } } 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); diff --git a/lib/reminder-delivery.ts b/lib/reminder-delivery.ts new file mode 100644 index 0000000..77d903e --- /dev/null +++ b/lib/reminder-delivery.ts @@ -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" + ); +}