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);
|
return () => clearTimeout(id);
|
||||||
}, [props.clubId]);
|
}, [props.clubId]);
|
||||||
const handleAddToCalendar = useCallback(() => {
|
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');
|
const uri = url.replace(/^http(s)?/, 'webcal');
|
||||||
console.debug(uri);
|
console.debug(uri);
|
||||||
window.open(uri);
|
window.open(uri);
|
||||||
@ -72,7 +72,7 @@ export const ClubEvenList = (props: Props) => {
|
|||||||
icon={<CalendarOutlined />}
|
icon={<CalendarOutlined />}
|
||||||
onClick={handleAddToCalendar}
|
onClick={handleAddToCalendar}
|
||||||
>
|
>
|
||||||
本页全部添加到日历
|
订阅该俱乐部比赛
|
||||||
</Button>
|
</Button>
|
||||||
{showAll ? (
|
{showAll ? (
|
||||||
<Pagination
|
<Pagination
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Button, Card, Statistic, Typography } from "antd";
|
import { Button, Card, Statistic, Typography } from "antd";
|
||||||
import type { IEventInfo } from "../types";
|
import type { IEventInfo } from "../types";
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { CalendarOutlined, EyeOutlined } from "@ant-design/icons";
|
import utc from 'dayjs/plugin/utc';
|
||||||
import { useCallback } from "react";
|
import timezone from 'dayjs/plugin/timezone';
|
||||||
|
import { EyeOutlined } from "@ant-design/icons";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
interface EventCardProps {
|
interface EventCardProps {
|
||||||
@ -16,10 +18,14 @@ export function EventCard(props: EventCardProps) {
|
|||||||
const handleView = useCallback(() => {
|
const handleView = useCallback(() => {
|
||||||
navigate(`/event/${e.matchId}`);
|
navigate(`/event/${e.matchId}`);
|
||||||
}, [e]);
|
}, [e]);
|
||||||
const handleAddCalendar = useCallback(() => {
|
const messageFormat = useMemo(() => {
|
||||||
const url = `${window.location.origin}/calendar/event/${e.matchId}/events.ics`;
|
if (e.isFinished) {
|
||||||
const uri = url.replace(/^http(s)?/, 'webcal');
|
return `已结束 DD 天`;
|
||||||
window.open(uri);
|
}
|
||||||
|
if (e.isProcessing) {
|
||||||
|
return '比赛进行中';
|
||||||
|
}
|
||||||
|
return `还有 DD 天 HH 时开始`;
|
||||||
}, [e]);
|
}, [e]);
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@ -27,13 +33,6 @@ export function EventCard(props: EventCardProps) {
|
|||||||
title={e.title}
|
title={e.title}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
actions={[
|
actions={[
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<CalendarOutlined />}
|
|
||||||
onClick={handleAddCalendar}
|
|
||||||
>
|
|
||||||
加入日历
|
|
||||||
</Button>,
|
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
onClick={handleView}
|
onClick={handleView}
|
||||||
@ -47,7 +46,7 @@ export function EventCard(props: EventCardProps) {
|
|||||||
<Statistic.Timer
|
<Statistic.Timer
|
||||||
type={e.isFinished ? 'countup' : 'countdown'}
|
type={e.isFinished ? 'countup' : 'countdown'}
|
||||||
value={day.toDate().getTime()}
|
value={day.toDate().getTime()}
|
||||||
format={`距离比赛${e.isFinished ? '结束' : '开始'}: DD 天${e.isFinished ? '' : ' HH 时'}`}
|
format={messageFormat}
|
||||||
styles={{ content: e.isFinished ? { color: 'gray' } : {} }}
|
styles={{ content: e.isFinished ? { color: 'gray' } : {} }}
|
||||||
/>
|
/>
|
||||||
{e.info.map(e => (
|
{e.info.map(e => (
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import { checkIsUserFav, favPlayer, listFavPlayers, unFavPlayer } from "./servic
|
|||||||
import { BattleService } from "./services/BattleService";
|
import { BattleService } from "./services/BattleService";
|
||||||
import { KaiqiuService } from "./services/KaiqiuService";
|
import { KaiqiuService } from "./services/KaiqiuService";
|
||||||
import dayjs from "dayjs";
|
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({
|
const server = serve({
|
||||||
port: process.env.PORT || 3000,
|
port: process.env.PORT || 3000,
|
||||||
@ -37,56 +43,35 @@ const server = serve({
|
|||||||
return Response.json(data);
|
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": {
|
"/api/club/:id/calendar.ics": {
|
||||||
async GET(req) {
|
async GET(req) {
|
||||||
const id = req.params.id;
|
const id = req.params.id;
|
||||||
const page = Number(new URL(req.url).searchParams.get('page')) || 1;
|
const clubInfo = await KaiqiuService.getClubInfo(id);
|
||||||
const events = await KaiqiuService.listClubEvents(id, page);
|
let allEvents: IEventInfo[] = [];
|
||||||
const configs: ics.EventAttributes[] = events?.data?.filter(e => !e.isFinished).map(e => ({
|
let page = 1;
|
||||||
start: dayjs(e.startDate).format('YYYY-MM-DD-HH-mm').split('-').map(v => Number(v)) as any,
|
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 },
|
duration: { hours: 6, minutes: 30 },
|
||||||
title: e.title,
|
title: e.title,
|
||||||
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
|
// end: dayjs(event.startDate).add(6, 'h').add(30, 'minute').format('YYYY-MM-DD HH:mm'),
|
||||||
// description: 'Annual 10-kilometer run in Boulder, Colorado',
|
// description: 'Annual 10-kilometer run in Boulder, Colorado',
|
||||||
// location: 'Folsom Field, University of Colorado (finish line)',
|
location: e.location,
|
||||||
url: e.url,
|
// url: e.url,
|
||||||
geo: { lat: events.location.lat, lon: events.location.lng },
|
// 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'],
|
// categories: ['10k races', 'Memorial Day Weekend', 'Boulder CO'],
|
||||||
// status: 'CONFIRMED',
|
// status: 'CONFIRMED',
|
||||||
// busyStatus: 'BUSY',
|
// 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' }
|
// { 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) {
|
if (err) {
|
||||||
console.log(err);
|
console.log(err);
|
||||||
resolve('');
|
resolve('');
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import * as cheerio from "cheerio";
|
|||||||
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
|
import { htmlRequestHeaders, redis, REDIS_CACHE_HOUR } from "../utils/server";
|
||||||
import type { ClubInfo, IEventInfo } from "../types";
|
import type { ClubInfo, IEventInfo } from "../types";
|
||||||
import dayjs from "dayjs";
|
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 {
|
export class KaiqiuService {
|
||||||
static #baseURL = 'https://kaiqiuwang.cc';
|
static #baseURL = 'https://kaiqiuwang.cc';
|
||||||
@ -59,7 +64,7 @@ export class KaiqiuService {
|
|||||||
if (!clubId) return {
|
if (!clubId) return {
|
||||||
data: [],
|
data: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
location: {
|
geo: {
|
||||||
lat: 0,
|
lat: 0,
|
||||||
lng: 0,
|
lng: 0,
|
||||||
},
|
},
|
||||||
@ -70,11 +75,11 @@ export class KaiqiuService {
|
|||||||
html = await this.#fetchEventListHTML(clubId, page);
|
html = await this.#fetchEventListHTML(clubId, page);
|
||||||
redis.setex(key, 60 * 60 * REDIS_CACHE_HOUR, html);
|
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);
|
const data = await this.#parseEventList(html);
|
||||||
return {
|
return {
|
||||||
...data,
|
...data,
|
||||||
location,
|
geo,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,14 +94,21 @@ export class KaiqiuService {
|
|||||||
const eventPath = $(titleEl).find('a').attr('href') ?? '';
|
const eventPath = $(titleEl).find('a').attr('href') ?? '';
|
||||||
const eventURL = `${this.#baseURL}/home/${eventPath}`;
|
const eventURL = `${this.#baseURL}/home/${eventPath}`;
|
||||||
const matchId = /\S+-(\d+).html$/.exec(eventPath)?.[1] ?? '';
|
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 = {
|
const event: IEventInfo = {
|
||||||
title,
|
title,
|
||||||
info: [`比赛时间:${startDate}`, place],
|
info: [`比赛时间:${startDate}`, place],
|
||||||
url: eventURL,
|
url: eventURL,
|
||||||
startDate,
|
startDate,
|
||||||
matchId,
|
matchId,
|
||||||
isFinished: dayjs(startDate).isBefore(),
|
isFinished,
|
||||||
|
isProcessing,
|
||||||
|
location,
|
||||||
}
|
}
|
||||||
list.push(event);
|
list.push(event);
|
||||||
}
|
}
|
||||||
@ -122,12 +134,13 @@ export class KaiqiuService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static async getEventInfo(eventId: string) {
|
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
|
// https://kaiqiuwang.cc/home/space-event-id-175775.html
|
||||||
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
|
const eventURL = `${this.#baseURL}/home/space-event-id-${eventId}.html`;
|
||||||
if (!eventPage) {
|
if (!eventPage) {
|
||||||
eventPage = await fetch(eventURL, { headers: htmlRequestHeaders }).then(res => res.text() ?? '');
|
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 $ = cheerio.load(eventPage);
|
||||||
const eventContent = $('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
|
const eventContent = $('.event_content').text().replace(/(\r|\n)/g, ',').split(',').filter(Boolean).join(' ');
|
||||||
@ -135,11 +148,20 @@ export class KaiqiuService {
|
|||||||
.exec(eventContent)?.groups ?? {};
|
.exec(eventContent)?.groups ?? {};
|
||||||
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
|
const startDate = y ? `${y}-${M}-${D} ${H}:${m}` : '';
|
||||||
const title = $('#mainarea > h2 > a:nth-child(3)').text().trim();
|
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 {
|
return {
|
||||||
title,
|
title,
|
||||||
startDate,
|
startDate,
|
||||||
url: eventURL,
|
url: eventURL,
|
||||||
isFinished: dayjs(startDate).isBefore(),
|
isProcessing,
|
||||||
|
isFinished,
|
||||||
|
location,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@ export interface IEventInfo {
|
|||||||
matchId: string;
|
matchId: string;
|
||||||
startDate: string;
|
startDate: string;
|
||||||
isFinished: boolean;
|
isFinished: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
location: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MatchInfo {
|
export interface MatchInfo {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user