diff --git a/src/modules/lolschedule/handlers.js b/src/modules/lolschedule/handlers.js index 4a6c63c..38aab6a 100644 --- a/src/modules/lolschedule/handlers.js +++ b/src/modules/lolschedule/handlers.js @@ -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 diff --git a/src/modules/lolschedule/index.js b/src/modules/lolschedule/index.js index 9015b2f..5d14bcc 100644 --- a/src/modules/lolschedule/index.js +++ b/src/modules/lolschedule/index.js @@ -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", diff --git a/src/modules/lolschedule/parse-date.js b/src/modules/lolschedule/parse-date.js new file mode 100644 index 0000000..a1b9751 --- /dev/null +++ b/src/modules/lolschedule/parse-date.js @@ -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 1–31.` }; + } + if (!Number.isInteger(month) || month < 1 || month > 12) { + return { ok: false, error: `Invalid month "${split.parts[1]}" — must be 1–12.` }; + } + 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 }; +} diff --git a/tests/modules/lolschedule/handlers.test.js b/tests/modules/lolschedule/handlers.test.js index 9a51dd1..4bef863 100644 --- a/tests/modules/lolschedule/handlers.test.js +++ b/tests/modules/lolschedule/handlers.test.js @@ -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; diff --git a/tests/modules/lolschedule/parse-date.test.js b/tests/modules/lolschedule/parse-date.test.js new file mode 100644 index 0000000..ef15dfb --- /dev/null +++ b/tests/modules/lolschedule/parse-date.test.js @@ -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()); + }); +});