diff --git a/apps/bot/src/reminders/time-parsing.ts b/apps/bot/src/reminders/time-parsing.ts index 69533b5..825d89c 100644 --- a/apps/bot/src/reminders/time-parsing.ts +++ b/apps/bot/src/reminders/time-parsing.ts @@ -51,6 +51,30 @@ export function formatCustomDay(dayOffset: number, timezone: string = DEFAULT_TI return dt.toFormat("EEE dd MMM"); } +/** + * Parse a typed YYYY-MM-DD string into a "day offset from today" relative to + * the operator's timezone. Returns the offset in days, or null if invalid / + * in the past. We return offset rather than a Date so the rest of the picker + * (hour, minute) works the same way as the preset-day path. + */ +export function parseTypedDate( + input: string, + timezone: string = DEFAULT_TIMEZONE, +): { ok: true; dayOffset: number; label: string } | { ok: false; reason: string } { + const trimmed = input.trim(); + const dt = DateTime.fromFormat(trimmed, "yyyy-MM-dd", { zone: timezone }); + if (!dt.isValid) { + return { ok: false, reason: "Couldn't parse — use YYYY-MM-DD, e.g. 2026-12-25" }; + } + const today = DateTime.now().setZone(timezone).startOf("day"); + const targetDay = dt.startOf("day"); + const diffDays = Math.round(targetDay.diff(today, "days").days); + if (diffDays < 0) { + return { ok: false, reason: "That date is in the past" }; + } + return { ok: true, dayOffset: diffDays, label: targetDay.toFormat("EEE dd MMM yyyy") }; +} + 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 d026c74..0905d82 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -36,6 +36,7 @@ import { wizardPickDay, wizardPickHour, wizardPickMinute, + wizardTypeDatePrompt, } from "./callbacks.js"; import { consumePendingPairLabel, @@ -131,6 +132,7 @@ export function createTelegramBot(): Bot { await wizardSetTimeQuick(ctx, choice as Quick); } }); + bot.callbackQuery("rmd:type", wizardTypeDatePrompt); bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => { await wizardPickDay(ctx, Number(ctx.match[1])); }); @@ -179,8 +181,9 @@ export function createTelegramBot(): Bot { return; } - // Reminder wizard — only the compose step accepts free text now. - // Custom date/time is fully menu-driven (rmd → rmh → rmm callbacks). + // Reminder wizard: + // compose step: free-text body (or media in the photo/video/doc handler) + // custom_date_input step: typed YYYY-MM-DD that gets parsed into a day-offset const w = getWizard(tgId); if (w && w.step === "compose") { updateWizard(tgId, { text: text.trim() }); @@ -188,6 +191,24 @@ export function createTelegramBot(): Bot { await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); return; } + if (w && w.step === "custom_date_input") { + const op = await db.query.operators.findFirst({ + where: (o, { eq }) => eq(o.telegramUserId, tgId), + }); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { parseTypedDate, formatCustomDay } = await import("../reminders/time-parsing.js"); + const { reminderPickHourMenu } = await import("./menus.js"); + const parsed = parseTypedDate(text, tz); + if (!parsed.ok) { + await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`); + return; + } + // Date accepted — drop the input flag and advance to hour picker + updateWizard(tgId, { step: "compose" /* not really compose; just exits the input state */ }); + const view = reminderPickHourMenu(formatCustomDay(parsed.dayOffset, tz), parsed.dayOffset); + await ctx.reply(view.text, { reply_markup: view.keyboard }); + return; + } await ctx.reply("Tap /menu to see what I can do."); }); diff --git a/apps/bot/src/telegram/callbacks.ts b/apps/bot/src/telegram/callbacks.ts index 008d89b..4e9c9c9 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -399,6 +399,15 @@ export async function wizardPickDay(ctx: Context, dayOffset: number): Promise { + 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( ctx: Context, dayOffset: number, diff --git a/apps/bot/src/telegram/menus.ts b/apps/bot/src/telegram/menus.ts index ccbb694..bdba97e 100644 --- a/apps/bot/src/telegram/menus.ts +++ b/apps/bot/src/telegram/menus.ts @@ -377,7 +377,7 @@ export function reminderTimeMenu(): MenuView { // All choices are encoded into callback_data so the wizard state stays simple. export function reminderPickDayMenu(timezone: string): MenuView { - const offsets = [0, 1, 2, 3, 4, 5, 7, 14, 30]; + const offsets = [0, 1, 2, 3, 4, 5, 7, 14, 30, 60, 90]; const labels: Record = { 0: "Today", 1: "Tomorrow", @@ -388,6 +388,8 @@ export function reminderPickDayMenu(timezone: string): MenuView { 7: "+1 week", 14: "+2 weeks", 30: "+1 month", + 60: "+2 months", + 90: "+3 months", }; const keyboard = new InlineKeyboard(); // 2 columns @@ -400,6 +402,7 @@ export function reminderPickDayMenu(timezone: string): MenuView { } keyboard.row(); } + keyboard.text("📝 Specific date…", "rmd:type").row(); keyboard.text("⬅ Back", "rm_t:back"); // Plain text — IANA timezone names contain `_` which Markdown reads as italic. return { @@ -409,6 +412,17 @@ export function reminderPickDayMenu(timezone: string): MenuView { }; } +export function reminderTypeDateMenu(): MenuView { + const keyboard = new InlineKeyboard().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.", + keyboard, + parseMode: undefined, + }; +} + export function reminderPickHourMenu(dayLabel: string, dayOffset: number): MenuView { const keyboard = new InlineKeyboard(); // 4 rows of 6 hours, ordered to put daytime hours first for ergonomics: diff --git a/apps/bot/src/telegram/state.ts b/apps/bot/src/telegram/state.ts index 5a8cd25..3d551ae 100644 --- a/apps/bot/src/telegram/state.ts +++ b/apps/bot/src/telegram/state.ts @@ -47,6 +47,7 @@ export type WizardStep = | "pick_account" | "pick_group" | "compose" + | "custom_date_input" | "set_time" | "confirm";