-
Browser permission
-
- {getNotificationPermissionLabel(notificationPermission)}
-
+
+
Reminder delivery
+
+ {reminderDeliveryOptions.map((option) => (
+
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"
+ }`}
+ >
+ {option.label}
+
+ {option.description}
+
+
+ ))}
+
+ {usesSystemReminders(preferences.dailyReminderDelivery) ? (
+
+ Browser permission
+
+ {getNotificationPermissionLabel(notificationPermission)}
+
+
+ ) : null}
Daily reminder time
- 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.
void handleTestNotification()}
>
- Send test notification
+ Send test reminder
{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}
+
+
+ {
+ setReminder(null);
+ router.push(reminder.targetUrl);
+ }}
+ >
+ Open Pet tab
+
+ setReminder(null)}
+ >
+ Dismiss
+
+
+
+
setReminder(null)}
+ >
+
+
+
+
+ );
+}
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"
+ );
+}