diff --git a/README-dev.md b/README-dev.md index a9ffb60..7385640 100644 --- a/README-dev.md +++ b/README-dev.md @@ -9,7 +9,6 @@ * [Adding commands](#adding-commands) * [Adding command options](#adding-command-options) * [Adding buttons](#adding-buttons) - * [Adding modals](#adding-modals) * [Util files](#util-files) * [Database changes](#database-changes) * [Misc](#misc) @@ -117,7 +116,7 @@ to the files that will need to be added/updated. ### Adding commands -1. Add a new `.command.ts` file under `src/commands/commands`, extending `EveryoneCommand` or `AdminCommand`. Subcommands are methods named `_` (e.g. `queues_add` for `/queues add`). Set `deferResponse = false` if the command opens a modal. +1. Add a new `.command.ts` file under `src/commands/commands`, extending `EveryoneCommand` or `AdminCommand`. Subcommands are methods named `_` (e.g. `queues_add` for `/queues add`). 2. Register it in `src/commands/commands.loader.ts`. 3. Update `README.md` and the help text in `src/commands/commands/help.command.ts`. @@ -131,11 +130,6 @@ to the files that will need to be added/updated. 1. Create a new `.button.ts` file in the `src/buttons/buttons` directory. Buttons should extend `EveryoneButton` or `AdminButton`. 2. Update the `src/buttons/buttons.loader.ts` file. -### Adding modals - -1. Create a new `.modal.ts` file in `src/modals` (see `join.modal.ts`). -2. The command opening the modal must set `deferResponse = false` — Discord rejects modals on deferred interactions. - ### Util files Non-trivial command/button logic should live in a `*.utils.ts` namespace under `src/utils`, keeping the command/button file thin. diff --git a/src/commands/commands/events.command.ts b/src/commands/commands/events.command.ts index a82b77f..bc2a7a9 100644 --- a/src/commands/commands/events.command.ts +++ b/src/commands/commands/events.command.ts @@ -4,7 +4,6 @@ import { findKey, isNil, omitBy } from "lodash-es"; import { Queries } from "../../db/queries.ts"; import { type DbEvent, EVENT_TABLE, QUEUE_TABLE } from "../../db/schema.ts"; -import { EventScheduleModal } from "../../modals/event-schedule.modal.ts"; import { AnnouncementChannelOption } from "../../options/options/announcement-channel.option.ts"; import { AnnouncementMessageOption } from "../../options/options/announcement-message.option.ts"; import { AutopullToggleOption } from "../../options/options/autopull-toggle.option.ts"; @@ -14,6 +13,7 @@ import { CleanupOffsetHoursOption } from "../../options/options/cleanup-offset-h import { ColorOption } from "../../options/options/color.option.ts"; import { CreateDiscordEventToggleOption } from "../../options/options/create-discord-event-toggle.option.ts"; import { CreateOffsetHoursOption } from "../../options/options/create-offset-hours.option.ts"; +import { DayOption } from "../../options/options/day.option.ts"; import { DiscordEventDescriptionOption } from "../../options/options/discord-event-description.option.ts"; import { ButtonsToggleOption } from "../../options/options/display-buttons.option.ts"; import { DisplayUpdateTypeOption } from "../../options/options/display-update-type.option.ts"; @@ -27,6 +27,7 @@ import { LockToggleOption } from "../../options/options/lock-toggle.option.ts"; import { MaxRoomsPerUserOption } from "../../options/options/max-rooms-per-user.option.ts"; import { MaxSubsPerUserOption } from "../../options/options/max-subs-per-user.option.ts"; import { MemberDisplayTypeOption } from "../../options/options/member-display-type.option.ts"; +import { MonthOption } from "../../options/options/month.option.ts"; import { NameOption } from "../../options/options/name.option.ts"; import { ParentSubMutuallyExclusiveOption } from "../../options/options/parent-sub-mutually-exclusive.option.ts"; import { PullBatchSizeOption } from "../../options/options/pull-batch-size.option.ts"; @@ -51,13 +52,17 @@ import { RoomSchedulingOption } from "../../options/options/room-scheduling.opti import { SizeOption } from "../../options/options/size.option.ts"; import { SlowmodeOption } from "../../options/options/slowmode.option.ts"; import { SlowmodeTimeOption } from "../../options/options/slowmode-time.option.ts"; +import { StartTimeOption } from "../../options/options/start-time.option.ts"; import { SubQueuesChannelOption } from "../../options/options/sub-queues-channel.option.ts"; import { TimestampTypeOption } from "../../options/options/timestamp-type.option.ts"; +import { TimezoneOption } from "../../options/options/timezone.option.ts"; import { VoiceDestinationChannelOption } from "../../options/options/voice-destination-channel.option.ts"; import { VoiceOnlyToggleOption } from "../../options/options/voice-only-toggle.option.ts"; +import { YearOption } from "../../options/options/year.option.ts"; import { AdminCommand } from "../../types/command.types.ts"; import { Color, EventQueueRole, type RoomScheduling } from "../../types/db.types.ts"; import type { SlashInteraction } from "../../types/interaction.types.ts"; +import { DateUtils } from "../../utils/date.utils.ts"; import { CustomError, EventNotFoundWarning } from "../../utils/error.utils.ts"; import { EventUtils } from "../../utils/event.utils.ts"; import { EventChannelUtils } from "../../utils/event-channel.utils.ts"; @@ -906,12 +911,54 @@ export class EventsCommand extends AdminCommand { static readonly SCHEDULE_OPTIONS = { event: new EventOption({ required: true, description: "Target event" }), + year: new YearOption({ required: true, description: "Start year" }), + month: new MonthOption({ required: true, description: "Start month" }), + day: new DayOption({ required: true, description: "Start day" }), + startTime: new StartTimeOption({ required: true, description: "Start time (12-hour, e.g. 9 AM, 9:30 PM)" }), + timezone: new TimezoneOption({ required: false, description: "IANA timezone", defaultValue: process.env.DEFAULT_SCHEDULE_TIMEZONE }), }; static async events_schedule(inter: SlashInteraction) { + await inter.deferReply(); + const event = await EventsCommand.SCHEDULE_OPTIONS.event.get(inter); EventUtils.assertHasRoomCategory(event); - await inter.showModal(EventScheduleModal.getModal({ eventId: event.id })); + + const yearStr = await EventsCommand.SCHEDULE_OPTIONS.year.get(inter); + const monthStr = await EventsCommand.SCHEDULE_OPTIONS.month.get(inter); + const dayStr = await EventsCommand.SCHEDULE_OPTIONS.day.get(inter); + const startTime = await EventsCommand.SCHEDULE_OPTIONS.startTime.get(inter); + const timezoneRaw = await EventsCommand.SCHEDULE_OPTIONS.timezone.get(inter); + + const parsed = DateUtils.parseScheduledStart({ + yearStr, + monthStr, + dayStr, + startTime, + timezone: timezoneRaw || process.env.DEFAULT_SCHEDULE_TIMEZONE || "UTC", + }); + + const startTimeMs = BigInt(parsed.valueOf()); + const occurrence = await EventUtils.scheduleOccurrence( + inter.store, + event, + startTimeMs, + timezoneRaw || undefined, + ); + + const startDate = new Date(Number(occurrence.startTime)); + const embed = new EmbedBuilder() + .setTitle(`Scheduled ${event.name}`) + .setColor(Color.Green) + .setDescription( + `Occurrence scheduled for ${time(startDate, TimestampStyles.LongDateTime)} (${time(startDate, TimestampStyles.RelativeTime)}).\n\n` + + `**Event:** ${eventMention(event)}\n` + + `**Opens:** ${time(new Date(Number(occurrence.startTime) - Number(event.createOffsetMs)), TimestampStyles.RelativeTime)}\n` + + `**Locks rooms:** ${time(new Date(Number(occurrence.startTime) + Number(event.lockOffsetMs)), TimestampStyles.RelativeTime)}\n` + + `**Cleans up:** ${time(new Date(EventUtils.getRoomsFinishMs(event, Number(occurrence.startTime)) + Number(event.cleanupOffsetMs)), TimestampStyles.RelativeTime)}`, + ); + + await inter.respond({ embeds: [embed] }); } // ==================================================================== @@ -997,7 +1044,7 @@ export class EventsCommand extends AdminCommand { "**Quick start:**\n" + `1. ${commandMention("events", "add")} — create an event with N rooms (${inlineCode("room_category")} is required; one private \`room-{N}\` channel and a \`{event} Room {N}\` role are auto-created per room)\n` + `2. ${commandMention("events", "set-room-defaults")} — configure room queue defaults (size, etc.)\n` + - `3. ${commandMention("events", "schedule")} — schedule an occurrence (opens a date/time modal)\n\n` + + `3. ${commandMention("events", "schedule")} — schedule an occurrence (\`event\`, \`year\`, \`month\`, \`day\`, \`start_time\` (12-hour, e.g. \`9:30 PM\`), optional \`timezone\`)\n\n` + "**Lifecycle per occurrence:**\n" + "- **T − create_offset** (default 24h before): queues unlock, displays refresh, announcement posts\n" + "- **T + lock_offset** (default 0): room queues lock (sub queues stay open)\n" + diff --git a/src/handlers/modal.handler.ts b/src/handlers/modal.handler.ts index eadd414..51e582e 100644 --- a/src/handlers/modal.handler.ts +++ b/src/handlers/modal.handler.ts @@ -1,6 +1,5 @@ import type { InteractionReplyOptions } from "discord.js"; -import { EventScheduleModal } from "../modals/event-schedule.modal.ts"; import { JoinModal } from "../modals/join.modal.ts"; import type { Handler } from "../types/handler.types.ts"; import type { BaseInteraction, ModalInteraction } from "../types/interaction.types.ts"; @@ -16,10 +15,7 @@ export class ModalHandler implements Handler { async handle() { this.inter.respond = (message: InteractionReplyOptions | string, log = false) => InteractionUtils.respond(this.inter, false, message, log); - if (this.inter.customId.startsWith(EventScheduleModal.ID)) { - await EventScheduleModal.handle(this.inter); - } - else if (this.inter.customId.startsWith(JoinModal.ID)) { + if (this.inter.customId.startsWith(JoinModal.ID)) { await JoinModal.handle(this.inter); } } diff --git a/src/modals/event-schedule.modal.ts b/src/modals/event-schedule.modal.ts deleted file mode 100644 index 2c85ba7..0000000 --- a/src/modals/event-schedule.modal.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { ActionRowBuilder, EmbedBuilder, type ModalActionRowComponentBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, time, TimestampStyles } from "discord.js"; -import moment from "moment-timezone"; - -import { Queries } from "../db/queries.ts"; -import { Color } from "../types/db.types.ts"; -import type { ModalInteraction } from "../types/interaction.types.ts"; -import { CustomError } from "../utils/error.utils.ts"; -import { EventUtils } from "../utils/event.utils.ts"; -import { ModalUtils } from "../utils/modal.utils.ts"; -import { eventMention } from "../utils/string.utils.ts"; - -export namespace EventScheduleModal { - export const ID = "event-schedule"; - - const DATE_FIELD_ID = "date"; - const TIME_FIELD_ID = "time"; - const TIMEZONE_FIELD_ID = "timezone"; - - export function getModal({ eventId }: { eventId: bigint }) { - const customId = ModalUtils.encodeCustomId(ID, eventId); - const modal = new ModalBuilder() - .setCustomId(customId) - .setTitle("Schedule Event Occurrence"); - - const dateInput = new TextInputBuilder() - .setCustomId(DATE_FIELD_ID) - .setLabel("Start date") - .setPlaceholder("YYYY-MM-DD") - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(10) - .setMaxLength(10); - - const timeInput = new TextInputBuilder() - .setCustomId(TIME_FIELD_ID) - .setLabel("Start time (24-hour)") - .setPlaceholder("HH:MM") - .setStyle(TextInputStyle.Short) - .setRequired(true) - .setMinLength(5) - .setMaxLength(5); - - const timezoneInput = new TextInputBuilder() - .setCustomId(TIMEZONE_FIELD_ID) - .setLabel("Timezone (optional)") - .setPlaceholder(process.env.DEFAULT_SCHEDULE_TIMEZONE ?? "UTC") - .setStyle(TextInputStyle.Short) - .setRequired(false) - .setMaxLength(64); - - modal.addComponents( - new ActionRowBuilder().addComponents(dateInput), - new ActionRowBuilder().addComponents(timeInput), - new ActionRowBuilder().addComponents(timezoneInput), - ); - - return modal; - } - - export async function handle(inter: ModalInteraction) { - const { queueId: eventId } = ModalUtils.decodeCustomId(inter.customId); - - const event = Queries.selectEvent({ guildId: inter.store.guild.id, id: eventId }); - if (!event) { - throw new CustomError({ message: "Event not found" }); - } - - const dateStr = inter.fields.getTextInputValue(DATE_FIELD_ID); - const timeStr = inter.fields.getTextInputValue(TIME_FIELD_ID); - let timezoneStr: string; - try { - timezoneStr = inter.fields.getTextInputValue(TIMEZONE_FIELD_ID); - } - catch (e) { - console.error(`EventScheduleModal.handle: timezone field missing, defaulting to "":`, e); - timezoneStr = ""; - } - - const tz = timezoneStr || process.env.DEFAULT_SCHEDULE_TIMEZONE || "UTC"; - const parsed = moment.tz(`${dateStr} ${timeStr}`, "YYYY-MM-DD HH:mm", tz); - - if (!parsed.isValid()) { - throw new CustomError({ message: "Invalid date/time. Use YYYY-MM-DD and HH:MM formats." }); - } - - const startTimeMs = BigInt(parsed.valueOf()); - - const occurrence = await EventUtils.scheduleOccurrence( - inter.store, - event, - startTimeMs, - timezoneStr || undefined, - ); - - const startDate = new Date(Number(occurrence.startTime)); - const embed = new EmbedBuilder() - .setTitle(`Scheduled ${event.name}`) - .setColor(Color.Green) - .setDescription( - `Occurrence scheduled for ${time(startDate, TimestampStyles.LongDateTime)} (${time(startDate, TimestampStyles.RelativeTime)}).\n\n` + - `**Event:** ${eventMention(event)}\n` + - `**Opens:** ${time(new Date(Number(occurrence.startTime) - Number(event.createOffsetMs)), TimestampStyles.RelativeTime)}\n` + - `**Locks rooms:** ${time(new Date(Number(occurrence.startTime) + Number(event.lockOffsetMs)), TimestampStyles.RelativeTime)}\n` + - `**Cleans up:** ${time(new Date(EventUtils.getRoomsFinishMs(event, Number(occurrence.startTime)) + Number(event.cleanupOffsetMs)), TimestampStyles.RelativeTime)}`, - ); - - await inter.respond({ embeds: [embed] }); - } -} diff --git a/src/options/options.loader.ts b/src/options/options.loader.ts index 391a0dc..6366491 100644 --- a/src/options/options.loader.ts +++ b/src/options/options.loader.ts @@ -17,6 +17,7 @@ import { CreateDiscordEventToggleOption } from "./options/create-discord-event-t import { CreateOffsetHoursOption } from "./options/create-offset-hours.option.ts"; import { CronOption } from "./options/cron.option.ts"; import { CustomCronOption } from "./options/custom-cron.option.ts"; +import { DayOption } from "./options/day.option.ts"; import { DiscordEventDescriptionOption } from "./options/discord-event-description.option.ts"; import { DisplayOption } from "./options/display.option.ts"; import { ButtonsToggleOption } from "./options/display-buttons.option.ts"; @@ -42,6 +43,7 @@ import { MembersOption } from "./options/members.option.ts"; import { MentionableOption } from "./options/mentionable.option.ts"; import { MessageOption } from "./options/message.option.ts"; import { MessageChannelOption } from "./options/message-channel.option.ts"; +import { MonthOption } from "./options/month.option.ts"; import { NameOption } from "./options/name.option.ts"; import { NumberOption } from "./options/number.option.ts"; import { ParentSubMutuallyExclusiveOption } from "./options/parent-sub-mutually-exclusive.option.ts"; @@ -76,6 +78,7 @@ import { ScopeOption } from "./options/scope.option.ts"; import { SizeOption } from "./options/size.option.ts"; import { SlowmodeOption } from "./options/slowmode.option.ts"; import { SlowmodeTimeOption } from "./options/slowmode-time.option.ts"; +import { StartTimeOption } from "./options/start-time.option.ts"; import { SubQueuesChannelOption } from "./options/sub-queues-channel.option.ts"; import { TimestampTypeOption } from "./options/timestamp-type.option.ts"; import { TimezoneOption } from "./options/timezone.option.ts"; @@ -86,6 +89,7 @@ import { VoiceSourceChannelOption } from "./options/voice-source-channel.option. import { VoicesOption } from "./options/voices.option.ts"; import { WhitelistedOption } from "./options/whitelisted.option.ts"; import { WhitelistedsOption } from "./options/whitelisteds.option.ts"; +import { YearOption } from "./options/year.option.ts"; export const OPTIONS = new Collection([ [AdminOption.ID, new AdminOption()], @@ -105,6 +109,7 @@ export const OPTIONS = new Collection([ [CreateOffsetHoursOption.ID, new CreateOffsetHoursOption()], [CronOption.ID, new CronOption()], [CustomCronOption.ID, new CustomCronOption()], + [DayOption.ID, new DayOption()], [DiscordEventDescriptionOption.ID, new DiscordEventDescriptionOption()], [DisplayOption.ID, new DisplayOption()], [DisplaysOption.ID, new DisplaysOption()], @@ -129,6 +134,7 @@ export const OPTIONS = new Collection([ [MentionableOption.ID, new MentionableOption()], [MessageChannelOption.ID, new MessageChannelOption()], [MessageOption.ID, new MessageOption()], + [MonthOption.ID, new MonthOption()], [NameOption.ID, new NameOption()], [NumberOption.ID, new NumberOption()], [ParentSubMutuallyExclusiveOption.ID, new ParentSubMutuallyExclusiveOption()], @@ -163,6 +169,7 @@ export const OPTIONS = new Collection([ [SizeOption.ID, new SizeOption()], [SlowmodeOption.ID, new SlowmodeOption()], [SlowmodeTimeOption.ID, new SlowmodeTimeOption()], + [StartTimeOption.ID, new StartTimeOption()], [SubQueuesChannelOption.ID, new SubQueuesChannelOption()], [TimestampTypeOption.ID, new TimestampTypeOption()], [TimezoneOption.ID, new TimezoneOption()], @@ -174,4 +181,5 @@ export const OPTIONS = new Collection([ [VoiceOnlyToggleOption.ID, new VoiceOnlyToggleOption()], [WhitelistedOption.ID, new WhitelistedOption()], [WhitelistedsOption.ID, new WhitelistedsOption()], + [YearOption.ID, new YearOption()], ]); diff --git a/src/options/options/day.option.ts b/src/options/options/day.option.ts new file mode 100644 index 0000000..d9dce17 --- /dev/null +++ b/src/options/options/day.option.ts @@ -0,0 +1,43 @@ +import moment from "moment-timezone"; + +import type { UIOption } from "../../types/handler.types.ts"; +import type { AutocompleteInteraction, SlashInteraction } from "../../types/interaction.types.ts"; +import { type AutoCompleteOptions, CustomOption } from "../base-option.ts"; + +export class DayOption extends CustomOption { + static readonly ID = "day"; + id = DayOption.ID; + required = true; + + getAutocompletions = DayOption.getAutocompletions; + + // force return type to be string + get(inter: AutocompleteInteraction | SlashInteraction) { + return super.get(inter) as Promise; + } + + protected async getUncached(inter: AutocompleteInteraction | SlashInteraction) { + return inter.options.getString(DayOption.ID); + } + + static async getAutocompletions(options: AutoCompleteOptions): Promise { + const { inter, lowerSearchText } = options; + const yearStr = inter.options.getString("year"); + const monthStr = inter.options.getString("month"); + + let daysInMonth = 31; + const year = Number(yearStr); + const month = Number(monthStr); + if (Number.isInteger(year) && Number.isInteger(month) && month >= 1 && month <= 12) { + daysInMonth = moment({ year, month: month - 1 }).daysInMonth(); + } + + const days: string[] = []; + for (let d = 1; d <= daysInMonth; d++) { + days.push(String(d)); + } + return days + .filter(d => d.includes(lowerSearchText)) + .map(d => ({ name: d, value: d })); + } +} diff --git a/src/options/options/month.option.ts b/src/options/options/month.option.ts new file mode 100644 index 0000000..c8d323e --- /dev/null +++ b/src/options/options/month.option.ts @@ -0,0 +1,30 @@ +import moment from "moment-timezone"; + +import type { UIOption } from "../../types/handler.types.ts"; +import type { AutocompleteInteraction, SlashInteraction } from "../../types/interaction.types.ts"; +import { type AutoCompleteOptions, CustomOption } from "../base-option.ts"; + +export class MonthOption extends CustomOption { + static readonly ID = "month"; + id = MonthOption.ID; + required = true; + + getAutocompletions = MonthOption.getAutocompletions; + + // force return type to be string + get(inter: AutocompleteInteraction | SlashInteraction) { + return super.get(inter) as Promise; + } + + protected async getUncached(inter: AutocompleteInteraction | SlashInteraction) { + return inter.options.getString(MonthOption.ID); + } + + static async getAutocompletions(options: AutoCompleteOptions): Promise { + const { lowerSearchText } = options; + const monthNames = moment.months(); + return monthNames + .map((name, index) => ({ name, value: String(index + 1) })) + .filter(entry => entry.name.toLowerCase().includes(lowerSearchText)); + } +} diff --git a/src/options/options/start-time.option.ts b/src/options/options/start-time.option.ts new file mode 100644 index 0000000..8e75e36 --- /dev/null +++ b/src/options/options/start-time.option.ts @@ -0,0 +1,56 @@ +import type { UIOption } from "../../types/handler.types.ts"; +import type { AutocompleteInteraction, SlashInteraction } from "../../types/interaction.types.ts"; +import { type AutoCompleteOptions, CustomOption } from "../base-option.ts"; + +const QUARTER_HOUR_MINUTES = ["00", "15", "30", "45"]; +const DEFAULT_START_HOURS_24 = [8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22]; + +function to12Hour(hour24: number): { hour12: number; meridiem: "AM" | "PM" } { + const meridiem = hour24 >= 12 ? "PM" : "AM"; + const hour12 = ((hour24 + 11) % 12) + 1; + return { hour12, meridiem }; +} + +export class StartTimeOption extends CustomOption { + static readonly ID = "start_time"; + id = StartTimeOption.ID; + required = true; + + getAutocompletions = StartTimeOption.getAutocompletions; + + // force return type to be string + get(inter: AutocompleteInteraction | SlashInteraction) { + return super.get(inter) as Promise; + } + + protected async getUncached(inter: AutocompleteInteraction | SlashInteraction) { + return inter.options.getString(StartTimeOption.ID); + } + + static async getAutocompletions(options: AutoCompleteOptions): Promise { + const { lowerSearchText } = options; + + if (!lowerSearchText) { + const defaults: string[] = []; + for (const hour24 of DEFAULT_START_HOURS_24) { + const { hour12, meridiem } = to12Hour(hour24); + defaults.push(`${hour12} ${meridiem}`); + } + return defaults.map(v => ({ name: v, value: v })); + } + + const candidates: string[] = []; + for (let hour12 = 1; hour12 <= 12; hour12++) { + for (const meridiem of ["AM", "PM"]) { + candidates.push(`${hour12} ${meridiem}`); + for (const minute of QUARTER_HOUR_MINUTES) { + candidates.push(`${hour12}:${minute} ${meridiem}`); + } + } + } + + return candidates + .filter(c => c.toLowerCase().includes(lowerSearchText)) + .map(v => ({ name: v, value: v })); + } +} diff --git a/src/options/options/year.option.ts b/src/options/options/year.option.ts new file mode 100644 index 0000000..7834cb4 --- /dev/null +++ b/src/options/options/year.option.ts @@ -0,0 +1,34 @@ +import moment from "moment-timezone"; + +import type { UIOption } from "../../types/handler.types.ts"; +import type { AutocompleteInteraction, SlashInteraction } from "../../types/interaction.types.ts"; +import { type AutoCompleteOptions, CustomOption } from "../base-option.ts"; + +export class YearOption extends CustomOption { + static readonly ID = "year"; + id = YearOption.ID; + required = true; + + getAutocompletions = YearOption.getAutocompletions; + + // force return type to be string + get(inter: AutocompleteInteraction | SlashInteraction) { + return super.get(inter) as Promise; + } + + protected async getUncached(inter: AutocompleteInteraction | SlashInteraction) { + return inter.options.getString(YearOption.ID); + } + + static async getAutocompletions(options: AutoCompleteOptions): Promise { + const { lowerSearchText } = options; + const currentYear = moment().year(); + const years: string[] = []; + for (let i = 0; i <= 5; i++) { + years.push(String(currentYear + i)); + } + return years + .filter(y => y.includes(lowerSearchText)) + .map(y => ({ name: y, value: y })); + } +} diff --git a/src/utils/date.utils.ts b/src/utils/date.utils.ts new file mode 100644 index 0000000..669cc60 --- /dev/null +++ b/src/utils/date.utils.ts @@ -0,0 +1,40 @@ +import moment from "moment-timezone"; + +import { CustomError } from "./error.utils.ts"; + +const START_TIME_REGEX = /^(1[0-2]|[1-9])(:[0-5][0-9])? ?(AM|PM)$/i; + +export namespace DateUtils { + export function parseScheduledStart(args: { + yearStr: string; + monthStr: string; + dayStr: string; + startTime: string; + timezone: string; + }) { + const year = Number(args.yearStr); + const month = Number(args.monthStr); + const day = Number(args.dayStr); + if (!Number.isInteger(year) || !Number.isInteger(month) || !Number.isInteger(day)) { + throw new CustomError({ message: "Year, month, and day must be whole numbers. Pick a value from the autocomplete list." }); + } + + const match = START_TIME_REGEX.exec(args.startTime); + if (!match) { + throw new CustomError({ + message: "Invalid start time. Use a 12-hour value like `9 AM`, `9AM`, `9:00 AM`, or `9:30 PM`.", + }); + } + const hour = match[1]; + const minutes = (match[2] ?? ":00").slice(1); + const meridiem = match[3].toUpperCase(); + const normalizedTime = `${hour}:${minutes} ${meridiem}`; + + const parsed = moment.tz(`${year}-${month}-${day} ${normalizedTime}`, "YYYY-M-D h:mm A", args.timezone); + if (!parsed.isValid()) { + throw new CustomError({ message: `Invalid date/time for ${year}-${month}-${day} ${normalizedTime} in ${args.timezone}.` }); + } + + return parsed; + } +}