// 追蹤個股:分群清單(持久化於 SQLite KV,與財報日曆 watchlist 分開) import { getCachedEntry, putCachedJSON } from './db.js'; const STORE_KEY = 'stock:watchlist:v1'; const MAX_GROUPS = 24; const MAX_SYMBOLS_PER_GROUP = 48; const MAX_SYMBOLS_TOTAL = 200; export const SYMBOL_RE = /^[A-Z0-9.\-]{1,12}$/; function newGroupId() { return `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; } export function defaultWatchlist() { return { groups: [ { id: 'default', name: '我的追蹤', symbols: [], order: 0 }, ], updatedAt: new Date().toISOString(), }; } function cleanSymbol(s) { const sym = String(s || '').trim().toUpperCase(); return SYMBOL_RE.test(sym) ? sym : null; } function normalizeGroup(g, idx) { const id = String(g?.id || '').trim() || newGroupId(); const name = String(g?.name || '').trim().slice(0, 40) || '未命名分群'; const symbols = [...new Set((g?.symbols || []).map(cleanSymbol).filter(Boolean))].slice(0, MAX_SYMBOLS_PER_GROUP); return { id, name, symbols, order: Number.isFinite(g?.order) ? g.order : idx }; } /** 驗證並正規化前端/API 送來的完整結構 */ export function normalizeWatchlistPayload(raw) { const base = defaultWatchlist(); if (!raw || typeof raw !== 'object') return base; let groups = Array.isArray(raw.groups) ? raw.groups.map(normalizeGroup) : base.groups; if (!groups.length) groups = base.groups; groups = groups.slice(0, MAX_GROUPS).sort((a, b) => a.order - b.order || a.name.localeCompare(b.name, 'zh-Hant')); const seenSym = new Set(); for (const g of groups) { g.symbols = g.symbols.filter(sym => { if (seenSym.has(sym) || seenSym.size >= MAX_SYMBOLS_TOTAL) return false; seenSym.add(sym); return true; }); } if (!groups.some(g => g.id === 'default')) { groups.unshift({ id: 'default', name: '我的追蹤', symbols: [], order: -1 }); } groups.forEach((g, i) => { g.order = i; }); return { groups, updatedAt: new Date().toISOString() }; } export function getStockWatchlist() { const row = getCachedEntry(STORE_KEY); const val = row?.value; if (!val?.groups) return defaultWatchlist(); return normalizeWatchlistPayload(val); } export function saveStockWatchlist(payload) { const normalized = normalizeWatchlistPayload(payload); putCachedJSON(STORE_KEY, normalized); return normalized; } export function allWatchlistSymbols(data) { const out = []; const seen = new Set(); for (const g of data?.groups || []) { for (const sym of g.symbols || []) { if (!seen.has(sym)) { seen.add(sym); out.push(sym); } } } return out; }