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:
@@ -6,6 +6,7 @@
|
|||||||
"name": "cup26",
|
"name": "cup26",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.95.0",
|
"@tanstack/react-router": "^1.95.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.2",
|
"zustand": "^5.0.2",
|
||||||
},
|
},
|
||||||
@@ -522,6 +524,8 @@
|
|||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"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=="],
|
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
|
||||||
|
|
||||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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-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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
|
||||||
|
|
||||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
"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=="],
|
"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=="],
|
"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=="],
|
"whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="],
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ services:
|
|||||||
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
|
- ENABLE_SOFASCORE=${ENABLE_SOFASCORE:-false}
|
||||||
- ENABLE_FOTMOB=${ENABLE_FOTMOB:-false}
|
- ENABLE_FOTMOB=${ENABLE_FOTMOB:-false}
|
||||||
- ARCHIVE_DIR=/data/archive
|
- 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:
|
volumes:
|
||||||
# The LIVE SQLite must stay on a real local filesystem (WAL + locking do
|
# 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.
|
# not survive network mounts). HOST_DATA_DIR can override if ever needed.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-router": "^1.95.0",
|
"@tanstack/react-router": "^1.95.0",
|
||||||
|
"@types/web-push": "^3.6.4",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-scale": "^4.0.2",
|
"d3-scale": "^4.0.2",
|
||||||
"lucide-react": "^1.17.0",
|
"lucide-react": "^1.17.0",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"recharts": "^2.15.0",
|
"recharts": "^2.15.0",
|
||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
|
"web-push": "^3.6.7",
|
||||||
"zod": "^3.24.1",
|
"zod": "^3.24.1",
|
||||||
"zustand": "^5.0.2"
|
"zustand": "^5.0.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
+3
-2
@@ -10,6 +10,7 @@
|
|||||||
"@fastify/static": "^9.1.3",
|
"@fastify/static": "^9.1.3",
|
||||||
"@fastify/websocket": "^11.2.0",
|
"@fastify/websocket": "^11.2.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,6 +93,13 @@ CREATE TABLE IF NOT EXISTS prediction_snapshots (
|
|||||||
market_json TEXT
|
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 (
|
CREATE TABLE IF NOT EXISTS inplay_history (
|
||||||
fixture_num INTEGER NOT NULL,
|
fixture_num INTEGER NOT NULL,
|
||||||
minute 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);
|
.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) ----
|
// ---- in-play win-probability history (the momentum curve on match pages) ----
|
||||||
export interface InplayPoint {
|
export interface InplayPoint {
|
||||||
minute: number;
|
minute: number;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { buildPreview, buildTeamProfile } from './preview';
|
|||||||
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
import { buildScoreboard, snapshotCheck } from './scoreboard';
|
||||||
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
|
import { db, healthAll, oddsFor, latestOddsAll, dbCounts, inplayHistory, recordInplay, getMatchExt } from './db/db';
|
||||||
import { inPlayProbs } from '../../src/lib/model/inplay';
|
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 type { EspnSummary } from './ingest/espnNormalize';
|
||||||
import { loadStatic } from './preview';
|
import { loadStatic } from './preview';
|
||||||
import { canonicalTeam } from '../../src/lib/teams';
|
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. */
|
/** Re-push the snapshot, and predictions too if the finished-result set moved. */
|
||||||
|
const notifier = new PushNotifier();
|
||||||
const broadcast = (): void => {
|
const broadcast = (): void => {
|
||||||
sendAll(snapshotMessage());
|
sendAll(snapshotMessage());
|
||||||
if (model.recompute(state.allFixtures())) sendAll(predictionsMessage());
|
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 —
|
// 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) : [] };
|
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
|
// Golden Boot: per-player goals aggregated from structured ESPN scoring
|
||||||
// plays across every started match (own goals + shootout kicks excluded).
|
// plays across every started match (own goals + shootout kicks excluded).
|
||||||
app.get('/api/goldenboot', async () => {
|
app.get('/api/goldenboot', async () => {
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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<number, ScoreState>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useParams } from '@tanstack/react-router';
|
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 { EloHistoryChart } from '@/components/EloHistoryChart';
|
||||||
|
import { pushSupported, serverPushKey, subscribeTeam, unsubscribePush } from '@/lib/push';
|
||||||
import { useFavoriteStore } from '@/stores/favoriteStore';
|
import { useFavoriteStore } from '@/stores/favoriteStore';
|
||||||
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
import { Card, CardBody, CardHeader } from '@/components/ui/Card';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
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() {
|
export function TeamProfilePage() {
|
||||||
const { name } = useParams({ strict: false }) as { name: string };
|
const { name } = useParams({ strict: false }) as { name: string };
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -82,9 +138,10 @@ export function TeamProfilePage() {
|
|||||||
<CardBody className="flex flex-wrap items-center gap-4">
|
<CardBody className="flex flex-wrap items-center gap-4">
|
||||||
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
<span aria-hidden className="text-5xl">{teamFlag(profile.team)}</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="flex items-center gap-2 font-display text-2xl font-extrabold text-ink">
|
<h1 className="flex items-center gap-1 font-display text-2xl font-extrabold text-ink">
|
||||||
{profile.team}
|
<span className="mr-1">{profile.team}</span>
|
||||||
<FavoriteStar team={profile.team} />
|
<FavoriteStar team={profile.team} />
|
||||||
|
<NotifyBell team={profile.team} />
|
||||||
</h1>
|
</h1>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm">
|
<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>}
|
{profile.group && <Badge tone="neutral">{fmt(t.common.group, { group: profile.group })}</Badge>}
|
||||||
|
|||||||
@@ -410,6 +410,9 @@ export const de: Dict = {
|
|||||||
allTeams: "Alle Teams",
|
allTeams: "Alle Teams",
|
||||||
follow: "Diesem Team folgen",
|
follow: "Diesem Team folgen",
|
||||||
unfollow: "Diesem Team nicht mehr 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}",
|
eloBadge: "Elo {rating}",
|
||||||
titleOddsBadge: "Titel {pct}",
|
titleOddsBadge: "Titel {pct}",
|
||||||
advanceOddsBadge: "Weiterkommen {pct}",
|
advanceOddsBadge: "Weiterkommen {pct}",
|
||||||
|
|||||||
@@ -409,6 +409,9 @@ export const en = {
|
|||||||
allTeams: "All teams",
|
allTeams: "All teams",
|
||||||
follow: "Follow this team",
|
follow: "Follow this team",
|
||||||
unfollow: "Unfollow 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}",
|
eloBadge: "Elo {rating}",
|
||||||
titleOddsBadge: "Title {pct}",
|
titleOddsBadge: "Title {pct}",
|
||||||
advanceOddsBadge: "Advance {pct}",
|
advanceOddsBadge: "Advance {pct}",
|
||||||
|
|||||||
@@ -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<string | null> | null = null;
|
||||||
|
export function serverPushKey(): Promise<string | null> {
|
||||||
|
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<PushSubscription | null> {
|
||||||
|
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<void> {
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
+3
-1
@@ -48,7 +48,9 @@ export default defineConfig({
|
|||||||
workbox: {
|
workbox: {
|
||||||
globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'],
|
globPatterns: ['**/*.{js,css,html,svg,png,woff2,json}'],
|
||||||
// server-only refit training data (~1MB) — keep it off every client
|
// 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,
|
maximumFileSizeToCacheInBytes: 6 * 1024 * 1024,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user