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:
parent
aef89d2341
commit
9657acea64
@ -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
|
||||
|
||||
@ -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 => (
|
||||
|
||||
@ -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('');
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,8 @@ export interface IEventInfo {
|
||||
matchId: string;
|
||||
startDate: string;
|
||||
isFinished: boolean;
|
||||
isProcessing: boolean;
|
||||
location: string;
|
||||
}
|
||||
|
||||
export interface MatchInfo {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user