Session 27: PWA autopilot — NetworkFirst cache policy, stale bucket cleanup, offline fallback, push helper, manifest polish, tier fix (1584 tests)
This commit is contained in:
@@ -4,6 +4,74 @@
|
|||||||
2026-06-12
|
2026-06-12
|
||||||
|
|
||||||
## Current Phase
|
## Current Phase
|
||||||
|
SHIP BUILD v27.0 — PWA autopilot: deployment-aware service worker, push foundation, offline fallback, install + cookie + tier polish (Session 27)
|
||||||
|
|
||||||
|
## Session 27 (2026-06-13) — SHIPPED
|
||||||
|
|
||||||
|
Upgraded the PWA from "caches stale content after deploy" to bulletproof.
|
||||||
|
The service worker stays — it powers push/offline/installs — but its cache
|
||||||
|
POLICY is now deployment-aware. Much of the foundation already existed
|
||||||
|
(skipWaiting/clientsClaim, push handlers, InstallPrompt, CookieConsent);
|
||||||
|
this session fixed the cache strategy and filled the gaps.
|
||||||
|
|
||||||
|
Backend 1579 → **1584 tests** (+5), 126 suites, zero regressions. Web build
|
||||||
|
clean.
|
||||||
|
|
||||||
|
### PHASE 1 — SW cache policy (the deploy-staleness fix)
|
||||||
|
- Replaced `defaultCache` with explicit `runtimeCaching` (Serwist v9
|
||||||
|
strategy classes): API + navigations + RSC/everything-else are
|
||||||
|
NetworkFirst (5s timeout) so sports data is never stale; only
|
||||||
|
content-hashed `/_next/static/` and images/fonts are CacheFirst.
|
||||||
|
- `skipWaiting`/`clientsClaim` already set — kept.
|
||||||
|
- Added an `activate` handler that deletes legacy cache buckets (the old
|
||||||
|
defaultCache set: start-url, next-data, apis, pages-rsc, …), preserving
|
||||||
|
CURRENT_CACHES + Serwist-managed precache.
|
||||||
|
|
||||||
|
### PHASE 2 — Push foundation
|
||||||
|
- SW `push` + `notificationclick` handlers already existed — kept + added
|
||||||
|
a `tag`. Created `web/src/lib/pushNotifications.ts`
|
||||||
|
(subscribeToPush / unsubscribeFromPush / isPushSupported / pushPermission).
|
||||||
|
Returns null gracefully until NEXT_PUBLIC_VAPID_PUBLIC_KEY exists.
|
||||||
|
|
||||||
|
### PHASE 3 — Offline fallback
|
||||||
|
- Created `web/src/app/offline/page.tsx` (dependency-free client page).
|
||||||
|
- SW pre-caches `/offline` on install; navigation handler serves it via a
|
||||||
|
`handlerDidError` plugin when network + cache both miss.
|
||||||
|
|
||||||
|
### PHASE 4 — Manifest polish
|
||||||
|
- `manifest.json`: full name "VYNDR — Sports Prop Intelligence", added
|
||||||
|
`categories`, explicit icon `purpose`. Kept brand `#06060B` (NOT the
|
||||||
|
spec's #0A0A0F) for splash/status-bar consistency. Layout already
|
||||||
|
emits theme-color + apple-mobile-web-app-* via Next metadata API.
|
||||||
|
|
||||||
|
### PHASE 5+6 — Install prompt & cookie consent (verified, already done)
|
||||||
|
- `InstallPrompt` (beforeinstallprompt + iOS hint + 7-day dismissal
|
||||||
|
cooldown, gated on ≥2 reads) and `CookieConsent` (persists
|
||||||
|
`vyndr_cookie_consent`, shows once) already implemented and mounted in
|
||||||
|
layout. No change needed.
|
||||||
|
|
||||||
|
### PHASE 7 — Copy
|
||||||
|
- The literal "NBA · MLB · WNBA" hero badge was already gone (Session 24 →
|
||||||
|
"EVERY SPORT · EVERY PROP"). Reframed the layout metadata description to
|
||||||
|
lead with "every sport" (kept per-sport SEO keywords).
|
||||||
|
|
||||||
|
### PHASE 8 — Profile tier
|
||||||
|
- `{profile.tier}` rendered blank when the API returned null/undefined.
|
||||||
|
Now falls back to 'free' so the tier field is never empty.
|
||||||
|
|
||||||
|
### Files created
|
||||||
|
- `web/src/app/offline/page.tsx`
|
||||||
|
- `web/src/lib/pushNotifications.ts`
|
||||||
|
- `tests/unit/pwaManifest.test.js`
|
||||||
|
|
||||||
|
### Files modified
|
||||||
|
- `web/src/sw.ts` (cache strategies + activate cleanup + offline precache)
|
||||||
|
- `web/public/manifest.json` (name, categories, icon purpose)
|
||||||
|
- `web/src/app/layout.tsx` (description), `web/src/app/profile/page.tsx` (tier)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Previous Phase
|
||||||
SHIP BUILD v26.0 — Cross-sport tab counts, scan copy, game-card visual polish, empty-section auto-hide (Session 26)
|
SHIP BUILD v26.0 — Cross-sport tab counts, scan copy, game-card visual polish, empty-section auto-hide (Session 26)
|
||||||
|
|
||||||
## Session 26 (2026-06-12) — SHIPPED
|
## Session 26 (2026-06-12) — SHIPPED
|
||||||
|
|||||||
@@ -738,3 +738,24 @@
|
|||||||
{"ts":"2026-06-12T22:47:16.309Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
{"ts":"2026-06-12T22:47:16.309Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
{"ts":"2026-06-12T22:47:16.363Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
{"ts":"2026-06-12T22:47:16.363Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
{"ts":"2026-06-12T22:47:16.575Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
{"ts":"2026-06-12T22:47:16.575Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:36:25.136Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-13T14:36:25.138Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-13T14:36:25.139Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-13T14:36:25.316Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-13T14:36:26.694Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:36:26.997Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-13T14:36:27.632Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:44:13.738Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.726Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.726Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.726Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.768Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.778Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
{"ts":"2026-06-13T14:44:14.864Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-13T14:45:07.568Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.319Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.431Z","sport":"nba","player_espn_id":"99999","player_name":"Phantom","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"B"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.483Z","sport":"nba","player_espn_id":"3934719","player_name":"OG Anunoby","stat_type":"points","line":16.5,"direction":"over","actual_value":17,"result":"hit","margin":0.5,"grade":"A-"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.483Z","sport":"nba","player_espn_id":"3136195","player_name":"Karl-Anthony Towns","stat_type":"rebounds","line":13.5,"direction":"under","actual_value":13,"result":"hit","margin":-0.5,"grade":"B+"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.484Z","sport":"nba","player_espn_id":"3062679","player_name":"Josh Hart","stat_type":"pts_reb_ast","line":10.5,"direction":"over","actual_value":10,"result":"miss","margin":-0.5,"grade":"C"}
|
||||||
|
{"ts":"2026-06-13T14:45:08.569Z","sport":"nba","player_espn_id":"999999999","player_name":"Phantom Player","stat_type":"points","line":10.5,"direction":"over","actual_value":null,"result":"void","grade":"C"}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Unit: PWA manifest validity (Session 27).
|
||||||
|
//
|
||||||
|
// The manifest IS the brand on a user's home screen. A malformed manifest
|
||||||
|
// silently breaks installability, so guard the required fields + that the
|
||||||
|
// icon files it references actually exist on disk.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const WEB_PUBLIC = path.join(__dirname, '..', '..', 'web', 'public');
|
||||||
|
const MANIFEST_PATH = path.join(WEB_PUBLIC, 'manifest.json');
|
||||||
|
|
||||||
|
describe('PWA manifest', () => {
|
||||||
|
let manifest;
|
||||||
|
beforeAll(() => {
|
||||||
|
manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is valid JSON with the required installability fields', () => {
|
||||||
|
expect(manifest.name).toBeTruthy();
|
||||||
|
expect(manifest.short_name).toBe('VYNDR');
|
||||||
|
expect(manifest.start_url).toBe('/dashboard');
|
||||||
|
expect(manifest.display).toBe('standalone');
|
||||||
|
expect(manifest.theme_color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
expect(manifest.background_color).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('declares app categories', () => {
|
||||||
|
expect(Array.isArray(manifest.categories)).toBe(true);
|
||||||
|
expect(manifest.categories).toContain('sports');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every PNG icon it references exists on disk', () => {
|
||||||
|
const pngs = manifest.icons.filter((i) => i.type === 'image/png');
|
||||||
|
expect(pngs.length).toBeGreaterThan(0);
|
||||||
|
for (const icon of pngs) {
|
||||||
|
const file = path.join(WEB_PUBLIC, icon.src.replace(/^\//, ''));
|
||||||
|
expect(fs.existsSync(file)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('provides a maskable icon (Android adaptive)', () => {
|
||||||
|
const maskable = manifest.icons.find((i) => (i.purpose || '').includes('maskable'));
|
||||||
|
expect(maskable).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('theme + background match the brand dark base', () => {
|
||||||
|
// Brand bg-0 is #06060B — keep manifest in lockstep so the splash /
|
||||||
|
// status bar don't flash a mismatched color.
|
||||||
|
expect(manifest.theme_color.toLowerCase()).toBe('#06060b');
|
||||||
|
expect(manifest.background_color.toLowerCase()).toBe('#06060b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "VYNDR",
|
"name": "VYNDR — Sports Prop Intelligence",
|
||||||
"short_name": "VYNDR",
|
"short_name": "VYNDR",
|
||||||
"description": "Grade your props with intelligence the books don't want you to have.",
|
"description": "Grade your props with intelligence the books don't want you to have.",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
"background_color": "#06060B",
|
"background_color": "#06060B",
|
||||||
"theme_color": "#06060B",
|
"theme_color": "#06060B",
|
||||||
"orientation": "portrait-primary",
|
"orientation": "portrait-primary",
|
||||||
|
"categories": ["sports", "finance", "entertainment"],
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" },
|
||||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
File diff suppressed because one or more lines are too long
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
|
|||||||
template: '%s · VYNDR',
|
template: '%s · VYNDR',
|
||||||
},
|
},
|
||||||
description:
|
description:
|
||||||
"Grade NBA, MLB, WNBA, and soccer props with intelligence the books don't want you to have. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.",
|
"Grade your props across every sport with intelligence the books don't want you to have. NBA, MLB, WNBA, and soccer today — NFL and more through 2026. World Cup 2026 intelligence: xG regression, altitude, referee, penalty taker. Built in Detroit.",
|
||||||
applicationName: 'VYNDR',
|
applicationName: 'VYNDR',
|
||||||
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
|
authors: [{ name: 'VYNDR', url: 'https://vyndr.app' }],
|
||||||
manifest: '/manifest.json',
|
manifest: '/manifest.json',
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Offline fallback (Session 27).
|
||||||
|
*
|
||||||
|
* Served by the service worker's navigation handler when a page request
|
||||||
|
* fails on the network AND misses the runtime cache. Pre-cached on SW
|
||||||
|
* install so it's always available. Kept dependency-free so it renders
|
||||||
|
* with zero network.
|
||||||
|
*/
|
||||||
|
export default function OfflinePage() {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: 'var(--bg-0, #06060B)',
|
||||||
|
color: 'var(--text-0, #F0F0F5)',
|
||||||
|
padding: 24,
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="mono"
|
||||||
|
style={{
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: '0.18em',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: 'var(--grade-a, #00D4A0)',
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
VYNDR
|
||||||
|
</div>
|
||||||
|
<h1 style={{ fontSize: 28, fontWeight: 800, marginBottom: 8, letterSpacing: '-0.02em' }}>
|
||||||
|
You're offline
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: 16,
|
||||||
|
color: 'var(--text-secondary, #8A8A9A)',
|
||||||
|
maxWidth: 400,
|
||||||
|
marginBottom: 24,
|
||||||
|
lineHeight: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Scores and grades will refresh the moment you reconnect. Anything you
|
||||||
|
loaded earlier is still cached and available.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
style={{
|
||||||
|
padding: '12px 24px',
|
||||||
|
background: 'var(--grade-a, #00D4A0)',
|
||||||
|
color: '#06060B',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: 6,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,8 +85,11 @@ export default function ProfilePage() {
|
|||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16 }}>
|
||||||
<div>
|
<div>
|
||||||
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>YOUR TIER</p>
|
<p className="mono" style={{ fontSize: 11, color: 'var(--text-tertiary)', letterSpacing: '0.08em' }}>YOUR TIER</p>
|
||||||
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier) }}>
|
{/* Session 27 — always render a tier label. When the profile
|
||||||
{profile.tier}
|
API returns null/undefined tier (free users sometimes do),
|
||||||
|
fall back to 'free' so the field is never blank. */}
|
||||||
|
<h2 style={{ fontSize: 28, fontWeight: 800, marginTop: 4, textTransform: 'capitalize', color: tierColor(profile.tier || 'free') }}>
|
||||||
|
{profile.tier || 'free'}
|
||||||
{profile.founder_pricing && (
|
{profile.founder_pricing && (
|
||||||
<span
|
<span
|
||||||
className="mono"
|
className="mono"
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Push notification subscription (Session 27).
|
||||||
|
*
|
||||||
|
* Client-side helpers to request permission and (un)subscribe to Web Push
|
||||||
|
* via the active service worker. The SW already handles incoming `push`
|
||||||
|
* and `notificationclick` events (see web/src/sw.ts); these helpers manage
|
||||||
|
* the browser→push-service subscription handshake.
|
||||||
|
*
|
||||||
|
* VAPID: subscription requires NEXT_PUBLIC_VAPID_PUBLIC_KEY. Until that
|
||||||
|
* key is generated and set (a future session), subscribeToPush returns
|
||||||
|
* null rather than throwing — callers degrade gracefully.
|
||||||
|
*
|
||||||
|
* The resulting PushSubscription is meant to be POSTed to the backend and
|
||||||
|
* stored (Supabase) so the notification trigger system can target it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Browser support check — both SW and PushManager must exist. */
|
||||||
|
export function isPushSupported(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
'serviceWorker' in navigator &&
|
||||||
|
'PushManager' in window &&
|
||||||
|
'Notification' in window
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VAPID public keys are base64url; the subscribe API wants a BufferSource.
|
||||||
|
* We return the backing ArrayBuffer so the type is unambiguously
|
||||||
|
* ArrayBuffer (not ArrayBufferLike) for `applicationServerKey`.
|
||||||
|
*/
|
||||||
|
function vapidKeyToBuffer(base64String: string): ArrayBuffer {
|
||||||
|
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
|
||||||
|
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const raw = window.atob(base64);
|
||||||
|
const buffer = new ArrayBuffer(raw.length);
|
||||||
|
const view = new Uint8Array(buffer);
|
||||||
|
for (let i = 0; i < raw.length; i += 1) view[i] = raw.charCodeAt(i);
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request permission and subscribe. Returns the PushSubscription on
|
||||||
|
* success, or null when unsupported / denied / VAPID key missing.
|
||||||
|
*/
|
||||||
|
export async function subscribeToPush(): Promise<PushSubscription | null> {
|
||||||
|
if (!isPushSupported()) return null;
|
||||||
|
|
||||||
|
const vapidKey = process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY;
|
||||||
|
if (!vapidKey) {
|
||||||
|
console.warn('[push] NEXT_PUBLIC_VAPID_PUBLIC_KEY not set — cannot subscribe yet.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permission = await Notification.requestPermission();
|
||||||
|
if (permission !== 'granted') return null;
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
|
||||||
|
// Reuse an existing subscription if the browser already has one.
|
||||||
|
const existing = await registration.pushManager.getSubscription();
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
return registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: vapidKeyToBuffer(vapidKey),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unsubscribe the active push subscription, if any. Idempotent. */
|
||||||
|
export async function unsubscribeFromPush(): Promise<boolean> {
|
||||||
|
if (!isPushSupported()) return true;
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
const subscription = await registration.pushManager.getSubscription();
|
||||||
|
if (!subscription) return true;
|
||||||
|
return subscription.unsubscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Current permission state without prompting — for UI affordances. */
|
||||||
|
export function pushPermission(): NotificationPermission | 'unsupported' {
|
||||||
|
if (!isPushSupported()) return 'unsupported';
|
||||||
|
return Notification.permission;
|
||||||
|
}
|
||||||
+137
-8
@@ -1,41 +1,170 @@
|
|||||||
/// <reference lib="webworker" />
|
/// <reference lib="webworker" />
|
||||||
/// <reference types="@serwist/next/typings" />
|
/// <reference types="@serwist/next/typings" />
|
||||||
|
|
||||||
import { defaultCache } from '@serwist/next/worker';
|
import {
|
||||||
import { Serwist } from 'serwist';
|
Serwist,
|
||||||
|
NetworkFirst,
|
||||||
|
CacheFirst,
|
||||||
|
ExpirationPlugin,
|
||||||
|
type RuntimeCaching,
|
||||||
|
} from 'serwist';
|
||||||
|
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
__SW_MANIFEST: (string | { url: string; revision: string | null })[];
|
__SW_MANIFEST: (string | { url: string; revision: string | null })[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VYNDR service worker (Session 27 — deployment-aware rewrite).
|
||||||
|
*
|
||||||
|
* The PWA stays — it powers push, offline, fast assets, and installs.
|
||||||
|
* The bug was the CACHE POLICY, not the SW. Sports data is time-
|
||||||
|
* sensitive: a 4-hour-old score is wrong, not stale. So pages + API +
|
||||||
|
* everything-dynamic are NetworkFirst (always fresh, cache only as an
|
||||||
|
* offline fallback). Only content-hashed static assets (which change
|
||||||
|
* URL every build) are CacheFirst.
|
||||||
|
*
|
||||||
|
* skipWaiting + clientsClaim mean a new build takes over on the very
|
||||||
|
* next navigation — no "close all tabs and hard-refresh" ritual.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const OFFLINE_URL = '/offline';
|
||||||
|
|
||||||
|
// The complete set of caches this SW version owns. The activate handler
|
||||||
|
// below deletes anything else (the old defaultCache buckets:
|
||||||
|
// start-url, next-data, apis, pages-rsc, static-js-assets, …) so a
|
||||||
|
// returning user isn't served by a stale bucket from a prior version.
|
||||||
|
const CURRENT_CACHES = [
|
||||||
|
'pages',
|
||||||
|
'api-responses',
|
||||||
|
'next-static',
|
||||||
|
'static-media',
|
||||||
|
'fallback',
|
||||||
|
'offline-fallback',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Navigation NetworkFirst, with a last-resort offline page when both the
|
||||||
|
// network AND the runtime cache miss (e.g. first visit to a never-cached
|
||||||
|
// route while offline).
|
||||||
|
const offlineFallbackPlugin = {
|
||||||
|
handlerDidError: async () => (await caches.match(OFFLINE_URL)) || Response.error(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const runtimeCaching: RuntimeCaching[] = [
|
||||||
|
// API responses — ALWAYS network-first. Schedule, game lines, odds,
|
||||||
|
// streaks must be fresh; the cache is only an offline courtesy.
|
||||||
|
{
|
||||||
|
matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/api/'),
|
||||||
|
handler: new NetworkFirst({
|
||||||
|
cacheName: 'api-responses',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 60 * 60 })],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// HTML navigations — network-first so scores/copy are never stale.
|
||||||
|
{
|
||||||
|
matcher: ({ request }) => request.mode === 'navigate',
|
||||||
|
handler: new NetworkFirst({
|
||||||
|
cacheName: 'pages',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
plugins: [
|
||||||
|
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
|
||||||
|
offlineFallbackPlugin,
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Next.js content-hashed static JS/CSS — cache-first is safe AND fast:
|
||||||
|
// a new build changes the URL, so old URLs are simply never requested.
|
||||||
|
{
|
||||||
|
matcher: ({ url, sameOrigin }) => sameOrigin && url.pathname.startsWith('/_next/static/'),
|
||||||
|
handler: new CacheFirst({
|
||||||
|
cacheName: 'next-static',
|
||||||
|
plugins: [new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 })],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Images + fonts — cache-first (slow to change, expensive to refetch).
|
||||||
|
{
|
||||||
|
matcher: ({ url }) =>
|
||||||
|
url.pathname.startsWith('/images/') ||
|
||||||
|
url.pathname.startsWith('/icons/') ||
|
||||||
|
/\.(?:png|jpe?g|gif|svg|webp|ico|woff2?)$/.test(url.pathname),
|
||||||
|
handler: new CacheFirst({
|
||||||
|
cacheName: 'static-media',
|
||||||
|
plugins: [new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 30 * 24 * 60 * 60 })],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
// Everything else (incl. RSC / _next/data payloads) — network-first so
|
||||||
|
// dynamic content stays fresh; cache is offline insurance only.
|
||||||
|
{
|
||||||
|
matcher: () => true,
|
||||||
|
handler: new NetworkFirst({
|
||||||
|
cacheName: 'fallback',
|
||||||
|
networkTimeoutSeconds: 5,
|
||||||
|
plugins: [new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 })],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
const serwist = new Serwist({
|
const serwist = new Serwist({
|
||||||
precacheEntries: self.__SW_MANIFEST,
|
precacheEntries: self.__SW_MANIFEST,
|
||||||
skipWaiting: true,
|
skipWaiting: true,
|
||||||
clientsClaim: true,
|
clientsClaim: true,
|
||||||
navigationPreload: true,
|
navigationPreload: true,
|
||||||
runtimeCaching: defaultCache,
|
runtimeCaching,
|
||||||
});
|
});
|
||||||
|
|
||||||
serwist.addEventListeners();
|
serwist.addEventListeners();
|
||||||
|
|
||||||
// Web Push handler — fires when the push service delivers a notification.
|
// Pre-cache the offline page so the navigation fallback always has it.
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open('offline-fallback').then((cache) => cache.add(OFFLINE_URL)).catch(() => {}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// On activation, delete cache buckets from prior SW versions. We keep our
|
||||||
|
// CURRENT_CACHES and anything Serwist manages (its precache cache is
|
||||||
|
// prefixed `serwist`), and drop the rest — clearing the legacy buckets a
|
||||||
|
// previous deploy left behind so users never get served from them.
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((names) =>
|
||||||
|
Promise.all(
|
||||||
|
names
|
||||||
|
.filter((name) => !CURRENT_CACHES.includes(name) && !name.startsWith('serwist'))
|
||||||
|
.map((name) => {
|
||||||
|
console.log('[SW] deleting stale cache:', name);
|
||||||
|
return caches.delete(name);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Web Push (Session 27 keeps the existing handlers) ----
|
||||||
// Pushes are emitted server-side by src/services/distribution/webPush.js.
|
// Pushes are emitted server-side by src/services/distribution/webPush.js.
|
||||||
self.addEventListener('push', (event) => {
|
self.addEventListener('push', (event) => {
|
||||||
if (!event.data) return;
|
if (!event.data) return;
|
||||||
let payload: { title?: string; body?: string; icon?: string; url?: string };
|
let payload: { title?: string; body?: string; icon?: string; url?: string; tag?: string };
|
||||||
try {
|
try {
|
||||||
payload = event.data.json();
|
payload = event.data.json();
|
||||||
} catch {
|
} catch {
|
||||||
payload = { title: 'VYNDR', body: event.data.text() };
|
payload = { title: 'VYNDR', body: event.data.text() };
|
||||||
}
|
}
|
||||||
const { title = 'VYNDR', body = '', icon = '/icons/icon-192.png', url = '/' } = payload;
|
const {
|
||||||
|
title = 'VYNDR',
|
||||||
|
body = '',
|
||||||
|
icon = '/icons/icon-192.png',
|
||||||
|
url = '/',
|
||||||
|
tag = 'vyndr-notification',
|
||||||
|
} = payload;
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
self.registration.showNotification(title, {
|
self.registration.showNotification(title, {
|
||||||
body,
|
body,
|
||||||
icon,
|
icon,
|
||||||
badge: '/icons/icon-192.png',
|
badge: '/icons/icon-192.png',
|
||||||
|
tag,
|
||||||
data: { url },
|
data: { url },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,6 +176,6 @@ self.addEventListener('notificationclick', (event) => {
|
|||||||
const existing = clients.find((c) => c.url.endsWith(url));
|
const existing = clients.find((c) => c.url.endsWith(url));
|
||||||
if (existing) return existing.focus();
|
if (existing) return existing.focus();
|
||||||
return self.clients.openWindow(url);
|
return self.clients.openWindow(url);
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user