diff --git a/.env.example b/.env.example
index 617f951a..bb08a5be 100644
--- a/.env.example
+++ b/.env.example
@@ -29,4 +29,9 @@ SRH_CONNECTION_STRING="redis://redis:6379"
# Marble Blog (optional)
MARBLE_WORKSPACE_KEY=
-MARBLE_API_URL=https://api.marblecms.com
\ No newline at end of file
+MARBLE_API_URL=https://api.marblecms.com
+
+# Web Push Notifications (PWA)
+# Generate VAPID keys using: npx web-push generate-vapid-keys
+NEXT_PUBLIC_VAPID_PUBLIC_KEY=
+VAPID_PRIVATE_KEY=
\ No newline at end of file
diff --git a/PWA_IMPLEMENTATION.md b/PWA_IMPLEMENTATION.md
new file mode 100644
index 00000000..93dde369
--- /dev/null
+++ b/PWA_IMPLEMENTATION.md
@@ -0,0 +1,347 @@
+# PWA Implementation Guide
+
+This document describes the Progressive Web Application (PWA) features that have been implemented in Analog Calendar.
+
+## Overview
+
+The PWA implementation includes:
+
+- **Web App Manifest**: Allows users to install the app on their home screen
+- **Service Worker**: Enables push notifications
+- **Push Notifications**: Server-side push notification support with tRPC
+- **Database Persistence**: Push subscriptions are stored in the database
+- **Security Headers**: Enhanced security for PWA features
+
+## Setup Instructions
+
+### 1. Generate VAPID Keys
+
+VAPID (Voluntary Application Server Identification) keys are required for web push notifications.
+
+```bash
+# Install web-push globally (if not already installed)
+npm install -g web-push
+
+# Generate VAPID keys
+npx web-push generate-vapid-keys
+```
+
+This will output something like:
+
+```
+=======================================
+Public Key:
+BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U
+
+Private Key:
+UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls
+=======================================
+```
+
+### 2. Configure Environment Variables
+
+Add the generated VAPID keys to your `.env` file:
+
+```bash
+NEXT_PUBLIC_VAPID_PUBLIC_KEY=BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U
+VAPID_PRIVATE_KEY=UUxI4O8-FbRouAevSmBQ6o18hgE4nSG3qwvJTfKc-ls
+```
+
+**Important**: Keep the private key secret! Never commit it to version control.
+
+### 3. Run Database Migration
+
+The PWA implementation adds a new `push_subscription` table to store user push notification subscriptions.
+
+```bash
+# Generate the migration
+bun run db:generate
+
+# Apply the migration
+bun run db:migrate
+```
+
+### 4. Create PWA Icons
+
+You need to create the following icon files and place them in the `apps/web/public/` directory:
+
+- `icon-192x192.png` - 192x192px icon
+- `icon-512x512.png` - 512x512px icon
+- `icon-192x192-maskable.png` - 192x192px maskable icon
+- `icon-512x512-maskable.png` - 512x512px maskable icon
+
+See `apps/web/public/PWA_ICONS_README.md` for detailed instructions on creating these icons.
+
+**Tools for generating icons:**
+
+- [RealFaviconGenerator](https://realfavicongenerator.net/)
+- [PWA Asset Generator](https://github.com/elegantapp/pwa-asset-generator)
+- [Maskable App Tool](https://maskable.app/)
+
+### 5. Start the Development Server
+
+```bash
+# For local HTTPS testing (recommended for PWA features)
+bun run dev --experimental-https
+
+# Or regular HTTP
+bun run dev
+```
+
+## Features
+
+### 1. App Installation
+
+Users can install Analog Calendar as a Progressive Web App:
+
+- **Desktop (Chrome/Edge)**: Click the install button in the address bar
+- **Mobile (Android)**: Tap "Add to Home Screen" from the browser menu
+- **Mobile (iOS)**: Tap the share button and select "Add to Home Screen"
+
+The app includes an `InstallPrompt` component that guides users through the installation process.
+
+### 2. Push Notifications
+
+Users can subscribe to push notifications through the app:
+
+1. Navigate to the settings or notification management page
+2. Click "Subscribe" to enable push notifications
+3. Grant permission when prompted by the browser
+4. Send test notifications to verify functionality
+
+**Browser Support:**
+
+- Chrome/Edge (Desktop & Android): Full support
+- Safari (iOS 16.4+ & macOS 13+): Full support
+- Firefox (Desktop & Android): Full support
+
+### 3. Using the PWA Components
+
+Import and use the PWA components in your app:
+
+```tsx
+import { InstallPrompt, PushNotificationManager } from "@/components/pwa";
+
+export default function SettingsPage() {
+ return (
+
+
+
+
+ );
+}
+```
+
+## API Reference
+
+### tRPC Router: `pushNotifications`
+
+The push notifications functionality is exposed through the tRPC API:
+
+#### `pushNotifications.subscribe`
+
+Subscribe to push notifications.
+
+```typescript
+const subscription = await trpc.pushNotifications.subscribe.mutate({
+ endpoint: "https://...",
+ keys: {
+ p256dh: "...",
+ auth: "...",
+ },
+ expirationTime: null,
+});
+```
+
+#### `pushNotifications.unsubscribe`
+
+Unsubscribe from all push notifications for the current user.
+
+```typescript
+await trpc.pushNotifications.unsubscribe.mutate();
+```
+
+#### `pushNotifications.send`
+
+Send a push notification to the current user's subscribed devices.
+
+```typescript
+await trpc.pushNotifications.send.mutate({
+ title: "New Event",
+ body: "You have a meeting in 15 minutes",
+ icon: "/icon-192x192.png",
+});
+```
+
+#### `pushNotifications.list`
+
+List all push subscriptions for the current user.
+
+```typescript
+const subscriptions = await trpc.pushNotifications.list.query();
+```
+
+## Database Schema
+
+### `push_subscription` Table
+
+```typescript
+{
+ id: string (primary key)
+ userId: string (foreign key to user.id)
+ endpoint: string
+ keys: {
+ p256dh: string
+ auth: string
+ }
+ expirationTime: timestamp | null
+ createdAt: timestamp
+ updatedAt: timestamp
+}
+```
+
+Indexes:
+
+- `push_subscription_user_id_idx` on `userId`
+- `push_subscription_endpoint_idx` on `endpoint`
+
+## Security Considerations
+
+### 1. VAPID Keys
+
+- The public key is safe to expose in client-side code
+- The private key must be kept secret on the server
+- Never commit the private key to version control
+- Rotate keys periodically for security
+
+### 2. Security Headers
+
+The following security headers are automatically configured:
+
+**Global Headers:**
+
+- `X-Content-Type-Options: nosniff`
+- `X-Frame-Options: DENY`
+- `Referrer-Policy: strict-origin-when-cross-origin`
+
+**Service Worker Headers:**
+
+- `Content-Type: application/javascript; charset=utf-8`
+- `Cache-Control: no-cache, no-store, must-revalidate`
+- `Content-Security-Policy: default-src 'self'; script-src 'self'`
+
+### 3. HTTPS Requirement
+
+PWAs require HTTPS in production. For local development:
+
+```bash
+# Use Next.js experimental HTTPS
+bun run dev --experimental-https
+```
+
+### 4. User Permissions
+
+Push notifications require explicit user permission. The browser will prompt users to grant or deny permission when they attempt to subscribe.
+
+## Testing
+
+### Local Testing
+
+1. Start the dev server with HTTPS:
+
+ ```bash
+ bun run dev --experimental-https
+ ```
+
+2. Open the app in a supported browser (Chrome recommended)
+
+3. Navigate to the notifications settings page
+
+4. Subscribe to push notifications
+
+5. Send a test notification
+
+### Browser DevTools
+
+**Chrome DevTools:**
+
+1. Open DevTools (F12)
+2. Go to Application → Service Workers
+3. Verify the service worker is registered
+4. Go to Application → Manifest
+5. Verify the manifest is valid
+
+**Testing Push Notifications:**
+
+1. Open DevTools → Application → Service Workers
+2. Find your service worker
+3. Use "Push" button to simulate a push event
+4. Or use the test notification feature in the app
+
+## Troubleshooting
+
+### Service Worker Not Registering
+
+- Ensure you're serving over HTTPS (or localhost)
+- Check browser console for errors
+- Verify the service worker file is accessible at `/sw.js`
+
+### Push Notifications Not Working
+
+- Verify VAPID keys are correctly configured
+- Check that browser notifications are enabled
+- Ensure the user has granted notification permissions
+- Check browser console and server logs for errors
+
+### Icons Not Showing
+
+- Verify icon files exist in the `public/` directory
+- Check file names match those in `manifest.ts`
+- Clear browser cache and reload
+
+### Manifest Not Loading
+
+- Verify `manifest.ts` exports a valid manifest
+- Check browser console for manifest errors
+- Use Chrome DevTools → Application → Manifest to debug
+
+## Production Deployment
+
+### Checklist
+
+- [ ] Generate production VAPID keys
+- [ ] Add VAPID keys to production environment variables
+- [ ] Run database migrations in production
+- [ ] Create and upload PWA icons
+- [ ] Ensure production site is served over HTTPS
+- [ ] Test PWA installation on production
+- [ ] Test push notifications on production
+- [ ] Monitor service worker updates
+
+### Vercel Deployment
+
+If deploying to Vercel, the environment variables can be set in the Vercel dashboard:
+
+1. Go to Project Settings → Environment Variables
+2. Add `NEXT_PUBLIC_VAPID_PUBLIC_KEY`
+3. Add `VAPID_PRIVATE_KEY`
+4. Redeploy the application
+
+## Future Enhancements
+
+Potential improvements to consider:
+
+1. **Offline Support**: Add caching strategies for offline functionality
+2. **Background Sync**: Queue actions when offline and sync when online
+3. **Periodic Background Sync**: Update calendar data in the background
+4. **Web Share API**: Share events with other apps
+5. **Badging API**: Show unread notifications count on the app icon
+6. **Push Notification Actions**: Add buttons to notifications (e.g., "Snooze", "View")
+
+## Resources
+
+- [MDN: Progressive Web Apps](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps)
+- [web.dev: PWA](https://web.dev/progressive-web-apps/)
+- [Web Push Protocol](https://datatracker.ietf.org/doc/html/rfc8030)
+- [VAPID Specification](https://datatracker.ietf.org/doc/html/rfc8292)
+- [Next.js: Progressive Web Apps](https://nextjs.org/docs/app/building-your-application/configuring/progressive-web-apps)
diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts
index e4c98330..85cb662c 100644
--- a/apps/web/next.config.ts
+++ b/apps/web/next.config.ts
@@ -21,7 +21,7 @@ const nextConfig: NextConfig = {
},
async headers() {
// For routes that are public
- const headers = [
+ const corsHeaders = [
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: "*" },
{
@@ -34,22 +34,59 @@ const nextConfig: NextConfig = {
},
];
+ // Global security headers
+ const securityHeaders = [
+ {
+ key: "X-Content-Type-Options",
+ value: "nosniff",
+ },
+ {
+ key: "X-Frame-Options",
+ value: "DENY",
+ },
+ {
+ key: "Referrer-Policy",
+ value: "strict-origin-when-cross-origin",
+ },
+ ];
+
return [
{
source: "/api/mcp/:path*",
- headers,
+ headers: corsHeaders,
},
{
source: "/api/auth/mcp/:path*",
- headers,
+ headers: corsHeaders,
},
{
source: "/api/v1/:path*",
- headers,
+ headers: corsHeaders,
},
{
source: "/api/openapi.json",
- headers,
+ headers: corsHeaders,
+ },
+ {
+ source: "/(.*)",
+ headers: securityHeaders,
+ },
+ {
+ source: "/sw.js",
+ headers: [
+ {
+ key: "Content-Type",
+ value: "application/javascript; charset=utf-8",
+ },
+ {
+ key: "Cache-Control",
+ value: "no-cache, no-store, must-revalidate",
+ },
+ {
+ key: "Content-Security-Policy",
+ value: "default-src 'self'; script-src 'self'",
+ },
+ ],
},
];
},
diff --git a/apps/web/public/PWA_ICONS_README.md b/apps/web/public/PWA_ICONS_README.md
new file mode 100644
index 00000000..d46e0e9e
--- /dev/null
+++ b/apps/web/public/PWA_ICONS_README.md
@@ -0,0 +1,40 @@
+# PWA Icons Required
+
+This PWA implementation requires the following icon files to be placed in the `public/` directory:
+
+## Required Icon Files
+
+- `icon-192x192.png` - 192x192px icon (any purpose)
+- `icon-512x512.png` - 512x512px icon (any purpose)
+- `icon-192x192-maskable.png` - 192x192px maskable icon
+- `icon-512x512-maskable.png` - 512x512px maskable icon
+
+## Generating Icons
+
+You can use the following tools to generate PWA icons:
+
+1. **RealFaviconGenerator**: https://realfavicongenerator.net/
+2. **PWA Asset Generator**: https://github.com/elegantapp/pwa-asset-generator
+
+## Icon Specifications
+
+### Standard Icons (any purpose)
+
+- Use your app logo with transparent or colored background
+- Should look good on any background color
+- Export as PNG with the specified dimensions
+
+### Maskable Icons
+
+- Must include a safe zone (inner 80% of the image)
+- Logo should be centered with padding
+- Background should be opaque (not transparent)
+- Use https://maskable.app/ to test your maskable icons
+
+## Temporary Placeholder
+
+Until proper icons are created, you can:
+
+1. Create simple placeholder icons using an image editor
+2. Or use the existing favicon if one exists
+3. Or use a solid color square with the app initial letter
diff --git a/apps/web/public/sw.js b/apps/web/public/sw.js
new file mode 100644
index 00000000..40c24c9b
--- /dev/null
+++ b/apps/web/public/sw.js
@@ -0,0 +1,67 @@
+/* eslint-disable no-undef */
+// Service Worker for Analog Calendar PWA
+
+self.addEventListener("push", function (event) {
+ if (event.data) {
+ const data = event.data.json();
+ const options = {
+ body: data.body,
+ icon: data.icon || "/icon.png",
+ badge: "/icon-192x192.png",
+ vibrate: [100, 50, 100],
+ data: {
+ dateOfArrival: Date.now(),
+ url: data.url || "/",
+ },
+ actions: data.actions || [],
+ requireInteraction: false,
+ tag: data.tag || "notification",
+ };
+
+ event.waitUntil(self.registration.showNotification(data.title, options));
+ }
+});
+
+self.addEventListener("notificationclick", function (event) {
+ console.log("Notification click received.");
+
+ event.notification.close();
+
+ // Open the app at the specified URL or default to home
+ const urlToOpen = new URL(event.notification.data?.url || "/", self.location.origin).href;
+
+ event.waitUntil(
+ clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
+ // Check if there's already a window/tab open with the target URL
+ for (let i = 0; i < clientList.length; i++) {
+ const client = clientList[i];
+ if (client.url === urlToOpen && "focus" in client) {
+ return client.focus();
+ }
+ }
+
+ // If no window/tab is open, open a new one
+ if (clients.openWindow) {
+ return clients.openWindow(urlToOpen);
+ }
+ })
+ );
+});
+
+self.addEventListener("notificationclose", function (event) {
+ console.log("Notification closed", event);
+});
+
+// Handle service worker installation
+self.addEventListener("install", function (event) {
+ console.log("Service Worker installing.");
+ // Force the waiting service worker to become the active service worker
+ self.skipWaiting();
+});
+
+// Handle service worker activation
+self.addEventListener("activate", function (event) {
+ console.log("Service Worker activating.");
+ // Claim all clients immediately
+ event.waitUntil(clients.claim());
+});
diff --git a/apps/web/src/app/manifest.ts b/apps/web/src/app/manifest.ts
new file mode 100644
index 00000000..f59e74e5
--- /dev/null
+++ b/apps/web/src/app/manifest.ts
@@ -0,0 +1,40 @@
+import type { MetadataRoute } from "next";
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: "Analog Calendar",
+ short_name: "Analog",
+ description:
+ "A beautiful, open-source calendar that syncs with Google Calendar and Microsoft 365",
+ start_url: "/",
+ display: "standalone",
+ background_color: "#ffffff",
+ theme_color: "#000000",
+ icons: [
+ {
+ src: "/icon-192x192.png",
+ sizes: "192x192",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: "/icon-512x512.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "any",
+ },
+ {
+ src: "/icon-192x192-maskable.png",
+ sizes: "192x192",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ {
+ src: "/icon-512x512-maskable.png",
+ sizes: "512x512",
+ type: "image/png",
+ purpose: "maskable",
+ },
+ ],
+ };
+}
diff --git a/apps/web/src/components/pwa/index.ts b/apps/web/src/components/pwa/index.ts
new file mode 100644
index 00000000..d5543794
--- /dev/null
+++ b/apps/web/src/components/pwa/index.ts
@@ -0,0 +1,2 @@
+export { PushNotificationManager } from "./push-notification-manager";
+export { InstallPrompt } from "./install-prompt";
diff --git a/apps/web/src/components/pwa/install-prompt.tsx b/apps/web/src/components/pwa/install-prompt.tsx
new file mode 100644
index 00000000..6958bc89
--- /dev/null
+++ b/apps/web/src/components/pwa/install-prompt.tsx
@@ -0,0 +1,133 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { Download, Smartphone } from "lucide-react";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card";
+
+interface BeforeInstallPromptEvent extends Event {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
+}
+
+export function InstallPrompt() {
+ const [isIOS, setIsIOS] = useState(false);
+ const [isStandalone, setIsStandalone] = useState(false);
+ const [deferredPrompt, setDeferredPrompt] =
+ useState(null);
+
+ useEffect(() => {
+ // Check if running on iOS
+ const userAgent = window.navigator.userAgent;
+ const isIOSDevice =
+ /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
+ setIsIOS(isIOSDevice);
+
+ // Check if already installed (running in standalone mode)
+ const isInStandaloneMode = window.matchMedia(
+ "(display-mode: standalone)",
+ ).matches;
+ setIsStandalone(isInStandaloneMode);
+
+ // Listen for the beforeinstallprompt event (Chrome, Edge, etc.)
+ const handleBeforeInstallPrompt = (e: Event) => {
+ e.preventDefault();
+ setDeferredPrompt(e as BeforeInstallPromptEvent);
+ };
+
+ window.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
+
+ return () => {
+ window.removeEventListener(
+ "beforeinstallprompt",
+ handleBeforeInstallPrompt,
+ );
+ };
+ }, []);
+
+ async function handleInstallClick() {
+ if (!deferredPrompt) {
+ return;
+ }
+
+ try {
+ await deferredPrompt.prompt();
+
+ const choiceResult = await deferredPrompt.userChoice;
+
+ if (choiceResult.outcome === "accepted") {
+ toast.success("App installed successfully");
+ }
+
+ setDeferredPrompt(null);
+ } catch (error) {
+ console.error("Error installing app:", error);
+ toast.error("Failed to install app");
+ }
+ }
+
+ // Don't show if already installed
+ if (isStandalone) {
+ return null;
+ }
+
+ return (
+
+
+ Install App
+
+ Install Analog Calendar on your device for a native app experience.
+
+
+
+ {isIOS ? (
+
+
+ To install this app on your iOS device:
+
+
+
+ Tap the share button{" "}
+
+ ⎋
+ {" "}
+ in your browser
+
+
+ Scroll down and tap "Add to Home Screen"{" "}
+
+ ➕
+
+
+
Confirm by tapping "Add"
+
+
+ ) : deferredPrompt ? (
+
+ ) : (
+
+
+ To install this app, open the browser menu and look for
+ "Install App" or "Add to Home Screen" option.
+
+
+
+ Available on Chrome, Edge, and other modern browsers
+