feat(club): upgrade event calendar subscription and add match status display

- Refactor ClubEventList to subscribe to all club events instead of just the current page, removing pagination from the ICS URL.
- Enhance ICS generation for club subscriptions:
  - Fetch all non-finished events across pages.
  - Include geographic location (lat/lon) in the event metadata.
  - Add 2-hour alarms (display and audio) to all subscribed events.
- Improve EventCard status display with logic for 'Finished', 'In Progress', and countdown formats.
- Update KaiqiuService to parse match status (isProcessing, isFinished) and location name accurately.
- Integrate dayjs timezone (Asia/Tokyo) and UTC plugins across index.tsx, services, and types.
- Update IEventInfo interface to include isProcessing and location fields.
This commit is contained in:
kyuuseiryuu 2026-03-14 19:56:48 +09:00
parent aef89d2341
commit 9657acea64
5 changed files with 78 additions and 68 deletions

View File

@ -51,7 +51,7 @@ export const ClubEvenList = (props: Props) => {
return () => clearTimeout(id);
}, [props.clubId]);
const handleAddToCalendar = useCallback(() => {
const url = `${window.location.origin}/api/club/${props.clubId}/calendar.ics?page=${page}`;
const url = `${window.location.origin}/api/club/${props.clubId}/calendar.ics`;
const uri = url.replace(/^http(s)?/, 'webcal');
console.debug(uri);
window.open(uri);
@ -72,7 +72,7 @@ export const ClubEvenList = (props: Props) => {
icon={<CalendarOutlined />}
onClick={handleAddToCalendar}
>
</Button>
{showAll ? (
<Pagination

View File

@ -1,8 +1,10 @@
import { Button, Card, Statistic, Typography } from "antd";
import type { IEventInfo } from "../types";
import dayjs from "dayjs";
import { CalendarOutlined, EyeOutlined } from "@ant-design/icons";
import { useCallback } from "react";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import { EyeOutlined } from "@ant-design/icons";
import { useCallback, useMemo } from "react";
import { useNavigate } from "react-router";
interface EventCardProps {
@ -16,10 +18,14 @@ export function EventCard(props: EventCardProps) {
const handleView = useCallback(() => {
navigate(`/event/${e.matchId}`);
}, [e]);
const handleAddCalendar = useCallback(() => {
const url = `${window.location.origin}/calendar/event/${e.matchId}/events.ics`;
const uri = url.replace(/^http(s)?/, 'webcal');
window.open(uri);
const messageFormat = useMemo(() => {
if (e.isFinished) {
return `已结束 DD 天`;
}
if (e.isProcessing) {
return '比赛进行中';
}
return `还有 DD 天 HH 时开始`;
}, [e]);
return (
<Card
@ -27,13 +33,6 @@ export function EventCard(props: EventCardProps) {
title={e.title}
style={{ width: '100%' }}
actions={[
<Button
type="link"
icon={<CalendarOutlined />}
onClick={handleAddCalendar}
>
</Button>,
<Button
type="link"
onClick={handleView}
@ -47,7 +46,7 @@ export function EventCard(props: EventCardProps) {
<Statistic.Timer
type={e.isFinished ? 'countup' : 'countdown'}
value={day.toDate().getTime()}
format={`距离比赛${e.isFinished ? '结束' : '开始'}: DD 天${e.isFinished ? '' : ' HH 时'}`}
format={messageFormat}
styles={{ content: e.isFinished ? { color: 'gray' } : {} }}
/>
{e.info.map(e => (

View File

@ -7,6 +7,12 @@ import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./servic
import { BattleService } from "./services/BattleService";
import { KaiqiuService } from "./services/KaiqiuService";
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import type { IEventInfo } from "./types";
dayjs.extend(utc);
dayjs.extend(timezone);
const server = serve({
port: process.env.PORT || 3000,
@ -37,56 +43,35 @@ const server = serve({
return Response.json(data);
}
},
// "/calendar/club/:id/events.ics": {
// },
"/calendar/event/:id/events.ics": {
async GET(req) {
const id = req.params.id;
const info = await KaiqiuService.getEventInfo(id);
const configs: ics.EventAttributes = {
start: dayjs(info.startDate).format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
duration: { hours: 6, minutes: 30 },
title: info.title,
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
// description: 'Annual 10-kilometer run in Boulder, Colorado',
// location: 'Folsom Field, University of Colorado (finish line)',
url: info.url,
// categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
// status: 'CONFIRMED',
// busyStatus: 'BUSY',
// organizer: { name: 'Admin', email: 'Race@BolderBOULDER.com' },
// attendees: [
// { name: 'Adam Gibbons', email: 'adam@example.com', rsvp: true, partstat: 'ACCEPTED', role: 'REQ-PARTICIPANT' },
// { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' }
// ]
};
const data: string = await new Promise(resolve => ics.createEvent(configs, (err, data) => {
if (err) {
console.log(err);
resolve('');
}
resolve(data);
}));
return new Response(data, { headers: {
'Content-Type': 'text/calendar; charset=utf-8',
} });
}
},
"/api/club/:id/calendar.ics": {
async GET(req) {
const id = req.params.id;
const page = Number(new URL(req.url).searchParams.get('page')) || 1;
const events = await KaiqiuService.listClubEvents(id, page);
const configs: ics.EventAttributes[] = events?.data?.filter(e => !e.isFinished).map(e => ({
start: dayjs(e.startDate).format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
const clubInfo = await KaiqiuService.getClubInfo(id);
let allEvents: IEventInfo[] = [];
let page = 1;
let events = await KaiqiuService.listClubEvents(id, page);
allEvents = allEvents.concat(...events.data);
while (events.data.every(e => !e.isFinished)) {
page += 1;
events = await KaiqiuService.listClubEvents(id, page);
allEvents = allEvents.concat(...events.data);
}
const noGeo = !events.geo.lat && !events.geo.lng;
const geo = noGeo ? {} : { lat: events.geo.lat, lon: events.geo.lng };
const configs: ics.EventAttributes[] = allEvents?.filter(e => !e.isFinished)?.map(e => ({
...geo,
start: dayjs.tz(e.startDate, 'Asia/Tokyo').format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
duration: { hours: 6, minutes: 30 },
title: e.title,
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
// description: 'Annual 10-kilometer run in Boulder, Colorado',
// location: 'Folsom Field, University of Colorado (finish line)',
url: e.url,
geo: { lat: events.location.lat, lon: events.location.lng },
location: e.location,
// url: e.url,
// geo: { lat: events.location.lat, lon: events.location.lng },
alarms: [
{ action: 'display', summary: e.title, description: '距离比赛开始还有 2 小时', trigger: { before: true, hours: 2 } },
{ action: 'audio', trigger: { before: true, hours: 2 } },
],
// categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
// status: 'CONFIRMED',
// busyStatus: 'BUSY',
@ -96,7 +81,9 @@ const server = serve({
// { name: 'Brittany Seaton', email: 'brittany@example2.org', dir: 'https://linkedin.com/in/brittanyseaton', role: 'OPT-PARTICIPANT' }
// ]
}));
const data: string = await new Promise(resolve => ics.createEvents(configs, (err, data) => {
const data: string = await new Promise(resolve => ics.createEvents(configs, {
calName: clubInfo?.name ? `${clubInfo.name}的比赛` : '',
}, (err, data) => {
if (err) {
console.log(err);
resolve('');

View File

@ -2,6 +2,11 @@ import * as cheerio from "cheerio";
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
import type { ClubInfo, IEventInfo } from "../types";
import dayjs from "dayjs";
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);
export class KaiqiuService {
static #baseURL = 'https://kaiqiuwang.cc';
@ -59,7 +64,7 @@ export class KaiqiuService {
if (!clubId) return {
data: [],
total: 0,
location: {
geo: {
lat: 0,
lng: 0,
},
@ -70,11 +75,11 @@ export class KaiqiuService {
html = await this.#fetchEventListHTML(clubId, page);
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
}
const location = await this.#getClubLocation(clubId);
const geo = await this.#getClubLocation(clubId);
const data = await this.#parseEventList(html);
return {
...data,
location,
geo,
}
}
@ -89,14 +94,21 @@ export class KaiqiuService {
const eventPath = $(titleEl).find('a').attr('href') ?? '';
const eventURL = `${this.#baseURL}/home/${eventPath}`;
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
const { startDate } = await this.getEventInfo(matchId);
const {
startDate,
isFinished,
isProcessing,
location,
} = await this.getEventInfo(matchId);
const event: IEventInfo = {
title,
info: [`比赛时间:${startDate}`, place],
url: eventURL,
startDate,
matchId,
isFinished: dayjs(startDate).isBefore(),
isFinished,
isProcessing,
location,
}
list.push(event);
}
@ -122,12 +134,13 @@ export class KaiqiuService {
}
public static async getEventInfo(eventId: string) {
let eventPage = await redis.get(`my-kaiqiuwang:match:${eventId}`) ?? '';
const key = `my-kaiqiuwang:match:${eventId}`;
let eventPage = await redis.get(key) ?? '';
// https://kaiqiuwang.cc/home/space-event-id-175775.html
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
if (!eventPage) {
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
await redis.setex(`my-kaiqiuwang:match:${eventId}`, 60 * 60 * 10, eventPage)
await redis.setex(key, 60 * 60 * 10, eventPage)
}
const $ = cheerio.load(eventPage);
const eventContent = $('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
@ -135,11 +148,20 @@ export class KaiqiuService {
.exec(eventContent)?.groups ?? {};
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
const title = $('#mainarea > h2 > a:nth-child(3)').text().trim();
const location = $('#content > div:nth-child(1) > div > div.event_content > dl > dd:nth-child(6) > a')
.text().split(' ')?.[1]?.trim() ?? '';
const startTime = dayjs.tz(startDate, 'Asia/Tokyo');
const overTime = startTime.add(6, 'hour');
const now = dayjs();
const isProcessing = now.isAfter(startTime) && now.isBefore(overTime);
const isFinished = now.isAfter(overTime);
return {
title,
startDate,
url: eventURL,
isFinished: dayjs(startDate).isBefore(),
isProcessing,
isFinished,
location,
}
}

View File

@ -10,6 +10,8 @@ export interface IEventInfo {
matchId: string;
startDate: string;
isFinished: boolean;
isProcessing: boolean;
location: string;
}
export interface MatchInfo {