260 lines
7.0 KiB
JavaScript
260 lines
7.0 KiB
JavaScript
const CACHE_NAME = 'wc2026-quant-20260615-network-realtime-v1';
|
|
const OFFLINE_URL = '/offline';
|
|
const ASSETS = ['/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
|
|
const ODDS_KEYS = 'wc2026:lastOddsSnapshot';
|
|
const MATCHES_API_PREFIX = '/api/analytics/matches';
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
|
|
self.skipWaiting();
|
|
});
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
(async () => {
|
|
const keys = await caches.keys();
|
|
await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)));
|
|
await self.clients.claim();
|
|
})(),
|
|
);
|
|
});
|
|
|
|
function cacheSafeCopy(request, response) {
|
|
const cacheable = request.method === 'GET' &&
|
|
(request.url.endsWith('.png') || request.url.endsWith('.svg'));
|
|
if (!cacheable) {
|
|
return;
|
|
}
|
|
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(request, response.clone());
|
|
});
|
|
}
|
|
|
|
function extractOddsSnapshot(payload) {
|
|
if (!payload || !Array.isArray(payload)) {
|
|
return [];
|
|
}
|
|
|
|
const now = new Date().toISOString();
|
|
return payload
|
|
.filter((item) => item?.market_type && item?.selection && typeof item?.decimal_odds === 'number')
|
|
.slice(0, 20)
|
|
.map((item) => ({
|
|
match_id: item.match_id,
|
|
home_team: item.home_team,
|
|
away_team: item.away_team,
|
|
odds: Number(item.decimal_odds),
|
|
market_type: item.market_type,
|
|
selection: item.selection,
|
|
captured_at: now,
|
|
}));
|
|
}
|
|
|
|
async function writeOddsSnapshotFromDetail(data) {
|
|
if (!data || typeof data !== 'object') {
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
|
|
if (Array.isArray(data.odds_series)) {
|
|
for (const row of data.odds_series) {
|
|
if (row && row.bookmaker) {
|
|
rows.push({
|
|
match_id: data.match_id,
|
|
home_team: data.home_team,
|
|
away_team: data.away_team,
|
|
decimal_odds: Number(row.decimal_odds),
|
|
market_type: row.market_type,
|
|
selection: row.selection,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const snapshot = extractOddsSnapshot(rows);
|
|
if (snapshot.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const cache = await caches.open(CACHE_NAME);
|
|
await cache.put(new Request(`/api/${ODDS_KEYS}`),
|
|
new Response(JSON.stringify(snapshot), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}));
|
|
|
|
const clientsList = await self.clients.matchAll({ type: 'window' });
|
|
clientsList.forEach((client) => {
|
|
client.postMessage({ type: 'odds-cache-updated', data: snapshot });
|
|
});
|
|
}
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const request = event.request;
|
|
if (request.method !== 'GET' || request.url.includes('/api/analytics/proof-of-yield')) {
|
|
return;
|
|
}
|
|
|
|
const url = new URL(request.url);
|
|
if (url.pathname === '/sw.js') {
|
|
return;
|
|
}
|
|
|
|
if (url.pathname.startsWith(MATCHES_API_PREFIX)) {
|
|
event.respondWith(
|
|
(async () => {
|
|
try {
|
|
const response = await fetch(request, { cache: 'no-store' });
|
|
if (response.ok) {
|
|
const payload = await response.clone().json().catch(() => null);
|
|
if (payload && !Array.isArray(payload)) {
|
|
await writeOddsSnapshotFromDetail(payload).catch(() => {});
|
|
}
|
|
}
|
|
return response;
|
|
} catch {
|
|
return new Response(JSON.stringify({
|
|
message: '賽事資料暫時無法即時更新,請稍後重新整理。',
|
|
offline: true,
|
|
source: 'service-worker-network-only',
|
|
}), {
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
});
|
|
}
|
|
})(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (request.mode === 'navigate') {
|
|
event.respondWith(
|
|
fetch(request).catch(() => caches.match(OFFLINE_URL)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
event.respondWith(
|
|
(async () => {
|
|
const cached = await caches.match(request);
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (!response.ok) {
|
|
return cached || Promise.reject(new TypeError('response-not-ok'));
|
|
}
|
|
|
|
cacheSafeCopy(request, response);
|
|
|
|
return response;
|
|
} catch {
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
|
|
if (request.mode === 'navigate') {
|
|
return caches.match(OFFLINE_URL);
|
|
}
|
|
|
|
if (url.pathname.startsWith('/api/')) {
|
|
return new Response(JSON.stringify({
|
|
message: '資料服務暫時無法連線,請稍後重新整理。',
|
|
offline: true,
|
|
source: 'service-worker',
|
|
}), {
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
});
|
|
}
|
|
|
|
return new Response('Service unavailable', {
|
|
status: 503,
|
|
statusText: 'Service Unavailable',
|
|
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
});
|
|
}
|
|
})(),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('message', (event) => {
|
|
if (event.data?.type === 'clear-cache') {
|
|
caches.keys().then((keys) => {
|
|
keys
|
|
.filter((key) => key.startsWith('wc2026-') || key.startsWith('fifa2026'))
|
|
.forEach((key) => caches.delete(key));
|
|
});
|
|
}
|
|
|
|
if (event.data?.type === 'skip-waiting') {
|
|
self.skipWaiting();
|
|
}
|
|
|
|
if (event.data?.type === 'persist-odds' && event.data?.payload) {
|
|
const payload = event.data.payload;
|
|
caches.open(CACHE_NAME).then((cache) => {
|
|
cache.put(
|
|
new Request(`/api/${ODDS_KEYS}`),
|
|
new Response(JSON.stringify(payload), {
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}),
|
|
);
|
|
});
|
|
}
|
|
});
|
|
|
|
self.addEventListener('push', (event) => {
|
|
let notify = {
|
|
title: '🚨 鎖利警報',
|
|
body: '鎖利機會已觸發,請檢查您的串關進度並考慮提前對沖。',
|
|
url: '/portfolio',
|
|
};
|
|
|
|
if (event.data) {
|
|
try {
|
|
const payload = event.data.json();
|
|
notify = {
|
|
title: payload.title || notify.title,
|
|
body: payload.body || notify.body,
|
|
url: payload.url || notify.url,
|
|
};
|
|
} catch {
|
|
notify.body = event.data.text() || notify.body;
|
|
}
|
|
}
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(notify.title, {
|
|
body: notify.body,
|
|
icon: '/manifest-icon-192.svg',
|
|
badge: '/manifest-icon-192.svg',
|
|
data: { url: notify.url },
|
|
tag: 'wc2026-alert',
|
|
requireInteraction: true,
|
|
vibrate: [80, 80, 80],
|
|
}),
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
const target = event.notification.data?.url || '/';
|
|
|
|
event.waitUntil(
|
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
|
|
for (const client of clients) {
|
|
if (client.url === `${self.location.origin}${target}` && 'focus' in client) {
|
|
return client.focus();
|
|
}
|
|
}
|
|
if (self.clients.openWindow) {
|
|
return self.clients.openWindow(target);
|
|
}
|
|
return undefined;
|
|
}),
|
|
);
|
|
});
|