Web Push: goal, kickoff and full-time alerts for a followed team

Fully progressive: without VAPID keys in the environment the API says
404, the bell never renders, and the service worker addition (a push
handler via workbox importScripts — no SW strategy change) is inert.
With keys: a bell on each team page requests permission, subscribes
(one team per device) and the server diffs fixture states on every
broadcast to push Kickoff / Goal / Full time to that team's followers.
Dead endpoints (404/410) self-clean. Round-trip verified locally with
dev VAPID keys; disabled mode verified too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 08:21:55 +02:00
parent ce83d4dbc4
commit 03b0fa734e
13 changed files with 331 additions and 6 deletions
+22
View File
@@ -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 () => {