diff --git a/apps/bot/src/reminders/time-parsing.test.ts b/apps/bot/src/reminders/time-parsing.test.ts index 53d31fd..8c8a78a 100644 --- a/apps/bot/src/reminders/time-parsing.test.ts +++ b/apps/bot/src/reminders/time-parsing.test.ts @@ -1,31 +1,36 @@ import { describe, it, expect } from "vitest"; -import { quickToDate, parseFreeText } from "./time-parsing.js"; +import { quickToDate, buildCustomDate, formatCustomDay } from "./time-parsing.js"; describe("quickToDate", () => { - it("in_1h returns ~1h ahead", () => { - const d = quickToDate("in_1h"); - const diffMs = d.getTime() - Date.now(); - expect(diffMs).toBeGreaterThan(55 * 60 * 1000); - expect(diffMs).toBeLessThan(65 * 60 * 1000); - }); it("now returns ~30s ahead", () => { const d = quickToDate("now"); - expect(d.getTime() - Date.now()).toBeGreaterThan(20 * 1000); + const diffMs = d.getTime() - Date.now(); + expect(diffMs).toBeGreaterThan(20 * 1000); + expect(diffMs).toBeLessThan(40 * 1000); + }); + it("tomorrow_9am returns a future Date", () => { + const d = quickToDate("tomorrow_9am"); + expect(d.getTime()).toBeGreaterThan(Date.now()); }); }); -describe("parseFreeText", () => { - it("accepts YYYY-MM-DD HH:MM", () => { - const r = parseFreeText("2099-12-31 23:59"); - expect(r.ok).toBe(true); - }); - it("rejects past times", () => { - const r = parseFreeText("2020-01-01 00:00"); +describe("buildCustomDate", () => { + it("rejects in-past day/hour/minute", () => { + const r = buildCustomDate(-1, 9, 0, "Asia/Kuala_Lumpur"); expect(r.ok).toBe(false); if (!r.ok) expect(r.reason).toMatch(/past/i); }); - it("rejects garbage", () => { - const r = parseFreeText("not a date"); - expect(r.ok).toBe(false); + it("accepts a far-future combination", () => { + const r = buildCustomDate(7, 23, 45, "Asia/Kuala_Lumpur"); + expect(r.ok).toBe(true); + }); +}); + +describe("formatCustomDay", () => { + it("returns 'Today (...)' for offset 0", () => { + expect(formatCustomDay(0, "Asia/Kuala_Lumpur")).toMatch(/^Today/); + }); + it("returns 'Tomorrow (...)' for offset 1", () => { + expect(formatCustomDay(1, "Asia/Kuala_Lumpur")).toMatch(/^Tomorrow/); }); }); diff --git a/apps/bot/src/reminders/time-parsing.ts b/apps/bot/src/reminders/time-parsing.ts index 34719b9..69533b5 100644 --- a/apps/bot/src/reminders/time-parsing.ts +++ b/apps/bot/src/reminders/time-parsing.ts @@ -1,17 +1,14 @@ import { DateTime } from "luxon"; import { DEFAULT_TIMEZONE } from "@cmbot/shared"; -export type Quick = "now" | "in_1h" | "in_3h" | "tomorrow_9am" | "next_mon_9am"; +export type Quick = "now" | "tomorrow_9am" | "next_mon_9am"; export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): Date { const now = DateTime.now().setZone(timezone); switch (quick) { case "now": + // Add 30s so pg-boss has time to schedule + the system has time to dispatch return now.plus({ seconds: 30 }).toJSDate(); - case "in_1h": - return now.plus({ hours: 1 }).toJSDate(); - case "in_3h": - return now.plus({ hours: 3 }).toJSDate(); case "tomorrow_9am": return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate(); case "next_mon_9am": { @@ -22,6 +19,38 @@ export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): } } +/** + * Build a Date from a day-offset (days from today, in the operator's timezone), + * an hour (0-23) and a minute (0-59). Returns the JS Date or null if the + * resulting time is in the past. + */ +export function buildCustomDate( + dayOffset: number, + hour: number, + minute: number, + timezone: string = DEFAULT_TIMEZONE, +): { ok: true; date: Date } | { ok: false; reason: string } { + const target = DateTime.now() + .setZone(timezone) + .plus({ days: dayOffset }) + .set({ hour, minute, second: 0, millisecond: 0 }); + if (!target.isValid) { + return { ok: false, reason: "Invalid date" }; + } + const jsDate = target.toJSDate(); + if (jsDate.getTime() <= Date.now()) { + return { ok: false, reason: "Time is in the past" }; + } + return { ok: true, date: jsDate }; +} + +export function formatCustomDay(dayOffset: number, timezone: string = DEFAULT_TIMEZONE): string { + const dt = DateTime.now().setZone(timezone).plus({ days: dayOffset }); + if (dayOffset === 0) return `Today (${dt.toFormat("EEE dd MMM")})`; + if (dayOffset === 1) return `Tomorrow (${dt.toFormat("EEE dd MMM")})`; + return dt.toFormat("EEE dd MMM"); +} + 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 79981e7..d026c74 100644 --- a/apps/bot/src/telegram/bot.ts +++ b/apps/bot/src/telegram/bot.ts @@ -32,6 +32,10 @@ import { wizardSetTimeCustomPrompt, wizardSave, showWizardConfirm, + wizardBackToTimeMenu, + wizardPickDay, + wizardPickHour, + wizardPickMinute, } from "./callbacks.js"; import { consumePendingPairLabel, @@ -43,7 +47,7 @@ import { clearWizard, } from "./state.js"; import { ingestTelegramFile } from "../media/ingest.js"; -import { parseFreeText, type Quick } from "../reminders/time-parsing.js"; +import type { Quick } from "../reminders/time-parsing.js"; import { reminderTimeMenu } from "./menus.js"; import { DEFAULT_TIMEZONE } from "@cmbot/shared"; @@ -118,13 +122,24 @@ export function createTelegramBot(): Bot { await wizardPickGroup(ctx, ctx.match[1]!); }); bot.callbackQuery(/^rm_t:(.+)$/, async (ctx) => { - const quick = ctx.match[1]!; - if (quick === "custom") { + const choice = ctx.match[1]!; + if (choice === "custom") { await wizardSetTimeCustomPrompt(ctx); + } else if (choice === "back") { + await wizardBackToTimeMenu(ctx); } else { - await wizardSetTimeQuick(ctx, quick as Quick); + await wizardSetTimeQuick(ctx, choice as Quick); } }); + bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => { + await wizardPickDay(ctx, Number(ctx.match[1])); + }); + bot.callbackQuery(/^rmh:(\d+):(\d+)$/, async (ctx) => { + await wizardPickHour(ctx, Number(ctx.match[1]), Number(ctx.match[2])); + }); + bot.callbackQuery(/^rmm:(\d+):(\d+):(\d+)$/, async (ctx) => { + await wizardPickMinute(ctx, Number(ctx.match[1]), Number(ctx.match[2]), Number(ctx.match[3])); + }); bot.callbackQuery(/^rm_del:(.+)$/, async (ctx) => { await deleteReminderCallback(ctx, ctx.match[1]!); }); @@ -164,29 +179,14 @@ export function createTelegramBot(): Bot { return; } - // Reminder wizard + // Reminder wizard — only the compose step accepts free text now. + // Custom date/time is fully menu-driven (rmd → rmh → rmm callbacks). const w = getWizard(tgId); - if (w) { - if (w.step === "compose") { - updateWizard(tgId, { text: text.trim() }); - const view = reminderTimeMenu(); - await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); - return; - } - if (w.step === "set_time") { - const op = await db.query.operators.findFirst({ - where: (o, { eq }) => eq(o.telegramUserId, tgId), - }); - const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; - const parsed = parseFreeText(text, tz); - if (!parsed.ok) { - await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`); - return; - } - updateWizard(tgId, { step: "confirm", scheduledAt: parsed.date }); - await showWizardConfirm(ctx); - return; - } + if (w && w.step === "compose") { + updateWizard(tgId, { text: text.trim() }); + const view = reminderTimeMenu(); + await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); + 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 caa9ffd..ef2535e 100644 --- a/apps/bot/src/telegram/callbacks.ts +++ b/apps/bot/src/telegram/callbacks.ts @@ -373,10 +373,66 @@ export async function wizardSetTimeCustomPrompt(ctx: Context): Promise { const userId = ctx.from?.id; if (!userId) return; updateWizard(userId, { step: "set_time" }); - await showMenu(ctx, { - text: "⌨️ Reply with date/time as `YYYY-MM-DD HH:MM`, e.g. `2026-05-15 09:00`.", - keyboard: new InlineKeyboard().text("⬅ Cancel", "m:reminders"), - }); + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { reminderPickDayMenu } = await import("./menus.js"); + await showMenu(ctx, reminderPickDayMenu(tz)); +} + +export async function wizardBackToTimeMenu(ctx: Context): Promise { + await ctx.answerCallbackQuery(); + const { reminderTimeMenu } = await import("./menus.js"); + await showMenu(ctx, reminderTimeMenu()); +} + +export async function wizardPickDay(ctx: Context, dayOffset: number): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { reminderPickHourMenu } = await import("./menus.js"); + const { formatCustomDay } = await import("../reminders/time-parsing.js"); + await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset)); +} + +export async function wizardPickHour( + ctx: Context, + dayOffset: number, + hour: number, +): Promise { + await ctx.answerCallbackQuery(); + const userId = ctx.from?.id; + if (!userId) return; + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { reminderPickMinuteMenu } = await import("./menus.js"); + const { formatCustomDay } = await import("../reminders/time-parsing.js"); + await showMenu(ctx, reminderPickMinuteMenu(formatCustomDay(dayOffset, tz), dayOffset, hour)); +} + +export async function wizardPickMinute( + ctx: Context, + dayOffset: number, + hour: number, + minute: number, +): Promise { + const userId = ctx.from?.id; + if (!userId) { + await ctx.answerCallbackQuery(); + return; + } + const op = await findOperator(ctx); + const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE; + const { buildCustomDate } = await import("../reminders/time-parsing.js"); + const result = buildCustomDate(dayOffset, hour, minute, tz); + if (!result.ok) { + await ctx.answerCallbackQuery({ text: result.reason, show_alert: true }); + return; + } + await ctx.answerCallbackQuery(); + updateWizard(userId, { step: "confirm", scheduledAt: result.date }); + await showWizardConfirm(ctx); } export async function showWizardConfirm(ctx: Context): Promise { diff --git a/apps/bot/src/telegram/menus.ts b/apps/bot/src/telegram/menus.ts index e5c8779..245b464 100644 --- a/apps/bot/src/telegram/menus.ts +++ b/apps/bot/src/telegram/menus.ts @@ -348,19 +348,100 @@ export function reminderComposeMenu(): MenuView { export function reminderTimeMenu(): MenuView { const keyboard = new InlineKeyboard() - .text("🕐 In 1 hour", "rm_t:in_1h") - .text("🕒 In 3 hours", "rm_t:in_3h") + .text("🕐 Now", "rm_t:now") .row() .text("🌅 Tomorrow 9 AM", "rm_t:tomorrow_9am") .text("📅 Next Mon 9 AM", "rm_t:next_mon_9am") .row() - .text("⌨️ Custom date/time", "rm_t:custom") + .text("📆 Custom day & time", "rm_t:custom") .row() .text("⬅ Cancel", "m:reminders"); return { text: "➕ *New Reminder — Step 4 / 4*\n\n" + - "When should it fire? Pick a quick option or type a date/time.", + "When should it fire?", + keyboard, + }; +} + +// Custom date+time picker — three sub-screens: pick day → pick hour → pick minute. +// 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 labels: Record = { + 0: "Today", + 1: "Tomorrow", + 2: "+2 days", + 3: "+3 days", + 4: "+4 days", + 5: "+5 days", + 7: "+1 week", + 14: "+2 weeks", + 30: "+1 month", + }; + const keyboard = new InlineKeyboard(); + // 2 columns + for (let i = 0; i < offsets.length; i += 2) { + const left = offsets[i]!; + keyboard.text(labels[left]!, `rmd:${left}`); + if (offsets[i + 1] !== undefined) { + const right = offsets[i + 1]!; + keyboard.text(labels[right]!, `rmd:${right}`); + } + keyboard.row(); + } + keyboard.text("⬅ Back", "rm_t:back"); + return { + text: `📆 *Custom — Step 1 / 3*\n\nPick a day (timezone: ${timezone}):`, + keyboard, + }; +} + +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: + // 06-11, 12-17, 18-23, 00-05 + const order = [ + [6, 7, 8, 9, 10, 11], + [12, 13, 14, 15, 16, 17], + [18, 19, 20, 21, 22, 23], + [0, 1, 2, 3, 4, 5], + ]; + for (const row of order) { + for (const h of row) { + keyboard.text(`${String(h).padStart(2, "0")}:00`, `rmh:${dayOffset}:${h}`); + } + keyboard.row(); + } + keyboard.text("⬅ Back", "rm_t:custom"); + return { + text: `📆 *Custom — Step 2 / 3*\n\nDay: ${dayLabel}\n\nPick an hour:`, + keyboard, + }; +} + +export function reminderPickMinuteMenu( + dayLabel: string, + dayOffset: number, + hour: number, +): MenuView { + const keyboard = new InlineKeyboard(); + const minutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]; + // 6 columns + for (let i = 0; i < minutes.length; i += 6) { + for (let j = 0; j < 6 && i + j < minutes.length; j++) { + const m = minutes[i + j]!; + keyboard.text(`:${String(m).padStart(2, "0")}`, `rmm:${dayOffset}:${hour}:${m}`); + } + keyboard.row(); + } + keyboard.text("⬅ Back", `rmd:${dayOffset}`); + return { + text: + `📆 *Custom — Step 3 / 3*\n\n` + + `Day: ${dayLabel}\nHour: ${String(hour).padStart(2, "0")}:00\n\n` + + `Pick minutes:`, keyboard, }; }