Files
2026FIFAWorldCup/platform/web/app/page.tsx
wooo 05a28833c7
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality, Security Gate & Testing (push) Successful in 3m37s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Gitea CD (push) Failing after 22s
ci: make frontend lint noninteractive
2026-06-18 12:04:00 +08:00

881 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import { formatToTaipeiTime } from '@/lib/timezone';
import {
getAllMatches,
getDailyCard,
getDailyCardCalendar,
getSourceHealth,
type DailyCardCalendarDate,
type DailyCardItem,
type DailyCardResponse,
type MatchListItem,
type SourceHealthResponse,
} from '@/lib/analytics-api';
type NewsItem = {
title: string;
url: string;
source: string;
publishedAt: string;
};
type LoadErrors = {
daily?: string;
matches?: string;
news?: string;
source?: string;
};
type GateState = {
label: string;
title: string;
detail: string;
tone: 'ready' | 'watch' | 'blocked';
primaryAction: string;
};
const WATCHLIST_KEY = 'fifa2026-daily-card-watchlist';
const TOURNAMENT_START_DATE = '2026-06-11';
const moduleLinks = [
{ href: '/daily-card', label: '每日作戰室', desc: '單關、串關、同場串關與賠率監控' },
{ href: '/live-score', label: '即時比分中心', desc: '日期切換、即時比分、完賽結果與場地資訊' },
{ href: '/schedule', label: '完整賽程表', desc: '依台北時間查看下一場與已完賽結果' },
{ href: '/battle-room', label: '對戰情報室', desc: '模型勝率、進球分布、環境條件與盤口快照' },
{ href: '/source-health', label: '資料健康總覽', desc: '檢查賽程、比分、新聞與盤口是否即時更新' },
{ href: '/recommendation-performance', label: '賽後校準室', desc: '逐玩法回看命中率並修正模型權重' },
{ href: '/agent-verification', label: 'AI 驗證室', desc: 'Gemini 費用上限、NemoTron 復核與量化閘門' },
{ href: '/news', label: '即時新聞情報', desc: '新聞、傷停與外部事件監控' },
];
function pickKey(item: DailyCardItem): string {
const normalize = (value: unknown) => String(value ?? '').replace(/\s+/g, ' ').trim();
return [normalize(item.match_id), normalize(item.market_type), normalize(item.selection)].join('|');
}
function formatTwd(value: number): string {
return new Intl.NumberFormat('zh-TW', {
style: 'currency',
currency: 'TWD',
maximumFractionDigits: 0,
}).format(value);
}
function stakeAmountTwd(item: DailyCardItem): number {
return item.stake_amount_twd ?? Math.round(item.stake_units * (item.unit_size_twd ?? 1000));
}
function itemsFromCard(card: DailyCardResponse | null): DailyCardItem[] {
if (!card) return [];
return [...card.safe_singles, ...card.safe_parlays, ...card.sgp_lotteries, ...card.high_risk_singles];
}
function matchDate(match: MatchListItem): string {
return formatToTaipeiTime(match.kickoff_utc, 'yyyy-MM-dd');
}
function isConditionalPick(item: DailyCardItem): boolean {
return (
item.has_market_odds === false ||
item.selection.includes('預掛條件') ||
item.selection.includes('參考盤監控') ||
item.rationale.includes('尚未取得可用即時盤口') ||
item.rationale.includes('尚未取得完整實盤賠率') ||
Boolean(item.legs?.some((leg) => leg.selection.includes('預掛條件')))
);
}
function recommendationText(item: DailyCardItem): string {
const conditional = isConditionalPick(item);
if (item.recommendation === 'SAFE_SINGLE') return conditional ? '觀察單關' : '核心單關';
if (item.recommendation === 'HIGH_RISK_SINGLE') return conditional ? '高賠觀察' : '小注高賠';
if (item.recommendation === 'SAFE_PARLAY') return conditional ? '觀察串關' : '跨場串關';
if (item.recommendation === 'SGP_LOTTERY') return conditional ? '同場觀察' : '同場小注';
return '研究候選';
}
function nextStepText(item: DailyCardItem): string {
if (isConditionalPick(item) || !item.has_market_odds) {
return '現在不要下注,先加入賠率監控。等平台開盤後,只有實際賠率達到卡片門檻才考慮。';
}
if (item.odds_source_kind === 'reference_market') {
return '已有台灣運彩參考盤,可先做下注前檢查;但它不是多來源正式盤,下單前仍要確認平台最新盤口。';
}
return '可進入下注前檢查。下單前仍要確認同一場、同一玩法、同一選項與賠率門檻。';
}
function buildGateState(
sourceHealth: SourceHealthResponse | null,
errors: LoadErrors,
liveCount: number,
watchCount: number,
): GateState {
if (errors.source) {
return {
label: '資料源異常',
title: watchCount > 0 ? '正式下注先保留,預掛候選持續監控' : '資料源恢復前只保留研究觀察',
detail: watchCount > 0
? `資料源健康狀態暫時無法讀取,但仍保留 ${watchCount} 組預掛候選。這些不是立即下注單,必須等資料源恢復並達到賠率門檻。`
: '資料源健康狀態暫時無法讀取,首頁只保留資訊瀏覽,不把不完整資料包裝成高勝率下注。',
tone: watchCount > 0 ? 'watch' : 'blocked',
primaryAction: watchCount > 0 ? '查看預掛候選' : '檢查資料源',
};
}
if (!sourceHealth) {
return {
label: '同步中',
title: '正在讀取最新資料',
detail: '系統正在同步賽程、賽果、新聞與盤口狀態,完成後才會顯示候選。',
tone: 'watch',
primaryAction: '稍候刷新',
};
}
if ((sourceHealth.stale_unsettled_matches ?? 0) > 0) {
return {
label: '賽果延遲',
title: watchCount > 0 ? '正式下注暫停,預掛觀察持續更新' : '賽果追上前不硬推下注',
detail: watchCount > 0
? `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果,所以不升級成正式下注推薦;但 ${watchCount} 組日期盤候選仍會持續監控賠率門檻。`
: `仍有 ${sourceHealth.stale_unsettled_matches} 場已開賽很久但尚未回寫結果。資料追上前,不會硬推下注,只保留資訊與條件監控。`,
tone: watchCount > 0 ? 'watch' : 'blocked',
primaryAction: watchCount > 0 ? '查看預掛候選' : '查看賽事中心',
};
}
if (sourceHealth.odds_coverage_status === 'full_market' && liveCount > 0) {
return {
label: '可檢查',
title: '已有實盤候選,可做下注前檢查',
detail: `目前有 ${liveCount} 組候選具備可比對盤口。仍需確認賠率、分線、傷停與注碼上限後才下單。`,
tone: 'ready',
primaryAction: '檢查推薦',
};
}
if (watchCount > 0) {
return {
label: '預掛觀察',
title: '日期盤候選持續推薦,先等賠率達標',
detail: `已找到 ${watchCount} 組模型候選,但正式盤口不足或仍是比分備援來源。這些會持續推薦為預掛觀察,不是叫你立刻下注;等平台賠率達到門檻再進下注前檢查。`,
tone: 'watch',
primaryAction: '查看預掛候選',
};
}
return {
label: '暫無候選',
title: '沒有達標候選就不硬推',
detail: '目前沒有同時通過勝率、最低賠率、資料新鮮度與風險上限的選項。保留資金比硬下注更專業。',
tone: 'blocked',
primaryAction: '等待刷新',
};
}
function gateClass(tone: GateState['tone']): string {
if (tone === 'ready') return 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]';
if (tone === 'blocked') return 'border-[#e7a49a] bg-[#fff0e8] text-[#b83822]';
return 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]';
}
function weekdayLabel(date: string): string {
return new Intl.DateTimeFormat('zh-TW', {
timeZone: 'Asia/Taipei',
weekday: 'short',
}).format(new Date(`${date}T00:00:00+08:00`));
}
function recommendationDateLabel(date: string, today: string, tomorrow: string): string {
if (date === today) return `今天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
if (date === tomorrow) return `明天 ${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
return `${date.slice(5).replace('-', '/')} ${weekdayLabel(date)}`;
}
function recommendationDateState(date: string, today: string): string {
if (date < today) return '已完賽回看';
if (date === today) return '今日優先';
return '預售分析';
}
export default function Home() {
const [dateWindow] = useState(() => {
const now = new Date();
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
return {
today: formatToTaipeiTime(now.toISOString(), 'yyyy-MM-dd'),
tomorrow: formatToTaipeiTime(tomorrow.toISOString(), 'yyyy-MM-dd'),
};
});
const [data, setData] = useState<DailyCardResponse | null>(null);
const [tomorrowData, setTomorrowData] = useState<DailyCardResponse | null>(null);
const [dailyCards, setDailyCards] = useState<Record<string, DailyCardResponse>>({});
const [calendarDates, setCalendarDates] = useState<DailyCardCalendarDate[]>([]);
const [selectedRecommendationDate, setSelectedRecommendationDate] = useState('');
const [matches, setMatches] = useState<MatchListItem[]>([]);
const [news, setNews] = useState<NewsItem[]>([]);
const [sourceHealth, setSourceHealth] = useState<SourceHealthResponse | null>(null);
const [watchlist, setWatchlist] = useState<DailyCardItem[]>([]);
const [feedbackMessage, setFeedbackMessage] = useState('');
const [loading, setLoading] = useState(true);
const [errors, setErrors] = useState<LoadErrors>({});
const [loadedAt, setLoadedAt] = useState('');
useEffect(() => {
try {
const raw = window.localStorage.getItem(WATCHLIST_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
setWatchlist(parsed.filter(Boolean).slice(0, 20));
}
} catch {
window.localStorage.removeItem(WATCHLIST_KEY);
}
}, []);
useEffect(() => {
let mounted = true;
async function load() {
setLoading(true);
setErrors({});
const [dailyResult, tomorrowResult, matchesResult, newsResult, sourceResult, calendarResult] = await Promise.allSettled([
getDailyCard(dateWindow.today),
getDailyCard(dateWindow.tomorrow),
getAllMatches(),
fetch('/api/news', { cache: 'no-store' }).then(async (response) => {
if (!response.ok) throw new Error(`news status ${response.status}`);
return response.json() as Promise<{ items?: NewsItem[] }>;
}),
getSourceHealth(),
getDailyCardCalendar(TOURNAMENT_START_DATE),
]);
if (!mounted) return;
const nextErrors: LoadErrors = {};
const calendarPayload = calendarResult.status === 'fulfilled' ? calendarResult.value.dates : [];
const calendarDateList = calendarPayload.map((item) => item.date).sort((a, b) => a.localeCompare(b));
const pinnedDates = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarDateList.includes(date));
const pinnedSet = new Set(pinnedDates);
const nextCards: Record<string, DailyCardResponse> = {};
const requestDates = [...pinnedDates, ...calendarDateList.filter((date) => !pinnedSet.has(date))];
const preferredDate = calendarPayload.find((item) => pinnedDates.includes(item.date) && item.recommendation_count > 0)?.date
?? calendarPayload.find((item) => item.date >= dateWindow.today && item.recommendation_count > 0)?.date
?? pinnedDates[0]
?? requestDates.find((date) => date >= dateWindow.today)
?? requestDates[0]
?? dateWindow.today;
if (!mounted) return;
if (dailyResult.status === 'fulfilled') {
setData(dailyResult.value);
nextCards[dateWindow.today] = dailyResult.value;
}
else nextErrors.daily = '每日推薦 API 尚未回應';
if (tomorrowResult.status === 'fulfilled') {
setTomorrowData(tomorrowResult.value);
nextCards[dateWindow.tomorrow] = tomorrowResult.value;
}
else nextErrors.daily = nextErrors.daily ?? '明日推薦 API 尚未回應';
if (matchesResult.status === 'fulfilled') setMatches(matchesResult.value);
else nextErrors.matches = '賽事中心 API 尚未回應';
if (newsResult.status === 'fulfilled') setNews(newsResult.value.items ?? []);
else nextErrors.news = '新聞 RSS 暫時無法更新';
if (sourceResult.status === 'fulfilled') setSourceHealth(sourceResult.value);
else nextErrors.source = '盤口資料源健康狀態暫時無法讀取';
if (calendarResult.status === 'fulfilled') setCalendarDates(calendarPayload);
else nextErrors.daily = nextErrors.daily ?? '日期推薦摘要 API 尚未回應';
setDailyCards(nextCards);
setSelectedRecommendationDate((current) => {
if (requestDates.includes(current)) return current;
return preferredDate;
});
setErrors(nextErrors);
setLoadedAt(formatToTaipeiTime(new Date().toISOString()));
setLoading(false);
}
load().catch((error) => {
if (mounted) {
setErrors({ daily: error instanceof Error ? error.message : '無法取得最新量化資料' });
setLoading(false);
}
});
const interval = window.setInterval(() => {
load().catch(() => undefined);
}, 60_000);
return () => {
mounted = false;
window.clearInterval(interval);
};
}, [dateWindow.today, dateWindow.tomorrow]);
useEffect(() => {
if (!selectedRecommendationDate || dailyCards[selectedRecommendationDate]) return undefined;
let mounted = true;
getDailyCard(selectedRecommendationDate)
.then((card) => {
if (!mounted) return;
setDailyCards((current) => ({ ...current, [selectedRecommendationDate]: card }));
})
.catch(() => {
if (!mounted) return;
setErrors((current) => ({ ...current, daily: '此日期推薦卡片暫時無法載入' }));
});
return () => {
mounted = false;
};
}, [dailyCards, selectedRecommendationDate]);
const matchesByDate = useMemo(() => {
const grouped = new Map<string, MatchListItem[]>();
matches.forEach((match) => {
const date = matchDate(match);
if (date < TOURNAMENT_START_DATE) return;
grouped.set(date, [...(grouped.get(date) ?? []), match]);
});
return grouped;
}, [matches]);
const chronologicalDates = useMemo(() => {
return calendarDates.map((item) => item.date).sort((a, b) => a.localeCompare(b));
}, [calendarDates]);
const calendarByDate = useMemo(() => {
return new Map(calendarDates.map((item) => [item.date, item]));
}, [calendarDates]);
const recommendationDateOptions = useMemo(() => {
const pinned = [dateWindow.today, dateWindow.tomorrow].filter((date) => calendarByDate.has(date));
const pinnedSet = new Set(pinned);
return [...pinned, ...chronologicalDates.filter((date) => !pinnedSet.has(date))];
}, [calendarByDate, chronologicalDates, dateWindow.today, dateWindow.tomorrow]);
const activeRecommendationDate = selectedRecommendationDate || recommendationDateOptions[0] || dateWindow.today;
const selectedCard = dailyCards[activeRecommendationDate]
?? (activeRecommendationDate === dateWindow.tomorrow ? tomorrowData : null)
?? (activeRecommendationDate === dateWindow.today ? data : null);
const recommendationItems = useMemo(() => itemsFromCard(selectedCard), [selectedCard]);
const selectedDateSummary = calendarByDate.get(activeRecommendationDate);
const selectedMatchCount = selectedDateSummary?.match_count ?? matchesByDate.get(activeRecommendationDate)?.length ?? selectedCard?.matched_matches ?? 0;
const dateSummaries = useMemo(() => {
return recommendationDateOptions.map((date) => {
const card = dailyCards[date]
?? (date === dateWindow.today ? data : null)
?? (date === dateWindow.tomorrow ? tomorrowData : null);
const items = itemsFromCard(card);
const summary = calendarByDate.get(date);
return {
date,
matchCount: summary?.match_count ?? matchesByDate.get(date)?.length ?? card?.matched_matches ?? 0,
recommendationCount: summary?.recommendation_count ?? items.length,
liveCount: summary?.live_count ?? items.filter((item) => item.has_market_odds).length,
};
});
}, [calendarByDate, dailyCards, data, dateWindow.today, dateWindow.tomorrow, matchesByDate, recommendationDateOptions, tomorrowData]);
const topPicks = useMemo<DailyCardItem[]>(() => {
const rank = (items: DailyCardItem[]) =>
[...items].sort((a, b) => {
const aLiveBonus = a.has_market_odds ? 18 : 0;
const bLiveBonus = b.has_market_odds ? 18 : 0;
const aScore = aLiveBonus + (a.confidence_score ?? a.win_prob) + (a.ev_percent * 0.22) - (a.stake_units * 0.8);
const bScore = bLiveBonus + (b.confidence_score ?? b.win_prob) + (b.ev_percent * 0.22) - (b.stake_units * 0.8);
return bScore - aScore;
});
const singles = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_SINGLE'));
const parlays = rank(recommendationItems.filter((item) => item.recommendation === 'SAFE_PARLAY'));
const sameGame = rank(recommendationItems.filter((item) => item.recommendation === 'SGP_LOTTERY'));
const highRisk = rank(recommendationItems.filter((item) => item.recommendation === 'HIGH_RISK_SINGLE'));
const balanced = [...singles.slice(0, 3), ...parlays.slice(0, 2), ...sameGame.slice(0, 1), ...highRisk.slice(0, 2)];
const used = new Set(balanced.map(pickKey));
const remaining = rank(recommendationItems).filter((item) => !used.has(pickKey(item)));
return [...balanced, ...remaining].slice(0, 10);
}, [recommendationItems]);
const categorySummary = useMemo(() => {
const count = (type: DailyCardItem['recommendation']) => recommendationItems.filter((item) => item.recommendation === type).length;
return [
{ label: '單關', value: count('SAFE_SINGLE') },
{ label: '跨場串關', value: count('SAFE_PARLAY') },
{ label: '同場串關', value: count('SGP_LOTTERY') },
{ label: '小注高賠', value: count('HIGH_RISK_SINGLE') },
];
}, [recommendationItems]);
const nextMatches = useMemo(() => {
const now = Date.now();
return matches
.filter((match) => new Date(match.kickoff_utc).getTime() >= now - 1000 * 60 * 120)
.sort((a, b) => new Date(a.kickoff_utc).getTime() - new Date(b.kickoff_utc).getTime())
.slice(0, 5);
}, [matches]);
const watchlistKeys = useMemo(() => new Set(watchlist.map(pickKey)), [watchlist]);
const liveRecommendationCount = recommendationItems.filter((item) => item.has_market_odds).length;
const watchOnlyRecommendationCount = recommendationItems.length - liveRecommendationCount;
const gate = buildGateState(sourceHealth, errors, liveRecommendationCount, watchOnlyRecommendationCount);
const latestResultLabel = sourceHealth?.latest_result_synced_at ? formatToTaipeiTime(sourceHealth.latest_result_synced_at) : '尚無賽果時間';
const latestNewsLabel = sourceHealth?.news_status?.run_at ? formatToTaipeiTime(sourceHealth.news_status.run_at) : '尚無新聞排程';
const latestFixturesLabel = sourceHealth?.fixtures_status?.run_at ? formatToTaipeiTime(sourceHealth.fixtures_status.run_at) : '尚無賽程排程';
const oddsCoverageLabel = sourceHealth?.odds_coverage_status === 'full_market'
? '完整實盤'
: sourceHealth?.odds_coverage_status === 'reference_market'
? '台灣運彩參考盤'
: sourceHealth?.odds_coverage_status === 'limited_scoreboard_fallback'
? '比分備援,盤口不足'
: sourceHealth?.odds_coverage_status === 'no_upcoming_market'
? '未來賽事無實盤'
: '資料不足';
const activeDateLabel = recommendationDateLabel(activeRecommendationDate, dateWindow.today, dateWindow.tomorrow);
const activeDateState = recommendationDateState(activeRecommendationDate, dateWindow.today);
const activeDateRecommendationCount = selectedDateSummary?.recommendation_count ?? recommendationItems.length;
const activeDateLiveCount = selectedDateSummary?.live_count ?? liveRecommendationCount;
const activeDateWatchCount = Math.max(activeDateRecommendationCount - activeDateLiveCount, 0);
const activeDateSummaryText = `${activeDateLabel}${selectedMatchCount} 場比賽、${activeDateRecommendationCount} 筆候選,其中 ${activeDateLiveCount} 筆可做下注前檢查、${activeDateWatchCount} 筆先監控賠率。`;
const healthItems = [
{
label: '資料可用性',
ok: Boolean(sourceHealth) && (sourceHealth?.stale_unsettled_matches ?? 0) === 0 && !errors.source,
detail: errors.source ?? `${oddsCoverageLabel};賽果 ${latestResultLabel},逾時未更新 ${sourceHealth?.stale_unsettled_matches ?? 0}`,
},
{
label: '推薦狀態',
ok: liveRecommendationCount > 0,
detail: `${liveRecommendationCount} 組可檢查、${watchOnlyRecommendationCount} 組只監控;目前日期掃描 ${selectedMatchCount}`,
},
{
label: '賽程與新聞',
ok: matches.length > 0 && news.length > 0 && !errors.matches && !errors.news,
detail: `賽程 ${matches.length} 場,排程 ${latestFixturesLabel};新聞 ${news.length} 則,排程 ${latestNewsLabel}`,
},
];
function handleTrack(item: DailyCardItem) {
const key = pickKey(item);
if (watchlistKeys.has(key)) {
setFeedbackMessage(`已在清單中:${item.match_label} ${item.selection}`);
return;
}
const next = [item, ...watchlist].slice(0, 20);
setWatchlist(next);
setFeedbackMessage(`已加入${isConditionalPick(item) || !item.has_market_odds ? '賠率監控清單' : '下注前檢查清單'}${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage(`已加入本頁清單,但瀏覽器暫時無法永久保存:${item.match_label} ${item.selection}`);
}
}
function handleRemoveTracked(item: DailyCardItem) {
const key = pickKey(item);
const next = watchlist.filter((tracked) => pickKey(tracked) !== key);
setWatchlist(next);
setFeedbackMessage(`已移除監控:${item.match_label} ${item.selection}`);
try {
window.localStorage.setItem(WATCHLIST_KEY, JSON.stringify(next));
} catch {
setFeedbackMessage('已從本頁清單移除;瀏覽器永久保存狀態稍後會在重新整理後同步。');
}
}
return (
<div className="space-y-8">
<section className="rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-xs font-semibold text-[#b83822]"> / {dateWindow.today} - {dateWindow.tomorrow}</p>
<h2 className="mt-2 text-3xl font-black text-[#3f2f25] md:text-5xl"></h2>
<p className="mt-3 max-w-3xl text-sm leading-7 text-[#6f4f3c]">
</p>
<p className="mt-3 rounded-2xl border border-[#eadcb9] bg-white/75 px-4 py-3 text-sm font-bold text-[#5f4330]">
<span className="text-[#b83822]">{activeRecommendationDate}</span>
{selectedMatchCount} {recommendationItems.length}
</p>
</div>
<div className="flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-[#e9f8ef] px-3 py-1 text-[#167a47]"> {liveRecommendationCount} </span>
<span className="rounded-full bg-[#fff7d6] px-3 py-1 text-[#8a6400]"> {watchOnlyRecommendationCount} </span>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-[#b83822]">60 </span>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
{dateSummaries.map((item) => {
const pinnedLabel = item.date === dateWindow.today ? '今天' : item.date === dateWindow.tomorrow ? '明天' : '';
return (
<button
key={item.date}
type="button"
onClick={() => setSelectedRecommendationDate(item.date)}
className={`rounded-full border px-3 py-1.5 text-left transition ${
activeRecommendationDate === item.date
? 'border-[#7d2a15] bg-[#7d2a15] text-white'
: 'border-[#d8b58c] bg-white/75 text-[#5f4330] hover:border-[#b83822]'
}`}
>
{item.date}{pinnedLabel ? ` ${pinnedLabel}` : ''}{item.matchCount} {item.recommendationCount}
{item.liveCount > 0 ? `${item.liveCount} 可檢查` : ''}
</button>
);
})}
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-black">
{categorySummary.map((item) => (
<span key={item.label} className="rounded-full border border-[#d8b58c] bg-white/75 px-3 py-1 text-[#5f4330]">
{item.label} {item.value}
</span>
))}
</div>
{feedbackMessage ? (
<p role="status" className="mt-4 rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
<section className="mt-5 rounded-3xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<p className="dot-matrix text-xs font-bold text-[#b83822]"></p>
<h3 className="mt-1 text-xl font-black text-[#3f2f25]">
{watchlist.length ? `已追蹤 ${watchlist.length} 組候選` : '還沒有加入候選'}
</h3>
<p className="mt-1 text-sm leading-6 text-[#7a5b46]">
</p>
</div>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-[#fff8e6] px-4 py-2 text-sm font-black text-[#7d2a15] transition hover:border-[#b83822]">
</Link>
</div>
{watchlist.length ? (
<div className="mt-4 grid gap-3 lg:grid-cols-2">
{watchlist.slice(0, 4).map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
return (
<article key={`watch-${pickKey(item)}`} className="rounded-2xl border border-[#e7c89b] bg-[#fff8e6]/90 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded-full px-2 py-1 text-[11px] font-black ${conditional ? 'bg-[#fff7d6] text-[#8a6400]' : 'bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '先監控' : '可檢查'}
</span>
{item.odds_source_label ? (
<span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span>
) : null}
</div>
<p className="mt-2 text-sm font-black text-[#3f2f25]">{item.match_label}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{item.market_type}{item.selection}</p>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs text-[#7a5b46]">
<p><br /><b className="text-[#b83822]">{item.target_odds.toFixed(2)}</b></p>
<p><br /><b className="text-[#167a47]">{typeof item.confidence_score === 'number' ? item.confidence_score.toFixed(1) : item.win_prob.toFixed(1)}</b></p>
<p><br /><b className="text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</b></p>
</div>
<button
type="button"
className="mt-3 rounded-full border border-[#d8b58c] bg-white px-3 py-1.5 text-xs font-black text-[#7d2a15] transition hover:border-[#b83822]"
onClick={() => handleRemoveTracked(item)}
>
</button>
</article>
);
})}
</div>
) : (
<p className="mt-4 rounded-2xl border border-dashed border-[#d8b58c] bg-[#fff8e6]/80 p-4 text-sm leading-6 text-[#7a5b46]">
便
</p>
)}
</section>
{loading ? (
<p className="mt-6 dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">...</p>
) : topPicks.length ? (
<div className="mt-6 grid gap-4 lg:grid-cols-3">
{topPicks.slice(0, 9).map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
const inList = watchlistKeys.has(pickKey(item));
return (
<article key={`hero-${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-white/80 p-5 shadow-[0_14px_36px_rgba(125,42,21,0.10)]">
<div className="flex flex-wrap items-center gap-2">
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-black ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '預掛觀察' : '下注前檢查'}
</span>
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-[#e9f8ef] px-2 py-1 text-[11px] font-black text-[#167a47]"> {item.confidence_score.toFixed(1)}</span> : null}
{item.odds_source_label ? <span className="rounded-full border border-[#d8b58c] bg-white px-2 py-1 text-[11px] font-black text-[#5f4330]">{item.odds_source_label}</span> : null}
</div>
<h3 className="mt-3 text-lg font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-xs font-bold text-[#7a5b46]">{activeDateLabel}</p>
<p className="mt-2 text-sm font-bold text-[#b83822]">{item.market_type}{item.selection}</p>
<p className="mt-2 text-xs leading-5 text-[#8a6b58]"></p>
<div className="mt-4 grid gap-2 text-sm text-[#5f4330]">
<p><span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p><span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p><span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<p className="mt-3 rounded-2xl bg-[#fff8e6] p-3 text-xs leading-5 text-[#6f4f3c]">
{nextStepText(item)} {item.target_odds.toFixed(2)}
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
aria-pressed={inList}
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
onClick={() => handleTrack(item)}
>
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
</button>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
</div>
</article>
);
})}
</div>
) : (
<div className="mt-6 rounded-3xl border border-dashed border-[#d8b58c] bg-white/65 p-5">
<p className="text-sm leading-7 text-[#6f4f3c]"></p>
</div>
)}
</section>
<section className="relative overflow-hidden rounded-[2rem] border border-[#e7c89b] bg-[#fff8e6]/95 p-6 shadow-[0_18px_60px_rgba(125,42,21,0.12)] md:p-8">
<div className="absolute inset-y-0 right-0 w-1/2 bg-[radial-gradient(circle_at_center,rgba(209,67,45,0.16),transparent_55%)]" />
<div className="relative grid gap-8 lg:grid-cols-[1.08fr_0.92fr]">
<div>
<p className="dot-matrix text-xs font-semibold text-[#b83822]"> / {activeDateState} / {activeRecommendationDate}</p>
<h2 className="mt-4 max-w-3xl text-4xl font-black leading-tight text-[#3f2f25] md:text-6xl">
</h2>
<p className="mt-4 max-w-2xl text-base leading-7 text-[#6f4f3c]">
{activeDateSummaryText}
</p>
<div className={`mt-6 rounded-3xl border p-5 ${gateClass(gate.tone)}`}>
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-white/70 px-3 py-1 text-xs font-black">{gate.label}</span>
<span className="text-xs font-semibold">{loadedAt || '同步中'}</span>
</div>
<h3 className="mt-3 text-2xl font-black">{gate.title}</h3>
<p className="mt-2 text-sm leading-6">{gate.detail}</p>
<div className="mt-4 flex flex-wrap gap-2 text-xs font-bold">
<span className="rounded-full bg-white/65 px-3 py-1">{sourceHealth?.odds_coverage_status === 'full_market' ? '已具備' : '不足'}</span>
<span className="rounded-full bg-white/65 px-3 py-1">{sourceHealth?.stale_unsettled_matches ?? 0} </span>
<span className="rounded-full bg-white/65 px-3 py-1">{activeDateLabel}</span>
<span className="rounded-full bg-white/65 px-3 py-1">{watchlist.length} </span>
</div>
</div>
<div className="mt-6 flex flex-wrap gap-3">
<Link href="/daily-card" className="rounded-full bg-[#d1432d] px-5 py-3 text-sm font-black text-white transition hover:bg-[#b83822]">
{gate.primaryAction}
</Link>
<Link href="/market-coverage" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
<Link href="/recommendation-readiness" className="rounded-full border border-[#d8b58c] bg-white/70 px-5 py-3 text-sm font-semibold text-[#5f4330] transition hover:border-[#1a9a57] hover:text-[#1a9a57]">
</Link>
</div>
</div>
<div className="rounded-3xl border border-[#e7c89b] bg-white/75 p-5 shadow-inner">
<div className="flex items-center justify-between">
<p className="dot-matrix text-sm font-semibold text-[#7d2a15]"></p>
<span className="rounded-full bg-[#fff0e2] px-3 py-1 text-xs text-[#7a5b46]">60 </span>
</div>
<div className="mt-4 grid gap-3">
{[
['1', '先看狀態', '若是只監控或資料延遲,就不要直接下注。'],
['2', '確認玩法', '單關、串關、同場串關要分開看,不混成同一種推薦。'],
['3', '比對賠率', '平台賠率低於最低門檻就跳過,不能硬追。'],
['4', '控制注碼', '只用卡片建議單位,尤其高賠與同場串關要小注。'],
].map(([step, title, detail]) => (
<article key={step} className="rounded-2xl border border-[#eadcb9] bg-[#fff8e6]/80 p-4">
<div className="flex gap-3">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#7d2a15] text-sm font-black text-white">{step}</span>
<div>
<p className="font-bold text-[#5f4031]">{title}</p>
<p className="mt-1 text-xs leading-5 text-[#7a5b46]">{detail}</p>
</div>
</div>
</article>
))}
</div>
</div>
</div>
</section>
<section className="grid gap-4 md:grid-cols-4">
{[
{ label: '可檢查候選', value: selectedCard ? liveRecommendationCount : '-', helper: '同日實盤資料' },
{ label: '監控候選', value: selectedCard ? watchOnlyRecommendationCount : '-', helper: '同日等賠率' },
{ label: '串關玩法', value: selectedCard ? recommendationItems.filter((item) => ['SAFE_PARLAY', 'SGP_LOTTERY'].includes(item.recommendation)).length : '-', helper: '跨場/同場' },
{ label: '近期賽事', value: nextMatches.length || '-', helper: '台北時間排序' },
].map((item) => (
<article key={item.label} className="panel-glow rounded-2xl p-5">
<p className="text-xs font-semibold tracking-[0.2em] text-[#8a6b58]">{item.helper}</p>
<p className="mt-3 text-3xl font-black text-[#7d2a15]">{item.value}</p>
<p className="mt-1 text-sm text-[#6f4f3c]">{item.label}</p>
</article>
))}
</section>
<section className="grid gap-6 lg:grid-cols-[1.38fr_0.62fr]">
<div className="space-y-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="dot-matrix text-xs text-[#b83822]"> / {activeDateState}</p>
<h2 className="text-2xl font-black text-[#3f2f25]">{activeDateLabel} </h2>
<p className="mt-2 text-sm text-[#7a5b46]">{activeDateSummaryText} </p>
</div>
<Link href="/daily-card" className="text-sm font-semibold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
{feedbackMessage ? (
<p role="status" className="rounded-2xl border border-[#9bd8b0] bg-[#e9f8ef] p-3 text-sm font-bold text-[#167a47]">
{feedbackMessage}
</p>
) : null}
{loading ? (
<p className="dot-matrix dot-matrix-loading text-sm text-[#7a5b46]">...</p>
) : topPicks.length ? (
<div className="grid gap-4 md:grid-cols-2">
{topPicks.map((item) => {
const conditional = isConditionalPick(item) || !item.has_market_odds;
const inList = watchlistKeys.has(pickKey(item));
return (
<article key={`${pickKey(item)}-${item.recommendation}`} className="rounded-3xl border border-[#e7c89b] bg-[#fff8e6] p-5 shadow-[0_18px_48px_rgba(125,42,21,0.10)]">
<div className="flex flex-wrap items-center gap-2">
<span className="dot-matrix text-xs font-bold text-[#b83822]">{recommendationText(item)}</span>
<span className={`rounded-full border px-2 py-1 text-[11px] font-bold ${conditional ? 'border-[#e7c462] bg-[#fff7d6] text-[#8a6400]' : 'border-[#7bcf9b] bg-[#e9f8ef] text-[#167a47]'}`}>
{conditional ? '先監控' : '可檢查'}
</span>
{typeof item.confidence_score === 'number' ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#167a47]"> {item.confidence_score.toFixed(1)}</span> : null}
<span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7a5b46]">{activeDateLabel}</span>
{item.confidence_band ? <span className="rounded-full bg-white/70 px-2 py-1 text-[11px] font-bold text-[#7d2a15]">{item.confidence_band}</span> : null}
</div>
<h3 className="mt-3 text-xl font-black text-[#3f2f25]">{item.match_label}</h3>
<p className="mt-1 text-sm text-[#7a5b46]">{item.market_type} {item.selection}</p>
<div className="mt-4 grid gap-2 text-sm text-[#5f4330] md:grid-cols-3">
<p> <span className="font-black text-[#167a47]">{item.win_prob.toFixed(2)}%</span></p>
<p> <span className="font-black text-[#b83822]">{item.target_odds.toFixed(2)}</span></p>
<p> <span className="font-black text-[#8a6400]">{formatTwd(stakeAmountTwd(item))}</span></p>
</div>
<p className="mt-3 rounded-2xl bg-white/70 p-3 text-sm leading-6 text-[#6f4f3c]">
{nextStepText(item)} {item.target_odds.toFixed(2)}
</p>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
aria-pressed={inList}
className={`rounded-full px-4 py-2 text-sm font-black text-white transition ${inList ? 'bg-[#167a47]' : 'bg-[#d1432d] hover:bg-[#b83822]'}`}
onClick={() => handleTrack(item)}
>
{inList ? '已加入清單' : conditional ? '加入賠率監控' : '加入下注前檢查'}
</button>
<Link href="/daily-card" className="rounded-full border border-[#d8b58c] bg-white/70 px-4 py-2 text-center text-sm font-bold text-[#5f4330] transition hover:border-[#b83822] hover:text-[#7d2a15]">
</Link>
</div>
</article>
);
})}
</div>
) : (
<div className="panel-glow rounded-2xl border-dashed p-6">
<p className="text-sm leading-7 text-[#6f4f3c]"></p>
<p className="mt-2 text-xs text-[#8a6b58]">{errors.daily ?? selectedCard?.summary ?? '等待下一次資料刷新。'}</p>
</div>
)}
</div>
<aside className="space-y-4">
<section className="panel-glow rounded-2xl p-5">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<div className="mt-4 space-y-3">
{healthItems.map((item) => (
<article key={item.label} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4">
<div className="flex items-center gap-2">
<span className={`status-led ${item.ok ? 'status-led-ok' : 'status-led-warn'}`} />
<p className="text-sm font-bold text-[#3f2f25]">{item.label}</p>
</div>
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.detail}</p>
</article>
))}
</div>
</section>
<section className="panel-glow rounded-2xl p-5">
<div className="flex items-center justify-between">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<Link href="/schedule" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
<div className="mt-4 space-y-3">
{nextMatches.length ? nextMatches.map((match) => (
<Link key={match.match_id} href={`/matches/${match.match_id}`} className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
<p className="text-sm font-bold text-[#3f2f25]">{match.home_team} vs {match.away_team}</p>
<p className="mt-1 text-xs text-[#7a5b46]">{formatToTaipeiTime(match.kickoff_utc, 'MM/dd HH:mm')} {match.status} {match.venue_city || '場地待定'}</p>
</Link>
)) : (
<p className="text-sm text-[#7a5b46]">{errors.matches ?? '尚無可顯示賽程。'}</p>
)}
</div>
</section>
<section className="panel-glow rounded-2xl p-5">
<div className="flex items-center justify-between">
<h3 className="dot-matrix text-sm font-bold text-[#7d2a15]"></h3>
<Link href="/news" className="text-xs font-bold text-[#b83822] hover:text-[#7d2a15]"></Link>
</div>
<div className="mt-4 space-y-3">
{news.slice(0, 5).map((item) => (
<a key={item.url} href={item.url} target="_blank" rel="noreferrer" className="block rounded-2xl border border-[#eadcb9] bg-white/70 p-3 transition hover:border-[#b83822]">
<p className="text-sm font-semibold leading-5 text-[#3f2f25]">{item.title}</p>
<p className="mt-2 text-xs text-[#7a5b46]">{item.source} {item.publishedAt ? formatToTaipeiTime(item.publishedAt, 'MM/dd HH:mm') : '時間待定'}</p>
</a>
))}
{!news.length ? <p className="text-sm text-[#7a5b46]">{errors.news ?? '尚無新聞資料。'}</p> : null}
</div>
</section>
</aside>
</section>
<section className="panel-glow rounded-2xl p-6">
<h2 className="dot-matrix text-xl text-[#7d2a15]"></h2>
<p className="mt-2 text-sm text-[#7a5b46]"></p>
<div className="mt-4 grid gap-3 md:grid-cols-2 lg:grid-cols-4">
{moduleLinks.map((item) => (
<Link key={item.href} href={item.href} className="rounded-2xl border border-[#eadcb9] bg-white/70 p-4 transition hover:border-[#b83822] hover:bg-[#fff0e2]">
<p className="font-bold text-[#3f2f25]">{item.label}</p>
<p className="mt-2 text-xs leading-5 text-[#7a5b46]">{item.desc}</p>
</Link>
))}
</div>
</section>
</div>
);
}