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") };
|
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 = [
|
||||||
|
|||||||
@ -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]));
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user