feat(bot): menu-driven year/month/day picker for exact dates

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.
This commit is contained in:
yiekheng 2026-05-09 18:06:11 +08:00
parent f5666a9d2c
commit 45fcc11e7b
4 changed files with 188 additions and 14 deletions

View File

@ -75,6 +75,35 @@ export function parseTypedDate(
return { ok: true, dayOffset: diffDays, label: targetDay.toFormat("EEE dd MMM yyyy") }; 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 }; export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string };
const FORMATS = [ const FORMATS = [

View File

@ -36,7 +36,11 @@ import {
wizardPickDay, wizardPickDay,
wizardPickHour, wizardPickHour,
wizardPickMinute, wizardPickMinute,
wizardTypeDatePrompt, wizardPickYearStart,
wizardPickYear,
wizardPickMonth,
wizardPickDayOfMonth,
wizardNoop,
} from "./callbacks.js"; } from "./callbacks.js";
import { import {
consumePendingPairLabel, consumePendingPairLabel,
@ -132,10 +136,25 @@ export function createTelegramBot(): Bot {
await wizardSetTimeQuick(ctx, choice as Quick); 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) => { bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => {
await wizardPickDay(ctx, Number(ctx.match[1])); 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) => { bot.callbackQuery(/^rmh:(\d+):(\d+)$/, async (ctx) => {
await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2])); await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2]));
}); });

View File

@ -399,13 +399,67 @@ export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<vo
await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset)); await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset));
} }
export async function wizardTypeDatePrompt(ctx: Context): Promise<void> { export async function wizardPickYearStart(ctx: Context): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
// Past months/days in the calendar use this to absorb taps without alerting.
await ctx.answerCallbackQuery(); 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( export async function wizardPickHour(

View File

@ -402,7 +402,7 @@ export function reminderPickDayMenu(timezone: string): MenuView {
} }
keyboard.row(); keyboard.row();
} }
keyboard.text("📝 Specific date…", "rmd:type").row(); keyboard.text("📅 Pick exact date…", "rmd:exact").row();
keyboard.text("⬅ Back", "rm_t:back"); keyboard.text("⬅ Back", "rm_t:back");
// Plain text — IANA timezone names contain `_` which Markdown reads as italic. // Plain text — IANA timezone names contain `_` which Markdown reads as italic.
return { return {
@ -412,12 +412,84 @@ export function reminderPickDayMenu(timezone: string): MenuView {
}; };
} }
export function reminderTypeDateMenu(): MenuView { export function reminderPickYearMenu(currentYear: number): MenuView {
const keyboard = new InlineKeyboard().text("⬅ Back", "rm_t:custom"); 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 { return {
text: text: "📅 Exact date — Step A / D\n\nPick a year:",
"📆 Type a date\n\nReply with a date in YYYY-MM-DD format, e.g. 2026-12-25.\n\n" + keyboard,
"I'll then ask for the hour and minute.", 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, keyboard,
parseMode: undefined, parseMode: undefined,
}; };