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 <noreply@anthropic.com>
This commit is contained in:
Gemini Agent
2026-01-25 00:49:05 +00:00
parent e35545b156
commit ad8b45ee1f
8 changed files with 311 additions and 13 deletions

BIN
public/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
public/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

30
public/manifest.json Normal file
View File

@@ -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"
}

94
public/sw.js Normal file
View File

@@ -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');
}

View File

@@ -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);

View File

@@ -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 (
<html lang="en">
<head>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
</head>
<body className="antialiased min-h-screen">
<ServiceWorkerProvider>
<AuthProvider>{children}</AuthProvider>
</ServiceWorkerProvider>
</body>
</html>
);

View File

@@ -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<UserSettings | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [showLlmSetup, setShowLlmSetup] = useState(false);
const [notificationPermission, setNotificationPermission] = useState<NotificationPermission>("default");
const [isPWA, setIsPWA] = useState(false);
// LLM setup state
const [selectedProvider, setSelectedProvider] = useState<string>("");
@@ -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() {
/>
</div>
)}
{settings.reminderEnabled && notificationPermission === "granted" && swRegistered && (
<div className="mt-3 flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-green-500">
<Check size={14} />
<span>Notifications ready{isPWA ? " (PWA mode)" : ""}</span>
</div>
<button
onClick={() => showNotification(APP_NAME, {
body: "Test notification - reminders are working!",
icon: "/icons/icon.svg",
tag: "test-notification",
})}
className="text-sm text-accent hover:underline"
>
Test
</button>
</div>
)}
{settings.reminderEnabled && notificationPermission === "granted" && !swRegistered && (
<p className="mt-2 text-sm text-amber-400">
Service worker loading... Notifications may be limited.
</p>
)}
{notificationPermission === "denied" && (
<p className="mt-2 text-sm text-red-400">
Notifications are blocked. Please enable them in your browser settings.
Notifications are blocked. Please enable them in your browser/device settings.
</p>
)}
{settings.reminderEnabled && notificationPermission === "default" && (
<p className="mt-2 text-sm text-amber-400">
Notification permission not yet granted. Toggle reminders off and on to request permission.
</p>
)}
</div>

View File

@@ -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<void>;
}
const ServiceWorkerContext = createContext<ServiceWorkerContextValue>({
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<ServiceWorkerRegistration | null>(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 (
<ServiceWorkerContext.Provider
value={{
isSupported,
isRegistered,
registration,
showNotification,
}}
>
{children}
</ServiceWorkerContext.Provider>
);
}