feat(lolschedule): group by league, rename commands, add daily push cron

- Commands renamed: /lol_today → /lolschedule_today, /lol_week → /lolschedule_week.
- Today view groups events under a league header per section.
- Week view nests leagues under each ICT day.
- LEAGUE_ORDER gives tier-1 tournaments priority (Worlds / MSI / First Stand,
  then LCK / LPL / LEC / LCS, etc).
- New cron "0 1 * * *" (08:00 ICT) pushes today's major-league schedule to
  LOLSCHEDULE_CHAT_ID via the Telegram Bot API. Skips cleanly when chat id
  or token is missing, or when today has no major-league matches.
This commit is contained in:
2026-04-21 10:20:20 +07:00
committed by Tien Nguyen Minh
parent cbb572fdd8
commit c69d1749b7
7 changed files with 364 additions and 97 deletions
+96 -59
View File
@@ -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("21");
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: "<script>",
match: {
...scheduled.match,
teams: [{ code: "<bad>" }, { code: "GEN" }],
},
});
const line = formatEventLine(event);
it("escapes HTML in block and team labels", () => {
const line = formatEventLine(
evt({
blockName: "<script>",
match: {
id: "x",
teams: [{ code: "<bad>" }, { code: "GEN" }],
strategy: { type: "bestOf", count: 3 },
},
}),
);
expect(line).not.toContain("<script>");
expect(line).toContain("&lt;script&gt;");
expect(line).toContain("&lt;bad&gt;");
expect(line).toContain("A&amp;B");
});
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");
it("shows TBD when a team is missing", () => {
const line = formatEventLine(
evt({
match: {
id: "x",
teams: [null, { code: "GEN" }],
strategy: { type: "bestOf", count: 3 },
},
}),
);
expect(line).toContain("TBD vs GEN");
});
});
@@ -116,19 +127,50 @@ describe("renderToday", () => {
expect(renderToday([], new Date("2026-04-21T00:00:00Z"))).toContain("No matches today.");
});
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);
it("groups by league with a header per league", () => {
const events = [
evt({ league: { name: "LPL", slug: "lpl" }, startTime: "2026-04-21T07:00:00Z" }),
evt({ league: { name: "LCK", slug: "lck" }, startTime: "2026-04-21T08:00:00Z" }),
evt({ league: { name: "LCK", slug: "lck" }, startTime: "2026-04-21T09:00:00Z" }),
];
const out = renderToday(events, new Date("2026-04-21T00:00:00Z"));
// LCK section appears before LPL because LEAGUE_ORDER ranks LCK higher
expect(out).toMatch(/<b>LCK<\/b>[\s\S]*<b>LPL<\/b>/);
// Two lines under LCK, one under LPL
expect((out.match(/🕒/g) || []).length).toBe(3);
});
it("ranks known leagues before unknown slug", () => {
const events = [
evt({ league: { name: "Foo Cup", slug: "foo-cup" } }),
evt({ league: { name: "LCK", slug: "lck" } }),
];
const out = renderToday(events, new Date("2026-04-21T00:00:00Z"));
expect(out.indexOf("<b>LCK</b>")).toBeLessThan(out.indexOf("<b>Foo Cup</b>"));
});
});
describe("renderWeek", () => {
it("groups events by ICT day in order", () => {
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.");
});
it("nests leagues under each ICT day in chronological order", () => {
const events = [
{ ...scheduled, startTime: "2026-04-21T09:00:00Z" },
{ ...scheduled, startTime: "2026-04-22T10:00:00Z" },
{ ...scheduled, startTime: "2026-04-22T12:00:00Z" },
evt({
startTime: "2026-04-21T09:00:00Z",
league: { name: "LCK", slug: "lck" },
}),
evt({
startTime: "2026-04-22T09:00:00Z",
league: { name: "LPL", slug: "lpl" },
}),
evt({
startTime: "2026-04-22T11:00:00Z",
league: { name: "LCK", slug: "lck" },
}),
];
const out = renderWeek(
events,
@@ -136,13 +178,8 @@ describe("renderWeek", () => {
new Date("2026-04-28T00:00:00Z"),
);
expect(out.indexOf("Apr 21")).toBeLessThan(out.indexOf("Apr 22"));
const apr22Section = out.split("Apr 22")[1] || "";
expect((apr22Section.match(/🕒/g) || []).length).toBe(2);
});
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.");
// Apr 22: LCK section appears before LPL (per LEAGUE_ORDER)
const apr22Block = out.split("Apr 22")[1] || "";
expect(apr22Block.indexOf("<b>LCK</b>")).toBeLessThan(apr22Block.indexOf("<b>LPL</b>"));
});
});