finance-tools/src/components/CalendarBoard.tsx

316 lines
10 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.

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>
);
}