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
+60 -3
View File
@@ -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<string | null>(() => {
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<void> => {
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 (
<span className="inline-flex items-center">
<button
type="button"
onClick={() => void toggle()}
aria-label={active ? t.team.notifyOff : t.team.notifyOn}
aria-pressed={active}
title={denied ? t.team.notifyDenied : active ? t.team.notifyOff : t.team.notifyOn}
className={`grid h-9 w-9 place-items-center rounded-md transition-colors ${active ? 'text-accent' : 'text-faint hover:text-ink'}`}
>
{active ? <BellRing size={18} /> : <Bell size={18} />}
</button>
{denied && <span className="text-[11px] text-faint">{t.team.notifyDenied}</span>}
</span>
);
}
export function TeamProfilePage() {
const { name } = useParams({ strict: false }) as { name: string };
const t = useT();
@@ -82,9 +138,10 @@ export function TeamProfilePage() {
<CardBody className="flex flex-wrap items-center gap-4">
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
<div>
<h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
{profile.team}
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink">
<span className="mr-1">{profile.team}</span>
<FavoriteStar team={profile.team} />
<NotifyBell team={profile.team} />
</h1>
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}