import ICAL from "ical.js" import dayjs from "dayjs" import { useEffect, useMemo, useRef, useState } from "react" import { Alert, Button, Calendar, Link, Spinner, Select, SelectItem } from "@heroui/react" import { today, getLocalTimeZone } from "@internationalized/date" import "dayjs/locale/zh-cn" dayjs.locale("zh-cn") type ScheduleEvent = { start: Date end: Date summary: string description: string recurrenceId?: string } const parseCal = async (): Promise => { const res = await fetch("https://ical.nbtca.space/").then(res => res.text()) const jcalData = ICAL.parse(res) return new ICAL.Component(jcalData) } const extractScheduleEventsInRange = ( icalComp: ICAL.Component, rangeStart: ICAL.Time, rangeEnd: ICAL.Time, ): ScheduleEvent[] => { const vevents = icalComp.getAllSubcomponents("vevent") // First, collect all exception events (events with RECURRENCE-ID) const exceptions = new Map>() // uid -> set of recurrence-id strings const exceptionEvents: ScheduleEvent[] = [] vevents.forEach((vevent) => { const recurrenceId = vevent.getFirstPropertyValue("recurrence-id") if (recurrenceId) { const uid = vevent.getFirstPropertyValue("uid") const event = new ICAL.Event(vevent) // Check if exception is in range if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) { exceptionEvents.push({ start: event.startDate.toJSDate(), end: event.endDate.toJSDate(), summary: event.summary, description: event.description, recurrenceId: recurrenceId.toString(), }) } // Track exception for filtering recurring events if (!exceptions.has(uid)) { exceptions.set(uid, new Set()) } exceptions.get(uid)!.add(recurrenceId.toString()) } }) // Process regular and recurring events const regularEvents = vevents .filter(vevent => !vevent.getFirstPropertyValue("recurrence-id")) .flatMap((vevent) => { const event = new ICAL.Event(vevent) if (!event.isRecurring()) { if (event.startDate.compare(rangeEnd) > 0 || event.endDate.compare(rangeStart) < 0) return [] return [{ start: event.startDate.toJSDate(), end: event.endDate.toJSDate(), summary: event.summary, description: event.description, recurrenceId: undefined, }] } const eventExceptions = exceptions.get(event.uid) || new Set() return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions) }) return [...regularEvents, ...exceptionEvents] .sort((a, b) => b.start.getTime() - a.start.getTime()) } const expandEventOccurrences = ( event: ICAL.Event, rangeStart: ICAL.Time, rangeEnd: ICAL.Time, exceptions?: Set, ): ScheduleEvent[] => { const occurrences: ScheduleEvent[] = [] const iterator = event.iterator() let next: ICAL.Time | null = null while ((next = iterator.next())) { if (!next) break if (next.compare(rangeEnd) > 0) break if (next.compare(rangeStart) < 0) continue // Skip this occurrence if it has an exception if (exceptions && exceptions.has(next.toString())) continue const details = event.getOccurrenceDetails(next) occurrences.push({ start: details.startDate.toJSDate(), end: details.endDate.toJSDate(), summary: event.summary, description: event.description, recurrenceId: next.toString(), }) } return occurrences } const extractScheduleEvents = (icalComp: ICAL.Component): ScheduleEvent[] => { const vevents = icalComp.getAllSubcomponents("vevent") const rangeEnd = ICAL.Time.fromDateString("2026-01-01") const rangeStart = ICAL.Time.fromDateString("2020-01-01") // First, collect all exception events (events with RECURRENCE-ID) const exceptions = new Map>() // uid -> set of recurrence-id strings const exceptionEvents: ScheduleEvent[] = [] vevents.forEach((vevent) => { const recurrenceId = vevent.getFirstPropertyValue("recurrence-id") if (recurrenceId) { const uid = vevent.getFirstPropertyValue("uid") const event = new ICAL.Event(vevent) // Check if exception is in range if (event.startDate.compare(rangeEnd) <= 0 && event.endDate.compare(rangeStart) >= 0) { exceptionEvents.push({ start: event.startDate.toJSDate(), end: event.endDate.toJSDate(), summary: event.summary, description: event.description, recurrenceId: recurrenceId.toString(), }) } // Track exception for filtering recurring events if (!exceptions.has(uid)) { exceptions.set(uid, new Set()) } exceptions.get(uid)!.add(recurrenceId.toString()) } }) // Process regular and recurring events const regularEvents = vevents .filter(vevent => !vevent.getFirstPropertyValue("recurrence-id")) .flatMap((vevent) => { const event = new ICAL.Event(vevent) if (event.iterator().complete) { return [{ start: event.startDate.toJSDate(), end: event.endDate.toJSDate(), summary: event.summary, description: event.description, recurrenceId: event.uid || event.startDate.toString(), }] } const eventExceptions = exceptions.get(event.uid) || new Set() return expandEventOccurrences(event, rangeStart, rangeEnd, eventExceptions) }) return [...regularEvents, ...exceptionEvents] .sort((a, b) => b.start.getTime() - a.start.getTime()) } const formatTimePair = (s: Date, e: Date): string => { const start = dayjs(s) const end = dayjs(e) if (start.isSame(end, "day")) { return `${start.format("HH:mm")} - ${end.format("HH:mm")}` } return `${start.format("MM.DD")} - ${end.format("MM.DD")}` } export default function Schedule() { const [scheduledEvents, setScheduledEvents] = useState([]) const defaultDate = today(getLocalTimeZone()) const [focusedDate, setFocusedDate] = useState(defaultDate) const dateRefs = useRef>({}) const calendarRef = useRef(null) const [loading, setLoading] = useState(true) const currentYear = focusedDate.year const currentMonth = focusedDate.month const years = Array.from({ length: 10 }, (_, i) => currentYear - 5 + i) const months = Array.from({ length: 12 }, (_, i) => i + 1) useEffect(() => { const rangeStart = ICAL.Time.fromJSDate(dayjs(focusedDate.toDate(getLocalTimeZone())).startOf("month").toDate()) const rangeEnd = ICAL.Time.fromJSDate(dayjs(focusedDate.toDate(getLocalTimeZone())).endOf("month").toDate()) if (!calendarRef.current) { setLoading(true) parseCal().then((icalComp) => { calendarRef.current = icalComp const events = extractScheduleEventsInRange( icalComp, rangeStart, rangeEnd, ) setScheduledEvents(events) setLoading(false) }) } else { const events = extractScheduleEventsInRange( calendarRef.current, rangeStart, rangeEnd, ) setScheduledEvents(events) } }, [focusedDate]) useEffect(() => { setLoading(true) parseCal().then((icalComp) => { setScheduledEvents(extractScheduleEvents(icalComp)) setLoading(false) }) }, []) const groupedEvents = useMemo(() => { const grouped = new Map() scheduledEvents.forEach((event) => { const dateKey = dayjs(event.start).format("YYYY-MM-DD") if (!grouped.has(dateKey)) { grouped.set(dateKey, []) } grouped.get(dateKey)!.push(event) }) // 可选:对每个日期内部事件排序(按开始时间升序) for (const events of grouped.values()) { events.sort((a, b) => a.start.getTime() - b.start.getTime()) } // 可选:按照日期从早到晚排序 return Array.from(grouped.entries()).sort(([a], [b]) => a.localeCompare(b)) }, [scheduledEvents]) const isUserChangeRef = useRef(false) useEffect(() => { if (!isUserChangeRef.current) return isUserChangeRef.current = false const dateKey = dayjs(focusedDate.toDate(getLocalTimeZone())).format("YYYY-MM-DD") const target = dateRefs.current[dateKey] if (target) { target.scrollIntoView({ behavior: "smooth", block: "start" }) } else { // to nearest date if not found const dates = Object.keys(dateRefs.current).map(d => dayjs(d)) const closestDate = dates.reduce((prev, curr) => { return Math.abs(curr.diff(focusedDate.toDate(getLocalTimeZone()), "day")) < Math.abs(prev.diff(focusedDate.toDate(getLocalTimeZone()), "day")) ? curr : prev }, dates[0]) const closestKey = closestDate.format("YYYY-MM-DD") const closestTarget = dateRefs.current[closestKey] if (closestTarget) { closestTarget.scrollIntoView({ behavior: "smooth", block: "start" }) } } }, [focusedDate, groupedEvents]) return (
日程
{/* Mobile Date Selector */}
console.log(e)} href="webcal://ical.nbtca.space" target="_blank" > 订阅 )} />
{ dayjs(focusedDate.toDate()).format("MMMM YYYY") }
{loading && (
)} {groupedEvents.length === 0 && !loading && (
本月暂无日程
)} { groupedEvents.map(([date, events]) => (
dateRefs.current[date] = el} className="mb-6" >
{ dayjs(date).format("MM.DD") } { dayjs(date).format("ddd") }
{events.map((event, index) => (
{event.summary}
{event.recurrenceId && ( // 重复 )}
{formatTimePair(event.start, event.end)} { event.description && (

{event.description}

) }
))}
)) }
{ isUserChangeRef.current = true setFocusedDate(v) }} />
) }