316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
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>
|
||
);
|
||
} |