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