Initial commit with 2026 World Cup Quant Platform core modules and CI/CD
This commit is contained in:
12
platform/web/public/manifest-icon-192.svg
Normal file
12
platform/web/public/manifest-icon-192.svg
Normal file
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0%" stop-color="#7d2a15" />
|
||||
<stop offset="100%" stop-color="#f6a45a" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="192" height="192" rx="30" fill="url(#g)" />
|
||||
<rect x="22" y="22" width="148" height="148" rx="20" fill="#f6f0e1" />
|
||||
<rect x="40" y="56" width="112" height="80" rx="8" fill="#7d2a15" />
|
||||
<text x="56" y="112" fill="#fff8e5" font-size="26" font-family="monospace">FIFA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 582 B |
13
platform/web/public/manifest-icon-512.svg
Normal file
13
platform/web/public/manifest-icon-512.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<radialGradient id="g" cx="0.2" cy="0.2" r="1">
|
||||
<stop offset="0%" stop-color="#7d2a15" />
|
||||
<stop offset="100%" stop-color="#f6a45a" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="64" fill="url(#g)" />
|
||||
<rect x="72" y="72" width="368" height="296" rx="30" fill="#f6f0e1" />
|
||||
<rect x="104" y="116" width="304" height="208" rx="18" fill="#7d2a15" />
|
||||
<text x="136" y="258" fill="#fff8e5" font-size="64" font-family="monospace">FIFA</text>
|
||||
<text x="136" y="332" fill="#fff8e5" font-size="48" font-family="monospace">2026</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
33
platform/web/public/manifest.json
Normal file
33
platform/web/public/manifest.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "2026 FIFA Quantum Ops",
|
||||
"short_name": "FIFA 2026 Ops",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"lang": "zh-Hant",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#f6f0e1",
|
||||
"theme_color": "#7d2a15",
|
||||
"description": "台北時區驅動的 2026 世界盃量化投注研究平台",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/manifest-icon-192.svg",
|
||||
"sizes": "192x192",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/manifest-icon-512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
],
|
||||
"screenshots": [
|
||||
{
|
||||
"src": "/manifest-icon-512.svg",
|
||||
"sizes": "512x512",
|
||||
"type": "image/svg+xml"
|
||||
}
|
||||
]
|
||||
}
|
||||
210
platform/web/public/sw.js
Normal file
210
platform/web/public/sw.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const CACHE_NAME = 'wc2026-quant-v1';
|
||||
const OFFLINE_URL = '/offline';
|
||||
const ASSETS = ['/', '/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
|
||||
const ODDS_KEYS = 'wc2026:lastOddsSnapshot';
|
||||
|
||||
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.includes('/api/analytics') || request.url.includes('/_next') || request.url.endsWith('.png') || request.url.endsWith('.svg') || request.url.endsWith('.css') || request.url.endsWith('.js'));
|
||||
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;
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/api/analytics/matches')) {
|
||||
const clone = response.clone();
|
||||
cacheSafeCopy(request, response);
|
||||
const payload = await clone.json().catch(() => null);
|
||||
await writeOddsSnapshotFromDetail(payload).catch(() => {});
|
||||
} else {
|
||||
cacheSafeCopy(request, response);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch {
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
if (request.mode === 'navigate') {
|
||||
return caches.match(OFFLINE_URL);
|
||||
}
|
||||
|
||||
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.delete(CACHE_NAME);
|
||||
}
|
||||
|
||||
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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user