mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-10-03 05:12:52 +00:00
refactor(lolschedule): swap Leaguepedia for lolesports.com esports-api
Leaguepedia's anonymous IP rate limit is too aggressive for a bot even from CF Worker egress (~1–2 req/min), and authenticated Fandom tokens don't lift it. Switching to the lolesports.com getSchedule endpoint — the same data feed powering the official site — removes the limit and provides richer fields: state (unstarted/inProgress/completed), per-team result.gameWins and outcome, league metadata, bestOf strategy. Handlers simplify back to cache-first (120 s fresh / 1 h stale fallback) with no cron needed. Results are filtered to major leagues (LCK, LPL, LEC, LCS, worlds, msi, first_stand, LCP, CBLOL, EMEA Masters) to keep the week view under Telegram's 4096-char message limit.
This commit is contained in:
1 parent
436664c8a1
commit
e10269ca0a
6 files changed
+438
-398
No files matched your search
@@ -1,41 +1,62 @@
|
||||
# lolschedule
|
||||
|
||||
LoL esports match schedule via the **Leaguepedia** MediaWiki/Cargo API.
|
||||
LoL esports match schedule via the **lolesports.com** esports-api (the data feed behind the official lolesports.com website).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/lol_today` | Today's matches (ICT). Scores if played / live. Times if scheduled. |
|
||||
| `/lol_today` | Today's matches (ICT). Scores for played + live. Times for upcoming. |
|
||||
| `/lol_week` | Next 7 days, grouped by day. |
|
||||
|
||||
## Data source
|
||||
|
||||
- Endpoint: `https://lol.fandom.com/api.php?action=cargoquery`
|
||||
- Table: `MatchSchedule`
|
||||
- Fields used: `DateTime_UTC`, `Team1`, `Team2`, `Team1Score`, `Team2Score`, `Winner`, `Tournament`, `BestOf`, `OverviewPage`
|
||||
- Auth: none. UA header identifies the bot.
|
||||
- Endpoint: `https://esports-api.lolesports.com/persisted/gw/getSchedule`
|
||||
- Header: `x-api-key: <public key>` — the same key lolesports.com's own web client sends. No registration, no token lifecycle. If Riot rotates it, lift the new value from their public JS bundle.
|
||||
- Companion endpoints used during design: `getLive`, `getLeagues`.
|
||||
|
||||
See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and `plans/reports/researcher-260421-0909-leaguepedia-auth-token.md` for the feasibility verdict + rate-limit strategy.
|
||||
## Why not Leaguepedia?
|
||||
|
||||
Initial design hit Fandom's anonymous IP rate limit even from Cloudflare Worker egress (~1–2 req/min). See `plans/reports/researcher-260421-0845-leaguepedia-api-verification.md` and the auth-token follow-up. lolesports.com API is:
|
||||
|
||||
- Official Riot-operated
|
||||
- Rate-limit friendly (powers the live site)
|
||||
- Richer shape: state (`unstarted` / `inProgress` / `completed`), per-team `result.gameWins`, league metadata, bestOf strategy
|
||||
|
||||
## Event shape (relevant fields)
|
||||
|
||||
```
|
||||
{
|
||||
startTime: "2026-04-21T09:00:00Z",
|
||||
state: "unstarted" | "inProgress" | "completed",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "...",
|
||||
teams: [
|
||||
{ name, code, image, result?: { outcome, gameWins }, record?: {...} },
|
||||
...
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
Two layers:
|
||||
Cache-first with KV. Key is `matches:{fromIso}:{toIso}`.
|
||||
|
||||
1. **Worker edge cache** — `fetch(url, { cf: { cacheTtl: 30, cacheEverything: true }})` dedupes near-simultaneous calls across requests to the same edge POP.
|
||||
2. **KV cache** (per module) — `matches:{fromIso}:{toIso}` with `ts`+`rows` payload. TTL: 60 s for `/lol_today`, 300 s for `/lol_week`. On fetch failure, stale cache (up to 4× TTL) is returned as a fallback.
|
||||
- Fresh TTL: 120 s (catches live score updates quickly)
|
||||
- Stale fallback: up to 1 h on upstream failure
|
||||
- No cron pre-warm needed — upstream is cheap
|
||||
|
||||
## Time zone
|
||||
|
||||
All rendering is in **ICT (UTC+7)**. `DateTime_UTC` strings from the wiki are parsed as UTC, then shifted for display. Change `TZ_OFFSET_MS` in `format.js` if you need a different zone.
|
||||
All rendering is in **ICT (UTC+7)**. `startTime` is UTC ISO; day boundaries for the `/lol_today` and `/lol_week` windows are anchored to ICT midnight.
|
||||
|
||||
## Files
|
||||
|
||||
- `index.js` — module contract, registers `/lol_today` and `/lol_week`.
|
||||
- `api-client.js` — cargoquery POST, range fetch, cache-first wrapper.
|
||||
- `format.js` — pure renderers (`classifyMatch`, `formatMatchLine`, `renderToday`, `renderWeek`).
|
||||
- `handlers.js` — grammY command handlers + ICT day-boundary helpers.
|
||||
|
||||
## Known caveat
|
||||
|
||||
The `where=` clause with `>=`/`<` operators hits `MWException` from shared egress IPs during verification (see auth-token report). POST + a proper UA appears to work from CF Worker egress, but confirm on first deploy. If it fails in production, fall back to `HOLDS`-based filters or a client-side filter over a broader fetch.
|
||||
- `index.js` — module contract
|
||||
- `api-client.js` — getSchedule client with pagination + cache
|
||||
- `format.js` — pure renderers (`formatEventLine`, `renderToday`, `renderWeek`)
|
||||
- `handlers.js` — grammY command handlers + ICT day-boundary helpers
|
||||
@@ -1,149 +1,157 @@
|
||||
/**
|
||||
* @file Leaguepedia cargoquery client for LoL esports match schedule.
|
||||
* @file lolesports.com esports-api client.
|
||||
*
|
||||
* Uses the MediaWiki Cargo extension on lol.fandom.com. No auth. We identify
|
||||
* ourselves with a contact UA (Fandom policy) and cache via both the Worker
|
||||
* edge cache (`cf.cacheTtl`) and the module's KVStore for cross-edge reuse.
|
||||
* Endpoint: https://esports-api.lolesports.com/persisted/gw/getSchedule
|
||||
* Auth: `x-api-key` header — the public key embedded in lolesports.com's own
|
||||
* web client. No registration. No practical rate limit (the same key serves
|
||||
* the live site). If Riot ever rotates it, lift the new value from their
|
||||
* public JS bundle.
|
||||
*
|
||||
* @see plans/reports/researcher-260421-0845-leaguepedia-api-verification.md
|
||||
* We cache responses in KV keyed by league filter so concurrent user requests
|
||||
* collapse to one upstream hit within the TTL window.
|
||||
*/
|
||||
|
||||
const API_URL = "https://lol.fandom.com/api.php";
|
||||
const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)";
|
||||
const API_URL = "https://esports-api.lolesports.com/persisted/gw/getSchedule";
|
||||
const API_KEY = "0TvQnueqKa5mxJntVWt0w4LpLfEkrV1Ta8rQBb9Z";
|
||||
const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot)";
|
||||
|
||||
/** Default KV cache windows — short enough to catch score updates mid-day. */
|
||||
const CACHE_TTL_TODAY_SEC = 60;
|
||||
const CACHE_TTL_WEEK_SEC = 300;
|
||||
/** Short cache — schedule data changes minute-by-minute during live events. */
|
||||
const CACHE_TTL_SEC = 120;
|
||||
/** Stale fallback ceiling for resilience during upstream hiccups. */
|
||||
const STALE_MAX_AGE_SEC = 60 * 60;
|
||||
|
||||
/**
|
||||
* @typedef {object} MatchRow
|
||||
* @property {string} DateTime — "YYYY-MM-DD HH:MM:SS" UTC (Leaguepedia convention).
|
||||
* @property {string} T1
|
||||
* @property {string} T2
|
||||
* @property {string|null} S1 — team-1 score, may be empty string before match.
|
||||
* @property {string|null} S2
|
||||
* @property {string|null} Winner — "1" | "2" | "" when unplayed.
|
||||
* @property {string} Tournament
|
||||
* @property {string|null} BO — best-of count as string.
|
||||
* @property {string} OP — OverviewPage (wiki slug) for deep-link.
|
||||
* @typedef {object} Team
|
||||
* @property {string} name
|
||||
* @property {string} code — short league tag, e.g. "T1", "GEN".
|
||||
* @property {string} [image]
|
||||
* @property {{ outcome: "win"|"loss", gameWins: number }} [result]
|
||||
* @property {{ wins: number, losses: number }} [record]
|
||||
*/
|
||||
|
||||
/**
|
||||
* Low-level cargoquery POST. Uses POST to avoid edge WAF stripping of `>=`/`<`
|
||||
* in the query string and to stay under URL-length limits for long where clauses.
|
||||
*
|
||||
* @param {Record<string, string>} params
|
||||
* @returns {Promise<object[]>} array of row `title` objects
|
||||
* @typedef {object} ScheduleEvent
|
||||
* @property {string} startTime — ISO 8601 UTC.
|
||||
* @property {"unstarted"|"inProgress"|"completed"} state
|
||||
* @property {string} [blockName] — e.g. "Week 4".
|
||||
* @property {{ name: string, slug: string, image?: string }} league
|
||||
* @property {{
|
||||
* id: string,
|
||||
* teams: Team[],
|
||||
* strategy: { type: string, count: number }
|
||||
* }} match
|
||||
*/
|
||||
async function cargoQuery(params) {
|
||||
const body = new URLSearchParams({
|
||||
action: "cargoquery",
|
||||
format: "json",
|
||||
...params,
|
||||
});
|
||||
const res = await fetch(API_URL, {
|
||||
method: "POST",
|
||||
|
||||
/**
|
||||
* Fetch one page of schedule events. Returns events + pagination tokens.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.pageToken] — `newer`/`older` cursor from a previous call.
|
||||
* @param {string} [opts.leagueId] — optional comma-separated league IDs.
|
||||
* @returns {Promise<{ events: ScheduleEvent[], olderToken?: string, newerToken?: string }>}
|
||||
*/
|
||||
export async function fetchSchedulePage({ pageToken, leagueId } = {}) {
|
||||
const url = new URL(API_URL);
|
||||
url.searchParams.set("hl", "en-US");
|
||||
if (pageToken) url.searchParams.set("pageToken", pageToken);
|
||||
if (leagueId) url.searchParams.set("leagueId", leagueId);
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"x-api-key": API_KEY,
|
||||
"User-Agent": USER_AGENT,
|
||||
Accept: "application/json",
|
||||
},
|
||||
body,
|
||||
cf: { cacheTtl: 30, cacheEverything: true },
|
||||
cf: { cacheTtl: 60, cacheEverything: true },
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
console.log(
|
||||
JSON.stringify({ msg: "lolschedule_fetch", status: res.status, body: text.slice(0, 500) }),
|
||||
);
|
||||
throw new Error(`Leaguepedia API HTTP ${res.status}`);
|
||||
throw new Error(`lolesports API HTTP ${res.status}`);
|
||||
}
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
console.log(
|
||||
JSON.stringify({ msg: "lolschedule_parse_fail", body: text.slice(0, 500) }),
|
||||
);
|
||||
throw new Error("Leaguepedia non-JSON response");
|
||||
throw new Error("lolesports non-JSON response");
|
||||
}
|
||||
if (json?.error) {
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
msg: "lolschedule_api_error",
|
||||
code: json.error.code,
|
||||
info: json.error.info,
|
||||
}),
|
||||
);
|
||||
throw new Error(`Leaguepedia error: ${json.error.info || json.error.code}`);
|
||||
}
|
||||
const rows = (json?.cargoquery || []).map((r) => r.title);
|
||||
console.log(JSON.stringify({ msg: "lolschedule_fetch_ok", rows: rows.length }));
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Format a JS Date as Leaguepedia's UTC literal: `YYYY-MM-DD HH:MM:SS`. */
|
||||
function toUtcLiteral(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return (
|
||||
`${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())} ` +
|
||||
`${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())}`
|
||||
);
|
||||
const schedule = json?.data?.schedule;
|
||||
const events = Array.isArray(schedule?.events) ? schedule.events : [];
|
||||
const filtered = events.filter((e) => e?.type !== "show"); // drop pre/post shows
|
||||
return {
|
||||
events: /** @type {ScheduleEvent[]} */ (filtered),
|
||||
olderToken: schedule?.pages?.older,
|
||||
newerToken: schedule?.pages?.newer,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch matches with DateTime_UTC in [from, to).
|
||||
* Fetch enough pages forward in time to cover the given UTC window.
|
||||
* Default page returns ~20 events; week view usually needs 1 extra page.
|
||||
*
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @returns {Promise<MatchRow[]>}
|
||||
* @param {number} [maxPages] — hard cap to keep it bounded.
|
||||
* @returns {Promise<ScheduleEvent[]>}
|
||||
*/
|
||||
export async function fetchMatchesInRange(from, to) {
|
||||
const fromLit = toUtcLiteral(from);
|
||||
const toLit = toUtcLiteral(to);
|
||||
const rows = await cargoQuery({
|
||||
tables: "MatchSchedule=MS",
|
||||
fields:
|
||||
"MS.DateTime_UTC=DateTime," +
|
||||
"MS.Team1=T1,MS.Team2=T2," +
|
||||
"MS.Team1Score=S1,MS.Team2Score=S2," +
|
||||
"MS.Winner=Winner,MS.Tournament=Tournament," +
|
||||
"MS.BestOf=BO,MS.OverviewPage=OP",
|
||||
where: `MS.DateTime_UTC >= "${fromLit}" AND MS.DateTime_UTC < "${toLit}"`,
|
||||
order_by: "MS.DateTime_UTC ASC",
|
||||
limit: "100",
|
||||
export async function fetchEventsInRange(from, to, maxPages = 3) {
|
||||
const fromMs = from.getTime();
|
||||
const toMs = to.getTime();
|
||||
/** @type {ScheduleEvent[]} */
|
||||
const collected = [];
|
||||
let pageToken;
|
||||
for (let i = 0; i < maxPages; i++) {
|
||||
const { events, newerToken } = await fetchSchedulePage({ pageToken });
|
||||
collected.push(...events);
|
||||
// If the latest event in the page is already past our window end, stop.
|
||||
const lastMs = events.length ? Date.parse(events[events.length - 1].startTime) : null;
|
||||
if (lastMs !== null && lastMs >= toMs) break;
|
||||
if (!newerToken) break;
|
||||
pageToken = newerToken;
|
||||
}
|
||||
return collected.filter((e) => {
|
||||
const t = Date.parse(e.startTime);
|
||||
return t >= fromMs && t < toMs;
|
||||
});
|
||||
return /** @type {MatchRow[]} */ (rows);
|
||||
}
|
||||
|
||||
/** Build the KV cache key for a date range. */
|
||||
function cacheKey(from, to) {
|
||||
return `matches:${from.toISOString()}:${to.toISOString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache-first match lookup keyed by date range. Returns cached rows without
|
||||
* refetch within TTL; on fetch failure, returns stale cache if available.
|
||||
* Cache-first lookup. Returns fresh cache within TTL, else tries upstream,
|
||||
* else returns stale cache up to {@link STALE_MAX_AGE_SEC}, else throws.
|
||||
*
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @param {number} ttlSec
|
||||
* @returns {Promise<MatchRow[]>}
|
||||
* @returns {Promise<ScheduleEvent[]>}
|
||||
*/
|
||||
export async function getCachedMatches(db, from, to, ttlSec) {
|
||||
const key = `matches:${from.toISOString()}:${to.toISOString()}`;
|
||||
export async function getEventsCached(db, from, to) {
|
||||
const key = cacheKey(from, to);
|
||||
const cached = await db.getJSON(key);
|
||||
if (cached?.ts && Date.now() - cached.ts < ttlSec * 1000) {
|
||||
return cached.rows;
|
||||
if (cached?.ts && Date.now() - cached.ts < CACHE_TTL_SEC * 1000) {
|
||||
return cached.events;
|
||||
}
|
||||
try {
|
||||
const rows = await fetchMatchesInRange(from, to);
|
||||
const events = await fetchEventsInRange(from, to);
|
||||
try {
|
||||
await db.putJSON(key, { ts: Date.now(), rows }, { expirationTtl: ttlSec * 4 });
|
||||
await db.putJSON(key, { ts: Date.now(), events }, { expirationTtl: STALE_MAX_AGE_SEC });
|
||||
} catch (err) {
|
||||
console.warn("lolschedule: KV putJSON failed", String(err));
|
||||
console.log(JSON.stringify({ msg: "lolschedule_kv_put_fail", err: String(err) }));
|
||||
}
|
||||
return rows;
|
||||
return events;
|
||||
} catch (err) {
|
||||
if (cached?.rows) return cached.rows; // stale fallback
|
||||
if (cached?.events && cached?.ts && Date.now() - cached.ts < STALE_MAX_AGE_SEC * 1000) {
|
||||
console.log(JSON.stringify({ msg: "lolschedule_stale_fallback", err: String(err) }));
|
||||
return cached.events;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC };
|
||||
export { CACHE_TTL_SEC, STALE_MAX_AGE_SEC };
|
||||
@@ -1,36 +1,32 @@
|
||||
/**
|
||||
* @file Pure formatters — Telegram HTML output for today / week match lists.
|
||||
*
|
||||
* All user-influenced substrings are escaped; match rows come from the public
|
||||
* wiki so we treat them as untrusted. Times are rendered in ICT (UTC+7) since
|
||||
* the bot's primary audience is VN; feel free to branch by chat locale later.
|
||||
* Takes lolesports.com schedule events and renders them. All user-influenced
|
||||
* substrings are HTML-escaped. Times are shown in ICT (UTC+7).
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
/** @typedef {import("./api-client.js").MatchRow} MatchRow */
|
||||
/** @typedef {import("./api-client.js").ScheduleEvent} ScheduleEvent */
|
||||
/** @typedef {import("./api-client.js").Team} Team */
|
||||
|
||||
const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7
|
||||
|
||||
/** Parse Leaguepedia's `YYYY-MM-DD HH:MM:SS` UTC literal into a Date. */
|
||||
export function parseUtc(literal) {
|
||||
return new Date(`${literal.replace(" ", "T")}Z`);
|
||||
}
|
||||
|
||||
/** Shift a UTC date by the ICT offset so getUTC* methods yield ICT components. */
|
||||
/** Shift a UTC Date by the ICT offset so getUTC* yields ICT components. */
|
||||
function toIct(date) {
|
||||
return new Date(date.getTime() + TZ_OFFSET_MS);
|
||||
}
|
||||
|
||||
/** Format ICT time as `HH:MM`. */
|
||||
function formatIctTime(date) {
|
||||
function pad(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function formatIctTime(date) {
|
||||
const d = toIct(date);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}`;
|
||||
}
|
||||
|
||||
/** Format ICT date as `Mon Apr 21` (weekday + month + day). */
|
||||
function formatIctDayLabel(date) {
|
||||
export function formatIctDayLabel(date) {
|
||||
const d = toIct(date);
|
||||
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||
const months = [
|
||||
@@ -50,116 +46,93 @@ function formatIctDayLabel(date) {
|
||||
return `${weekdays[d.getUTCDay()]} ${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
|
||||
}
|
||||
|
||||
/** ICT calendar-day key `YYYY-MM-DD` (used for grouping). */
|
||||
function ictDayKey(date) {
|
||||
const d = toIct(date);
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
/** Coerce Leaguepedia's string score into a number, or null. */
|
||||
function parseScore(s) {
|
||||
if (s == null || s === "") return null;
|
||||
const n = Number(s);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
/** Pick the visible short tag for a team. */
|
||||
function teamLabel(team) {
|
||||
if (!team) return "TBD";
|
||||
return team.code || team.name || "TBD";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify a match row's display state.
|
||||
* Render one event line (no leading newline).
|
||||
*
|
||||
* @param {MatchRow} row
|
||||
* @param {number} nowMs — injected for testability.
|
||||
* @returns {"played"|"live"|"scheduled"}
|
||||
* @param {ScheduleEvent} event
|
||||
* @returns {string} escaped HTML
|
||||
*/
|
||||
export function classifyMatch(row, nowMs = Date.now()) {
|
||||
const startMs = parseUtc(row.DateTime).getTime();
|
||||
const winner = String(row.Winner ?? "");
|
||||
if (winner === "1" || winner === "2") return "played";
|
||||
const s1 = parseScore(row.S1);
|
||||
const s2 = parseScore(row.S2);
|
||||
const hasScore = (s1 ?? 0) + (s2 ?? 0) > 0;
|
||||
if (startMs <= nowMs && hasScore) return "live";
|
||||
return "scheduled";
|
||||
export function formatEventLine(event) {
|
||||
const teams = event?.match?.teams || [];
|
||||
const t1Label = escapeHtml(teamLabel(teams[0]));
|
||||
const t2Label = escapeHtml(teamLabel(teams[1]));
|
||||
const league = escapeHtml(event?.league?.name || "");
|
||||
const block = event?.blockName ? ` (${escapeHtml(event.blockName)})` : "";
|
||||
const bestOf = event?.match?.strategy?.count;
|
||||
const bo = bestOf ? ` · Bo${bestOf}` : "";
|
||||
|
||||
if (event?.state === "completed") {
|
||||
const w1 = teams[0]?.result?.gameWins ?? 0;
|
||||
const w2 = teams[1]?.result?.gameWins ?? 0;
|
||||
const winner1 = teams[0]?.result?.outcome === "win";
|
||||
const winner2 = teams[1]?.result?.outcome === "win";
|
||||
const l = winner1 ? `<b>${t1Label}</b>` : t1Label;
|
||||
const r = winner2 ? `<b>${t2Label}</b>` : t2Label;
|
||||
return `✅ ${l} ${w1}–${w2} ${r}${bo} · ${league}${block}`;
|
||||
}
|
||||
if (event?.state === "inProgress") {
|
||||
const w1 = teams[0]?.result?.gameWins ?? 0;
|
||||
const w2 = teams[1]?.result?.gameWins ?? 0;
|
||||
return `🔴 LIVE ${t1Label} ${w1}–${w2} ${t2Label}${bo} · ${league}${block}`;
|
||||
}
|
||||
// unstarted or unknown
|
||||
const time = formatIctTime(new Date(event.startTime));
|
||||
return `🕒 ${time} ${t1Label} vs ${t2Label}${bo} · ${league}${block}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one match line (no leading newline).
|
||||
* Render today's reply.
|
||||
*
|
||||
* @param {MatchRow} row
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string} HTML — already escaped
|
||||
*/
|
||||
export function formatMatchLine(row, nowMs = Date.now()) {
|
||||
const t1 = escapeHtml(row.T1 || "TBD");
|
||||
const t2 = escapeHtml(row.T2 || "TBD");
|
||||
const tournament = escapeHtml(row.Tournament || "");
|
||||
const bo = row.BO ? ` · Bo${escapeHtml(row.BO)}` : "";
|
||||
const state = classifyMatch(row, nowMs);
|
||||
|
||||
if (state === "played") {
|
||||
const s1 = parseScore(row.S1) ?? 0;
|
||||
const s2 = parseScore(row.S2) ?? 0;
|
||||
const w1 = String(row.Winner) === "1" ? "<b>" : "";
|
||||
const w1c = w1 ? "</b>" : "";
|
||||
const w2 = String(row.Winner) === "2" ? "<b>" : "";
|
||||
const w2c = w2 ? "</b>" : "";
|
||||
return `✅ ${w1}${t1}${w1c} ${s1}–${s2} ${w2}${t2}${w2c}${bo} · ${tournament}`;
|
||||
}
|
||||
if (state === "live") {
|
||||
const s1 = parseScore(row.S1) ?? 0;
|
||||
const s2 = parseScore(row.S2) ?? 0;
|
||||
return `🔴 LIVE ${t1} ${s1}–${s2} ${t2}${bo} · ${tournament}`;
|
||||
}
|
||||
const time = formatIctTime(parseUtc(row.DateTime));
|
||||
return `🕒 ${time} ${t1} vs ${t2}${bo} · ${tournament}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "today" command reply.
|
||||
*
|
||||
* @param {MatchRow[]} rows
|
||||
* @param {ScheduleEvent[]} events
|
||||
* @param {Date} day — any moment on the target ICT day.
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderToday(rows, day, nowMs = Date.now()) {
|
||||
export function renderToday(events, day) {
|
||||
const header = `<b>LoL — ${escapeHtml(formatIctDayLabel(day))}</b> (ICT)`;
|
||||
if (rows.length === 0) return `${header}\nNo matches today.`;
|
||||
return `${header}\n${rows.map((r) => formatMatchLine(r, nowMs)).join("\n")}`;
|
||||
if (events.length === 0) return `${header}\nNo matches today.`;
|
||||
return `${header}\n${events.map(formatEventLine).join("\n")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the "week" command reply — matches grouped by ICT day.
|
||||
* Render week reply — grouped by ICT day.
|
||||
*
|
||||
* @param {MatchRow[]} rows
|
||||
* @param {ScheduleEvent[]} events
|
||||
* @param {Date} from
|
||||
* @param {Date} to
|
||||
* @param {number} [nowMs]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function renderWeek(rows, from, to, nowMs = Date.now()) {
|
||||
export function renderWeek(events, from, to) {
|
||||
const fromLbl = escapeHtml(formatIctDayLabel(from));
|
||||
// `to` is exclusive; subtract 1 ms for a friendlier "through" label.
|
||||
const toLbl = escapeHtml(formatIctDayLabel(new Date(to.getTime() - 1)));
|
||||
const header = `<b>LoL — ${fromLbl} → ${toLbl}</b> (ICT)`;
|
||||
if (rows.length === 0) return `${header}\nNo matches this week.`;
|
||||
if (events.length === 0) return `${header}\nNo matches this week.`;
|
||||
|
||||
/** @type {Map<string, { label: string, lines: string[] }>} */
|
||||
const groups = new Map();
|
||||
for (const row of rows) {
|
||||
const d = parseUtc(row.DateTime);
|
||||
for (const event of events) {
|
||||
const d = new Date(event.startTime);
|
||||
const key = ictDayKey(d);
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
g = { label: formatIctDayLabel(d), lines: [] };
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.lines.push(formatMatchLine(row, nowMs));
|
||||
g.lines.push(formatEventLine(event));
|
||||
}
|
||||
|
||||
const sections = [];
|
||||
const sortedKeys = [...groups.keys()].sort();
|
||||
for (const key of sortedKeys) {
|
||||
for (const key of [...groups.keys()].sort()) {
|
||||
const g = groups.get(key);
|
||||
sections.push(`<b>${escapeHtml(g.label)}</b>\n${g.lines.join("\n")}`);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
/**
|
||||
* @file /lol_today and /lol_week command handlers.
|
||||
*
|
||||
* Day boundaries are defined in ICT (UTC+7), converted to the UTC literals
|
||||
* that Leaguepedia's cargo query expects. Errors are surfaced as a short reply
|
||||
* — we never throw through grammY.
|
||||
* Day boundaries are defined in ICT (UTC+7). Data comes from lolesports.com
|
||||
* via a cache-first fetcher; no cron pre-warm is needed because the upstream
|
||||
* API is rate-limit friendly.
|
||||
*/
|
||||
|
||||
import { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC, getCachedMatches } from "./api-client.js";
|
||||
import { getEventsCached } from "./api-client.js";
|
||||
import { renderToday, renderWeek } from "./format.js";
|
||||
|
||||
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Start of the current ICT calendar day, expressed as a UTC `Date`.
|
||||
* @param {number} [nowMs]
|
||||
*/
|
||||
// Top-tier league allowlist. The API returns every regional/academy league
|
||||
// (135+ events/week); filtering keeps the reply under Telegram's 4096-char
|
||||
// limit and focuses on what most viewers care about.
|
||||
const MAJOR_LEAGUE_SLUGS = new Set([
|
||||
"lck",
|
||||
"lpl",
|
||||
"lec",
|
||||
"lcs",
|
||||
"worlds",
|
||||
"msi",
|
||||
"first_stand",
|
||||
"lcp",
|
||||
"cblol-brazil",
|
||||
"emea_masters",
|
||||
]);
|
||||
|
||||
function filterMajor(events) {
|
||||
return events.filter((e) => MAJOR_LEAGUE_SLUGS.has(e?.league?.slug));
|
||||
}
|
||||
|
||||
/** Start of the current ICT calendar day, expressed as a UTC `Date`. */
|
||||
export function ictDayStart(nowMs = Date.now()) {
|
||||
const shifted = new Date(nowMs + ICT_OFFSET_MS);
|
||||
shifted.setUTCHours(0, 0, 0, 0);
|
||||
@@ -38,12 +55,10 @@ export async function handleToday(ctx, db) {
|
||||
const from = ictDayStart();
|
||||
const to = addDays(from, 1);
|
||||
try {
|
||||
const rows = await getCachedMatches(db, from, to, CACHE_TTL_TODAY_SEC);
|
||||
await ctx.reply(renderToday(rows, from), { parse_mode: "HTML" });
|
||||
const events = filterMajor(await getEventsCached(db, from, to));
|
||||
await ctx.reply(renderToday(events, from), { parse_mode: "HTML" });
|
||||
} catch (err) {
|
||||
console.log(
|
||||
JSON.stringify({ msg: "lolschedule_today_fail", err: String(err) }),
|
||||
);
|
||||
console.log(JSON.stringify({ msg: "lolschedule_today_fail", err: String(err) }));
|
||||
await ctx.reply("Could not fetch today's matches. Try again later.");
|
||||
}
|
||||
}
|
||||
@@ -60,12 +75,10 @@ export async function handleWeek(ctx, db) {
|
||||
const from = ictDayStart();
|
||||
const to = addDays(from, 7);
|
||||
try {
|
||||
const rows = await getCachedMatches(db, from, to, CACHE_TTL_WEEK_SEC);
|
||||
await ctx.reply(renderWeek(rows, from, to), { parse_mode: "HTML" });
|
||||
const events = filterMajor(await getEventsCached(db, from, to));
|
||||
await ctx.reply(renderWeek(events, from, to), { parse_mode: "HTML" });
|
||||
} catch (err) {
|
||||
console.log(
|
||||
JSON.stringify({ msg: "lolschedule_week_fail", err: String(err) }),
|
||||
);
|
||||
console.log(JSON.stringify({ msg: "lolschedule_week_fail", err: String(err) }));
|
||||
await ctx.reply("Could not fetch this week's matches. Try again later.");
|
||||
}
|
||||
}
|
||||
@@ -1,119 +1,141 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStore } from "../../../src/db/create-store.js";
|
||||
import {
|
||||
fetchMatchesInRange,
|
||||
getCachedMatches,
|
||||
fetchEventsInRange,
|
||||
fetchSchedulePage,
|
||||
getEventsCached,
|
||||
} from "../../../src/modules/lolschedule/api-client.js";
|
||||
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
|
||||
|
||||
function cargoResponse(rows) {
|
||||
const payload = { cargoquery: rows.map((title) => ({ title })) };
|
||||
function scheduleResponse(events, { newer, older } = {}) {
|
||||
const payload = {
|
||||
data: { schedule: { events, pages: { newer, older } } },
|
||||
};
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(payload),
|
||||
json: async () => payload,
|
||||
};
|
||||
}
|
||||
|
||||
const sampleRow = {
|
||||
DateTime: "2026-04-21 09:00:00",
|
||||
T1: "T1",
|
||||
T2: "Gen.G",
|
||||
S1: "",
|
||||
S2: "",
|
||||
Winner: "",
|
||||
Tournament: "LCK 2026 Spring",
|
||||
BO: "3",
|
||||
OP: "LCK/2026_Season/Spring_Season",
|
||||
};
|
||||
function evt(startTime, state = "unstarted") {
|
||||
return {
|
||||
startTime,
|
||||
state,
|
||||
type: "match",
|
||||
blockName: "W1",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: `m-${startTime}`,
|
||||
teams: [{ code: "T1" }, { code: "GEN" }],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("fetchMatchesInRange", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
describe("fetchSchedulePage", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("posts cargoquery with correct where clause and parses rows", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow]));
|
||||
global.fetch = fetchSpy;
|
||||
it("calls the getSchedule endpoint with the public API key", async () => {
|
||||
const spy = vi.fn().mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")]));
|
||||
global.fetch = spy;
|
||||
|
||||
const from = new Date("2026-04-21T00:00:00Z");
|
||||
const to = new Date("2026-04-22T00:00:00Z");
|
||||
const rows = await fetchMatchesInRange(from, to);
|
||||
|
||||
expect(rows).toEqual([sampleRow]);
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
const [url, opts] = fetchSpy.mock.calls[0];
|
||||
expect(url).toBe("https://lol.fandom.com/api.php");
|
||||
expect(opts.method).toBe("POST");
|
||||
const { events } = await fetchSchedulePage();
|
||||
expect(events).toHaveLength(1);
|
||||
const [url, opts] = spy.mock.calls[0];
|
||||
expect(url).toContain("esports-api.lolesports.com/persisted/gw/getSchedule");
|
||||
expect(url).toContain("hl=en-US");
|
||||
expect(opts.headers["x-api-key"]).toBeDefined();
|
||||
expect(opts.headers["User-Agent"]).toMatch(/miti99bot/);
|
||||
const body = opts.body.toString();
|
||||
expect(body).toContain("action=cargoquery");
|
||||
expect(body).toContain("MatchSchedule%3DMS"); // "MatchSchedule=MS" url-encoded
|
||||
expect(body).toContain("2026-04-21+00%3A00%3A00");
|
||||
expect(body).toContain("2026-04-22+00%3A00%3A00");
|
||||
});
|
||||
|
||||
it("surfaces Leaguepedia API error field", async () => {
|
||||
const errPayload = { error: { info: "Bad query", code: "x" } };
|
||||
it("drops type=show entries (pre/post shows)", async () => {
|
||||
const show = { ...evt("2026-04-21T07:00:00Z"), type: "show" };
|
||||
global.fetch = vi.fn().mockResolvedValue(scheduleResponse([show, evt("2026-04-21T09:00:00Z")]));
|
||||
const { events } = await fetchSchedulePage();
|
||||
expect(events.map((e) => e.type)).not.toContain("show");
|
||||
});
|
||||
|
||||
it("surfaces non-2xx responses as errors", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => JSON.stringify(errPayload),
|
||||
json: async () => errPayload,
|
||||
ok: false,
|
||||
status: 403,
|
||||
text: async () => "forbidden",
|
||||
});
|
||||
await expect(
|
||||
fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")),
|
||||
).rejects.toThrow(/Bad query/);
|
||||
await expect(fetchSchedulePage()).rejects.toThrow(/HTTP 403/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCachedMatches", () => {
|
||||
describe("fetchEventsInRange", () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("filters events by startTime in [from, to)", async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
scheduleResponse(
|
||||
[
|
||||
evt("2026-04-20T09:00:00Z"), // yesterday
|
||||
evt("2026-04-21T09:00:00Z"), // today
|
||||
evt("2026-04-22T09:00:00Z"), // tomorrow — outside 1-day window
|
||||
],
|
||||
{ newer: null },
|
||||
),
|
||||
);
|
||||
const out = await fetchEventsInRange(
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-22T00:00:00Z"),
|
||||
);
|
||||
expect(out.map((e) => e.startTime)).toEqual(["2026-04-21T09:00:00Z"]);
|
||||
});
|
||||
|
||||
it("pages forward when the last event is before window end and a newer token exists", async () => {
|
||||
const spy = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: "tok1" }))
|
||||
.mockResolvedValueOnce(scheduleResponse([evt("2026-04-25T09:00:00Z")], { newer: null }));
|
||||
global.fetch = spy;
|
||||
const out = await fetchEventsInRange(
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
);
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEventsCached", () => {
|
||||
let db;
|
||||
let kv;
|
||||
|
||||
beforeEach(() => {
|
||||
kv = makeFakeKv();
|
||||
db = createStore("lolschedule", { KV: kv });
|
||||
db = createStore("lolschedule", { KV: makeFakeKv() });
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const from = new Date("2026-04-21T00:00:00Z");
|
||||
const to = new Date("2026-04-22T00:00:00Z");
|
||||
|
||||
it("caches a successful fetch then serves from cache", async () => {
|
||||
const spy = vi
|
||||
.fn()
|
||||
.mockResolvedValue(scheduleResponse([evt("2026-04-21T09:00:00Z")], { newer: null }));
|
||||
global.fetch = spy;
|
||||
const r1 = await getEventsCached(db, from, to);
|
||||
const r2 = await getEventsCached(db, from, to);
|
||||
expect(r1).toHaveLength(1);
|
||||
expect(r2).toHaveLength(1);
|
||||
expect(spy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("caches a successful fetch and returns cached on the second call", async () => {
|
||||
const fetchSpy = vi.fn().mockResolvedValue(cargoResponse([sampleRow]));
|
||||
global.fetch = fetchSpy;
|
||||
|
||||
const from = new Date("2026-04-21T00:00:00Z");
|
||||
const to = new Date("2026-04-22T00:00:00Z");
|
||||
const r1 = await getCachedMatches(db, from, to, 60);
|
||||
const r2 = await getCachedMatches(db, from, to, 60);
|
||||
|
||||
expect(r1).toEqual([sampleRow]);
|
||||
expect(r2).toEqual([sampleRow]);
|
||||
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("falls back to stale cache when the fetch fails", async () => {
|
||||
const from = new Date("2026-04-21T00:00:00Z");
|
||||
const to = new Date("2026-04-22T00:00:00Z");
|
||||
|
||||
// Seed the cache with stale data directly.
|
||||
it("falls back to stale cache if the fetch fails and cache is within STALE_MAX_AGE_SEC", async () => {
|
||||
await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, {
|
||||
ts: Date.now() - 10 * 60 * 1000, // 10 min ago — older than the 60s TTL
|
||||
rows: [sampleRow],
|
||||
ts: Date.now() - 10 * 60 * 1000, // 10 min ago, older than 120s TTL, newer than 1h stale max
|
||||
events: [evt("2026-04-21T09:00:00Z")],
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
const out = await getCachedMatches(db, from, to, 60);
|
||||
expect(out).toEqual([sampleRow]);
|
||||
const out = await getEventsCached(db, from, to);
|
||||
expect(out).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("propagates the error when no cache is available", async () => {
|
||||
it("throws when fetch fails and no cache is available", async () => {
|
||||
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
|
||||
await expect(
|
||||
getCachedMatches(db, new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z"), 60),
|
||||
).rejects.toThrow(/boom/);
|
||||
await expect(getEventsCached(db, from, to)).rejects.toThrow(/boom/);
|
||||
});
|
||||
});
|
||||
@@ -1,145 +1,148 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
classifyMatch,
|
||||
formatMatchLine,
|
||||
parseUtc,
|
||||
formatEventLine,
|
||||
formatIctDayLabel,
|
||||
formatIctTime,
|
||||
renderToday,
|
||||
renderWeek,
|
||||
} from "../../../src/modules/lolschedule/format.js";
|
||||
|
||||
const scheduled = {
|
||||
DateTime: "2026-04-21 09:00:00",
|
||||
T1: "T1",
|
||||
T2: "Gen.G",
|
||||
S1: "",
|
||||
S2: "",
|
||||
Winner: "",
|
||||
Tournament: "LCK 2026 Spring",
|
||||
BO: "3",
|
||||
OP: "LCK/2026_Season/Spring_Season",
|
||||
};
|
||||
/** @typedef {import("../../../src/modules/lolschedule/api-client.js").ScheduleEvent} ScheduleEvent */
|
||||
|
||||
const live = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-21 08:00:00",
|
||||
S1: "1",
|
||||
S2: "0",
|
||||
};
|
||||
const completed = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-20T08:00:00Z",
|
||||
state: "completed",
|
||||
blockName: "Week 3",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "1",
|
||||
teams: [
|
||||
{ name: "T1", code: "T1", result: { outcome: "win", gameWins: 2 } },
|
||||
{ name: "Gen.G", code: "GEN", result: { outcome: "loss", gameWins: 1 } },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
const played = {
|
||||
...scheduled,
|
||||
DateTime: "2026-04-20 10:00:00",
|
||||
S1: "2",
|
||||
S2: "1",
|
||||
Winner: "1",
|
||||
};
|
||||
const live = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T08:00:00Z",
|
||||
state: "inProgress",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "2",
|
||||
teams: [
|
||||
{ name: "Hanwha Life", code: "HLE", result: { gameWins: 1 } },
|
||||
{ name: "DRX", code: "DRX", result: { gameWins: 0 } },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT)
|
||||
const nowMs = Date.parse("2026-04-21T08:30:00Z");
|
||||
const scheduled = /** @type {ScheduleEvent} */ ({
|
||||
startTime: "2026-04-21T09:00:00Z", // 16:00 ICT
|
||||
state: "unstarted",
|
||||
blockName: "Week 4",
|
||||
league: { name: "LCK", slug: "lck" },
|
||||
match: {
|
||||
id: "3",
|
||||
teams: [
|
||||
{ name: "KT Rolster", code: "KT" },
|
||||
{ name: "Dplus KIA", code: "DK" },
|
||||
],
|
||||
strategy: { type: "bestOf", count: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
describe("parseUtc", () => {
|
||||
it("parses Leaguepedia UTC literal", () => {
|
||||
expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z");
|
||||
describe("formatIctTime / formatIctDayLabel", () => {
|
||||
it("formats UTC datetime in ICT", () => {
|
||||
expect(formatIctTime(new Date("2026-04-21T09:00:00Z"))).toBe("16:00");
|
||||
expect(formatIctDayLabel(new Date("2026-04-21T00:00:00Z"))).toBe("Tue Apr 21");
|
||||
});
|
||||
});
|
||||
|
||||
describe("classifyMatch", () => {
|
||||
it("returns played when Winner is 1 or 2", () => {
|
||||
expect(classifyMatch(played, nowMs)).toBe("played");
|
||||
expect(classifyMatch({ ...played, Winner: "2" }, nowMs)).toBe("played");
|
||||
});
|
||||
|
||||
it("returns live when started, has partial score, no winner", () => {
|
||||
expect(classifyMatch(live, nowMs)).toBe("live");
|
||||
});
|
||||
|
||||
it("returns scheduled when start time is in the future", () => {
|
||||
expect(classifyMatch(scheduled, nowMs)).toBe("scheduled");
|
||||
});
|
||||
|
||||
it("returns scheduled when start is past but no score", () => {
|
||||
const row = { ...scheduled, DateTime: "2026-04-21 07:00:00" };
|
||||
expect(classifyMatch(row, nowMs)).toBe("scheduled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMatchLine", () => {
|
||||
it("renders scheduled with ICT time and vs", () => {
|
||||
const line = formatMatchLine(scheduled, nowMs);
|
||||
// 09:00 UTC → 16:00 ICT
|
||||
expect(line).toContain("16:00");
|
||||
expect(line).toContain("vs");
|
||||
expect(line).toContain("Bo3");
|
||||
expect(line).toContain("LCK 2026 Spring");
|
||||
expect(line.startsWith("🕒")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders played with score and winner bolded", () => {
|
||||
const line = formatMatchLine(played, nowMs);
|
||||
expect(line).toContain("2–1");
|
||||
expect(line).toContain("<b>T1</b>");
|
||||
describe("formatEventLine", () => {
|
||||
it("renders completed with bolded winner + score", () => {
|
||||
const line = formatEventLine(completed);
|
||||
expect(line.startsWith("✅")).toBe(true);
|
||||
expect(line).toContain("<b>T1</b>");
|
||||
expect(line).toContain("2–1");
|
||||
expect(line).toContain("GEN");
|
||||
expect(line).toContain("Bo3");
|
||||
expect(line).toContain("LCK");
|
||||
expect(line).toContain("Week 3");
|
||||
});
|
||||
|
||||
it("renders live prefix", () => {
|
||||
const line = formatMatchLine(live, nowMs);
|
||||
it("renders live with LIVE prefix + current score", () => {
|
||||
const line = formatEventLine(live);
|
||||
expect(line.startsWith("🔴 LIVE")).toBe(true);
|
||||
expect(line).toContain("1–0");
|
||||
});
|
||||
|
||||
it("escapes HTML in team and tournament names", () => {
|
||||
const row = { ...scheduled, T1: "<script>", Tournament: "A&B" };
|
||||
const line = formatMatchLine(row, nowMs);
|
||||
it("renders unstarted with ICT time + vs", () => {
|
||||
const line = formatEventLine(scheduled);
|
||||
expect(line.startsWith("🕒 16:00")).toBe(true);
|
||||
expect(line).toContain("KT vs DK");
|
||||
});
|
||||
|
||||
it("escapes HTML in league, block, team names", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
league: { name: "A&B", slug: "ab" },
|
||||
blockName: "<script>",
|
||||
match: {
|
||||
...scheduled.match,
|
||||
teams: [{ code: "<bad>" }, { code: "GEN" }],
|
||||
},
|
||||
});
|
||||
const line = formatEventLine(event);
|
||||
expect(line).not.toContain("<script>");
|
||||
expect(line).toContain("<script>");
|
||||
expect(line).toContain("<bad>");
|
||||
expect(line).toContain("A&B");
|
||||
});
|
||||
|
||||
it("shows TBD when team field is empty", () => {
|
||||
const row = { ...scheduled, T1: "" };
|
||||
expect(formatMatchLine(row, nowMs)).toContain("TBD");
|
||||
it("shows TBD when team is missing", () => {
|
||||
const event = /** @type {ScheduleEvent} */ ({
|
||||
...scheduled,
|
||||
match: { ...scheduled.match, teams: [null, { code: "GEN" }] },
|
||||
});
|
||||
expect(formatEventLine(event)).toContain("TBD vs GEN");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderToday", () => {
|
||||
it("returns empty-state message when no rows", () => {
|
||||
const out = renderToday([], new Date("2026-04-21T00:00:00Z"), nowMs);
|
||||
expect(out).toContain("No matches today.");
|
||||
it("empty state when no events", () => {
|
||||
expect(renderToday([], new Date("2026-04-21T00:00:00Z"))).toContain("No matches today.");
|
||||
});
|
||||
|
||||
it("renders header + one line per match", () => {
|
||||
const out = renderToday([scheduled, played], new Date("2026-04-21T00:00:00Z"), nowMs);
|
||||
expect(out).toContain("<b>LoL —");
|
||||
expect(out.split("\n").length).toBeGreaterThanOrEqual(3);
|
||||
it("renders header + one line per event", () => {
|
||||
const out = renderToday([scheduled, live], new Date("2026-04-21T00:00:00Z"));
|
||||
expect(out).toContain("<b>LoL — Tue Apr 21</b>");
|
||||
expect(out.split("\n")).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderWeek", () => {
|
||||
it("groups rows by ICT day", () => {
|
||||
const rows = [
|
||||
{ ...scheduled, DateTime: "2026-04-21 09:00:00" }, // Apr 21 ICT
|
||||
{ ...scheduled, DateTime: "2026-04-22 10:00:00" }, // Apr 22 ICT
|
||||
{ ...scheduled, DateTime: "2026-04-22 12:00:00" }, // Apr 22 ICT
|
||||
it("groups events by ICT day in order", () => {
|
||||
const events = [
|
||||
{ ...scheduled, startTime: "2026-04-21T09:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T10:00:00Z" },
|
||||
{ ...scheduled, startTime: "2026-04-22T12:00:00Z" },
|
||||
];
|
||||
const out = renderWeek(
|
||||
rows,
|
||||
events,
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
nowMs,
|
||||
);
|
||||
// Day labels appear once each, in order
|
||||
expect(out.indexOf("Apr 21")).toBeLessThan(out.indexOf("Apr 22"));
|
||||
const apr22Matches = out.split("Apr 22")[1] || "";
|
||||
expect((apr22Matches.match(/🕒/g) || []).length).toBe(2);
|
||||
const apr22Section = out.split("Apr 22")[1] || "";
|
||||
expect((apr22Section.match(/🕒/g) || []).length).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty-state when no matches", () => {
|
||||
const out = renderWeek(
|
||||
[],
|
||||
new Date("2026-04-21T00:00:00Z"),
|
||||
new Date("2026-04-28T00:00:00Z"),
|
||||
nowMs,
|
||||
);
|
||||
expect(out).toContain("No matches this week.");
|
||||
it("empty state when no events", () => {
|
||||
expect(
|
||||
renderWeek([], new Date("2026-04-21T00:00:00Z"), new Date("2026-04-28T00:00:00Z")),
|
||||
).toContain("No matches this week.");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user