From ad8b45ee1fcbe8a7e16ef3e3792b689c64ddf2b8 Mon Sep 17 00:00:00 2001 From: Gemini Agent Date: Sun, 25 Jan 2026 00:49:05 +0000 Subject: [PATCH] Add PWA infrastructure for iOS notification support - Add manifest.json for proper PWA installation - Add service worker (sw.js) for push notification handling - Add ServiceWorkerProvider to register SW and manage notifications - Generate PNG icons (192x192, 512x512) for iOS compatibility - Update settings to show notification status and test button - Use service worker showNotification for iOS Safari compatibility Co-Authored-By: Claude Opus 4.5 --- public/icons/icon-192.png | Bin 0 -> 4210 bytes public/icons/icon-512.png | Bin 0 -> 15139 bytes public/manifest.json | 30 +++++++ public/sw.js | 94 +++++++++++++++++++++ scripts/generate-icons.mjs | 26 ++++++ src/app/layout.tsx | 17 ++-- src/app/settings/page.tsx | 58 +++++++++++-- src/components/ServiceWorkerProvider.tsx | 99 +++++++++++++++++++++++ 8 files changed, 311 insertions(+), 13 deletions(-) create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/manifest.json create mode 100644 public/sw.js create mode 100644 scripts/generate-icons.mjs create mode 100644 src/components/ServiceWorkerProvider.tsx diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..5bc9d48ffdae3a6bbb2ff0550631c43852fd430a GIT binary patch literal 4210 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 I+71!_2TX { + console.log('[SW] Installing service worker...'); + self.skipWaiting(); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', (event) => { + console.log('[SW] Activating service worker...'); + event.waitUntil(clients.claim()); +}); + +// Push event - handle incoming push notifications +self.addEventListener('push', (event) => { + console.log('[SW] Push event received'); + + let data = { + title: 'Quiet Thanks', + body: 'Take a moment to reflect on what you\'re grateful for today.', + icon: '/icons/icon.svg', + badge: '/icons/icon.svg', + tag: 'daily-reminder', + }; + + if (event.data) { + try { + const payload = event.data.json(); + data = { ...data, ...payload }; + } catch (e) { + data.body = event.data.text(); + } + } + + const options = { + body: data.body, + icon: data.icon, + badge: data.badge, + tag: data.tag, + vibrate: [100, 50, 100], + data: { + url: '/', + }, + actions: [ + { action: 'open', title: 'Open app' }, + { action: 'dismiss', title: 'Dismiss' }, + ], + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +// Notification click handler +self.addEventListener('notificationclick', (event) => { + console.log('[SW] Notification clicked'); + event.notification.close(); + + if (event.action === 'dismiss') { + return; + } + + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + // Focus existing window if available + for (const client of clientList) { + if (client.url.includes(self.registration.scope) && 'focus' in client) { + return client.focus(); + } + } + // Open new window + if (clients.openWindow) { + return clients.openWindow('/'); + } + }) + ); +}); + +// Periodic sync for background reminders (when supported) +self.addEventListener('periodicsync', (event) => { + if (event.tag === 'daily-reminder') { + event.waitUntil(checkAndShowReminder()); + } +}); + +async function checkAndShowReminder() { + // This would check the reminder time and show notification if appropriate + // For now, we rely on the client-side scheduler + console.log('[SW] Periodic sync triggered'); +} diff --git a/scripts/generate-icons.mjs b/scripts/generate-icons.mjs new file mode 100644 index 0000000..2a95766 --- /dev/null +++ b/scripts/generate-icons.mjs @@ -0,0 +1,26 @@ +import sharp from "sharp"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; +import { readFileSync, writeFileSync } from "fs"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const publicDir = join(__dirname, "..", "public", "icons"); + +const svgPath = join(publicDir, "icon.svg"); +const svgContent = readFileSync(svgPath, "utf-8"); + +async function generateIcons() { + const sizes = [192, 512]; + + for (const size of sizes) { + const outputPath = join(publicDir, `icon-${size}.png`); + await sharp(Buffer.from(svgContent)) + .resize(size, size) + .png() + .toFile(outputPath); + console.log(`Generated ${outputPath}`); + } +} + +generateIcons().catch(console.error); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 2c27cea..0f2c6a1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,13 +2,20 @@ import type { Metadata, Viewport } from "next"; import "./globals.css"; import { APP_NAME } from "@/lib/constants"; import { AuthProvider } from "@/components/AuthProvider"; +import { ServiceWorkerProvider } from "@/components/ServiceWorkerProvider"; export const metadata: Metadata = { title: APP_NAME, description: "A calm, private gratitude and mood log", + manifest: "/manifest.json", icons: { icon: "/icons/icon.svg", - apple: "/icons/icon.svg", + apple: "/icons/icon-192.png", + }, + appleWebApp: { + capable: true, + statusBarStyle: "black-translucent", + title: APP_NAME, }, }; @@ -27,12 +34,10 @@ export default function RootLayout({ }>) { return ( - - - - - {children} + + {children} + ); diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 1643a68..f006fb8 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect } from "react"; import { AppShell } from "@/components/AppShell"; import { useAuth } from "@/components/AuthProvider"; +import { useServiceWorker } from "@/components/ServiceWorkerProvider"; import { APP_NAME } from "@/lib/constants"; import { LogOut, Users, Bell, Sparkles, Loader2, Check, Key, ChevronDown } from "lucide-react"; import Link from "next/link"; @@ -29,11 +30,13 @@ const PROVIDERS = [ export default function SettingsPage() { const { user, logout } = useAuth(); + const { isSupported: swSupported, isRegistered: swRegistered, showNotification } = useServiceWorker(); const [settings, setSettings] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [showLlmSetup, setShowLlmSetup] = useState(false); const [notificationPermission, setNotificationPermission] = useState("default"); + const [isPWA, setIsPWA] = useState(false); // LLM setup state const [selectedProvider, setSelectedProvider] = useState(""); @@ -68,6 +71,11 @@ export default function SettingsPage() { if ("Notification" in window) { setNotificationPermission(Notification.permission); } + + // Detect if running as PWA (standalone mode) + const isStandalone = window.matchMedia("(display-mode: standalone)").matches || + (window.navigator as Navigator & { standalone?: boolean }).standalone === true; + setIsPWA(isStandalone); }, []); const updateSetting = async (key: string, value: unknown) => { @@ -204,27 +212,35 @@ export default function SettingsPage() { } }; - // Reminder check interval + // Reminder check interval - uses service worker for iOS compatibility useEffect(() => { if (!settings?.reminderEnabled || !settings?.reminderTime) return; - const checkReminder = () => { + // Track if we've shown the notification this minute + let lastShownMinute = -1; + + const checkReminder = async () => { const now = new Date(); const [hours, minutes] = settings.reminderTime.split(":").map(Number); + const currentMinute = now.getHours() * 60 + now.getMinutes(); - if (now.getHours() === hours && now.getMinutes() === minutes) { + if (now.getHours() === hours && now.getMinutes() === minutes && currentMinute !== lastShownMinute) { + lastShownMinute = currentMinute; if (Notification.permission === "granted") { - new Notification(APP_NAME, { + await showNotification(APP_NAME, { body: "Take a moment to reflect on what you're grateful for today.", - icon: "/icon.png", + icon: "/icons/icon.svg", + tag: "daily-reminder", }); } } }; + // Check immediately and then every minute + checkReminder(); const interval = setInterval(checkReminder, 60000); return () => clearInterval(interval); - }, [settings?.reminderEnabled, settings?.reminderTime]); + }, [settings?.reminderEnabled, settings?.reminderTime, showNotification]); const getProviderName = (id: string) => PROVIDERS.find(p => p.id === id)?.name || id; @@ -302,9 +318,37 @@ export default function SettingsPage() { /> )} + {settings.reminderEnabled && notificationPermission === "granted" && swRegistered && ( +
+
+ + Notifications ready{isPWA ? " (PWA mode)" : ""} +
+ +
+ )} + {settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && ( +

+ Service worker loading... Notifications may be limited. +

+ )} {notificationPermission === "denied" && (

- Notifications are blocked. Please enable them in your browser settings. + Notifications are blocked. Please enable them in your browser/device settings. +

+ )} + {settings.reminderEnabled && notificationPermission === "default" && ( +

+ Notification permission not yet granted. Toggle reminders off and on to request permission.

)} diff --git a/src/components/ServiceWorkerProvider.tsx b/src/components/ServiceWorkerProvider.tsx new file mode 100644 index 0000000..8346f4a --- /dev/null +++ b/src/components/ServiceWorkerProvider.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useEffect, createContext, useContext, useState, useCallback, ReactNode } from "react"; + +interface ServiceWorkerContextValue { + isSupported: boolean; + isRegistered: boolean; + registration: ServiceWorkerRegistration | null; + showNotification: (title: string, options?: NotificationOptions) => Promise; +} + +const ServiceWorkerContext = createContext({ + isSupported: false, + isRegistered: false, + registration: null, + showNotification: async () => {}, +}); + +export function useServiceWorker() { + return useContext(ServiceWorkerContext); +} + +interface ServiceWorkerProviderProps { + children: ReactNode; +} + +export function ServiceWorkerProvider({ children }: ServiceWorkerProviderProps) { + const [isSupported, setIsSupported] = useState(false); + const [isRegistered, setIsRegistered] = useState(false); + const [registration, setRegistration] = useState(null); + + useEffect(() => { + // Check if service workers are supported + if (!("serviceWorker" in navigator)) { + console.log("[SW Provider] Service workers not supported"); + return; + } + + setIsSupported(true); + + // Register the service worker + async function registerServiceWorker() { + try { + const reg = await navigator.serviceWorker.register("/sw.js", { + scope: "/", + }); + console.log("[SW Provider] Service worker registered:", reg.scope); + setRegistration(reg); + setIsRegistered(true); + + // Check for updates + reg.addEventListener("updatefound", () => { + console.log("[SW Provider] Service worker update found"); + }); + } catch (error) { + console.error("[SW Provider] Service worker registration failed:", error); + } + } + + registerServiceWorker(); + }, []); + + const showNotification = useCallback( + async (title: string, options?: NotificationOptions) => { + if (!registration) { + console.log("[SW Provider] No registration, falling back to Notification API"); + if ("Notification" in window && Notification.permission === "granted") { + new Notification(title, options); + } + return; + } + + try { + await registration.showNotification(title, options); + console.log("[SW Provider] Notification shown via service worker"); + } catch (error) { + console.error("[SW Provider] Failed to show notification:", error); + // Fallback to regular Notification API + if ("Notification" in window && Notification.permission === "granted") { + new Notification(title, options); + } + } + }, + [registration] + ); + + return ( + + {children} + + ); +}