diff --git a/.dockerignore b/.dockerignore
index 932f6d7..4b720b6 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,3 +1,5 @@
node_modules
.next/
next-env.d.ts
+#env*
+push-subscriptions.json
diff --git a/.gitignore b/.gitignore
index 5ef6a52..021a1c7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
+
+# misc
+push-subscriptions.json
diff --git a/Dockerfile b/Dockerfile
index fd69d6f..563b5e9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -34,6 +34,7 @@ COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --chown=nextjs:nodejs schedule.json .
+RUN echo '[]' > push-subscriptions.json && chown nextjs:nodejs push-subscriptions.json
USER nextjs
diff --git a/app/api/events/actions.ts b/app/api/events/actions.ts
index cbf0d34..520172c 100644
--- a/app/api/events/actions.ts
+++ b/app/api/events/actions.ts
@@ -1,8 +1,63 @@
"use server";
+import webPush from "web-push";
import { Schedule } from "@/app/schedule/types";
import { readFile, writeFile } from "fs/promises";
import { broadcastUpdate } from "./streams";
+webPush.setVapidDetails(
+ "mailto:webmaster@hornwitser.no",
+ process.env.VAPID_PUBLIC_KEY!,
+ process.env.VAPID_PRIVATE_KEY!,
+)
+
+async function sendPush(title: string, body: string) {
+ const payload = JSON.stringify({ title, body });
+ let subscriptions: PushSubscriptionJSON[];
+ try {
+ subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
+ } catch (err: any) {
+ if (err.code !== "ENOENT") {
+ console.log(`Dropping "${payload}", no push subscribers`);
+ return;
+ }
+ subscriptions = [];
+ }
+ console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`);
+ const removeIndexes = [];
+ for (let index = 0; index < subscriptions.length; index += 1) {
+ const subscription = subscriptions[index];
+ try {
+ await webPush.sendNotification(
+ subscription as webPush.PushSubscription,
+ payload,
+ {
+ TTL: 3600,
+ urgency: "high",
+ }
+ )
+ } catch (err: any) {
+ console.error("Received error sending push notice:", err.message, err?.statusCode)
+ console.error(err);
+ if (err?.statusCode >= 400 && err?.statusCode < 500) {
+ removeIndexes.push(index)
+ }
+ }
+ }
+ if (removeIndexes.length) {
+ console.log(`Removing indexes ${removeIndexes} from subscriptions`)
+ removeIndexes.reverse();
+ for (const index of removeIndexes) {
+ subscriptions.splice(index, 1);
+ }
+ await writeFile(
+ "push-subscriptions.json",
+ JSON.stringify(subscriptions, undefined, "\t"),
+ "utf-8"
+ );
+ }
+ console.log("Push notices sent");
+}
+
export async function createEvent(formData: FormData) {
const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8"));
const id = formData.get("id") as string;
@@ -26,6 +81,7 @@ export async function createEvent(formData: FormData) {
});
broadcastUpdate(schedule);
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
+ await sendPush("New event", `${name} will start at ${start}`);
}
export async function modifyEvent(formData: FormData) {
@@ -40,6 +96,7 @@ export async function modifyEvent(formData: FormData) {
if (index === -1) {
throw Error("No such event");
}
+ const timeChanged = schedule.events[index].slots[0].start !== start + "Z";
schedule.events[index] = {
name,
id,
@@ -55,6 +112,8 @@ export async function modifyEvent(formData: FormData) {
};
broadcastUpdate(schedule);
await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8");
+ if (timeChanged)
+ await sendPush(`New time for ${name}`, `${name} will now start at ${start}`);
}
export async function deleteEvent(formData: FormData) {
diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts
new file mode 100644
index 0000000..fde326f
--- /dev/null
+++ b/app/api/subscribe/route.ts
@@ -0,0 +1,29 @@
+import { readFile, writeFile } from "fs/promises";
+
+export async function POST(request: Request) {
+ const body: { subscription: PushSubscriptionJSON } = await request.json();
+ let subscriptions: PushSubscriptionJSON[];
+ try {
+ subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
+ } catch (err: any) {
+ if (err.code !== "ENOENT") {
+ throw err;
+ }
+ subscriptions = [];
+ }
+ const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint);
+ if (existingIndex !== -1) {
+ subscriptions[existingIndex] = body.subscription;
+ } else {
+ subscriptions.push(body.subscription);
+ }
+ await writeFile(
+ "push-subscriptions.json",
+ JSON.stringify(subscriptions, undefined, "\t"),
+ "utf-8"
+ );
+ if (existingIndex !== -1) {
+ return new Response(JSON.stringify({ message: "Existing subscription refreshed."}));
+ }
+ return new Response(JSON.stringify({ message: "New subscription registered."}));
+}
diff --git a/app/api/unsubscribe/route.ts b/app/api/unsubscribe/route.ts
new file mode 100644
index 0000000..830781e
--- /dev/null
+++ b/app/api/unsubscribe/route.ts
@@ -0,0 +1,26 @@
+import { readFile, writeFile } from "fs/promises";
+
+export async function POST(request: Request) {
+ const body: { subscription: PushSubscriptionJSON } = await request.json();
+ let subscriptions: PushSubscriptionJSON[];
+ try {
+ subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8"));
+ } catch (err: any) {
+ if (err.code !== "ENOENT") {
+ throw err;
+ }
+ subscriptions = [];
+ }
+ const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint);
+ if (existingIndex !== -1) {
+ subscriptions.splice(existingIndex, 1);
+ } else {
+ return new Response(JSON.stringify({ message: "No subscription registered."}));
+ }
+ await writeFile(
+ "push-subscriptions.json",
+ JSON.stringify(subscriptions, undefined, "\t"),
+ "utf-8"
+ );
+ return new Response(JSON.stringify({ message: "Existing subscription removed."}));
+}
diff --git a/app/globals.css b/app/globals.css
index b6c5b22..b392990 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -61,6 +61,10 @@ label>* {
margin-inline-start: 0.5rem;
}
+p + p {
+ margin-block-start: 0.5rem;
+}
+
label + label {
margin-block-start: 0.5rem;
}
diff --git a/app/schedule/page.tsx b/app/schedule/page.tsx
index e0f7770..c3eab5a 100644
--- a/app/schedule/page.tsx
+++ b/app/schedule/page.tsx
@@ -5,6 +5,7 @@ import { ScheduleProvider } from "./context"
import { Events } from "@/ui/events";
import { Locations } from "@/ui/locations";
import { EventsEdit } from "@/ui/events-edit";
+import { PushNotification } from "@/ui/push-notification";
export const dynamic = "force-dynamic";
@@ -17,6 +18,10 @@ export default async function page() {
Study carefully, we only hold these events once a year.
+
+ Get notified about updates
+
+
Schedule
diff --git a/generate-vapid-keys.mjs b/generate-vapid-keys.mjs
new file mode 100644
index 0000000..2fd9b9f
--- /dev/null
+++ b/generate-vapid-keys.mjs
@@ -0,0 +1,10 @@
+import webPush from "web-push";
+import fs from "node:fs/promises";
+
+const vapidKeys = webPush.generateVAPIDKeys();
+
+const envData = `\
+VAPID_PUBLIC_KEY=${vapidKeys.publicKey}
+VAPID_PRIVATE_KEY=${vapidKeys.privateKey}
+`;
+await fs.writeFile(".env", envData, "utf-8");
diff --git a/package.json b/package.json
index 0926378..bd0ccc2 100644
--- a/package.json
+++ b/package.json
@@ -11,12 +11,14 @@
"dependencies": {
"next": "15.1.7",
"react": "^19.0.0",
- "react-dom": "^19.0.0"
+ "react-dom": "^19.0.0",
+ "web-push": "^3.6.7"
},
"devDependencies": {
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/web-push": "^3.6.4",
"csstype": "^3.1.3",
"typescript": "^5"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a32729a..28caba7 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -14,6 +14,9 @@ dependencies:
react-dom:
specifier: ^19.0.0
version: 19.0.0(react@19.0.0)
+ web-push:
+ specifier: ^3.6.7
+ version: 3.6.7
devDependencies:
'@types/node':
@@ -25,6 +28,9 @@ devDependencies:
'@types/react-dom':
specifier: ^19
version: 19.0.0
+ '@types/web-push':
+ specifier: ^3.6.4
+ version: 3.6.4
csstype:
specifier: ^3.1.3
version: 3.1.3
@@ -324,6 +330,34 @@ packages:
csstype: 3.1.3
dev: true
+ /@types/web-push@3.6.4:
+ resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
+ dependencies:
+ '@types/node': 20.0.0
+ dev: true
+
+ /agent-base@7.1.3:
+ resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
+ engines: {node: '>= 14'}
+ dev: false
+
+ /asn1.js@5.4.1:
+ resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
+ dependencies:
+ bn.js: 4.12.1
+ inherits: 2.0.4
+ minimalistic-assert: 1.0.1
+ safer-buffer: 2.1.2
+ dev: false
+
+ /bn.js@4.12.1:
+ resolution: {integrity: sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==}
+ dev: false
+
+ /buffer-equal-constant-time@1.0.1:
+ resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
+ dev: false
+
/busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
@@ -377,6 +411,18 @@ packages:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dev: true
+ /debug@4.4.0:
+ resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+ dependencies:
+ ms: 2.1.3
+ dev: false
+
/detect-libc@2.0.3:
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
engines: {node: '>=8'}
@@ -384,12 +430,64 @@ packages:
dev: false
optional: true
+ /ecdsa-sig-formatter@1.0.11:
+ resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
+ dependencies:
+ safe-buffer: 5.2.1
+ dev: false
+
+ /http_ece@1.2.0:
+ resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
+ engines: {node: '>=16'}
+ dev: false
+
+ /https-proxy-agent@7.0.6:
+ resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
+ engines: {node: '>= 14'}
+ dependencies:
+ agent-base: 7.1.3
+ debug: 4.4.0
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
+
+ /inherits@2.0.4:
+ resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+ dev: false
+
/is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
requiresBuild: true
dev: false
optional: true
+ /jwa@2.0.0:
+ resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
+ dependencies:
+ buffer-equal-constant-time: 1.0.1
+ ecdsa-sig-formatter: 1.0.11
+ safe-buffer: 5.2.1
+ dev: false
+
+ /jws@4.0.0:
+ resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
+ dependencies:
+ jwa: 2.0.0
+ safe-buffer: 5.2.1
+ dev: false
+
+ /minimalistic-assert@1.0.1:
+ resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
+ dev: false
+
+ /minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+ dev: false
+
+ /ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ dev: false
+
/nanoid@3.3.8:
resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -468,6 +566,14 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
+ /safe-buffer@5.2.1:
+ resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+ dev: false
+
+ /safer-buffer@2.1.2:
+ resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ dev: false
+
/scheduler@0.25.0:
resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==}
dev: false
@@ -555,3 +661,17 @@ packages:
engines: {node: '>=12.20'}
hasBin: true
dev: true
+
+ /web-push@3.6.7:
+ resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
+ engines: {node: '>= 16'}
+ hasBin: true
+ dependencies:
+ asn1.js: 5.4.1
+ http_ece: 1.2.0
+ https-proxy-agent: 7.0.6
+ jws: 4.0.0
+ minimist: 1.2.8
+ transitivePeerDependencies:
+ - supports-color
+ dev: false
diff --git a/public/sw.js b/public/sw.js
new file mode 100644
index 0000000..205c28f
--- /dev/null
+++ b/public/sw.js
@@ -0,0 +1,25 @@
+self.addEventListener("push", function (event) {
+ console.log(event);
+ if (!event.data)
+ return;
+
+ const payload = event.data.json();
+ const { body, icon, image, badge, url, title } = payload;
+ const notificationTitle = title ?? "No title";
+ const notificationOptions = {
+ body,
+ icon,
+ image,
+ data: {
+ url,
+ },
+ badge,
+ };
+
+ event.waitUntil(
+ self.registration.showNotification(notificationTitle, notificationOptions)
+ .then(() => {
+ console.log("Web push delivered");
+ })
+ );
+});
diff --git a/ui/push-notification.tsx b/ui/push-notification.tsx
new file mode 100644
index 0000000..cfbe648
--- /dev/null
+++ b/ui/push-notification.tsx
@@ -0,0 +1,120 @@
+"use client";
+import { useEffect, useState } from "react";
+
+function notificationUnsupported() {
+ return (
+ !("serviceWorker" in navigator)
+ || !("PushManager" in window)
+ || !("showNotification" in ServiceWorkerRegistration.prototype)
+ )
+}
+
+async function registerAndSubscribe(
+ vapidPublicKey: string,
+ onSubscribe: (subs: PushSubscription | null ) => void,
+) {
+ try {
+ await navigator.serviceWorker.register("/sw.js");
+ await subscribe(vapidPublicKey, onSubscribe);
+ } catch (err) {
+ console.error("Failed to register service worker:", err);
+ }
+}
+
+async function subscribe(
+ vapidPublicKey: string,
+ onSubscribe: (subs: PushSubscription | null) => void
+) {
+ try {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: vapidPublicKey,
+ });
+ console.log("Got subscription object", subscription.toJSON());
+ await submitSubscription(subscription);
+ onSubscribe(subscription);
+ } catch (err) {
+ console.error("Failed to subscribe:" , err);
+ }
+}
+
+async function unsubscribe(
+ subscription: PushSubscription,
+ onUnsubscribed: () => void,
+) {
+ const body = JSON.stringify({ subscription });
+ await subscription.unsubscribe();
+ const res = await fetch("/api/unsubscribe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body,
+ });
+ const result = await res.json();
+ console.log("/api/unsubscribe returned", result);
+ onUnsubscribed();
+}
+
+async function submitSubscription(subscription: PushSubscription) {
+ const res = await fetch("/api/subscribe", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ subscription }),
+ });
+ const result = await res.json();
+ console.log("/api/subscribe returned", result);
+}
+
+async function getSubscription(
+ onSubscribe: (subs: PushSubscription | null) => void,
+) {
+ const registration = await navigator.serviceWorker.ready;
+ const subscription = await registration.pushManager.getSubscription();
+ if (subscription) {
+ onSubscribe(subscription);
+ }
+}
+
+export function PushNotification(props: { vapidPublicKey: string }) {
+ const [unsupported, setUnsupported] = useState(undefined);
+ const [subscription, setSubscription] = useState(null);
+ useEffect(() => {
+ const isUnsupported = notificationUnsupported();
+ setUnsupported(isUnsupported);
+ if (isUnsupported) {
+ return;
+ }
+
+ getSubscription(setSubscription);
+ }, [props.vapidPublicKey])
+
+ return
+ Notifications are: {subscription ? "Enabled" : "Disabled"}
+
+
+
+ Debug
+
+
+ {JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set"}
+
+
+
+ ;
+}