feat(bot): redesign reminder time picker (menu-driven)

Time picker UX changes after live testing:
- Add "🕐 Now" quick option (fires within 30s)
- Remove "🕐 In 1 hour" / "🕒 In 3 hours" — Now + Tomorrow 9 AM cover the
  practical fast-path
- Replace free-text custom date input with a 3-step menu picker:
  Day (Today, Tomorrow, +2d, +3d, +4d, +5d, +1w, +2w, +1m)
  → Hour (24-hour grid, daytime first)
  → Minute (5-min increments)
- Validate the chosen day+hour+minute against "now" and reject if past

Drops parseFreeText path entirely; the wizard's set_time step is gone.
This commit is contained in:
yiekheng 2026-05-09 17:45:08 +08:00
parent 2129403f39
commit a5bbf3a25d
5 changed files with 228 additions and 57 deletions

View File

@ -1,31 +1,36 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { quickToDate, parseFreeText } from "./time-parsing.js"; import { quickToDate, buildCustomDate, formatCustomDay } from "./time-parsing.js";
describe("quickToDate", () => { 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", () => { it("now returns ~30s ahead", () => {
const d = quickToDate("now"); 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", () => { describe("buildCustomDate", () => {
it("accepts YYYY-MM-DD HH:MM", () => { it("rejects in-past day/hour/minute", () => {
const r = parseFreeText("2099-12-31 23:59"); const r = buildCustomDate(-1, 9, 0, "Asia/Kuala_Lumpur");
expect(r.ok).toBe(true);
});
it("rejects past times", () => {
const r = parseFreeText("2020-01-01 00:00");
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
if (!r.ok) expect(r.reason).toMatch(/past/i); if (!r.ok) expect(r.reason).toMatch(/past/i);
}); });
it("rejects garbage", () => { it("accepts a far-future combination", () => {
const r = parseFreeText("not a date"); const r = buildCustomDate(7, 23, 45, "Asia/Kuala_Lumpur");
expect(r.ok).toBe(false); 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/);
}); });
}); });

View File

@ -1,17 +1,14 @@
import { DateTime } from "luxon"; import { DateTime } from "luxon";
import { DEFAULT_TIMEZONE } from "@cmbot/shared"; 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 { export function quickToDate(quick: Quick, timezone: string = DEFAULT_TIMEZONE): Date {
const now = DateTime.now().setZone(timezone); const now = DateTime.now().setZone(timezone);
switch (quick) { switch (quick) {
case "now": case "now":
// Add 30s so pg-boss has time to schedule + the system has time to dispatch
return now.plus({ seconds: 30 }).toJSDate(); 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": case "tomorrow_9am":
return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate(); return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
case "next_mon_9am": { 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 }; export type ParseResult = { ok: true; date: Date } | { ok: false; reason: string };
const FORMATS = [ const FORMATS = [

View File

@ -32,6 +32,10 @@ import {
wizardSetTimeCustomPrompt, wizardSetTimeCustomPrompt,
wizardSave, wizardSave,
showWizardConfirm, showWizardConfirm,
wizardBackToTimeMenu,
wizardPickDay,
wizardPickHour,
wizardPickMinute,
} from "./callbacks.js"; } from "./callbacks.js";
import { import {
consumePendingPairLabel, consumePendingPairLabel,
@ -43,7 +47,7 @@ import {
clearWizard, clearWizard,
} from "./state.js"; } from "./state.js";
import { ingestTelegramFile } from "../media/ingest.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 { reminderTimeMenu } from "./menus.js";
import { DEFAULT_TIMEZONE } from "@cmbot/shared"; import { DEFAULT_TIMEZONE } from "@cmbot/shared";
@ -118,13 +122,24 @@ export function createTelegramBot(): Bot {
await wizardPickGroup(ctx, ctx.match[1]!); await wizardPickGroup(ctx, ctx.match[1]!);
}); });
bot.callbackQuery(/^rm_t:(.+)$/, async (ctx) => { bot.callbackQuery(/^rm_t:(.+)$/, async (ctx) => {
const quick = ctx.match[1]!; const choice = ctx.match[1]!;
if (quick === "custom") { if (choice === "custom") {
await wizardSetTimeCustomPrompt(ctx); await wizardSetTimeCustomPrompt(ctx);
} else if (choice === "back") {
await wizardBackToTimeMenu(ctx);
} else { } 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) => { bot.callbackQuery(/^rm_del:(.+)$/, async (ctx) => {
await deleteReminderCallback(ctx, ctx.match[1]!); await deleteReminderCallback(ctx, ctx.match[1]!);
}); });
@ -164,29 +179,14 @@ export function createTelegramBot(): Bot {
return; 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); const w = getWizard(tgId);
if (w) { if (w && w.step === "compose") {
if (w.step === "compose") { updateWizard(tgId, { text: text.trim() });
updateWizard(tgId, { text: text.trim() }); const view = reminderTimeMenu();
const view = reminderTimeMenu(); await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" });
await ctx.reply(view.text, { reply_markup: view.keyboard, parse_mode: "Markdown" }); return;
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;
}
} }
await ctx.reply("Tap /menu to see what I can do."); await ctx.reply("Tap /menu to see what I can do.");

View File

@ -373,10 +373,66 @@ export async function wizardSetTimeCustomPrompt(ctx: Context): Promise<void> {
const userId = ctx.from?.id; const userId = ctx.from?.id;
if (!userId) return; if (!userId) return;
updateWizard(userId, { step: "set_time" }); updateWizard(userId, { step: "set_time" });
await showMenu(ctx, { const op = await findOperator(ctx);
text: "⌨️ Reply with date/time as `YYYY-MM-DD HH:MM`, e.g. `2026-05-15 09:00`.", const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
keyboard: new InlineKeyboard().text("⬅ Cancel", "m:reminders"), const { reminderPickDayMenu } = await import("./menus.js");
}); await showMenu(ctx, reminderPickDayMenu(tz));
}
export async function wizardBackToTimeMenu(ctx: Context): Promise<void> {
await ctx.answerCallbackQuery();
const { reminderTimeMenu } = await import("./menus.js");
await showMenu(ctx, reminderTimeMenu());
}
export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<void> {
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<void> {
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<void> {
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<void> { export async function showWizardConfirm(ctx: Context): Promise<void> {

View File

@ -348,19 +348,100 @@ export function reminderComposeMenu(): MenuView {
export function reminderTimeMenu(): MenuView { export function reminderTimeMenu(): MenuView {
const keyboard = new InlineKeyboard() const keyboard = new InlineKeyboard()
.text("🕐 In 1 hour", "rm_t:in_1h") .text("🕐 Now", "rm_t:now")
.text("🕒 In 3 hours", "rm_t:in_3h")
.row() .row()
.text("🌅 Tomorrow 9 AM", "rm_t:tomorrow_9am") .text("🌅 Tomorrow 9 AM", "rm_t:tomorrow_9am")
.text("📅 Next Mon 9 AM", "rm_t:next_mon_9am") .text("📅 Next Mon 9 AM", "rm_t:next_mon_9am")
.row() .row()
.text("⌨️ Custom date/time", "rm_t:custom") .text("📆 Custom day & time", "rm_t:custom")
.row() .row()
.text("⬅ Cancel", "m:reminders"); .text("⬅ Cancel", "m:reminders");
return { return {
text: text:
" *New Reminder — Step 4 / 4*\n\n" + " *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<number, string> = {
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, keyboard,
}; };
} }