From 61878da91d6627cd8cd36a9e001f080cd7c4f8a2 Mon Sep 17 00:00:00 2001 From: wooo Date: Thu, 18 Jun 2026 12:38:06 +0800 Subject: [PATCH] fix: restore production build dependencies --- .gitea/workflows/cd.yaml | 10 +++ platform/web/Dockerfile | 4 +- platform/web/components/HeaderNav.tsx | 93 +++++++++++++++++++++ platform/web/lib/match-order.ts | 111 ++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 platform/web/components/HeaderNav.tsx create mode 100644 platform/web/lib/match-order.ts diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 1d9480a..e1e0c2b 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -88,6 +88,16 @@ jobs: cd platform/web npm run lint + - name: Run Frontend Production Build + env: + DATABASE_URL: postgresql://fifa_user:ci-placeholder-db-password@127.0.0.1:5432/fifa2026 + NEXTAUTH_SECRET: ci-placeholder-nextauth-secret + NEXTAUTH_URL: https://2026fifa.wooo.work + ANALYTICS_BACKEND_URL: http://127.0.0.1:8000 + run: | + cd platform/web + npm run build + - name: Validate Docker Compose env: DB_PASSWORD: ci-placeholder-db-password diff --git a/platform/web/Dockerfile b/platform/web/Dockerfile index 094fc83..b51e920 100644 --- a/platform/web/Dockerfile +++ b/platform/web/Dockerfile @@ -3,8 +3,8 @@ FROM node:22-alpine AS deps WORKDIR /app RUN apk add --no-cache openssl -COPY package.json ./ -RUN npm install --legacy-peer-deps +COPY package.json package-lock.json ./ +RUN npm ci --legacy-peer-deps # ── stage 2: build ───────────────────────────────────────────────────────────── FROM node:22-alpine AS builder diff --git a/platform/web/components/HeaderNav.tsx b/platform/web/components/HeaderNav.tsx new file mode 100644 index 0000000..c25b309 --- /dev/null +++ b/platform/web/components/HeaderNav.tsx @@ -0,0 +1,93 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +type NavItem = { + href: string; + label: string; + shortLabel: string; + description: string; +}; + +const PRIMARY_NAV_ITEMS: NavItem[] = [ + { href: '/', label: '主控總覽', shortLabel: '總覽', description: '首頁先看今日推薦與資料健康' }, + { href: '/daily-card', label: '每日作戰室', shortLabel: '作戰室', description: '依日期檢查賽事與推薦' }, + { href: '/matches', label: '完整賽程', shortLabel: '賽程', description: '所有賽事、比分與狀態' }, + { href: '/deep-bet', label: '投注研究', shortLabel: '投注', description: '單關、串關與組合評估' }, + { href: '/odds', label: '盤口監控', shortLabel: '盤口', description: '賠率覆蓋與即時變化' }, + { href: '/sharp-money', label: '聰明錢追蹤', shortLabel: '聰明錢', description: '資金流向與市場偏差' }, +]; + +const RESEARCH_NAV_ITEMS: NavItem[] = [ + { href: '/rlm', label: '反向盤口雷達', shortLabel: '反向盤', description: '少數派資金與賠率逆行' }, + { href: '/models', label: '量化模型', shortLabel: '模型', description: '泊松、EV 與風險校準' }, + { href: '/ml-edge', label: '機器學習邊緣', shortLabel: 'ML', description: '模型機率與市場機率差' }, + { href: '/match-conditions', label: '裁判/天候模型', shortLabel: '天候', description: '場地、熱度與判罰風險' }, + { href: '/props', label: '球員道具盤', shortLabel: '道具', description: '射門、傳球與球員線' }, + { href: '/kelly', label: '凱利配置', shortLabel: '凱利', description: '注碼上限與風險控管' }, + { href: '/backtesting', label: '策略回測', shortLabel: '回測', description: '歷史命中率與收益曲線' }, + { href: '/proof-of-yield', label: '公開收益帳本', shortLabel: '帳本', description: '推薦賽後校準與命中率' }, + { href: '/portfolio', label: '個人組合', shortLabel: '組合', description: '追蹤清單與部位管理' }, +]; + +function isActivePath(pathname: string, href: string): boolean { + if (href === '/') { + return pathname === '/'; + } + return pathname === href || pathname.startsWith(`${href}/`); +} + +function NavPill({ item, active }: { item: NavItem; active: boolean }) { + return ( + + {item.label} + {item.shortLabel} + + ); +} + +export function HeaderNav() { + const pathname = usePathname() || '/'; + const allItems = [...PRIMARY_NAV_ITEMS, ...RESEARCH_NAV_ITEMS]; + const activeItem = allItems.find((item) => isActivePath(pathname, item.href)) ?? PRIMARY_NAV_ITEMS[0]; + + return ( +
+ +

