feat(bot): more day options + free-text date input
Day picker was limited to ≤1 month. Two enhancements after live testing:
- Add +2 months and +3 months presets
- Add a "📝 Specific date…" option that prompts the operator to type
YYYY-MM-DD; the bot validates, computes the day-offset, and continues
straight to the hour picker (rest of the wizard unchanged)
Lets the operator schedule reminders at arbitrary future dates without
expanding the preset list to absurd lengths.
This commit is contained in:
parent
689891dd87
commit
f5666a9d2c
@ -51,6 +51,30 @@ export function formatCustomDay(dayOffset: number, timezone: string = DEFAULT_TI
|
|||||||
return dt.toFormat("EEE dd MMM");
|
return dt.toFormat("EEE dd MMM");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a typed YYYY-MM-DD string into a "day offset from today" relative to
|
||||||
|
* the operator's timezone. Returns the offset in days, or null if invalid /
|
||||||
|
* in the past. We return offset rather than a Date so the rest of the picker
|
||||||
|
* (hour, minute) works the same way as the preset-day path.
|
||||||
|
*/
|
||||||
|
export function parseTypedDate(
|
||||||
|
input: string,
|
||||||
|
timezone: string = DEFAULT_TIMEZONE,
|
||||||
|
): { ok: true; dayOffset: number; label: string } | { ok: false; reason: string } {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const dt = DateTime.fromFormat(trimmed, "yyyy-MM-dd", { zone: timezone });
|
||||||
|
if (!dt.isValid) {
|
||||||
|
return { ok: false, reason: "Couldn't parse — use YYYY-MM-DD, e.g. 2026-12-25" };
|
||||||
|
}
|
||||||
|
const today = DateTime.now().setZone(timezone).startOf("day");
|
||||||
|
const targetDay = dt.startOf("day");
|
||||||
|
const diffDays = Math.round(targetDay.diff(today, "days").days);
|
||||||
|
if (diffDays < 0) {
|
||||||
|
return { ok: false, reason: "That date is in the past" };
|
||||||
|
}
|
||||||
|
return { ok: true, dayOffset: diffDays, label: targetDay.toFormat("EEE dd MMM yyyy") };
|
||||||
|
}
|
||||||
|
|
||||||
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 = [
|
||||||
|
|||||||
@ -36,6 +36,7 @@ import {
|
|||||||
wizardPickDay,
|
wizardPickDay,
|
||||||
wizardPickHour,
|
wizardPickHour,
|
||||||
wizardPickMinute,
|
wizardPickMinute,
|
||||||
|
wizardTypeDatePrompt,
|
||||||
} from "./callbacks.js";
|
} from "./callbacks.js";
|
||||||
import {
|
import {
|
||||||
consumePendingPairLabel,
|
consumePendingPairLabel,
|
||||||
@ -131,6 +132,7 @@ export function createTelegramBot(): Bot {
|
|||||||
await wizardSetTimeQuick(ctx, choice as Quick);
|
await wizardSetTimeQuick(ctx, choice as Quick);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
bot.callbackQuery("rmd:type", wizardTypeDatePrompt);
|
||||||
bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => {
|
bot.callbackQuery(/^rmd:(\d+)$/, async (ctx) => {
|
||||||
await wizardPickDay(ctx, Number(ctx.match[1]));
|
await wizardPickDay(ctx, Number(ctx.match[1]));
|
||||||
});
|
});
|
||||||
@ -179,8 +181,9 @@ export function createTelegramBot(): Bot {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reminder wizard — only the compose step accepts free text now.
|
// Reminder wizard:
|
||||||
// Custom date/time is fully menu-driven (rmd → rmh → rmm callbacks).
|
// compose step: free-text body (or media in the photo/video/doc handler)
|
||||||
|
// custom_date_input step: typed YYYY-MM-DD that gets parsed into a day-offset
|
||||||
const w = getWizard(tgId);
|
const w = getWizard(tgId);
|
||||||
if (w && w.step === "compose") {
|
if (w && w.step === "compose") {
|
||||||
updateWizard(tgId, { text: text.trim() });
|
updateWizard(tgId, { text: text.trim() });
|
||||||
@ -188,6 +191,24 @@ export function createTelegramBot(): Bot {
|
|||||||
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 && w.step === "custom_date_input") {
|
||||||
|
const op = await db.query.operators.findFirst({
|
||||||
|
where: (o, { eq }) => eq(o.telegramUserId, tgId),
|
||||||
|
});
|
||||||
|
const tz = op?.defaultTimezone ?? DEFAULT_TIMEZONE;
|
||||||
|
const { parseTypedDate, formatCustomDay } = await import("../reminders/time-parsing.js");
|
||||||
|
const { reminderPickHourMenu } = await import("./menus.js");
|
||||||
|
const parsed = parseTypedDate(text, tz);
|
||||||
|
if (!parsed.ok) {
|
||||||
|
await ctx.reply(`❌ ${parsed.reason}\n\nTry again or tap /menu to cancel.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Date accepted — drop the input flag and advance to hour picker
|
||||||
|
updateWizard(tgId, { step: "compose" /* not really compose; just exits the input state */ });
|
||||||
|
const view = reminderPickHourMenu(formatCustomDay(parsed.dayOffset, tz), parsed.dayOffset);
|
||||||
|
await ctx.reply(view.text, { reply_markup: view.keyboard });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.reply("Tap /menu to see what I can do.");
|
await ctx.reply("Tap /menu to see what I can do.");
|
||||||
});
|
});
|
||||||
|
|||||||
@ -399,6 +399,15 @@ export async function wizardPickDay(ctx: Context, dayOffset: number): Promise<vo
|
|||||||
await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset));
|
await showMenu(ctx, reminderPickHourMenu(formatCustomDay(dayOffset, tz), dayOffset));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function wizardTypeDatePrompt(ctx: Context): Promise<void> {
|
||||||
|
await ctx.answerCallbackQuery();
|
||||||
|
const userId = ctx.from?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
updateWizard(userId, { step: "custom_date_input" });
|
||||||
|
const { reminderTypeDateMenu } = await import("./menus.js");
|
||||||
|
await showMenu(ctx, reminderTypeDateMenu());
|
||||||
|
}
|
||||||
|
|
||||||
export async function wizardPickHour(
|
export async function wizardPickHour(
|
||||||
ctx: Context,
|
ctx: Context,
|
||||||
dayOffset: number,
|
dayOffset: number,
|
||||||
|
|||||||
@ -377,7 +377,7 @@ export function reminderTimeMenu(): MenuView {
|
|||||||
// All choices are encoded into callback_data so the wizard state stays simple.
|
// All choices are encoded into callback_data so the wizard state stays simple.
|
||||||
|
|
||||||
export function reminderPickDayMenu(timezone: string): MenuView {
|
export function reminderPickDayMenu(timezone: string): MenuView {
|
||||||
const offsets = [0, 1, 2, 3, 4, 5, 7, 14, 30];
|
const offsets = [0, 1, 2, 3, 4, 5, 7, 14, 30, 60, 90];
|
||||||
const labels: Record<number, string> = {
|
const labels: Record<number, string> = {
|
||||||
0: "Today",
|
0: "Today",
|
||||||
1: "Tomorrow",
|
1: "Tomorrow",
|
||||||
@ -388,6 +388,8 @@ export function reminderPickDayMenu(timezone: string): MenuView {
|
|||||||
7: "+1 week",
|
7: "+1 week",
|
||||||
14: "+2 weeks",
|
14: "+2 weeks",
|
||||||
30: "+1 month",
|
30: "+1 month",
|
||||||
|
60: "+2 months",
|
||||||
|
90: "+3 months",
|
||||||
};
|
};
|
||||||
const keyboard = new InlineKeyboard();
|
const keyboard = new InlineKeyboard();
|
||||||
// 2 columns
|
// 2 columns
|
||||||
@ -400,6 +402,7 @@ export function reminderPickDayMenu(timezone: string): MenuView {
|
|||||||
}
|
}
|
||||||
keyboard.row();
|
keyboard.row();
|
||||||
}
|
}
|
||||||
|
keyboard.text("📝 Specific date…", "rmd:type").row();
|
||||||
keyboard.text("⬅ Back", "rm_t:back");
|
keyboard.text("⬅ Back", "rm_t:back");
|
||||||
// Plain text — IANA timezone names contain `_` which Markdown reads as italic.
|
// Plain text — IANA timezone names contain `_` which Markdown reads as italic.
|
||||||
return {
|
return {
|
||||||
@ -409,6 +412,17 @@ export function reminderPickDayMenu(timezone: string): MenuView {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function reminderTypeDateMenu(): MenuView {
|
||||||
|
const keyboard = new InlineKeyboard().text("⬅ Back", "rm_t:custom");
|
||||||
|
return {
|
||||||
|
text:
|
||||||
|
"📆 Type a date\n\nReply with a date in YYYY-MM-DD format, e.g. 2026-12-25.\n\n" +
|
||||||
|
"I'll then ask for the hour and minute.",
|
||||||
|
keyboard,
|
||||||
|
parseMode: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function reminderPickHourMenu(dayLabel: string, dayOffset: number): MenuView {
|
export function reminderPickHourMenu(dayLabel: string, dayOffset: number): MenuView {
|
||||||
const keyboard = new InlineKeyboard();
|
const keyboard = new InlineKeyboard();
|
||||||
// 4 rows of 6 hours, ordered to put daytime hours first for ergonomics:
|
// 4 rows of 6 hours, ordered to put daytime hours first for ergonomics:
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export type WizardStep =
|
|||||||
| "pick_account"
|
| "pick_account"
|
||||||
| "pick_group"
|
| "pick_group"
|
||||||
| "compose"
|
| "compose"
|
||||||
|
| "custom_date_input"
|
||||||
| "set_time"
|
| "set_time"
|
||||||
| "confirm";
|
| "confirm";
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user