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.
78 lines
2.7 KiB
TypeScript
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)" };
|
|
}
|