finance-tools/src/components/CalendarBoard.tsx

316 lines
10 KiB
TypeScript
Raw Normal View History

2026-06-21 20:28:06 +00:00
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<string, number> = { 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 (
<div className="cal-day-panel">
<div className="cal-day-detail-head">
<b>{formatDayLabel(date)}</b>
<span>{events.length} </span>
</div>
{!events.length ? (
<p className="muted small cal-day-empty"></p>
) : (
<div className="cal-detail-list">
{events.map((ev, i) => {
const ci = categoryTip(ev.category);
return (
<div className={`cal-detail-row ${ev.impact || "low"}`} key={`${ev.title}-${i}`}>
<div className="cal-detail-main">
<div className="cal-detail-title">
<span className={`event-impact ${ev.impact || "low"}`}>
{impactLabel(ev.impact).slice(0, 1)}
</span>
<b>{ev.titleZh || ev.title}</b>
{ev.symbol && (
<Link to={`/research?sym=${encodeURIComponent(ev.symbol)}`} className="event-symbol">
{ev.symbol}
</Link>
)}
<Tag>{ci?.label}</Tag>
<span className={"tag " + impactClass(ev.impact)}>{impactLabel(ev.impact)}</span>
<Explain tip={ci?.tip} label={`${ci?.label}・這天會公布什麼`} />
</div>
<div className="cal-detail-note">
{ev.note || "—"}
{ev.time ? ` · ${ev.time}` : ""}
</div>
</div>
<div className="cal-detail-meta">
{ci?.label}
{ev.source ? <small>{ev.source}</small> : null}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
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<string>(range.today);
const byDate = useMemo(() => {
const map = new Map<string, CalendarEvent[]>();
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(<div key={`pad-${y}-${m}-${i}`} className="cal-cell pad" />);
}
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(
<button
key={iso}
type="button"
className={cls}
disabled={!inRange}
aria-label={`${iso}${dayEvents.length} 項事件`}
aria-pressed={selectedDate === iso}
onClick={() => inRange && setSelectedDate(iso)}
>
<div className="cal-day-top">
<span className="cal-day">{day}</span>
{dayEvents.length > 0 && <span className="cal-count">{dayEvents.length}</span>}
</div>
{dayEvents.length > 0 && (
<div className="cal-dots" aria-hidden>
{dayEvents.slice(0, 4).map((ev, i) => (
<span key={`${ev.date}-${ev.symbol || ""}-${i}`} className={dotClass(ev)} />
))}
{dayEvents.length > 4 && <span className="cal-dots-more">+</span>}
</div>
)}
<div className="cal-events">
{dayEvents.length === 0 ? (
<span className="cal-quiet" />
) : (
<>
{dayEvents.slice(0, 2).map((ev, i) => (
<span
key={`${ev.date}-${ev.symbol || ""}-${ev.title}-${i}`}
className={chipClass(ev)}
title={`${ev.titleZh || ev.title}${ev.time ? ` · ${ev.time}` : ""}${ev.note ? `\n${ev.note}` : ""}`}
>
{calendarEventShortLabel(ev)}
</span>
))}
{dayEvents.length > 2 && <span className="cal-more">+{dayEvents.length - 2}</span>}
</>
)}
</div>
</button>,
);
}
return (
<div className="cal-board-wrap">
<div className="cal-summary">
<div className="cal-stat">
<b>{eventCount}</b>
<span></span>
</div>
<div className="cal-stat">
<b>{range.start.slice(5)}</b>
<span></span>
</div>
<div className="cal-stat">
<b>{range.end.slice(5)}</b>
<span></span>
</div>
<div className="cal-stat">
<b>{portfolioCount}</b>
<span></span>
</div>
</div>
<div className="cal-legend">
<span>
<i className="leg high" />
</span>
<span>
<i className="leg medium" />
</span>
<span>
<i className="leg fed" />
</span>
<span>
<i className="leg deriv" />
</span>
<span>
<i className="leg earn" />
</span>
<span className="cal-legend-note"> ? </span>
</div>
<div className="cal-layout">
<section className="cal-month">
<div className="cal-month-head">
<div className="cal-nav">
<button type="button" className="cal-nav-btn" onClick={goPrevMonth} disabled={!canPrev} aria-label="上個月">
</button>
<h3>{viewMonthLabel}</h3>
<button type="button" className="cal-nav-btn" onClick={goNextMonth} disabled={!canNext} aria-label="下個月">
</button>
</div>
<button type="button" className="cal-today-btn" onClick={goToday}>
</button>
</div>
<div className="cal-weekdays">
{WEEKDAYS.map((w) => (
<span key={w}>{w}</span>
))}
</div>
<div className="cal-grid">{cells}</div>
</section>
<CalendarDayDetail date={selectedDate} events={selectedEvents} />
</div>
</div>
);
}