feat: add push notifications for calendar event reminders

- Service worker + Web Push API for browser/mobile notifications
- PWA manifest with icons for home screen install (iOS 16.4+)
- Push subscription management (subscribe/unsubscribe API routes)
- Notification toggle in Settings > General
- Push scheduler script (cron) checks cached events and sends
  notifications at reminder times, with dedup tracking
- Test notification endpoint at /api/push/test
- Persists reminders in calendar event cache for scheduler access
- New DB models: PushSubscription, SentNotification
- New user field: notificationsEnabled

Setup: generate VAPID keys with `npx tsx scripts/generate-vapid-keys.ts`
and add to .env, then run scheduler via cron every minute.

v1.37.0
This commit is contained in:
mARTin 2026-03-16 21:38:15 +01:00
parent d991e7a65b
commit f9c7893341
17 changed files with 708 additions and 20 deletions

81
package-lock.json generated
View File

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

View File

@ -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": {

View File

@ -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])
}

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

51
public/sw.js Normal file
View File

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

View File

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

196
scripts/push-scheduler.ts Normal file
View File

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

View File

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

View File

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

View File

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

17
src/app/manifest.ts Normal file
View File

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

View File

@ -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 <SessionProvider refetchOnWindowFocus={false}>{children}</SessionProvider>;
}

View File

@ -10475,6 +10475,60 @@ function SettingsSidebar({
</label>
</div>
{/* Push Notifications */}
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>
<input
type="checkbox"
id="notificationsEnabled"
checked={profile.notificationsEnabled || false}
onChange={async (e) => {
const enabled = e.target.checked;
if (enabled) {
try {
const { isNotificationSupported, requestNotificationPermission, registerServiceWorker, subscribeToPush, sendSubscriptionToServer } = await import('@/lib/push-notifications');
if (!isNotificationSupported()) {
alert('Push notifications are not supported in this browser.');
return;
}
const permission = await requestNotificationPermission();
if (permission !== 'granted') {
alert('Notification permission was denied.');
return;
}
const registration = await registerServiceWorker();
if (!registration) { alert('Failed to register service worker.'); return; }
const subscription = await subscribeToPush(registration);
if (!subscription) { alert('Failed to subscribe to push notifications.'); return; }
const sent = await sendSubscriptionToServer(subscription);
if (!sent) { alert('Failed to save subscription.'); return; }
setProfile({ ...profile, notificationsEnabled: true });
} catch (err) {
console.error('Push notification setup failed:', err);
alert('Failed to enable notifications.');
}
} else {
try {
const { unsubscribeFromPush } = await import('@/lib/push-notifications');
const registration = await navigator.serviceWorker.ready;
await unsubscribeFromPush(registration);
} catch (err) {
console.error('Unsubscribe failed:', err);
}
setProfile({ ...profile, notificationsEnabled: false });
}
}}
style={{ width: "16px", height: "16px" }}
/>
<label
htmlFor="notificationsEnabled"
style={{ fontSize: "0.9rem", fontWeight: 600 }}
>
{(profile.language || "en") === "de" ? "Push-Benachrichtigungen" : "Push Notifications"}
</label>
</div>
<div
style={{ display: "flex", alignItems: "center", gap: "8px" }}
>

View File

@ -127,6 +127,7 @@ export async function refreshConnectionCache(
startDate: ev.start.date ?? null,
endDateTime: ev.end.dateTime ? new Date(ev.end.dateTime) : null,
endDate: ev.end.date ?? null,
reminders: ev.reminders ? JSON.parse(JSON.stringify(ev.reminders)) : null,
weekStart,
syncedAt: now,
}));
@ -181,6 +182,7 @@ export async function upsertCachedEvent(
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
weekStart,
syncedAt: new Date(),
},
@ -197,6 +199,7 @@ export async function upsertCachedEvent(
startDate: event.start.date ?? null,
endDateTime: event.end.dateTime ? new Date(event.end.dateTime) : null,
endDate: event.end.date ?? null,
reminders: event.reminders ? JSON.parse(JSON.stringify(event.reminders)) : null,
weekStart,
syncedAt: new Date(),
},

View File

@ -0,0 +1,121 @@
/**
* Client-side push notification helpers.
* All functions are safe to call in the browser only.
*/
export function isNotificationSupported(): boolean {
return (
typeof window !== 'undefined' &&
'serviceWorker' in navigator &&
'PushManager' in window &&
'Notification' in window
);
}
export function getNotificationPermission(): NotificationPermission | 'unsupported' {
if (!isNotificationSupported()) return 'unsupported';
return Notification.permission;
}
export async function requestNotificationPermission(): Promise<NotificationPermission> {
return Notification.requestPermission();
}
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) return null;
try {
const registration = await navigator.serviceWorker.register('/sw.js');
return registration;
} catch (err) {
console.error('[Push] Service worker registration failed:', err);
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
export async function subscribeToPush(
registration: ServiceWorkerRegistration
): Promise<PushSubscription | null> {
const vapidPublicKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
if (!vapidPublicKey) {
console.error('[Push] VAPID public key not configured');
return null;
}
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
return subscription;
} catch (err) {
console.error('[Push] Subscribe failed:', err);
return null;
}
}
export async function sendSubscriptionToServer(
subscription: PushSubscription
): Promise<boolean> {
try {
const res = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
keys: {
p256dh: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('p256dh')!))),
auth: btoa(String.fromCharCode(...new Uint8Array(subscription.getKey('auth')!))),
},
}),
});
return res.ok;
} catch (err) {
console.error('[Push] Failed to send subscription to server:', err);
return false;
}
}
export async function unsubscribeFromPush(
registration: ServiceWorkerRegistration
): Promise<boolean> {
try {
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return true;
// Delete from server
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
// Unsubscribe locally
await subscription.unsubscribe();
return true;
} catch (err) {
console.error('[Push] Unsubscribe failed:', err);
return false;
}
}
export async function isCurrentlySubscribed(): Promise<boolean> {
if (!isNotificationSupported()) return false;
try {
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
return !!subscription;
} catch {
return false;
}
}

View File

@ -7,7 +7,9 @@ export async function middleware(request: NextRequest) {
request.nextUrl.pathname.startsWith('/auth') ||
request.nextUrl.pathname.startsWith('/api/auth') || // CRITICAL: Allow NextAuth API routes
request.nextUrl.pathname.startsWith('/_next') ||
request.nextUrl.pathname === '/favicon.ico'
request.nextUrl.pathname === '/favicon.ico' ||
request.nextUrl.pathname === '/sw.js' ||
request.nextUrl.pathname === '/manifest.webmanifest'
) {
return NextResponse.next();
}