diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..5bc9d48 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..d29ccab Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..7cff6ca --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Quiet Thanks", + "short_name": "Quiet Thanks", + "description": "A calm, private gratitude and mood log", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + }, + { + "src": "/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "categories": ["lifestyle", "health"], + "id": "quiet-thanks-pwa" +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..49cf59a --- /dev/null +++ b/public/sw.js @@ -0,0 +1,94 @@ +// Service Worker for Quiet Thanks PWA +const CACHE_NAME = 'quiet-thanks-v1'; + +// Install event - cache essential assets +self.addEventListener('install', (event) => { + 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} + + ); +}