diff --git a/README.md b/README.md
index 614a3da..81a7fa2 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ Atlas via long polling and an in-process cron scheduler.
| `misc` | `/ping`, `/ping_stats`, `/random`, `/wheelofnames`, `/wheelofnamesbeta`, `/the_answer`, `/trongtruonghop` + `/tth` disclaimer |
| `wordle` | Daily Wordle game |
| `loldle` | League-of-Legends "guess the champion" |
-| `lol` | Pro-match schedule + daily push |
+| `lol` | Pro-match schedule (`/lol`, `/lol_tomorrow`, `/lol_this_week`, `/lol_nextweek`) + daily push |
| `wc` | World Cup schedule + silent daily push |
| `stock` | VN-stocks paper trading |
| `gold` | Gold paper trading (opt-in; VNAppMob SJC buy/sell VND/luong) |
diff --git a/internal/modules/lol/format.go b/internal/modules/lol/format.go
index bbf80a3..c350329 100644
--- a/internal/modules/lol/format.go
+++ b/internal/modules/lol/format.go
@@ -199,12 +199,10 @@ func renderLeagueSection(g leagueGroup) string {
return "" + html.EscapeString(g.Name) + "\n" + strings.Join(lines, "\n")
}
-// RenderToday renders the today reply — grouped by league. day may be any
-// instant on the target ICT day.
-func RenderToday(events []ScheduleEvent, day time.Time) string {
+func renderDay(events []ScheduleEvent, day time.Time, emptyLine string) string {
header := "LoL — " + html.EscapeString(formatIctDayLabel(day)) + " (ICT)"
if len(events) == 0 {
- return header + "\nNo major LoL matches today."
+ return header + "\n" + emptyLine
}
groups := groupByLeague(events)
sections := make([]string, len(groups))
@@ -214,15 +212,18 @@ func RenderToday(events []ScheduleEvent, day time.Time) string {
return header + "\n\n" + strings.Join(sections, "\n\n")
}
-// RenderWeek renders a week-range reply — grouped by league → day.
-// `to` is exclusive (the start of the day after the range), so the label
-// uses to-1.
-func RenderWeek(events []ScheduleEvent, from, to time.Time) string {
+// RenderToday renders the today reply — grouped by league. day may be any
+// instant on the target ICT day.
+func RenderToday(events []ScheduleEvent, day time.Time) string {
+ return renderDay(events, day, "No major LoL matches today.")
+}
+
+func renderWeek(events []ScheduleEvent, from, to time.Time, emptyLine string) string {
fromLbl := html.EscapeString(formatIctDayLabel(from))
toLbl := html.EscapeString(formatIctDayLabel(to.Add(-time.Millisecond)))
header := "LoL — " + fromLbl + " → " + toLbl + " (ICT)"
if len(events) == 0 {
- return header + "\nNo major LoL matches this week."
+ return header + "\n" + emptyLine
}
leagueBlocks := make([]string, 0, len(events))
@@ -262,3 +263,10 @@ func RenderWeek(events []ScheduleEvent, from, to time.Time) string {
}
return header + "\n\n" + strings.Join(leagueBlocks, "\n\n")
}
+
+// RenderWeek renders a week-range reply — grouped by league → day.
+// `to` is exclusive (the start of the day after the range), so the label
+// uses to-1.
+func RenderWeek(events []ScheduleEvent, from, to time.Time) string {
+ return renderWeek(events, from, to, "No major LoL matches this week.")
+}
diff --git a/internal/modules/lol/format_test.go b/internal/modules/lol/format_test.go
index 386e019..f3926e8 100644
--- a/internal/modules/lol/format_test.go
+++ b/internal/modules/lol/format_test.go
@@ -120,6 +120,14 @@ func TestRenderToday_EmptyShowsNoMatches(t *testing.T) {
}
}
+func TestRenderDay_CustomEmptyLine(t *testing.T) {
+ day := time.Date(2026, 5, 10, 0, 0, 0, 0, IctLocation)
+ got := renderDay(nil, day, "No major LoL matches tomorrow.")
+ if !strings.Contains(got, "Sun May 10") || !strings.Contains(got, "No major LoL matches tomorrow.") {
+ t.Errorf("custom day empty render wrong: %q", got)
+ }
+}
+
func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) {
from := time.Date(2026, 5, 9, 0, 0, 0, 0, IctLocation)
to := from.AddDate(0, 0, 7)
@@ -140,6 +148,17 @@ func TestRenderWeek_GroupsByLeagueAndDay(t *testing.T) {
}
}
+func TestRenderWeek_CustomEmptyLine(t *testing.T) {
+ from := time.Date(2026, 5, 11, 0, 0, 0, 0, IctLocation)
+ to := from.AddDate(0, 0, 7)
+ got := renderWeek(nil, from, to, "No major LoL matches next week.")
+ for _, want := range []string{"Mon May 11", "Sun May 17", "No major LoL matches next week."} {
+ if !strings.Contains(got, want) {
+ t.Errorf("custom week empty render missing %q in %q", want, got)
+ }
+ }
+}
+
func TestFilterMajor(t *testing.T) {
events := []ScheduleEvent{
{League: League{Slug: "lck"}},
diff --git a/internal/modules/lol/handlers.go b/internal/modules/lol/handlers.go
index ee92636..0c3df92 100644
--- a/internal/modules/lol/handlers.go
+++ b/internal/modules/lol/handlers.go
@@ -48,7 +48,23 @@ func (s *state) handleSchedule(ctx context.Context, b *bot.Bot, update *models.U
return chathelper.Reply(ctx, b, msg, parsed.Error)
}
useFallback := strings.TrimSpace(arg) == ""
- return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback)
+ emptyLine := "No major LoL matches on this day."
+ if useFallback {
+ emptyLine = "No major LoL matches today."
+ }
+ return s.replyForRange(ctx, b, msg, parsed.Date, addDays(parsed.Date, 1), false, useFallback,
+ emptyLine, "")
+}
+
+// handleTomorrow is /lol_tomorrow — matches for tomorrow's ICT day.
+func (s *state) handleTomorrow(ctx context.Context, b *bot.Bot, update *models.Update) error {
+ msg := update.Message
+ if msg == nil {
+ return nil
+ }
+ from := addDays(ictDayStartOf(s.now()), 1)
+ return s.replyForRange(ctx, b, msg, from, addDays(from, 1), false, true,
+ "No major LoL matches tomorrow.", "Could not fetch tomorrow's matches. Try again later.")
}
// handleWeek is /lol_this_week — the current ICT calendar week
@@ -59,12 +75,25 @@ func (s *state) handleWeek(ctx context.Context, b *bot.Bot, update *models.Updat
return nil
}
from := ictWeekStartOf(s.now())
- return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true)
+ return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true,
+ "No major LoL matches this week.", "")
}
-// replyForRange fetches + filters + renders a date window. week=true uses
-// RenderWeek; false uses RenderToday.
-func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool) error {
+// handleNextWeek is /lol_nextweek — the next ICT calendar week
+// (Monday 00:00 ICT through the following Monday 00:00 ICT, exclusive).
+func (s *state) handleNextWeek(ctx context.Context, b *bot.Bot, update *models.Update) error {
+ msg := update.Message
+ if msg == nil {
+ return nil
+ }
+ from := ictNextWeekStartOf(s.now())
+ return s.replyForRange(ctx, b, msg, from, addDays(from, 7), true, true,
+ "No major LoL matches next week.", "Could not fetch next week's matches. Try again later.")
+}
+
+// replyForRange fetches, filters, and renders a date window. week=true groups
+// by league and day; false groups by league only.
+func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Message, from, to time.Time, week, useFallback bool, emptyLine, fetchErrorHint string) error {
var (
events []ScheduleEvent
err error
@@ -76,18 +105,20 @@ func (s *state) replyForRange(ctx context.Context, b *bot.Bot, msg *models.Messa
}
if err != nil {
log.Error("lol_fetch_fail", "err", err, "from", from, "to", to)
- hint := "Could not fetch matches. Try again later."
- if week {
- hint = "Could not fetch this week's matches. Try again later."
+ if fetchErrorHint == "" {
+ fetchErrorHint = "Could not fetch matches. Try again later."
+ if week {
+ fetchErrorHint = "Could not fetch this week's matches. Try again later."
+ }
}
- return chathelper.Reply(ctx, b, msg, hint)
+ return chathelper.Reply(ctx, b, msg, fetchErrorHint)
}
filtered := FilterMajor(events)
var text string
if week {
- text = RenderWeek(filtered, from, to)
+ text = renderWeek(filtered, from, to, emptyLine)
} else {
- text = RenderToday(filtered, from)
+ text = renderDay(filtered, from, emptyLine)
}
return chathelper.ReplyHTML(ctx, b, msg, text)
}
diff --git a/internal/modules/lol/handlers_test.go b/internal/modules/lol/handlers_test.go
index 1c12ece..6632501 100644
--- a/internal/modules/lol/handlers_test.go
+++ b/internal/modules/lol/handlers_test.go
@@ -39,7 +39,9 @@ func installSchedule(t *testing.T, bodyJSON string, nowMs int64) (*testutil.Reco
Name: "lol",
Commands: []modules.Command{
{Name: "lol", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSchedule},
+ {Name: "lol_tomorrow", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleTomorrow},
{Name: "lol_this_week", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleWeek},
+ {Name: "lol_nextweek", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleNextWeek},
{Name: "lol_subscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleSubscribe},
{Name: "lol_unsubscribe", Visibility: modules.VisibilityPublic, Description: "x", Handler: s.handleUnsubscribe},
},
@@ -75,6 +77,28 @@ const todayBody = `{
}
}`
+const futureBody = `{
+ "data": {
+ "schedule": {
+ "events": [
+ {
+ "startTime": "2026-05-10T05:00:00Z",
+ "state": "unstarted",
+ "league": {"slug": "lck", "name": "LCK"},
+ "match": {"teams": [{"code":"DK"},{"code":"KT"}], "strategy":{"count":3}}
+ },
+ {
+ "startTime": "2026-05-12T08:00:00Z",
+ "state": "unstarted",
+ "league": {"slug": "lpl", "name": "LPL"},
+ "match": {"teams": [{"code":"JDG"},{"code":"BLG"}], "strategy":{"count":5}}
+ }
+ ],
+ "pages": {"newer": null}
+ }
+ }
+}`
+
func TestHandleSchedule_DefaultsToToday(t *testing.T) {
rb, _ := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol"))
@@ -119,6 +143,49 @@ func TestHandleWeek_RendersWeek(t *testing.T) {
}
}
+func TestHandleTomorrow_RendersTomorrow(t *testing.T) {
+ rb, _ := installSchedule(t, futureBody, fakeNowMs)
+ rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_tomorrow"))
+
+ got := rb.LastSent().Text()
+ for _, want := range []string{"Sun May 10", "LCK", "DK vs KT"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("tomorrow reply missing %q in:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "JDG vs BLG") {
+ t.Errorf("tomorrow reply included next-week match:\n%s", got)
+ }
+}
+
+func TestHandleNextWeek_RendersNextWeek(t *testing.T) {
+ rb, _ := installSchedule(t, futureBody, fakeNowMs)
+ rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(1, "/lol_nextweek"))
+
+ got := rb.LastSent().Text()
+ for _, want := range []string{"Mon May 11", "Sun May 17", "LPL", "JDG vs BLG"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("next-week reply missing %q in:\n%s", want, got)
+ }
+ }
+ if strings.Contains(got, "DK vs KT") {
+ t.Errorf("next-week reply included tomorrow match:\n%s", got)
+ }
+}
+
+func TestNewRegistersScheduleShortcuts(t *testing.T) {
+ mod := New(modules.Deps{Store: storage.NewMemoryProvider().Collection("lol")})
+ got := map[string]bool{}
+ for _, cmd := range mod.Commands {
+ got[cmd.Name] = true
+ }
+ for _, want := range []string{"lol", "lol_tomorrow", "lol_this_week", "lol_nextweek"} {
+ if !got[want] {
+ t.Errorf("New() missing command %s", want)
+ }
+ }
+}
+
func TestHandleSubscribe_AddsAndIsIdempotent(t *testing.T) {
rb, subsStore := installSchedule(t, todayBody, fakeNowMs)
rb.Bot.ProcessUpdate(context.Background(), testutil.NewPrivateMessage(7, "/lol_subscribe"))
diff --git a/internal/modules/lol/lol.go b/internal/modules/lol/lol.go
index 72795d4..c3fc6bd 100644
--- a/internal/modules/lol/lol.go
+++ b/internal/modules/lol/lol.go
@@ -8,7 +8,7 @@ import (
// CollectionName is the MongoDB collection/module key used by the registry.
const CollectionName = "lol"
-// New is the lol module Factory. The 4 user-facing commands plus the
+// New is the lol module Factory. The 6 user-facing commands plus the
// daily-push cron (lol_daily_push at 08:00 ICT, fan-out to
// subscribers) are wired here. The cron handler reads deps.Bot at invoke
// time — main.go must wire BuildOptions.Bot for the cron to function;
@@ -28,12 +28,24 @@ func New(deps modules.Deps) modules.Module {
Description: "LoL matches for a date (dd, dd-mm, dd/mm, ddmm, or full date; default today)",
Handler: s.handleSchedule,
},
+ {
+ Name: "lol_tomorrow",
+ Visibility: modules.VisibilityPublic,
+ Description: "LoL esports matches for tomorrow (ICT)",
+ Handler: s.handleTomorrow,
+ },
{
Name: "lol_this_week",
Visibility: modules.VisibilityPublic,
Description: "LoL esports matches for this week (Mon–Sun, ICT)",
Handler: s.handleWeek,
},
+ {
+ Name: "lol_nextweek",
+ Visibility: modules.VisibilityPublic,
+ Description: "LoL esports matches for next week (Mon–Sun, ICT)",
+ Handler: s.handleNextWeek,
+ },
{
Name: "lol_subscribe",
Visibility: modules.VisibilityPublic,
diff --git a/internal/modules/lol/parse_date.go b/internal/modules/lol/parse_date.go
index 8bc0bf6..556666a 100644
--- a/internal/modules/lol/parse_date.go
+++ b/internal/modules/lol/parse_date.go
@@ -47,6 +47,12 @@ func ictWeekStartOf(now time.Time) time.Time {
return day.AddDate(0, 0, -daysFromMonday).UTC()
}
+// ictNextWeekStartOf returns the Monday 00:00 ICT after the current ICT
+// calendar week, expressed as a UTC instant.
+func ictNextWeekStartOf(now time.Time) time.Time {
+ return addDays(ictWeekStartOf(now), 7)
+}
+
// addDays returns date + days, preserving time-of-day.
func addDays(date time.Time, days int) time.Time {
return date.Add(time.Duration(days) * 24 * time.Hour)
diff --git a/internal/modules/lol/parse_date_test.go b/internal/modules/lol/parse_date_test.go
index 77aca30..47f1925 100644
--- a/internal/modules/lol/parse_date_test.go
+++ b/internal/modules/lol/parse_date_test.go
@@ -128,6 +128,15 @@ func TestIctWeekStartOf(t *testing.T) {
}
}
+func TestIctNextWeekStartOf(t *testing.T) {
+ // refNow is Sat 2026-05-09 19:00 ICT. Next ICT calendar week starts
+ // Mon 2026-05-11 00:00 ICT = 2026-05-10 17:00 UTC.
+ want := time.Date(2026, 5, 10, 17, 0, 0, 0, time.UTC)
+ if got := ictNextWeekStartOf(refNow); !got.Equal(want) {
+ t.Errorf("ictNextWeekStartOf(Sat) = %v, want %v", got, want)
+ }
+}
+
func TestAddDays(t *testing.T) {
base := time.Date(2026, 5, 9, 12, 30, 0, 0, time.UTC)
got := addDays(base, 3)
diff --git a/telegram-commands.json b/telegram-commands.json
index d97b540..5724102 100644
--- a/telegram-commands.json
+++ b/telegram-commands.json
@@ -60,10 +60,18 @@
"command": "lol",
"description": "LoL matches for a date; supports dd, dd-mm, dd/mm, ddmm"
},
+ {
+ "command": "lol_tomorrow",
+ "description": "LoL matches for tomorrow"
+ },
{
"command": "lol_this_week",
"description": "LoL matches for this week"
},
+ {
+ "command": "lol_nextweek",
+ "description": "LoL matches for next week"
+ },
{
"command": "lol_subscribe",
"description": "Get the daily LoL schedule digest"