diff --git a/src/modules/lolschedule/README.md b/src/modules/lolschedule/README.md
index 4e57250..9cabfed 100644
--- a/src/modules/lolschedule/README.md
+++ b/src/modules/lolschedule/README.md
@@ -6,8 +6,16 @@ LoL esports match schedule via the **lolesports.com** esports-api (the data feed
| Command | Description |
|---|---|
-| `/lol_today` | Today's matches (ICT). Scores for played + live. Times for upcoming. |
-| `/lol_week` | Next 7 days, grouped by day. |
+| `/lolschedule_today` | Today's matches (ICT), grouped by league. Scores for played + live, times for upcoming. |
+| `/lolschedule_week` | Next 7 days, grouped by day → league. |
+
+## Cron
+
+| Schedule (UTC) | Local (ICT) | Purpose |
+|---|---|---|
+| `0 1 * * *` | 08:00 | Push today's major-league schedule to `LOLSCHEDULE_CHAT_ID`. Skipped silently when unset or when there are no matches today. |
+
+Set the chat id with `wrangler secret put LOLSCHEDULE_CHAT_ID` (recommended) or as a `[vars]` entry in `wrangler.toml`.
## Data source
@@ -50,6 +58,12 @@ Cache-first with KV. Key is `matches:{fromIso}:{toIso}`.
- Stale fallback: up to 1 h on upstream failure
- No cron pre-warm needed — upstream is cheap
+## Grouping
+
+- `/lolschedule_today` — one section per league (header + match lines).
+- `/lolschedule_week` — one section per ICT day; within each day, leagues are sub-grouped.
+- League ordering follows `LEAGUE_ORDER` in `format.js` (worlds / msi / first_stand first, then LCK / LPL / LEC / LCS, then the rest).
+
## Time 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.
diff --git a/src/modules/lolschedule/format.js b/src/modules/lolschedule/format.js
index d34b4e5..68248a5 100644
--- a/src/modules/lolschedule/format.js
+++ b/src/modules/lolschedule/format.js
@@ -1,8 +1,9 @@
/**
* @file Pure formatters — Telegram HTML output for today / week match lists.
*
- * Takes lolesports.com schedule events and renders them. All user-influenced
- * substrings are HTML-escaped. Times are shown in ICT (UTC+7).
+ * Takes lolesports.com schedule events and renders them, grouped by league
+ * (today) and by day → league (week). All user-influenced substrings are
+ * HTML-escaped. Times are shown in ICT (UTC+7).
*/
import { escapeHtml } from "../../util/escape-html.js";
@@ -12,6 +13,20 @@ import { escapeHtml } from "../../util/escape-html.js";
const TZ_OFFSET_MS = 7 * 60 * 60 * 1000; // ICT = UTC+7
+/** Ordering for league sections — most prestigious tournaments first. */
+const LEAGUE_ORDER = [
+ "worlds",
+ "msi",
+ "first_stand",
+ "lck",
+ "lpl",
+ "lec",
+ "lcs",
+ "lcp",
+ "cblol-brazil",
+ "emea_masters",
+];
+
/** Shift a UTC Date by the ICT offset so getUTC* yields ICT components. */
function toIct(date) {
return new Date(date.getTime() + TZ_OFFSET_MS);
@@ -58,41 +73,75 @@ function teamLabel(team) {
}
/**
- * Render one event line (no leading newline).
+ * Render one event line (no leading newline). By default the league name is
+ * omitted because events are rendered under a league header; pass
+ * `{ showLeague: true }` to include it for flat lists.
*
* @param {ScheduleEvent} event
+ * @param {{ showLeague?: boolean }} [opts]
* @returns {string} escaped HTML
*/
-export function formatEventLine(event) {
+export function formatEventLine(event, { showLeague = false } = {}) {
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}` : "";
+ const leagueSuffix =
+ showLeague && event?.league?.name ? ` · ${escapeHtml(event.league.name)}` : "";
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 ? `${t1Label}` : t1Label;
- const r = winner2 ? `${t2Label}` : t2Label;
- return `✅ ${l} ${w1}–${w2} ${r}${bo} · ${league}${block}`;
+ const l = teams[0]?.result?.outcome === "win" ? `${t1Label}` : t1Label;
+ const r = teams[1]?.result?.outcome === "win" ? `${t2Label}` : t2Label;
+ return `✅ ${l} ${w1}–${w2} ${r}${bo}${leagueSuffix}${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}`;
+ return `🔴 LIVE ${t1Label} ${w1}–${w2} ${t2Label}${bo}${leagueSuffix}${block}`;
}
- // unstarted or unknown
const time = formatIctTime(new Date(event.startTime));
- return `🕒 ${time} ${t1Label} vs ${t2Label}${bo} · ${league}${block}`;
+ return `🕒 ${time} ${t1Label} vs ${t2Label}${bo}${leagueSuffix}${block}`;
}
/**
- * Render today's reply.
+ * Group events by league slug, preserving LEAGUE_ORDER for known leagues and
+ * falling back to alphabetical for anything else.
+ *
+ * @param {ScheduleEvent[]} events
+ * @returns {Array<{ slug: string, name: string, events: ScheduleEvent[] }>}
+ */
+function groupByLeague(events) {
+ /** @type {Map} */
+ const bySlug = new Map();
+ for (const event of events) {
+ const slug = event?.league?.slug || "unknown";
+ const name = event?.league?.name || slug;
+ let g = bySlug.get(slug);
+ if (!g) {
+ g = { slug, name, events: [] };
+ bySlug.set(slug, g);
+ }
+ g.events.push(event);
+ }
+ const known = LEAGUE_ORDER.filter((slug) => bySlug.has(slug)).map((slug) => bySlug.get(slug));
+ const unknown = [...bySlug.values()]
+ .filter((g) => !LEAGUE_ORDER.includes(g.slug))
+ .sort((a, b) => a.name.localeCompare(b.name));
+ return [...known, ...unknown];
+}
+
+/** Render a league section (header + lines). */
+function renderLeagueSection(group) {
+ const lines = group.events.map((e) => formatEventLine(e));
+ return `${escapeHtml(group.name)}\n${lines.join("\n")}`;
+}
+
+/**
+ * Render today's reply — grouped by league.
*
* @param {ScheduleEvent[]} events
* @param {Date} day — any moment on the target ICT day.
@@ -101,11 +150,12 @@ export function formatEventLine(event) {
export function renderToday(events, day) {
const header = `LoL — ${escapeHtml(formatIctDayLabel(day))} (ICT)`;
if (events.length === 0) return `${header}\nNo matches today.`;
- return `${header}\n${events.map(formatEventLine).join("\n")}`;
+ const sections = groupByLeague(events).map(renderLeagueSection);
+ return `${header}\n\n${sections.join("\n\n")}`;
}
/**
- * Render week reply — grouped by ICT day.
+ * Render week reply — grouped by ICT day → league.
*
* @param {ScheduleEvent[]} events
* @param {Date} from
@@ -118,23 +168,24 @@ export function renderWeek(events, from, to) {
const header = `LoL — ${fromLbl} → ${toLbl} (ICT)`;
if (events.length === 0) return `${header}\nNo matches this week.`;
- /** @type {Map} */
- const groups = new Map();
+ /** @type {Map} */
+ const days = new Map();
for (const event of events) {
const d = new Date(event.startTime);
const key = ictDayKey(d);
- let g = groups.get(key);
+ let g = days.get(key);
if (!g) {
- g = { label: formatIctDayLabel(d), lines: [] };
- groups.set(key, g);
+ g = { label: formatIctDayLabel(d), events: [] };
+ days.set(key, g);
}
- g.lines.push(formatEventLine(event));
+ g.events.push(event);
}
- const sections = [];
- for (const key of [...groups.keys()].sort()) {
- const g = groups.get(key);
- sections.push(`${escapeHtml(g.label)}\n${g.lines.join("\n")}`);
+ const dayBlocks = [];
+ for (const key of [...days.keys()].sort()) {
+ const day = days.get(key);
+ const leagueSections = groupByLeague(day.events).map(renderLeagueSection);
+ dayBlocks.push(`${escapeHtml(day.label)}\n${leagueSections.join("\n")}`);
}
- return `${header}\n\n${sections.join("\n\n")}`;
+ return `${header}\n\n${dayBlocks.join("\n\n")}`;
}
diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js
index fe6ab44..2140c56 100644
--- a/src/modules/lolschedule/handlers.js
+++ b/src/modules/lolschedule/handlers.js
@@ -1,9 +1,10 @@
/**
- * @file /lol_today and /lol_week command handlers.
+ * @file Command + cron handlers for lolschedule.
*
* 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.
+ * API is rate-limit friendly. A daily cron also pushes today's schedule to a
+ * configured chat.
*/
import { getEventsCached } from "./api-client.js";
@@ -82,3 +83,64 @@ export async function handleWeek(ctx, db) {
await ctx.reply("Could not fetch this week's matches. Try again later.");
}
}
+
+/**
+ * Send a Telegram HTML message directly via the Bot API. Used from the cron
+ * path where the grammY bot context is not available.
+ *
+ * @param {string} token
+ * @param {string|number} chatId
+ * @param {string} text
+ */
+async function sendTelegramMessage(token, chatId, text) {
+ const res = await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ parse_mode: "HTML",
+ disable_web_page_preview: true,
+ }),
+ });
+ if (!res.ok) {
+ const body = await res.text().catch(() => "");
+ throw new Error(`telegram sendMessage HTTP ${res.status}: ${body.slice(0, 300)}`);
+ }
+}
+
+/**
+ * Cron handler — pushes today's major-league schedule to `LOLSCHEDULE_CHAT_ID`.
+ * Silently skips when the env var or token is missing, or when there are no
+ * major-league matches today.
+ *
+ * @param {any} _event
+ * @param {{ db: import("../../db/kv-store-interface.js").KVStore, env: any }} ctx
+ */
+export async function handleDailyPushCron(_event, ctx) {
+ const { db, env } = ctx;
+ const chatId = env?.LOLSCHEDULE_CHAT_ID;
+ const token = env?.TELEGRAM_BOT_TOKEN;
+ if (!chatId || !token) {
+ console.log(
+ JSON.stringify({
+ msg: "lolschedule_cron_skip",
+ reason: !chatId ? "no_chat_id" : "no_token",
+ }),
+ );
+ return;
+ }
+ const from = ictDayStart();
+ const to = addDays(from, 1);
+ try {
+ const events = filterMajor(await getEventsCached(db, from, to));
+ if (events.length === 0) {
+ console.log(JSON.stringify({ msg: "lolschedule_cron_empty" }));
+ return;
+ }
+ await sendTelegramMessage(token, chatId, renderToday(events, from));
+ console.log(JSON.stringify({ msg: "lolschedule_cron_sent", events: events.length }));
+ } catch (err) {
+ console.log(JSON.stringify({ msg: "lolschedule_cron_fail", err: String(err) }));
+ }
+}
diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js
index d6c8205..1904974 100644
--- a/src/modules/lolschedule/index.js
+++ b/src/modules/lolschedule/index.js
@@ -3,14 +3,18 @@
* lolesports.com esports-api (the data feed behind lolesports.com).
*
* Commands:
- * /lol_today — matches scheduled for the current ICT day, with live/played scores.
- * /lol_week — next 7 ICT days, grouped per day.
+ * /lolschedule_today — matches scheduled for the current ICT day, with live/played scores.
+ * /lolschedule_week — next 7 ICT days, grouped per day → league.
*
- * See the module README for the data-source rationale and the verification
+ * Cron:
+ * 0 1 * * * (08:00 ICT) — push today's major-league schedule to
+ * LOLSCHEDULE_CHAT_ID when configured.
+ *
+ * See the module README for data-source rationale and the verification
* reports under plans/reports/ for historical context.
*/
-import { handleToday, handleWeek } from "./handlers.js";
+import { handleDailyPushCron, handleToday, handleWeek } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
@@ -23,18 +27,25 @@ const lolscheduleModule = {
},
commands: [
{
- name: "lol_today",
+ name: "lolschedule_today",
visibility: "public",
description: "Today's LoL esports matches (scores if played)",
handler: (ctx) => handleToday(ctx, db),
},
{
- name: "lol_week",
+ name: "lolschedule_week",
visibility: "public",
description: "LoL esports matches for the next 7 days",
handler: (ctx) => handleWeek(ctx, db),
},
],
+ crons: [
+ {
+ schedule: "0 1 * * *",
+ name: "daily-push",
+ handler: handleDailyPushCron,
+ },
+ ],
};
export default lolscheduleModule;
diff --git a/tests/modules/lolschedule/format.test.js b/tests/modules/lolschedule/format.test.js
index eeea79f..ffc81f0 100644
--- a/tests/modules/lolschedule/format.test.js
+++ b/tests/modules/lolschedule/format.test.js
@@ -9,13 +9,30 @@ import {
/** @typedef {import("../../../src/modules/lolschedule/api-client.js").ScheduleEvent} ScheduleEvent */
-const completed = /** @type {ScheduleEvent} */ ({
+function evt(overrides = {}) {
+ return /** @type {ScheduleEvent} */ ({
+ startTime: "2026-04-21T09:00:00Z",
+ state: "unstarted",
+ blockName: "Week 4",
+ league: { name: "LCK", slug: "lck" },
+ match: {
+ id: "m1",
+ teams: [
+ { name: "T1", code: "T1" },
+ { name: "Gen.G", code: "GEN" },
+ ],
+ strategy: { type: "bestOf", count: 3 },
+ },
+ ...overrides,
+ });
+}
+
+const completed = evt({
startTime: "2026-04-20T08:00:00Z",
state: "completed",
blockName: "Week 3",
- league: { name: "LCK", slug: "lck" },
match: {
- id: "1",
+ id: "m2",
teams: [
{ name: "T1", code: "T1", result: { outcome: "win", gameWins: 2 } },
{ name: "Gen.G", code: "GEN", result: { outcome: "loss", gameWins: 1 } },
@@ -24,13 +41,11 @@ const completed = /** @type {ScheduleEvent} */ ({
},
});
-const live = /** @type {ScheduleEvent} */ ({
+const live = evt({
startTime: "2026-04-21T08:00:00Z",
state: "inProgress",
- blockName: "Week 4",
- league: { name: "LCK", slug: "lck" },
match: {
- id: "2",
+ id: "m3",
teams: [
{ name: "Hanwha Life", code: "HLE", result: { gameWins: 1 } },
{ name: "DRX", code: "DRX", result: { gameWins: 0 } },
@@ -39,21 +54,6 @@ const live = /** @type {ScheduleEvent} */ ({
},
});
-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("formatIctTime / formatIctDayLabel", () => {
it("formats UTC datetime in ICT", () => {
expect(formatIctTime(new Date("2026-04-21T09:00:00Z"))).toBe("16:00");
@@ -62,6 +62,14 @@ describe("formatIctTime / formatIctDayLabel", () => {
});
describe("formatEventLine", () => {
+ it("omits league name by default (renders under league header)", () => {
+ expect(formatEventLine(evt())).not.toContain("LCK");
+ });
+
+ it("includes league name when showLeague is true", () => {
+ expect(formatEventLine(evt(), { showLeague: true })).toContain("LCK");
+ });
+
it("renders completed with bolded winner + score", () => {
const line = formatEventLine(completed);
expect(line.startsWith("✅")).toBe(true);
@@ -69,7 +77,6 @@ describe("formatEventLine", () => {
expect(line).toContain("2–1");
expect(line).toContain("GEN");
expect(line).toContain("Bo3");
- expect(line).toContain("LCK");
expect(line).toContain("Week 3");
});
@@ -80,34 +87,38 @@ describe("formatEventLine", () => {
});
it("renders unstarted with ICT time + vs", () => {
- const line = formatEventLine(scheduled);
+ const line = formatEventLine(evt());
expect(line.startsWith("🕒 16:00")).toBe(true);
- expect(line).toContain("KT vs DK");
+ expect(line).toContain("T1 vs GEN");
});
- it("escapes HTML in league, block, team names", () => {
- const event = /** @type {ScheduleEvent} */ ({
- ...scheduled,
- league: { name: "A&B", slug: "ab" },
- blockName: "