+ 2026 世界盃
量化投注研究中心 +

+

+ 台北時間 (UTC+8) | 台灣量化實戰研究版 +

+

+ 目前頁面:{activeItem.label} +

+ + + +
+ ); +} diff --git a/platform/web/lib/match-order.ts b/platform/web/lib/match-order.ts new file mode 100644 index 0000000..bc6061e --- /dev/null +++ b/platform/web/lib/match-order.ts @@ -0,0 +1,111 @@ +export type MatchStatusKind = 'live' | 'upcoming' | 'finished' | 'postponed' | 'unknown'; + +type MatchStatusInput = { + kickoff_utc?: string | null; + match_time_utc?: string | null; + match_time?: string | null; + status?: string | null; + home_score?: number | null; + away_score?: number | null; +}; + +const LIVE_WINDOW_MS = 150 * 60 * 1000; +const RESULT_BACKFILL_GRACE_MS = 240 * 60 * 1000; + +function kickoffTimestamp(match: MatchStatusInput): number | null { + const raw = match.kickoff_utc || match.match_time_utc || match.match_time; + if (!raw) { + return null; + } + const timestamp = Date.parse(raw); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function normalizedStatus(match: MatchStatusInput): string { + return (match.status || '').trim().toLowerCase().replace(/_/g, '-'); +} + +function hasScore(match: MatchStatusInput): boolean { + return typeof match.home_score === 'number' && typeof match.away_score === 'number'; +} + +export function matchStatusKind(match: MatchStatusInput, now: Date = new Date()): MatchStatusKind { + const status = normalizedStatus(match); + const kickoff = kickoffTimestamp(match); + const nowMs = now.getTime(); + + if (/postponed|delayed|suspended|cancelled|canceled|abandoned/.test(status)) { + return 'postponed'; + } + + if (/finished|final|full-time|fulltime|ft|completed|closed/.test(status)) { + return 'finished'; + } + + if (/live|in-play|inplay|playing|ongoing|1st|2nd|first-half|second-half|halftime|half-time/.test(status)) { + return 'live'; + } + + if (kickoff === null) { + return hasScore(match) ? 'finished' : 'unknown'; + } + + if (kickoff > nowMs) { + return 'upcoming'; + } + + const elapsed = nowMs - kickoff; + if (elapsed <= LIVE_WINDOW_MS) { + return 'live'; + } + + if (hasScore(match) || elapsed > RESULT_BACKFILL_GRACE_MS) { + return hasScore(match) ? 'finished' : 'unknown'; + } + + return 'unknown'; +} + +export function matchStatusLabel(match: MatchStatusInput, now: Date = new Date()): string { + const kind = matchStatusKind(match, now); + const score = hasScore(match) ? `${match.home_score}-${match.away_score}` : null; + + if (kind === 'live') { + return score ? `進行中 ${score}` : '進行中'; + } + if (kind === 'finished') { + return score ? `已完賽 ${score}` : '已完賽'; + } + if (kind === 'postponed') { + return '延期/中止'; + } + if (kind === 'upcoming') { + return '未開賽'; + } + return '結果待回填'; +} + +export function sortMatchesForProfessionalDisplay(matches: T[], now: Date = new Date()): T[] { + const rank: Record = { + live: 0, + upcoming: 1, + unknown: 2, + postponed: 3, + finished: 4, + }; + + return [...matches].sort((a, b) => { + const kindA = matchStatusKind(a, now); + const kindB = matchStatusKind(b, now); + if (kindA !== kindB) { + return rank[kindA] - rank[kindB]; + } + + const timeA = kickoffTimestamp(a) ?? 0; + const timeB = kickoffTimestamp(b) ?? 0; + if (kindA === 'finished') { + return timeB - timeA; + } + return timeA - timeB; + }); +}