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:
parent
2129403f39
commit
a5bbf3a25d
@ -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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 = [
|
||||||
|
|||||||
@ -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.");
|
||||||
|
|||||||
@ -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> {
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user