import { useMemo, useState, type ReactNode } from "react"; import { Link } from "react-router-dom"; import type { CalendarEvent } from "../lib/api"; import { calendarEventShortLabel, calendarRangeFromToday, categoryTip, impactClass, impactLabel, } from "../lib/calendarInfo"; import { Explain, Tag } from "./ui"; const WEEKDAYS = ["日", "一", "二", "三", "四", "五", "六"] as const; const IMPACT_RANK: Record = { high: 0, medium: 1, low: 2 }; function sortDayEvents(list: CalendarEvent[]) { return [...list].sort((a, b) => { const ra = IMPACT_RANK[a.impact || ""] ?? 2; const rb = IMPACT_RANK[b.impact || ""] ?? 2; if (ra !== rb) return ra - rb; return String(a.titleZh || a.title).localeCompare(String(b.titleZh || b.title)); }); } function formatDayLabel(iso: string) { const d = new Date(`${iso}T12:00:00`); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString("zh-TW", { month: "long", day: "numeric", weekday: "long" }); } function isoFromParts(y: number, m: number, day: number) { return `${y}-${String(m + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`; } function chipClass(ev: CalendarEvent) { const cat = ev.category || "macro"; const imp = ev.impact || "low"; return `cal-ev ${imp} cat-${cat}`; } function dotClass(ev: CalendarEvent) { const cat = ev.category || "macro"; const imp = ev.impact || "low"; return `cal-dot ${imp} cat-${cat}`; } function CalendarDayDetail({ date, events }: { date: string; events: CalendarEvent[] }) { return (
{formatDayLabel(date)} {events.length} 項事件
{!events.length ? (

這天沒有事件。點其他日期格子查看。

) : (
{events.map((ev, i) => { const ci = categoryTip(ev.category); return (
{impactLabel(ev.impact).slice(0, 1)} {ev.titleZh || ev.title} {ev.symbol && ( {ev.symbol} )} {ci?.label} {impactLabel(ev.impact)}
{ev.note || "—"} {ev.time ? ` · ${ev.time}` : ""}
{ci?.label} {ev.source ? {ev.source} : null}
); })}
)}
); } export default function CalendarBoard({ events, portfolioCount = 0, start: startIn, end: endIn, }: { events: CalendarEvent[]; portfolioCount?: number; start?: string; end?: string; }) { const fallback = calendarRangeFromToday(60); const range = { start: startIn || fallback.start, end: endIn || fallback.end, today: fallback.today, }; const todayDate = useMemo(() => new Date(`${range.today}T12:00:00`), [range.today]); const [viewMonth, setViewMonth] = useState(() => ({ y: todayDate.getFullYear(), m: todayDate.getMonth(), })); const [selectedDate, setSelectedDate] = useState(range.today); const byDate = useMemo(() => { const map = new Map(); for (const ev of events) { if (ev.date < range.start || ev.date > range.end) continue; const list = map.get(ev.date) || []; list.push(ev); map.set(ev.date, list); } for (const [k, list] of map) map.set(k, sortDayEvents(list)); return map; }, [events, range.start, range.end]); const monthBounds = useMemo(() => { const start = new Date(`${range.start}T12:00:00`); const end = new Date(`${range.end}T12:00:00`); return { minY: start.getFullYear(), minM: start.getMonth(), maxY: end.getFullYear(), maxM: end.getMonth(), }; }, [range.start, range.end]); const canPrev = useMemo(() => { const { minY, minM } = monthBounds; return viewMonth.y > minY || (viewMonth.y === minY && viewMonth.m > minM); }, [monthBounds, viewMonth]); const canNext = useMemo(() => { const { maxY, maxM } = monthBounds; return viewMonth.y < maxY || (viewMonth.y === maxY && viewMonth.m < maxM); }, [monthBounds, viewMonth]); const selectedEvents = byDate.get(selectedDate) || []; const eventCount = useMemo(() => { let n = 0; for (const [, list] of byDate) n += list.length; return n; }, [byDate]); const viewMonthLabel = useMemo(() => { const d = new Date(viewMonth.y, viewMonth.m, 1); return d.toLocaleDateString("zh-TW", { year: "numeric", month: "long" }); }, [viewMonth]); const goPrevMonth = () => { if (!canPrev) return; setViewMonth((v) => (v.m === 0 ? { y: v.y - 1, m: 11 } : { y: v.y, m: v.m - 1 })); }; const goNextMonth = () => { if (!canNext) return; setViewMonth((v) => (v.m === 11 ? { y: v.y + 1, m: 0 } : { y: v.y, m: v.m + 1 })); }; const goToday = () => { setSelectedDate(range.today); setViewMonth({ y: todayDate.getFullYear(), m: todayDate.getMonth() }); }; const { y, m } = viewMonth; const firstDow = new Date(y, m, 1).getDay(); const daysInMonth = new Date(y, m + 1, 0).getDate(); const cells: ReactNode[] = []; for (let i = 0; i < firstDow; i++) { cells.push(
); } for (let day = 1; day <= daysInMonth; day++) { const iso = isoFromParts(y, m, day); const inRange = iso >= range.start && iso <= range.end; const dayEvents = byDate.get(iso) || []; const cls = [ "cal-cell", inRange ? "in-range" : "off", iso === range.today ? "today" : "", selectedDate === iso ? "selected" : "", dayEvents.length ? "has-events" : "", dayEvents.some((e) => e.impact === "high") ? "has-hot" : "", ] .filter(Boolean) .join(" "); cells.push( , ); } return (
{eventCount} 區間內事件
{range.start.slice(5)} 起算(今天)
{range.end.slice(5)} 結束(約兩個月)
{portfolioCount} 背包財報檔數
高關注 中關注 聯準會 衍生品 背包財報 點日期格子 → 下方看完整說明與 ? 解讀

{viewMonthLabel}

{WEEKDAYS.map((w) => ( {w} ))}
{cells}
); }