diff --git a/bun.lock b/bun.lock index 63fe440..4b49ab4 100644 --- a/bun.lock +++ b/bun.lock @@ -6,6 +6,7 @@ "name": "cup26", "dependencies": { "@tanstack/react-router": "^1.95.0", + "@types/web-push": "^3.6.4", "clsx": "^2.1.1", "d3-scale": "^4.0.2", "lucide-react": "^1.17.0", @@ -13,6 +14,7 @@ "react-dom": "^19.0.0", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", + "web-push": "^3.6.7", "zod": "^3.24.1", "zustand": "^5.0.2", }, @@ -522,6 +524,8 @@ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="], + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.61.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/type-utils": "8.61.0", "@typescript-eslint/utils": "8.61.0", "@typescript-eslint/visitor-keys": "8.61.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.61.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw=="], @@ -584,6 +588,8 @@ "arraybuffer.prototype.slice": ["arraybuffer.prototype.slice@1.0.4", "", { "dependencies": { "array-buffer-byte-length": "^1.0.1", "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.5", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "is-array-buffer": "^3.0.4" } }, "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ=="], + "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], @@ -610,10 +616,14 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.35", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg=="], + "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="], + "brace-expansion": ["brace-expansion@1.1.15", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], @@ -730,6 +740,8 @@ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], "electron-to-chromium": ["electron-to-chromium@1.5.371", "", {}, "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w=="], @@ -886,6 +898,8 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], @@ -1000,6 +1014,10 @@ "jsonpointer": ["jsonpointer@5.0.1", "", {}, "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ=="], + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "leven": ["leven@3.1.0", "", {}, "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A=="], @@ -1064,8 +1082,12 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="], + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -1394,6 +1416,8 @@ "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-push": ["web-push@3.6.7", "", { "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" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="], + "webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="], "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], diff --git a/deploy/cup26.compose.yml b/deploy/cup26.compose.yml index 9cf1581..1093a8b 100644 --- a/deploy/cup26.compose.yml +++ b/deploy/cup26.compose.yml @@ -21,6 +21,11 @@ services: - ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false} - ENABLE_FOTMOB=${ENABLE_FOTMOB:-false} - ARCHIVE_DIR=/data/archive + # Web Push (optional): goal/kickoff alerts. Generate once with + # `npx web-push generate-vapid-keys`; absent keys = feature hidden. + - VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY:-} + - VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY:-} + - VAPID_SUBJECT=${VAPID_SUBJECT:-} volumes: # The LIVE SQLite must stay on a real local filesystem (WAL + locking do # not survive network mounts). HOST_DATA_DIR can override if ever needed. diff --git a/package.json b/package.json index fa805c8..dfd7aca 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@tanstack/react-router": "^1.95.0", + "@types/web-push": "^3.6.4", "clsx": "^2.1.1", "d3-scale": "^4.0.2", "lucide-react": "^1.17.0", @@ -31,6 +32,7 @@ "react-dom": "^19.0.0", "recharts": "^2.15.0", "tailwind-merge": "^2.6.0", + "web-push": "^3.6.7", "zod": "^3.24.1", "zustand": "^5.0.2" }, diff --git a/public/push-sw.js b/public/push-sw.js new file mode 100644 index 0000000..c5fd180 --- /dev/null +++ b/public/push-sw.js @@ -0,0 +1,29 @@ +/* Imported into the generated service worker (workbox importScripts). + Handles Web Push for goal/kickoff alerts — additive only. */ +self.addEventListener('push', (event) => { + if (!event.data) return; + let data = {}; + try { data = event.data.json(); } catch { return; } + if (!data.title) return; + event.waitUntil( + self.registration.showNotification(data.title, { + body: data.body || '', + icon: '/pwa-192x192.png', + badge: '/pwa-192x192.png', + data: { url: data.url || '/' }, + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || '/'; + event.waitUntil( + clients.matchAll({ type: 'window', includeUncontrolled: true }).then((list) => { + for (const c of list) { + if ('focus' in c) { c.navigate(url); return c.focus(); } + } + return clients.openWindow(url); + }), + ); +}); diff --git a/server/package.json b/server/package.json index cb4793e..9c40955 100644 --- a/server/package.json +++ b/server/package.json @@ -10,6 +10,7 @@ "@fastify/static": "^9.1.3", "@fastify/websocket": "^11.2.0", "fastify": "^5.8.5", - "zod": "^3.25.76" + "zod": "^3.25.76", + "web-push": "^3.6.7" } -} +} \ No newline at end of file diff --git a/server/src/db/db.ts b/server/src/db/db.ts index d9f9549..274eb21 100644 --- a/server/src/db/db.ts +++ b/server/src/db/db.ts @@ -93,6 +93,13 @@ CREATE TABLE IF NOT EXISTS prediction_snapshots ( market_json TEXT ); +CREATE TABLE IF NOT EXISTS push_subs ( + endpoint TEXT PRIMARY KEY, + json TEXT NOT NULL, + team TEXT, + created_at INTEGER NOT NULL +); + CREATE TABLE IF NOT EXISTS inplay_history ( fixture_num INTEGER NOT NULL, minute INTEGER NOT NULL, @@ -147,6 +154,31 @@ export function cacheSet(key: string, value: string, ttl: number): void { .run(key, value, Date.now(), ttl); } +// ---- Web Push subscriptions (goal/kickoff notifications, opt-in per team) ---- +export interface PushSub { + endpoint: string; + json: string; + team: string | null; +} + +export function addPushSub(endpoint: string, json: string, team: string | null): void { + db() + .prepare('INSERT OR REPLACE INTO push_subs (endpoint, json, team, created_at) VALUES (?, ?, ?, ?)') + .run(endpoint, json, team, Date.now()); +} + +export function removePushSub(endpoint: string): void { + db().prepare('DELETE FROM push_subs WHERE endpoint = ?').run(endpoint); +} + +export function pushSubsForTeams(teams: string[]): PushSub[] { + if (teams.length === 0) return []; + const marks = teams.map(() => '?').join(','); + return db() + .prepare(`SELECT endpoint, json, team FROM push_subs WHERE team IN (${marks})`) + .all(...teams) as unknown as PushSub[]; +} + // ---- in-play win-probability history (the momentum curve on match pages) ---- export interface InplayPoint { minute: number; diff --git a/server/src/index.ts b/server/src/index.ts index b0911cf..787bd37 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -12,6 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview'; import { buildScoreboard, snapshotCheck } from './scoreboard'; import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db'; import { inPlayProbs } from '../../src/lib/model/inplay'; +import { PushNotifier, pushEnabled, subscribe as pushSubscribe, unsubscribe as pushUnsubscribe, vapidPublicKey } from './push'; import type { EspnSummary } from './ingest/espnNormalize'; import { loadStatic } from './preview'; import { canonicalTeam } from '../../src/lib/teams'; @@ -53,9 +54,11 @@ export function buildServer() { }; /** Re-push the snapshot, and predictions too if the finished-result set moved. */ + const notifier = new PushNotifier(); const broadcast = (): void => { sendAll(snapshotMessage()); if (model.recompute(state.allFixtures())) sendAll(predictionsMessage()); + try { notifier.tick(state.allFixtures()); } catch { /* never block a broadcast */ } }; // Record the in-play win probability per game state while matches run — @@ -88,6 +91,25 @@ export function buildServer() { return { points: Number.isFinite(num) ? inplayHistory(num) : [] }; }); + // Web Push (feature-flagged on VAPID env): key discovery + subscriptions. + app.get('/api/push/key', async (_req, reply) => + pushEnabled ? { key: vapidPublicKey } : reply.code(404).send({ error: 'push disabled' }), + ); + app.post('/api/push/subscribe', async (req, reply) => { + if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' }); + const b = (req.body ?? {}) as { subscription?: { endpoint?: string }; team?: string }; + if (!b.subscription?.endpoint || !b.team) return reply.code(400).send({ error: 'subscription + team required' }); + pushSubscribe(b.subscription as { endpoint: string }, b.team); + return { ok: true }; + }); + app.post('/api/push/unsubscribe', async (req, reply) => { + if (!pushEnabled) return reply.code(404).send({ error: 'push disabled' }); + const b = (req.body ?? {}) as { endpoint?: string }; + if (!b.endpoint) return reply.code(400).send({ error: 'endpoint required' }); + pushUnsubscribe(b.endpoint); + return { ok: true }; + }); + // Golden Boot: per-player goals aggregated from structured ESPN scoring // plays across every started match (own goals + shootout kicks excluded). app.get('/api/goldenboot', async () => { diff --git a/server/src/push.ts b/server/src/push.ts new file mode 100644 index 0000000..9345ad0 --- /dev/null +++ b/server/src/push.ts @@ -0,0 +1,84 @@ +// Web Push for goal/kickoff/full-time alerts on a followed team. Entirely +// opt-in and feature-flagged: without VAPID keys in the environment the API +// reports disabled, the client hides its UI, and nothing else changes. +import webpush from 'web-push'; +import { addPushSub, pushSubsForTeams, removePushSub } from './db/db'; +import type { Fixture } from '../../src/lib/types'; + +const PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY?.trim() ?? ''; +const PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY?.trim() ?? ''; +const SUBJECT = process.env.VAPID_SUBJECT?.trim() || 'mailto:nilsbriggen@gmail.com'; + +export const pushEnabled = Boolean(PUBLIC_KEY && PRIVATE_KEY); +export const vapidPublicKey = PUBLIC_KEY; + +if (pushEnabled) { + webpush.setVapidDetails(SUBJECT, PUBLIC_KEY, PRIVATE_KEY); + console.log('[push] web push enabled'); +} + +export function subscribe(subscription: { endpoint: string }, team: string): void { + addPushSub(subscription.endpoint, JSON.stringify(subscription), team); +} + +export function unsubscribe(endpoint: string): void { + removePushSub(endpoint); +} + +async function send(team: string[], title: string, body: string, url: string): Promise { + if (!pushEnabled) return; + const subs = pushSubsForTeams(team); + await Promise.allSettled( + subs.map(async (s) => { + try { + await webpush.sendNotification(JSON.parse(s.json), JSON.stringify({ title, body, url })); + } catch (e) { + const code = (e as { statusCode?: number }).statusCode; + if (code === 404 || code === 410) removePushSub(s.endpoint); // gone — clean up + } + }), + ); +} + +interface ScoreState { home: number | null; away: number | null; status: string } + +/** Diff fixture states between broadcasts and push kickoff/goal/full-time + * alerts to followers of either team. State lives in-memory: after a restart + * the first broadcast just primes the cache (no notification replays). */ +export class PushNotifier { + private last = new Map(); + private primed = false; + + tick(fixtures: Fixture[]): void { + if (!pushEnabled) return; + const interesting = fixtures.filter((f) => f.status !== 'scheduled' && f.home.team && f.away.team); + if (!this.primed) { + for (const f of interesting) this.last.set(f.num, { home: f.homeScore, away: f.awayScore, status: f.status }); + this.primed = true; + return; + } + for (const f of interesting) { + const prev = this.last.get(f.num); + const cur: ScoreState = { home: f.homeScore, away: f.awayScore, status: f.status }; + this.last.set(f.num, cur); + const home = f.home.team!; + const away = f.away.team!; + const teams = [home, away]; + const url = `/match/${f.num}`; + const score = `${cur.home ?? 0}–${cur.away ?? 0}`; + if (!prev) { + if (f.status === 'live') void send(teams, `Kickoff: ${home} vs ${away}`, 'The match is underway.', url); + continue; + } + if (prev.status !== 'live' && f.status === 'live') { + void send(teams, `Kickoff: ${home} vs ${away}`, 'The match is underway.', url); + } + if ((cur.home ?? 0) + (cur.away ?? 0) > (prev.home ?? 0) + (prev.away ?? 0)) { + void send(teams, `Goal! ${home} ${score} ${away}`, f.minute ? `${f.minute}'` : '', url); + } + if (prev.status === 'live' && f.status === 'finished') { + void send(teams, `Full time: ${home} ${score} ${away}`, '', url); + } + } + } +} diff --git a/src/features/team/TeamProfilePage.tsx b/src/features/team/TeamProfilePage.tsx index 68c5c57..c13d42c 100644 --- a/src/features/team/TeamProfilePage.tsx +++ b/src/features/team/TeamProfilePage.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from 'react'; import { Link, useParams } from '@tanstack/react-router'; -import { ArrowLeft, Shield, Star } from 'lucide-react'; +import { ArrowLeft, Bell, BellRing, Shield, Star } from 'lucide-react'; import { EloHistoryChart } from '@/components/EloHistoryChart'; +import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push'; import { useFavoriteStore } from '@/stores/favoriteStore'; import { Card, CardBody, CardHeader } from '@/components/ui/Card'; import { Badge } from '@/components/ui/Badge'; @@ -51,6 +52,61 @@ function FavoriteStar({ team }: { team: string }) { ); } +const PUSH_TEAM_KEY = 'cup26:push-team'; + +/** Goal/kickoff alerts for this team — shown only when the server has push + * enabled and the browser supports it. One followed team per device. */ +function NotifyBell({ team }: { team: string }) { + const t = useT(); + const [avail, setAvail] = useState(false); + const [denied, setDenied] = useState(false); + const [subTeam, setSubTeam] = useState(() => { + try { return localStorage.getItem(PUSH_TEAM_KEY); } catch { return null; } + }); + + useEffect(() => { + let alive = true; + void serverPushKey().then((k) => alive && setAvail(Boolean(k) && pushSupported())); + return () => { alive = false; }; + }, []); + + if (!avail) return null; + const active = subTeam === team; + + const toggle = async (): Promise => { + if (active) { + await unsubscribePush(); + try { localStorage.removeItem(PUSH_TEAM_KEY); } catch { /* ok */ } + setSubTeam(null); + return; + } + const res = await subscribeTeam(team); + if (res === 'subscribed') { + try { localStorage.setItem(PUSH_TEAM_KEY, team); } catch { /* ok */ } + setSubTeam(team); + setDenied(false); + } else if (res === 'denied') { + setDenied(true); + } + }; + + return ( + + + {denied && {t.team.notifyDenied}} + + ); +} + export function TeamProfilePage() { const { name } = useParams({ strict: false }) as { name: string }; const t = useT(); @@ -82,9 +138,10 @@ export function TeamProfilePage() { {teamFlag(profile.team)}
-

- {profile.team} +

+ {profile.team} +

{profile.group && {fmt(t.common.group, { group: profile.group })}} diff --git a/src/lib/i18n/de.ts b/src/lib/i18n/de.ts index 11cfb87..8b46ff4 100644 --- a/src/lib/i18n/de.ts +++ b/src/lib/i18n/de.ts @@ -410,6 +410,9 @@ export const de: Dict = { allTeams: "Alle Teams", follow: "Diesem Team folgen", unfollow: "Diesem Team nicht mehr folgen", + notifyOn: "Tor-Benachrichtigungen für dieses Team", + notifyOff: "Benachrichtigungen ausschalten", + notifyDenied: "Benachrichtigungen sind in den Browser-Einstellungen blockiert.", eloBadge: "Elo {rating}", titleOddsBadge: "Titel {pct}", advanceOddsBadge: "Weiterkommen {pct}", diff --git a/src/lib/i18n/en.ts b/src/lib/i18n/en.ts index fdeb40f..c5e0557 100644 --- a/src/lib/i18n/en.ts +++ b/src/lib/i18n/en.ts @@ -409,6 +409,9 @@ export const en = { allTeams: "All teams", follow: "Follow this team", unfollow: "Unfollow this team", + notifyOn: "Get goal alerts for this team", + notifyOff: "Stop goal alerts", + notifyDenied: "Notifications are blocked in your browser settings.", eloBadge: "Elo {rating}", titleOddsBadge: "Title {pct}", advanceOddsBadge: "Advance {pct}", diff --git a/src/lib/push.ts b/src/lib/push.ts new file mode 100644 index 0000000..c63e930 --- /dev/null +++ b/src/lib/push.ts @@ -0,0 +1,61 @@ +// Client-side Web Push: feature detection + subscribe/unsubscribe round trip. +// Everything degrades silently — no key from the server, no UI. + +function b64ToUint8(base64: string): Uint8Array { + const pad = '='.repeat((4 - (base64.length % 4)) % 4); + const raw = atob((base64 + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(raw, (c) => c.charCodeAt(0)); +} + +export const pushSupported = (): boolean => + 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + +let keyCache: Promise | null = null; +export function serverPushKey(): Promise { + keyCache ??= fetch('/api/push/key') + .then((r) => (r.ok ? r.json() : null)) + .then((d: { key: string } | null) => d?.key ?? null) + .catch(() => null); + return keyCache; +} + +export async function currentSubscription(): Promise { + if (!pushSupported()) return null; + const reg = await navigator.serviceWorker.ready; + return reg.pushManager.getSubscription(); +} + +/** Returns 'subscribed' | 'denied' | 'failed'. */ +export async function subscribeTeam(team: string): Promise<'subscribed' | 'denied' | 'failed'> { + try { + const key = await serverPushKey(); + if (!key || !pushSupported()) return 'failed'; + const perm = await Notification.requestPermission(); + if (perm !== 'granted') return 'denied'; + const reg = await navigator.serviceWorker.ready; + const sub = + (await reg.pushManager.getSubscription()) ?? + (await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64ToUint8(key) as BufferSource })); + const res = await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ subscription: sub.toJSON(), team }), + }); + return res.ok ? 'subscribed' : 'failed'; + } catch { + return 'failed'; + } +} + +export async function unsubscribePush(): Promise { + try { + const sub = await currentSubscription(); + if (!sub) return; + await fetch('/api/push/unsubscribe', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + await sub.unsubscribe(); + } catch { /* best effort */ } +} diff --git a/vite.config.ts b/vite.config.ts index 7b59280..07b2df6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -48,7 +48,9 @@ export default defineConfig({ workbox: { globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'], // server-only refit training data (~1MB) — keep it off every client - globIgnores: ['**/dcTrain.json'], + globIgnores: ['**/dcTrain.json', '**/push-sw.js'], + // goal/kickoff Web Push handlers ride along with the generated SW + importScripts: ['push-sw.js'], maximumFileSizeToCacheInBytes: 6 * 1024 * 1024, skipWaiting: true, clientsClaim: true,