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:
parent
f5666a9d2c
commit
45fcc11e7b
@ -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 = [
|
||||
|
||||
@ -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]));
|
||||
});
|
||||
|
||||
@ -399,13 +399,67 @@ export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<vo
|
||||
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();
|
||||
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(
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user