finance-dashboard/lib/calendar-cache.js

145 lines
4.8 KiB
JavaScript
Raw Permalink Normal View History

2026-06-03 16:42:07 +00:00
// 日曆資料庫快取:以「日」為單位,預設 24 小時內不重抓;重啟 server 仍保留
import { buildCalendar } from './calendar.js';
import { localizeCalendarPayload } from './calendar-i18n.js';
import { getCachedEntry, putCachedJSON } from './db.js';
export const CALENDAR_DAY_MS = (Number(process.env.CALENDAR_TTL_HOURS) || 24) * 3600 * 1000;
export function calendarCacheDay(d = new Date()) {
return d.toISOString().slice(0, 10);
}
function baseKey(day) {
return `calendar:base:v5:${day}`;
}
function earnKey(day, symbols) {
return `calendar:earn:v5:${day}:${[...symbols].sort().join(',') || '_'}`;
}
function watchlistKey() {
return 'calendar:watchlist:v1';
}
export function getCalendarWatchlist() {
const row = getCachedEntry(watchlistKey());
const val = row?.value;
return Array.isArray(val) ? val : [];
}
export function saveCalendarWatchlist(symbols) {
const clean = [...new Set((symbols || []).map(s => String(s).trim().toUpperCase()).filter(Boolean))].slice(0, 30);
putCachedJSON(watchlistKey(), clean);
return clean;
}
function isFresh(entry, day) {
if (!entry?.value) return false;
if (entry.value.cacheDay && entry.value.cacheDay !== day) return false;
return Date.now() - entry.updatedAt < CALENDAR_DAY_MS;
}
function mergeEvents(baseEvents, earnEvents) {
const seen = new Set();
return [...(baseEvents || []), ...(earnEvents || [])]
.filter(ev => {
const key = `${ev.date}|${ev.time}|${ev.category}|${ev.symbol || ''}|${ev.title}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
})
.sort((a, b) => (a.date + a.time).localeCompare(b.date + b.time));
}
function filterRange(events, start, end) {
return (events || []).filter(ev => ev.date >= start && ev.date <= end);
}
function wrapResponse(payload, entry, { cached, stale, fetchError } = {}) {
return {
...payload,
cached: !!cached,
stale: !!stale,
fetchError: fetchError || null,
cachedAt: entry?.updatedAt ? new Date(entry.updatedAt).toISOString() : payload.updatedAt,
cacheDay: payload.cacheDay,
};
}
async function buildAndStore({ start, end, symbols, key, day }) {
const payload = await buildCalendar({ start, end, symbols });
const localized = await localizeCalendarPayload(payload);
localized.cacheDay = day;
localized.cachedAt = new Date().toISOString();
putCachedJSON(key, localized);
return { value: localized, updatedAt: Date.now() };
}
export async function getCalendarPayload({ start, end, symbols, forceFresh = false }) {
const day = calendarCacheDay();
const sym = [...symbols].sort();
let baseEntry = getCachedEntry(baseKey(day));
let earnEntry = sym.length ? getCachedEntry(earnKey(day, sym)) : null;
const needBase = forceFresh || !isFresh(baseEntry, day);
const needEarn = sym.length > 0 && (forceFresh || !isFresh(earnEntry, day));
try {
if (needBase) {
baseEntry = await buildAndStore({ start, end, symbols: [], key: baseKey(day), day });
}
if (needEarn) {
earnEntry = await buildAndStore({ start, end, symbols: sym, key: earnKey(day, sym), day });
}
const baseEvents = (baseEntry.value.events || []).filter(e => e.category !== 'earnings');
const fullEarn = earnEntry?.value?.events || [];
const earnEvents = sym.length ? fullEarn.filter(e => e.category === 'earnings') : [];
const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end);
const sources = baseEntry.value.sources || [];
const payload = {
updatedAt: new Date().toISOString(),
cacheDay: day,
start,
end,
symbols: sym,
events,
sources,
};
return wrapResponse(payload, baseEntry, { cached: !needBase && !needEarn });
} catch (err) {
const staleBase = baseEntry?.value;
const staleEarn = earnEntry?.value;
if (staleBase) {
const baseEvents = (staleBase.events || []).filter(e => e.category !== 'earnings');
const earnEvents = sym.length ? (staleEarn?.events || []).filter(e => e.category === 'earnings') : [];
const events = filterRange(mergeEvents(baseEvents, earnEvents), start, end);
return wrapResponse({
updatedAt: staleBase.updatedAt,
cacheDay: staleBase.cacheDay || day,
start,
end,
symbols: sym,
events,
sources: staleBase.sources || [],
}, baseEntry, { cached: true, stale: true, fetchError: String(err?.message || err) });
}
throw err;
}
}
export async function warmCalendarCache() {
const today = calendarCacheDay();
const start = today;
const end = addDaysISO(today, 60);
if (isFresh(getCachedEntry(baseKey(today)), today)) return;
await buildAndStore({ start, end, symbols: [], key: baseKey(today), day: today });
}
function addDaysISO(iso, days) {
const d = new Date(iso + 'T00:00:00Z');
d.setUTCDate(d.getUTCDate() + days);
return d.toISOString().slice(0, 10);
}