feat(lolschedule): add LoL esports match schedule module

New module exposing /lol_today and /lol_week commands, backed by the
Leaguepedia Cargo API (MatchSchedule table). Renders scores for
played/live matches and ICT times for scheduled ones. Caches range
queries in KV (60s today, 300s week) with stale-fallback on fetch error.
This commit is contained in:
tiennm99 authored and Tien Nguyen Minh committed 2026-04-21 09:36:39 +07:00
1 parent b180ea6660
commit a7797f16b2
10 files changed
+701 -2

No files matched your search

+1 -1
View File
@@ -12,4 +12,4 @@ WORKER_URL=
# Same MODULES value as wrangler.toml [vars]. Duplicated here so the register
# script can derive the public command list without parsing wrangler.toml.
MODULES=util,wordle,loldle,misc
MODULES=util,wordle,loldle,misc,trading,lolschedule
+1
View File
@@ -14,4 +14,5 @@ export const moduleRegistry = {
loldle: () => import("./loldle/index.js"),
misc: () => import("./misc/index.js"),
trading: () => import("./trading/index.js"),
lolschedule: () => import("./lolschedule/index.js"),
};
+41
View File
@@ -0,0 +1,41 @@
# lolschedule
LoL esports match schedule via the **Leaguepedia** MediaWiki/Cargo API.
## Commands
| Command | Description |
|---|---|
| `/lol_today` | Today's matches (ICT). Scores if played / live. Times if scheduled. |
| `/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.
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.
## Caching
Two layers:
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.
## 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.
## 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.
+124
View File
@@ -0,0 +1,124 @@
/**
* @file Leaguepedia cargoquery client for LoL esports match schedule.
*
* 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.
*
* @see plans/reports/researcher-260421-0845-leaguepedia-api-verification.md
*/
const API_URL = "https://lol.fandom.com/api.php";
const USER_AGENT = "miti99bot/0.1 (https://t.me/miti99bot; minhtienit99@gmail.com)";
/** Default KV cache windows — short enough to catch score updates mid-day. */
const CACHE_TTL_TODAY_SEC = 60;
const CACHE_TTL_WEEK_SEC = 300;
/**
* @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.
*/
/**
* 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
*/
async function cargoQuery(params) {
const body = new URLSearchParams({
action: "cargoquery",
format: "json",
...params,
});
const res = await fetch(API_URL, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": USER_AGENT,
Accept: "application/json",
},
body,
cf: { cacheTtl: 30, cacheEverything: true },
});
if (!res.ok) throw new Error(`Leaguepedia API HTTP ${res.status}`);
const json = await res.json();
if (json?.error) throw new Error(`Leaguepedia error: ${json.error.info || json.error.code}`);
return (json?.cargoquery || []).map((r) => r.title);
}
/** 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())}`
);
}
/**
* Fetch matches with DateTime_UTC in [from, to).
*
* @param {Date} from
* @param {Date} to
* @returns {Promise<MatchRow[]>}
*/
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",
});
return /** @type {MatchRow[]} */ (rows);
}
/**
* Cache-first match lookup keyed by date range. Returns cached rows without
* refetch within TTL; on fetch failure, returns stale cache if available.
*
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {Date} from
* @param {Date} to
* @param {number} ttlSec
* @returns {Promise<MatchRow[]>}
*/
export async function getCachedMatches(db, from, to, ttlSec) {
const key = `matches:${from.toISOString()}:${to.toISOString()}`;
const cached = await db.getJSON(key);
if (cached?.ts && Date.now() - cached.ts < ttlSec * 1000) {
return cached.rows;
}
try {
const rows = await fetchMatchesInRange(from, to);
try {
await db.putJSON(key, { ts: Date.now(), rows }, { expirationTtl: ttlSec * 4 });
} catch (err) {
console.warn("lolschedule: KV putJSON failed", String(err));
}
return rows;
} catch (err) {
if (cached?.rows) return cached.rows; // stale fallback
throw err;
}
}
export { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC };
+167
View File
@@ -0,0 +1,167 @@
/**
* @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.
*/
import { escapeHtml } from "../../util/escape-html.js";
/** @typedef {import("./api-client.js").MatchRow} MatchRow */
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. */
function toIct(date) {
return new Date(date.getTime() + TZ_OFFSET_MS);
}
/** Format ICT time as `HH:MM`. */
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) {
const d = toIct(date);
const weekdays = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
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;
}
/**
* Classify a match row's display state.
*
* @param {MatchRow} row
* @param {number} nowMs — injected for testability.
* @returns {"played"|"live"|"scheduled"}
*/
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";
}
/**
* Render one match line (no leading newline).
*
* @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 {Date} day — any moment on the target ICT day.
* @param {number} [nowMs]
* @returns {string}
*/
export function renderToday(rows, day, nowMs = Date.now()) {
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")}`;
}
/**
* Render the "week" command reply — matches grouped by ICT day.
*
* @param {MatchRow[]} rows
* @param {Date} from
* @param {Date} to
* @param {number} [nowMs]
* @returns {string}
*/
export function renderWeek(rows, from, to, nowMs = Date.now()) {
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.`;
/** @type {Map<string, { label: string, lines: string[] }>} */
const groups = new Map();
for (const row of rows) {
const d = parseUtc(row.DateTime);
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));
}
const sections = [];
const sortedKeys = [...groups.keys()].sort();
for (const key of sortedKeys) {
const g = groups.get(key);
sections.push(`<b>${escapeHtml(g.label)}</b>\n${g.lines.join("\n")}`);
}
return `${header}\n\n${sections.join("\n\n")}`;
}
+67
View File
@@ -0,0 +1,67 @@
/**
* @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.
*/
import { CACHE_TTL_TODAY_SEC, CACHE_TTL_WEEK_SEC, getCachedMatches } 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]
*/
export function ictDayStart(nowMs = Date.now()) {
const shifted = new Date(nowMs + ICT_OFFSET_MS);
shifted.setUTCHours(0, 0, 0, 0);
return new Date(shifted.getTime() - ICT_OFFSET_MS);
}
/** Add `days` days to a Date, preserving time-of-day. */
export function addDays(date, days) {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
*/
export async function handleToday(ctx, db) {
if (!db) {
await ctx.reply("lolschedule: storage unavailable");
return;
}
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" });
} catch (err) {
console.warn("lolschedule /lol_today failed", String(err));
await ctx.reply("Could not fetch today's matches. Try again later.");
}
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
*/
export async function handleWeek(ctx, db) {
if (!db) {
await ctx.reply("lolschedule: storage unavailable");
return;
}
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" });
} catch (err) {
console.warn("lolschedule /lol_week failed", String(err));
await ctx.reply("Could not fetch this week's matches. Try again later.");
}
}
+39
View File
@@ -0,0 +1,39 @@
/**
* @file lolschedule module — LoL esports match schedule via Leaguepedia API.
*
* Commands:
* /lol_today — matches scheduled for the current ICT day, with live/played scores.
* /lol_week — next 7 ICT days, grouped per day.
*
* Data source: Leaguepedia Cargo `MatchSchedule` table on lol.fandom.com.
* See plans/reports/researcher-260421-0845-leaguepedia-api-verification.md.
*/
import { handleToday, handleWeek } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const lolscheduleModule = {
name: "lolschedule",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "lol_today",
visibility: "public",
description: "Today's LoL esports matches (scores if played)",
handler: (ctx) => handleToday(ctx, db),
},
{
name: "lol_week",
visibility: "public",
description: "LoL esports matches for the next 7 days",
handler: (ctx) => handleWeek(ctx, db),
},
],
};
export default lolscheduleModule;
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
fetchMatchesInRange,
getCachedMatches,
} from "../../../src/modules/lolschedule/api-client.js";
import { makeFakeKv } from "../../fakes/fake-kv-namespace.js";
function cargoResponse(rows) {
return {
ok: true,
status: 200,
json: async () => ({ cargoquery: rows.map((title) => ({ title })) }),
};
}
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",
};
describe("fetchMatchesInRange", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("posts cargoquery with correct where clause and parses rows", 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 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");
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 () => {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ error: { info: "Bad query", code: "x" } }),
});
await expect(
fetchMatchesInRange(new Date("2026-04-21T00:00:00Z"), new Date("2026-04-22T00:00:00Z")),
).rejects.toThrow(/Bad query/);
});
});
describe("getCachedMatches", () => {
let db;
let kv;
beforeEach(() => {
kv = makeFakeKv();
db = createStore("lolschedule", { KV: kv });
});
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.
await db.putJSON(`matches:${from.toISOString()}:${to.toISOString()}`, {
ts: Date.now() - 10 * 60 * 1000, // 10 min ago — older than the 60s TTL
rows: [sampleRow],
});
global.fetch = vi.fn().mockRejectedValue(new Error("boom"));
const out = await getCachedMatches(db, from, to, 60);
expect(out).toEqual([sampleRow]);
});
it("propagates the error when 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/);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it } from "vitest";
import {
classifyMatch,
formatMatchLine,
parseUtc,
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",
};
const live = {
...scheduled,
DateTime: "2026-04-21 08:00:00",
S1: "1",
S2: "0",
};
const played = {
...scheduled,
DateTime: "2026-04-20 10:00:00",
S1: "2",
S2: "1",
Winner: "1",
};
// Fixed reference — 2026-04-21 08:30:00 UTC (i.e. 15:30 ICT)
const nowMs = Date.parse("2026-04-21T08:30:00Z");
describe("parseUtc", () => {
it("parses Leaguepedia UTC literal", () => {
expect(parseUtc("2026-04-21 09:00:00").toISOString()).toBe("2026-04-21T09:00:00.000Z");
});
});
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>");
expect(line.startsWith("✅")).toBe(true);
});
it("renders live prefix", () => {
const line = formatMatchLine(live, nowMs);
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);
expect(line).not.toContain("<script>");
expect(line).toContain("&lt;script&gt;");
expect(line).toContain("A&amp;B");
});
it("shows TBD when team field is empty", () => {
const row = { ...scheduled, T1: "" };
expect(formatMatchLine(row, nowMs)).toContain("TBD");
});
});
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("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);
});
});
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
];
const out = renderWeek(
rows,
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);
});
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.");
});
});
+1 -1
View File
@@ -5,7 +5,7 @@ compatibility_date = "2025-10-01"
# Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
[vars]
MODULES = "util,wordle,loldle,misc,trading"
MODULES = "util,wordle,loldle,misc,trading,lolschedule"
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
# Create with: