diff --git a/package-lock.json b/package-lock.json
index e823d92..4e72f04 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "my-weekly-todo-list",
- "version": "1.15.8",
+ "version": "1.36.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "my-weekly-todo-list",
- "version": "1.15.8",
+ "version": "1.36.1",
"license": "MIT",
"dependencies": {
"@auth/prisma-adapter": "^2.11.1",
@@ -15,7 +15,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.2.0",
"@next-auth/prisma-adapter": "^1.0.7",
- "@prisma/client": "^5.22.0",
+ "@prisma/client": "5.22.0",
"@tiptap/extension-link": "^3.20.0",
"@tiptap/extension-placeholder": "^3.20.0",
"@tiptap/extension-underline": "^3.20.0",
@@ -28,17 +28,17 @@
"googleapis": "^170.1.0",
"ical.js": "^2.2.1",
"lucide-react": "^0.563.0",
- "next": "^14.0.0",
+ "next": "14.2.35",
"next-auth": "^4.24.13",
"nodemailer": "^7.0.13",
"postcss-cli": "^11.0.1",
- "prisma": "^5.0.0",
+ "prisma": "5.22.0",
"react": "^18.2.0",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"tsdav": "^2.1.8",
- "undici": "^7.19.1",
+ "web-push": "^3.6.7",
"zustand": "^4.4.0"
},
"devDependencies": {
@@ -3978,6 +3978,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/asn1.js": {
+ "version": "5.4.1",
+ "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
+ "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^4.0.0",
+ "inherits": "^2.0.1",
+ "minimalistic-assert": "^1.0.0",
+ "safer-buffer": "^2.1.0"
+ }
+ },
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4270,6 +4282,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/bn.js": {
+ "version": "4.12.3",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz",
+ "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
@@ -6618,6 +6636,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/http_ece": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
+ "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
@@ -6720,7 +6747,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/internal-slot": {
@@ -9172,6 +9198,12 @@
"node": ">=6"
}
},
+ "node_modules/minimalistic-assert": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
+ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
+ "license": "ISC"
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -9189,7 +9221,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
- "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -11066,6 +11097,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/sax": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
@@ -12180,15 +12217,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/undici": {
- "version": "7.22.0",
- "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
- "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
- "license": "MIT",
- "engines": {
- "node": ">=20.18.1"
- }
- },
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
@@ -12342,6 +12370,25 @@
"makeerror": "1.0.12"
}
},
+ "node_modules/web-push": {
+ "version": "3.6.7",
+ "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
+ "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
+ "license": "MPL-2.0",
+ "dependencies": {
+ "asn1.js": "^5.3.0",
+ "http_ece": "1.2.0",
+ "https-proxy-agent": "^7.0.0",
+ "jws": "^4.0.0",
+ "minimist": "^1.2.5"
+ },
+ "bin": {
+ "web-push": "src/cli.js"
+ },
+ "engines": {
+ "node": ">= 16"
+ }
+ },
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
diff --git a/package.json b/package.json
index 2b7235d..a377237 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "my-weekly-todo-list",
- "version": "1.36.1",
+ "version": "1.37.0",
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
"main": "index.js",
"scripts": {
@@ -50,6 +50,7 @@
"react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
"tsdav": "^2.1.8",
+ "web-push": "^3.6.7",
"zustand": "^4.4.0"
},
"devDependencies": {
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 479e9f4..c55ef5b 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -109,9 +109,12 @@ model User {
calendarConnections CalendarConnection[]
projects Project[]
sessions Session[]
+ notificationsEnabled Boolean @default(false)
somedayLists SomedayList[]
tasks Task[]
weeklyGoals WeeklyGoal[]
+ pushSubscriptions PushSubscription[]
+ sentNotifications SentNotification[]
}
model Account {
@@ -253,6 +256,8 @@ model CachedCalendarEvent {
connection CalendarConnection @relation(fields: [connectionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ reminders Json?
+
@@unique([userId, externalId, provider])
@@index([userId, startDateTime])
@@index([userId, startDate])
@@ -287,3 +292,29 @@ model Project {
@@index([userId])
}
+
+model PushSubscription {
+ id String @id @default(cuid())
+ userId String
+ endpoint String @unique
+ p256dh String
+ auth String
+ userAgent String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId])
+}
+
+model SentNotification {
+ id String @id @default(cuid())
+ userId String
+ eventId String
+ minutes Int
+ sentAt DateTime @default(now())
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@unique([userId, eventId, minutes])
+ @@index([sentAt])
+}
diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png
new file mode 100644
index 0000000..4694d29
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..42454fe
Binary files /dev/null and b/public/icons/icon-512.png differ
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..7fbe489
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,51 @@
+// Push notification service worker
+self.addEventListener('push', function(event) {
+ if (!event.data) return;
+
+ let data;
+ try {
+ data = event.data.json();
+ } catch (e) {
+ data = { title: 'Calendar Reminder', body: event.data.text() };
+ }
+
+ const options = {
+ body: data.body || '',
+ icon: data.icon || '/icons/icon-192.png',
+ badge: '/icons/icon-192.png',
+ data: { url: data.url || '/tasks' },
+ vibrate: [200, 100, 200],
+ tag: data.eventId || 'calendar-reminder',
+ renotify: true,
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(data.title || 'Calendar Reminder', options)
+ );
+});
+
+self.addEventListener('notificationclick', function(event) {
+ event.notification.close();
+
+ const url = event.notification.data?.url || '/tasks';
+
+ event.waitUntil(
+ clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
+ // Focus existing window if available
+ for (var i = 0; i < clientList.length; i++) {
+ var client = clientList[i];
+ if (client.url.includes('/tasks') && 'focus' in client) {
+ return client.focus();
+ }
+ }
+ // Open new window
+ if (clients.openWindow) {
+ return clients.openWindow(url);
+ }
+ })
+ );
+});
+
+self.addEventListener('activate', function(event) {
+ event.waitUntil(clients.claim());
+});
diff --git a/scripts/generate-vapid-keys.ts b/scripts/generate-vapid-keys.ts
new file mode 100644
index 0000000..0b86e8e
--- /dev/null
+++ b/scripts/generate-vapid-keys.ts
@@ -0,0 +1,9 @@
+#!/usr/bin/env npx tsx
+import webpush from 'web-push';
+
+const vapidKeys = webpush.generateVAPIDKeys();
+
+console.log('Add these to your .env file:\n');
+console.log(`NEXT_PUBLIC_VAPID_PUBLIC_KEY=${vapidKeys.publicKey}`);
+console.log(`VAPID_PRIVATE_KEY=${vapidKeys.privateKey}`);
+console.log(`VAPID_SUBJECT=mailto:admin@todo.martin-bierschenk.de`);
diff --git a/scripts/push-scheduler.ts b/scripts/push-scheduler.ts
new file mode 100644
index 0000000..1c2acf8
--- /dev/null
+++ b/scripts/push-scheduler.ts
@@ -0,0 +1,196 @@
+#!/usr/bin/env npx tsx
+/**
+ * Push Notification Scheduler
+ *
+ * Run this as a cron job every minute:
+ * * * * * * cd /path/to/app && npx tsx scripts/push-scheduler.ts
+ *
+ * Or via PM2:
+ * pm2 start scripts/push-scheduler.ts --interpreter="npx" --interpreter-args="tsx" --cron-restart="* * * * *" --no-autorestart
+ *
+ * What it does:
+ * 1. Finds all users with notificationsEnabled=true
+ * 2. For each user, checks cached calendar events with reminders
+ * 3. If an event's reminder time matches "now" (within 1-minute window), sends a push notification
+ * 4. Tracks sent notifications to avoid duplicates
+ */
+
+import { PrismaClient } from '@prisma/client';
+import webpush from 'web-push';
+
+const prisma = new PrismaClient();
+
+const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
+const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
+const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@todo.martin-bierschenk.de';
+
+if (!vapidPublicKey || !vapidPrivateKey) {
+ console.error('[Push Scheduler] VAPID keys not configured. Set NEXT_PUBLIC_VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY in .env');
+ process.exit(1);
+}
+
+webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
+
+interface Reminder {
+ method: string;
+ minutes: number;
+}
+
+async function run() {
+ const now = new Date();
+ console.log(`[Push Scheduler] Running at ${now.toISOString()}`);
+
+ // Find users with notifications enabled
+ const users = await prisma.user.findMany({
+ where: { notificationsEnabled: true },
+ select: {
+ id: true,
+ timezone: true,
+ pushSubscriptions: true,
+ },
+ });
+
+ if (users.length === 0) {
+ console.log('[Push Scheduler] No users with notifications enabled');
+ await prisma.$disconnect();
+ return;
+ }
+
+ console.log(`[Push Scheduler] Checking ${users.length} users`);
+
+ for (const user of users) {
+ if (user.pushSubscriptions.length === 0) continue;
+
+ // Get upcoming events with reminders (next 7 days)
+ const windowStart = new Date(now);
+ const windowEnd = new Date(now);
+ windowEnd.setDate(windowEnd.getDate() + 7);
+
+ const events = await prisma.cachedCalendarEvent.findMany({
+ where: {
+ userId: user.id,
+ startDateTime: { gte: now, lte: windowEnd },
+ reminders: { not: null },
+ },
+ select: {
+ externalId: true,
+ title: true,
+ startDateTime: true,
+ reminders: true,
+ calendarTitle: true,
+ },
+ });
+
+ for (const event of events) {
+ if (!event.startDateTime || !event.reminders) continue;
+
+ let reminders: Reminder[] = [];
+ try {
+ reminders = event.reminders as unknown as Reminder[];
+ if (!Array.isArray(reminders)) continue;
+ } catch {
+ continue;
+ }
+
+ for (const reminder of reminders) {
+ if (reminder.minutes < 0) continue;
+
+ // Calculate when notification should fire
+ const notifyAt = new Date(event.startDateTime.getTime() - reminder.minutes * 60 * 1000);
+
+ // Check if we're within the 1-minute window
+ const diffMs = Math.abs(now.getTime() - notifyAt.getTime());
+ if (diffMs > 60 * 1000) continue; // Outside window
+
+ // Check if already sent
+ const alreadySent = await prisma.sentNotification.findUnique({
+ where: {
+ userId_eventId_minutes: {
+ userId: user.id,
+ eventId: event.externalId,
+ minutes: reminder.minutes,
+ },
+ },
+ });
+
+ if (alreadySent) continue;
+
+ // Build notification payload
+ const timeStr = event.startDateTime.toLocaleTimeString('en-US', {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ });
+ const body = reminder.minutes === 0
+ ? `Starting now at ${timeStr}`
+ : reminder.minutes < 60
+ ? `In ${reminder.minutes} minutes (${timeStr})`
+ : reminder.minutes < 1440
+ ? `In ${Math.round(reminder.minutes / 60)} hour(s) (${timeStr})`
+ : `In ${Math.round(reminder.minutes / 1440)} day(s) (${timeStr})`;
+
+ const payload = JSON.stringify({
+ title: event.title || 'Calendar Event',
+ body,
+ icon: '/icons/icon-192.png',
+ url: '/tasks',
+ eventId: event.externalId,
+ });
+
+ // Send to all user's subscriptions
+ let sentOk = false;
+ for (const sub of user.pushSubscriptions) {
+ try {
+ await webpush.sendNotification(
+ {
+ endpoint: sub.endpoint,
+ keys: { p256dh: sub.p256dh, auth: sub.auth },
+ },
+ payload
+ );
+ sentOk = true;
+ console.log(`[Push Scheduler] Sent: "${event.title}" to user ${user.id}`);
+ } catch (err: any) {
+ if (err.statusCode === 410 || err.statusCode === 404) {
+ // Subscription expired, remove it
+ await prisma.pushSubscription.delete({ where: { id: sub.id } }).catch(() => { });
+ console.log(`[Push Scheduler] Removed stale subscription ${sub.id}`);
+ } else {
+ console.error(`[Push Scheduler] Send failed:`, err.message);
+ }
+ }
+ }
+
+ // Record as sent
+ if (sentOk) {
+ await prisma.sentNotification.create({
+ data: {
+ userId: user.id,
+ eventId: event.externalId,
+ minutes: reminder.minutes,
+ },
+ });
+ }
+ }
+ }
+ }
+
+ // Cleanup old sent notifications (older than 7 days)
+ const weekAgo = new Date(now);
+ weekAgo.setDate(weekAgo.getDate() - 7);
+ const deleted = await prisma.sentNotification.deleteMany({
+ where: { sentAt: { lt: weekAgo } },
+ });
+ if (deleted.count > 0) {
+ console.log(`[Push Scheduler] Cleaned up ${deleted.count} old sent notifications`);
+ }
+
+ await prisma.$disconnect();
+ console.log('[Push Scheduler] Done');
+}
+
+run().catch((err) => {
+ console.error('[Push Scheduler] Fatal error:', err);
+ prisma.$disconnect();
+ process.exit(1);
+});
diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts
new file mode 100644
index 0000000..1860120
--- /dev/null
+++ b/src/app/api/push/subscribe/route.ts
@@ -0,0 +1,78 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+
+// Subscribe to push notifications
+export async function POST(request: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const { endpoint, keys } = await request.json();
+ if (!endpoint || !keys?.p256dh || !keys?.auth) {
+ return NextResponse.json({ error: 'Invalid subscription data' }, { status: 400 });
+ }
+
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
+
+ // Upsert subscription (endpoint is unique)
+ await prisma.pushSubscription.upsert({
+ where: { endpoint },
+ create: {
+ userId: user.id,
+ endpoint,
+ p256dh: keys.p256dh,
+ auth: keys.auth,
+ userAgent: request.headers.get('user-agent') || undefined,
+ },
+ update: {
+ p256dh: keys.p256dh,
+ auth: keys.auth,
+ userAgent: request.headers.get('user-agent') || undefined,
+ },
+ });
+
+ // Enable notifications for user
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { notificationsEnabled: true },
+ });
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('[Push] Subscribe error:', error);
+ return NextResponse.json({ error: 'Failed to subscribe' }, { status: 500 });
+ }
+}
+
+// Unsubscribe from push notifications
+export async function DELETE(request: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ try {
+ const { endpoint } = await request.json();
+ if (!endpoint) return NextResponse.json({ error: 'Endpoint required' }, { status: 400 });
+
+ await prisma.pushSubscription.deleteMany({ where: { endpoint } });
+
+ // Check if user has any remaining subscriptions
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (user) {
+ const count = await prisma.pushSubscription.count({ where: { userId: user.id } });
+ if (count === 0) {
+ await prisma.user.update({
+ where: { id: user.id },
+ data: { notificationsEnabled: false },
+ });
+ }
+ }
+
+ return NextResponse.json({ success: true });
+ } catch (error) {
+ console.error('[Push] Unsubscribe error:', error);
+ return NextResponse.json({ error: 'Failed to unsubscribe' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/push/test/route.ts b/src/app/api/push/test/route.ts
new file mode 100644
index 0000000..630e36a
--- /dev/null
+++ b/src/app/api/push/test/route.ts
@@ -0,0 +1,66 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getServerSession } from 'next-auth';
+import { authOptions } from '@/lib/auth';
+import { prisma } from '@/lib/prisma';
+import webpush from 'web-push';
+
+// Send a test notification to the current user
+export async function POST(request: NextRequest) {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.email) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+
+ const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
+ const vapidPrivateKey = process.env.VAPID_PRIVATE_KEY;
+ const vapidSubject = process.env.VAPID_SUBJECT || 'mailto:admin@todo.martin-bierschenk.de';
+
+ if (!vapidPublicKey || !vapidPrivateKey) {
+ return NextResponse.json({ error: 'VAPID keys not configured' }, { status: 500 });
+ }
+
+ webpush.setVapidDetails(vapidSubject, vapidPublicKey, vapidPrivateKey);
+
+ try {
+ const user = await prisma.user.findUnique({ where: { email: session.user.email } });
+ if (!user) return NextResponse.json({ error: 'User not found' }, { status: 404 });
+
+ const subscriptions = await prisma.pushSubscription.findMany({
+ where: { userId: user.id },
+ });
+
+ if (subscriptions.length === 0) {
+ return NextResponse.json({ error: 'No push subscriptions found' }, { status: 404 });
+ }
+
+ const payload = JSON.stringify({
+ title: 'Test Notification',
+ body: 'Push notifications are working!',
+ icon: '/icons/icon-192.png',
+ url: '/tasks',
+ });
+
+ let sent = 0;
+ for (const sub of subscriptions) {
+ try {
+ await webpush.sendNotification(
+ {
+ endpoint: sub.endpoint,
+ keys: { p256dh: sub.p256dh, auth: sub.auth },
+ },
+ payload
+ );
+ sent++;
+ } catch (err: any) {
+ // Remove stale subscriptions (410 Gone or 404)
+ if (err.statusCode === 410 || err.statusCode === 404) {
+ await prisma.pushSubscription.delete({ where: { id: sub.id } });
+ }
+ console.error('[Push] Test send failed for subscription:', sub.id, err.message);
+ }
+ }
+
+ return NextResponse.json({ success: true, sent });
+ } catch (error) {
+ console.error('[Push] Test notification error:', error);
+ return NextResponse.json({ error: 'Failed to send test notification' }, { status: 500 });
+ }
+}
diff --git a/src/app/api/user/profile/route.ts b/src/app/api/user/profile/route.ts
index eda10f2..2c9b6dd 100644
--- a/src/app/api/user/profile/route.ts
+++ b/src/app/api/user/profile/route.ts
@@ -95,6 +95,7 @@ export async function GET(request: NextRequest) {
accountNumber: true,
lightTheme: true,
darkTheme: true,
+ notificationsEnabled: true,
createdAt: true
}
});
@@ -137,7 +138,7 @@ export async function PATCH(request: NextRequest) {
yearFontFamily, yearFontSize, yearFontWeight, yearColor,
showTaskCheckboxes, dayHeaderGap,
showCompletedTasks, showLines, startDayOffset, weekdayFormat, weekdayCase, customWeekdayNames, quoteSourceUrls, quoteLanguages,
- kanbanStages, lightTheme, darkTheme
+ kanbanStages, lightTheme, darkTheme, notificationsEnabled
} = body;
const updateData: any = {
@@ -221,6 +222,7 @@ export async function PATCH(request: NextRequest) {
...(kanbanStages !== undefined && { kanbanStages }),
...(lightTheme !== undefined && { lightTheme }),
...(darkTheme !== undefined && { darkTheme }),
+ ...(notificationsEnabled !== undefined && { notificationsEnabled }),
};
if (password && password.trim() !== "") {
updateData.passwordHash = await bcrypt.hash(password, 10);
@@ -313,6 +315,7 @@ export async function PATCH(request: NextRequest) {
accountNumber: true,
lightTheme: true,
darkTheme: true,
+ notificationsEnabled: true,
}
});
diff --git a/src/app/manifest.ts b/src/app/manifest.ts
new file mode 100644
index 0000000..4cc4bd7
--- /dev/null
+++ b/src/app/manifest.ts
@@ -0,0 +1,17 @@
+import type { MetadataRoute } from 'next';
+
+export default function manifest(): MetadataRoute.Manifest {
+ return {
+ name: 'My Weekly To Do List',
+ short_name: 'Weekly ToDo',
+ description: 'A weekly task management app with calendar integration',
+ start_url: '/tasks',
+ display: 'standalone',
+ background_color: '#ffffff',
+ theme_color: '#0ea5e9',
+ icons: [
+ { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' },
+ { src: '/icons/icon-512.png', sizes: '512x512', type: 'image/png' },
+ ],
+ };
+}
diff --git a/src/app/providers.tsx b/src/app/providers.tsx
index cc69565..1b709ce 100644
--- a/src/app/providers.tsx
+++ b/src/app/providers.tsx
@@ -1,7 +1,16 @@
'use client';
+import { useEffect } from 'react';
import { SessionProvider } from 'next-auth/react';
export function Providers({ children }: { children: React.ReactNode }) {
+ useEffect(() => {
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/sw.js').catch((err) => {
+ console.error('[SW] Registration failed:', err);
+ });
+ }
+ }, []);
+
return