From 45fcc11e7b23ba01baedbee2c6e411b89dd9e6b4 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sat, 9 May 2026 18:06:11 +0800 Subject: [PATCH] feat(bot): menu-driven year/month/day picker for exact dates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the typed-date input with a fully button-driven calendar: Year (current + next 4) → Month (12 buttons, past months disabled) → Day (calendar grid for that month, past days disabled) → Hour → Minute (existing screens, computed day-offset) Past months/days render as inert "·" cells with a no-op callback so operator taps don't error. Year picker covers up to 4 years out — well beyond the typical reminder horizon. Replaces the "📝 Specific date…" typed input with "📅 Pick exact date…" which never asks for keyboard text. --- apps/bot/src/reminders/time-parsing.ts | 29 +++++++++ apps/bot/src/telegram/bot.ts | 23 ++++++- apps/bot/src/telegram/callbacks.ts | 66 ++++++++++++++++++-- apps/bot/src/telegram/menus.ts | 84 ++++++++++++++++++++++++-- 4 files changed, 188 insertions(+), 14 deletions(-) diff --git a/apps/bot/src/reminders/time-parsing.ts b/apps/bot/src/reminders/time-parsing.ts index 825d89c..cb1220b 100644 --- a/apps/bot/src/reminders/time-parsing.ts +++ b/apps/bot/src/reminders/time-parsing.ts @@ -75,6 +75,35 @@ export function parseTypedDate( return { ok: true, dayOffset: diffDays, label: targetDay.toFormat("EEE dd MMM yyyy") }; } +/** Compute the day-offset from "today" in the given timezone for a year/month/day. */ +export function dayOffsetFromYMD( + year: number, + month: number, + day: number, + timezone: string = DEFAULT_TIMEZONE, +): { ok: true; dayOffset: number; label: string } | { ok: false; reason: string } { + const target = DateTime.fromObject({ year, month, day }, { zone: timezone }); + if (!target.isValid) { + return { ok: false, reason: "Invalid date" }; + } + const today = DateTime.now().setZone(timezone).startOf("day"); + const diffDays = Math.round(target.startOf("day").diff(today, "days").days); + if (diffDays < 0) { + return { ok: false, reason: "That date is in the past" }; + } + return { ok: true, dayOffset: diffDays, label: target.toFormat("EEE dd MMM yyyy") }; +} + +/** Today's year/month/day in a given timezone. Used by the calendar picker. */ +export function todayYMD(timezone: string = DEFAULT_TIMEZONE): { + year: number; + month: number; + day: number; +} { + const now = DateTime.now().setZone(timezone); + return { year: now.year, month: now.month, day: now.day }; +} + export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string }; const FORMATS = [ diff --git a/apps/bot/src/telegram/bot.ts b/apps/bot/src/telegram/bot.ts index 0905d82..61ba07c 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -36,7 +36,11 @@ import { wizardPickDay, wizardPickHour, wizardPickMinute, - wizardTypeDatePrompt, + wizardPickYearStart, + wizardPickYear, + wizardPickMonth, + wizardPickDayOfMonth, + wizardNoop, } from "./callbacks.js"; import { consumePendingPairLabel, @@ -132,10 +136,25 @@ export function createTelegramBot(): Bot { await wizardSetTimeQuick(ctx, choice as Quick); } }); - bot.callbackQuery("rmd:type", wizardTypeDatePrompt); + bot.callbackQuery("rmd:exact", wizardPickYearStart); + bot.callbackQuery("rm_noop", wizardNoop); bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => { await wizardPickDay(ctx, Number(ctx.match[1])); }); + bot.callbackQuery(/^rmy:(\d+)$/, async (ctx) => { + await wizardPickYear(ctx, Number(ctx.match[1])); + }); + bot.callbackQuery(/^rmM:(\d+):(\d+)$/, async (ctx) => { + await wizardPickMonth(ctx, Number(ctx.match[1]), Number(ctx.match[2])); + }); + bot.callbackQuery(/^rmD:(\d+):(\d+):(\d+)$/, async (ctx) => { + await wizardPickDayOfMonth( + ctx, + Number(ctx.match[1]), + Number(ctx.match[2]), + Number(ctx.match[3]), + ); + }); bot.callbackQuery(/^rmh:(\d+):(\d+)$/, async (ctx) => { await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2])); }); diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index 4e9c9c9..a02f473 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -399,13 +399,67 @@ export async function wizardPickDay(ctx: Context, dayOffset: number): Promise { +export async function wizardPickYearStart(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { todayYMD } = await import("../reminders/time-parsing.js"); + const { reminderPickYearMenu } = await import("./menus.js"); + await showMenu(ctx, reminderPickYearMenu(todayYMD(tz).year)); +} + +export async function wizardPickYear(ctx: Context, year: number): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { todayYMD } = await import("../reminders/time-parsing.js"); + const { reminderPickMonthMenu } = await import("./menus.js"); + const today = todayYMD(tz); + await showMenu(ctx, reminderPickMonthMenu(year, today.year, today.month)); +} + +export async function wizardPickMonth( + ctx: Context, + year: number, + month: number, +): Promise { + await ctx.answerCallbackQuery(); + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { todayYMD } = await import("../reminders/time-parsing.js"); + const { reminderPickDayOfMonthMenu } = await import("./menus.js"); + const today = todayYMD(tz); + await showMenu( + ctx, + reminderPickDayOfMonthMenu(year, month, today.year, today.month, today.day), + ); +} + +export async function wizardPickDayOfMonth( + ctx: Context, + year: number, + month: number, + day: number, +): Promise { + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { dayOffsetFromYMD, formatCustomDay } = await import("../reminders/time-parsing.js"); + const result = dayOffsetFromYMD(year, month, day, tz); + if (!result.ok) { + await ctx.answerCallbackQuery({ text: result.reason, show_alert: true }); + return; + } + await ctx.answerCallbackQuery(); + const { reminderPickHourMenu } = await import("./menus.js"); + await showMenu( + ctx, + reminderPickHourMenu(formatCustomDay(result.dayOffset, tz), result.dayOffset), + ); +} + +export async function wizardNoop(ctx: Context): Promise { + // Past months/days in the calendar use this to absorb taps without alerting. await ctx.answerCallbackQuery(); - const userId = ctx.from?.id; - if (!userId) return; - updateWizard(userId, { step: "custom_date_input" }); - const { reminderTypeDateMenu } = await import("./menus.js"); - await showMenu(ctx, reminderTypeDateMenu()); } export async function wizardPickHour( diff --git a/apps/bot/src/telegram/menus.ts b/apps/bot/src/telegram/menus.ts index bdba97e..59e3b22 100644 --- a/apps/bot/src/telegram/menus.ts +++ b/apps/bot/src/telegram/menus.ts @@ -402,7 +402,7 @@ export function reminderPickDayMenu(timezone: string): MenuView { } keyboard.row(); } - keyboard.text("📝 Specific date…", "rmd:type").row(); + keyboard.text("📅 Pick exact date…", "rmd:exact").row(); keyboard.text("⬅ Back", "rm_t:back"); // Plain text — IANA timezone names contain `_` which Markdown reads as italic. return { @@ -412,12 +412,84 @@ export function reminderPickDayMenu(timezone: string): MenuView { }; } -export function reminderTypeDateMenu(): MenuView { - const keyboard = new InlineKeyboard().text("⬅ Back", "rm_t:custom"); +export function reminderPickYearMenu(currentYear: number): MenuView { + const keyboard = new InlineKeyboard(); + // Show current year + next 4 years, two columns + const years = [currentYear, currentYear + 1, currentYear + 2, currentYear + 3, currentYear + 4]; + for (let i = 0; i < years.length; i += 2) { + keyboard.text(String(years[i]!), `rmy:${years[i]}`); + if (years[i + 1] !== undefined) { + keyboard.text(String(years[i + 1]!), `rmy:${years[i + 1]}`); + } + keyboard.row(); + } + keyboard.text("⬅ Back", "rm_t:custom"); return { - text: - "📆 Type a date\n\nReply with a date in YYYY-MM-DD format, e.g. 2026-12-25.\n\n" + - "I'll then ask for the hour and minute.", + text: "📅 Exact date — Step A / D\n\nPick a year:", + keyboard, + parseMode: undefined, + }; +} + +const MONTH_NAMES = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +export function reminderPickMonthMenu(year: number, currentYear: number, currentMonth: number): MenuView { + const keyboard = new InlineKeyboard(); + // Disable months in the past (only relevant when year === currentYear). + // 4 columns × 3 rows. + for (let row = 0; row < 3; row++) { + for (let col = 0; col < 4; col++) { + const month = row * 4 + col + 1; // 1..12 + const isPast = year === currentYear && month < currentMonth; + const label = isPast ? `· ${MONTH_NAMES[month - 1]} ·` : MONTH_NAMES[month - 1]!; + // Past months use a no-op callback so taps just refresh the menu. + keyboard.text(label, isPast ? "rm_noop" : `rmM:${year}:${month}`); + } + keyboard.row(); + } + keyboard.text("⬅ Back", "rmd:exact"); + return { + text: `📅 Exact date — Step B / D\n\nYear: ${year}\n\nPick a month:`, + keyboard, + parseMode: undefined, + }; +} + +/** + * Calendar grid for the chosen year+month. Disables days strictly before today. + * `currentYear`, `currentMonth`, `currentDay` describe today in the operator's + * timezone. Returns a 7-column grid with Mon/Tue/.../Sun headers omitted (each + * cell is just the day number — keeps the keyboard compact). + */ +export function reminderPickDayOfMonthMenu( + year: number, + month: number, + currentYear: number, + currentMonth: number, + currentDay: number, +): MenuView { + const daysInMonth = new Date(year, month, 0).getDate(); // month is 1-indexed; new Date(y, m, 0) gives last day of m-1 + const keyboard = new InlineKeyboard(); + let col = 0; + for (let day = 1; day <= daysInMonth; day++) { + const isPast = + (year === currentYear && month === currentMonth && day < currentDay) || + (year === currentYear && month < currentMonth); + const label = isPast ? "·" : String(day).padStart(2, "0"); + keyboard.text(label, isPast ? "rm_noop" : `rmD:${year}:${month}:${day}`); + col++; + if (col === 7) { + keyboard.row(); + col = 0; + } + } + if (col > 0) keyboard.row(); + keyboard.text("⬅ Back", `rmy:${year}`); + return { + text: `📅 Exact date — Step C / D\n\nYear: ${year}, Month: ${MONTH_NAMES[month - 1]}\n\nPick a day:`, keyboard, parseMode: undefined, };