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, };