feat(lolschedule): add /lolschedule [date] command

Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy with trailing month/year
optional. Empty defaults to today's ICT day. Rejects month/year
without day, invalid ranges, and impossible dates.
This commit is contained in:
2026-05-08 14:42:59 +07:00
parent d6af89efe7
commit b2616155dd
5 changed files with 324 additions and 0 deletions
+30
View File
@@ -9,6 +9,7 @@
import { getEventsCached } from "./api-client.js";
import { renderToday, renderWeek } from "./format.js";
import { parseScheduleDate } from "./parse-date.js";
import { addSubscriber, listSubscribers, removeSubscriber } from "./subscribers.js";
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
@@ -65,6 +66,35 @@ export async function handleToday(ctx, db) {
}
}
/**
* /lolschedule [date] — schedule for a specific ICT day. Accepts dd-mm-yyyy,
* dd/mm/yyyy, or ddmmyyyy with trailing month/year optional. Empty → today.
*
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
*/
export async function handleSchedule(ctx, db) {
if (!db) {
await ctx.reply("lolschedule: storage unavailable");
return;
}
const arg = (ctx.match || "").trim();
const parsed = parseScheduleDate(arg);
if (!parsed.ok) {
await ctx.reply(parsed.error);
return;
}
const from = parsed.date;
const to = addDays(from, 1);
try {
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_fail", err: String(err) }));
await ctx.reply("Could not fetch matches. Try again later.");
}
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore | null} db
+10
View File
@@ -3,6 +3,9 @@
* lolesports.com esports-api (the data feed behind lolesports.com).
*
* Commands:
* /lolschedule [date] — matches for a specific ICT day (defaults to today).
* Accepts dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; trailing
* month/year may be omitted.
* /lolschedule_today — matches scheduled for the current ICT day, with live/played scores.
* /lolschedule_week — next 7 ICT days, grouped per day → league.
*
@@ -16,6 +19,7 @@
import {
handleDailyPushCron,
handleSchedule,
handleSubscribe,
handleToday,
handleUnsubscribe,
@@ -32,6 +36,12 @@ const lolscheduleModule = {
db = store;
},
commands: [
{
name: "lolschedule",
visibility: "public",
description: "LoL matches for a date (dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy; default today)",
handler: (ctx) => handleSchedule(ctx, db),
},
{
name: "lolschedule_today",
visibility: "public",
+103
View File
@@ -0,0 +1,103 @@
/**
* @file Parse a user-supplied date param for /lolschedule.
*
* Supported formats: dd-mm-yyyy, dd/mm/yyyy, ddmmyyyy. Trailing components
* may be omitted (year, then month) — falling back to the current ICT day.
* Empty input → today. Day is mandatory; month/year without day is rejected.
*/
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
const FORMAT_HINT = "Use dd-mm-yyyy, dd/mm/yyyy, or ddmmyyyy.";
function toIct(date) {
return new Date(date.getTime() + ICT_OFFSET_MS);
}
function ictDayStartOf(now) {
const shifted = toIct(now);
shifted.setUTCHours(0, 0, 0, 0);
return new Date(shifted.getTime() - ICT_OFFSET_MS);
}
/**
* Split the raw input into [dd, mm?, yyyy?] string parts, or return an error.
*
* @param {string} trimmed
* @returns {{ ok: true, parts: string[] } | { ok: false, error: string }}
*/
function splitParts(trimmed) {
if (trimmed.includes("-") || trimmed.includes("/")) {
const parts = trimmed.split(/[-/]/);
if (parts.length < 1 || parts.length > 3) {
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
}
if (parts.some((p) => p === "" || !/^\d+$/.test(p))) {
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
}
return { ok: true, parts };
}
if (!/^\d+$/.test(trimmed)) {
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
}
if (trimmed.length === 1 || trimmed.length === 2) {
return { ok: true, parts: [trimmed] };
}
if (trimmed.length === 4) {
return { ok: true, parts: [trimmed.slice(0, 2), trimmed.slice(2)] };
}
if (trimmed.length === 8) {
return {
ok: true,
parts: [trimmed.slice(0, 2), trimmed.slice(2, 4), trimmed.slice(4)],
};
}
return { ok: false, error: `Invalid date "${trimmed}". ${FORMAT_HINT}` };
}
/**
* Parse a user date param into the start of the requested ICT day.
*
* @param {string|undefined|null} input
* @param {Date} [now]
* @returns {{ ok: true, date: Date } | { ok: false, error: string }}
*/
export function parseScheduleDate(input, now = new Date()) {
const trimmed = (input ?? "").trim();
if (!trimmed) return { ok: true, date: ictDayStartOf(now) };
const split = splitParts(trimmed);
if (!split.ok) return split;
const ictNow = toIct(now);
const day = Number(split.parts[0]);
const month = split.parts.length >= 2 ? Number(split.parts[1]) : ictNow.getUTCMonth() + 1;
const year = split.parts.length >= 3 ? Number(split.parts[2]) : ictNow.getUTCFullYear();
if (!Number.isInteger(day) || day < 1 || day > 31) {
return { ok: false, error: `Invalid day "${split.parts[0]}" — must be 131.` };
}
if (!Number.isInteger(month) || month < 1 || month > 12) {
return { ok: false, error: `Invalid month "${split.parts[1]}" — must be 112.` };
}
if (!Number.isInteger(year) || year < 1970 || year > 2100) {
return { ok: false, error: `Invalid year "${split.parts[2]}".` };
}
// ICT midnight of requested day, expressed as a UTC instant.
const utcMs = Date.UTC(year, month - 1, day, 0, 0, 0) - ICT_OFFSET_MS;
const date = new Date(utcMs);
// Reject impossible dates like 31-04 or 29-02 in non-leap years.
const verified = toIct(date);
if (
verified.getUTCFullYear() !== year ||
verified.getUTCMonth() + 1 !== month ||
verified.getUTCDate() !== day
) {
return { ok: false, error: `Invalid date — ${day}/${month}/${year} does not exist.` };
}
return { ok: true, date };
}
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createStore } from "../../../src/db/create-store.js";
import {
handleDailyPushCron,
handleSchedule,
handleSubscribe,
handleUnsubscribe,
} from "../../../src/modules/lolschedule/handlers.js";
@@ -81,6 +82,48 @@ describe("handleSubscribe / handleUnsubscribe", () => {
});
});
describe("handleSchedule", () => {
let db;
beforeEach(() => {
db = createStore("lolschedule", { KV: makeFakeKv() });
});
afterEach(() => vi.restoreAllMocks());
it("replies with a parse error on a bad date param", async () => {
const ctx = { ...fakeCtx(1), match: "abc" };
await handleSchedule(ctx, db);
expect(ctx.replied[0].text).toMatch(/Invalid date/);
});
it("rejects a 6-digit blob (month+year without day)", async () => {
const ctx = { ...fakeCtx(1), match: "052026" };
await handleSchedule(ctx, db);
expect(ctx.replied[0].text).toMatch(/Invalid date/);
});
it("fetches the requested ICT day when input parses", async () => {
global.fetch = vi.fn(async () => scheduleResponse([majorEvt("2025-06-01T09:00:00Z")]));
const ctx = { ...fakeCtx(1), match: "01-06-2025" };
await handleSchedule(ctx, db);
expect(ctx.replied[0].text).toMatch(/LCK/);
expect(ctx.replied[0].opts).toEqual({ parse_mode: "HTML" });
});
it("defaults to today when input is empty", async () => {
const ictOffsetMs = 7 * 60 * 60 * 1000;
const ictDayStart = new Date(
new Date(Date.now() + ictOffsetMs).setUTCHours(0, 0, 0, 0) - ictOffsetMs,
);
const pickTime = new Date(ictDayStart.getTime() + 12 * 60 * 60 * 1000).toISOString();
global.fetch = vi.fn(async () => scheduleResponse([majorEvt(pickTime)]));
const ctx = { ...fakeCtx(1), match: "" };
await handleSchedule(ctx, db);
expect(ctx.replied[0].text).toMatch(/LCK/);
});
});
describe("handleDailyPushCron", () => {
let db;
let telegramSpy;
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import { parseScheduleDate } from "../../../src/modules/lolschedule/parse-date.js";
const ICT_OFFSET_MS = 7 * 60 * 60 * 1000;
/** Build a Date that represents the start of the given ICT day. */
function ictStart(year, month, day) {
return new Date(Date.UTC(year, month - 1, day, 0, 0, 0) - ICT_OFFSET_MS);
}
/** Pick a `now` clock anchored at noon ICT on a known day. */
function nowAt(year, month, day) {
return new Date(Date.UTC(year, month - 1, day, 12, 0, 0) - ICT_OFFSET_MS);
}
describe("parseScheduleDate", () => {
const now = nowAt(2026, 5, 8); // 8 May 2026 ICT
it("returns today's ICT day start when input is empty", () => {
const r = parseScheduleDate("", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
});
it("returns today when input is whitespace", () => {
const r = parseScheduleDate(" ", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
});
it("returns today when input is undefined", () => {
const r = parseScheduleDate(undefined, now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 5, 8).getTime());
});
it("parses dd-mm-yyyy", () => {
const r = parseScheduleDate("01-06-2025", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
});
it("parses dd/mm/yyyy", () => {
const r = parseScheduleDate("01/06/2025", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
});
it("parses ddmmyyyy (no separator)", () => {
const r = parseScheduleDate("01062025", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2025, 6, 1).getTime());
});
it("fills in current year when only dd-mm given", () => {
const r = parseScheduleDate("12-09", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
});
it("fills in current year when only dd/mm given", () => {
const r = parseScheduleDate("12/09", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
});
it("fills in current year when only ddmm (4 digits) given", () => {
const r = parseScheduleDate("1209", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 9, 12).getTime());
});
it("fills in current month and year when only dd given (2 digits)", () => {
const r = parseScheduleDate("20", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 5, 20).getTime());
});
it("fills in current month and year when only d given (1 digit)", () => {
const r = parseScheduleDate("3", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2026, 5, 3).getTime());
});
it("rejects a 6-digit blob (mmyyyy with no day)", () => {
const r = parseScheduleDate("052026", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Invalid date/);
});
it("rejects a 3-digit blob (ambiguous)", () => {
const r = parseScheduleDate("123", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Invalid date/);
});
it("rejects empty leading part with separator (no day)", () => {
const r = parseScheduleDate("/05/2026", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Invalid date/);
});
it("rejects empty middle part", () => {
const r = parseScheduleDate("12//2026", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Invalid date/);
});
it("rejects non-numeric input", () => {
const r = parseScheduleDate("abc", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/Invalid date/);
});
it("rejects out-of-range day", () => {
const r = parseScheduleDate("32-05-2026", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/day/);
});
it("rejects out-of-range month", () => {
const r = parseScheduleDate("01-13-2026", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/month/);
});
it("rejects impossible calendar dates (Feb 30)", () => {
const r = parseScheduleDate("30-02-2025", now);
expect(r.ok).toBe(false);
expect(r.error).toMatch(/does not exist/);
});
it("accepts Feb 29 in a leap year", () => {
const r = parseScheduleDate("29-02-2024", now);
expect(r.ok).toBe(true);
expect(r.date.getTime()).toBe(ictStart(2024, 2, 29).getTime());
});
});