yiekheng a5bbf3a25d 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.
2026-05-09 17:45:08 +08:00

78 lines
2.7 KiB
TypeScript

import { DateTime } from "luxon";
import { DEFAULT_TIMEZONE } from "@cmbot/shared";
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 "tomorrow_9am":
return now.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
case "next_mon_9am": {
const dow = now.weekday; // 1 = Mon
const daysUntilMon = ((1 - dow + 7) % 7) || 7;
return now.plus({ days: daysUntilMon }).set({ hour: 9, minute: 0, second: 0, millisecond: 0 }).toJSDate();
}
}
}
/**
* 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 = [
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd HH:mm:ss",
"yyyy/MM/dd HH:mm",
"dd/MM/yyyy HH:mm",
"dd-MM-yyyy HH:mm",
];
export function parseFreeText(input: string, timezone: string = DEFAULT_TIMEZONE): ParseResult {
const trimmed = input.trim();
for (const fmt of FORMATS) {
const dt = DateTime.fromFormat(trimmed, fmt, { zone: timezone });
if (dt.isValid) {
const jsDate = dt.toJSDate();
if (jsDate.getTime() <= Date.now()) {
return { ok: false, reason: "Time is in the past" };
}
return { ok: true, date: jsDate };
}
}
return { ok: false, reason: "Couldn't parse — try YYYY-MM-DD HH:MM (e.g. 2026-05-15 09:00)" };
}