Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion data/timezones.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
{
"366895110027214858": "EST"
"version": 1,
"updatedAt": "2026-01-17T01:18:58.565Z",
"zones": {
"global:366895110027214858": {
"version": 1,
"userId": "366895110027214858",
"guildId": null,
"timezone": "America/New_York",
"label": "ET",
"createdAt": "2026-01-17T01:18:58.565Z",
"updatedAt": "2026-01-17T01:18:58.565Z"
}
}
}
135 changes: 104 additions & 31 deletions src/commands/timezone/timezone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ export const data = new SlashCommandBuilder()
.addSubcommand((s) =>
s
.setName("set")
.setDescription("Save your timezone (IANA like America/New_York or alias like EST)")
.setDescription(
"Save your timezone (IANA like America/New_York or short name like ET)",
)
.addStringOption((o) =>
o
.setName("zone")
.setDescription("IANA zone (America/New_York) or alias (EST, CST, PST)")
.setDescription('Examples: "US Eastern", "ET", "America/New_York"')
.setRequired(true),
)
.addBooleanOption((o) =>
Expand Down Expand Up @@ -87,7 +89,9 @@ export const data = new SlashCommandBuilder()
.addStringOption((o) =>
o
.setName("to")
.setDescription("Target zone (IANA or alias like PST)")
.setDescription(
'Target zone (examples: "US Pacific", "PT", "America/Los_Angeles")',
)
.setRequired(true),
)
.addStringOption((o) =>
Expand All @@ -108,7 +112,6 @@ export const data = new SlashCommandBuilder()
export async function execute(interaction: ChatInputCommandInteraction): Promise<void> {
const sub = interaction.options.getSubcommand(true);

// Most timezone ops should be ephemeral to avoid channel noise.
await interaction.deferReply({ ephemeral: true });

try {
Expand All @@ -126,20 +129,54 @@ export async function execute(interaction: ChatInputCommandInteraction): Promise
}

/* -------------------------------------------------------------------------- */
/* IMPLEMENTATION */
/* Timezone input normalization */
/* -------------------------------------------------------------------------- */

const ALIAS_TO_IANA: Record<string, { tz: string; label: string }> = {
est: { tz: "America/New_York", label: "EST" },
edt: { tz: "America/New_York", label: "EDT" },
cst: { tz: "America/Chicago", label: "CST" },
cdt: { tz: "America/Chicago", label: "CDT" },
mst: { tz: "America/Denver", label: "MST" },
mdt: { tz: "America/Denver", label: "MDT" },
pst: { tz: "America/Los_Angeles", label: "PST" },
pdt: { tz: "America/Los_Angeles", label: "PDT" },
gmt: { tz: "Etc/UTC", label: "GMT" },
/**
* Small curated set of friendly names -> IANA zones.
* This avoids exposing the full IANA list while covering common needs.
*/
const COMMON_TIMEZONES: Array<{ name: string; tz: string; label?: string }> = [
{ name: "US Eastern", tz: "America/New_York", label: "ET" },
{ name: "US Central", tz: "America/Chicago", label: "CT" },
{ name: "US Mountain", tz: "America/Denver", label: "MT" },
{ name: "US Pacific", tz: "America/Los_Angeles", label: "PT" },

{ name: "UTC", tz: "Etc/UTC", label: "UTC" },

{ name: "UK", tz: "Europe/London" },
{ name: "Central Europe", tz: "Europe/Berlin" },

{ name: "India", tz: "Asia/Kolkata" },
{ name: "Japan", tz: "Asia/Tokyo" },
{ name: "Australia East", tz: "Australia/Sydney" },
];

/**
* Common abbreviations people actually type.
* Note: abbreviations are ambiguous globally, but this is a pragmatic bot UX choice.
*/
const ALIAS_TO_IANA: Record<string, { tz: string; label?: string }> = {
// US / common
et: { tz: "America/New_York", label: "ET" },
est: { tz: "America/New_York", label: "ET" },
edt: { tz: "America/New_York", label: "ET" },

ct: { tz: "America/Chicago", label: "CT" },
cst: { tz: "America/Chicago", label: "CT" },
cdt: { tz: "America/Chicago", label: "CT" },

mt: { tz: "America/Denver", label: "MT" },
mst: { tz: "America/Denver", label: "MT" },
mdt: { tz: "America/Denver", label: "MT" },

pt: { tz: "America/Los_Angeles", label: "PT" },
pst: { tz: "America/Los_Angeles", label: "PT" },
pdt: { tz: "America/Los_Angeles", label: "PT" },

// UTC-ish
utc: { tz: "Etc/UTC", label: "UTC" },
gmt: { tz: "Etc/UTC", label: "UTC" },
};

function isValidIanaZone(tz: string): boolean {
Expand All @@ -151,14 +188,34 @@ function isValidIanaZone(tz: string): boolean {
}
}

function normalizeKey(s: string): string {
return s.trim().toLowerCase().replace(/\s+/g, " ");
}

function normalizeZoneInput(raw: string): { tz: string; label?: string } | null {
const v = raw.trim();
if (!v) return null;

const key = v.toLowerCase();
const hit = ALIAS_TO_IANA[key];
if (hit) return { tz: hit.tz, label: hit.label };
const key = normalizeKey(v);

// Friendly names: "us eastern", "central europe", etc.
for (const z of COMMON_TIMEZONES) {
if (normalizeKey(z.name) === key) return { tz: z.tz, label: z.label };
}

// Allow a couple shorthand friendly variants people type
if (key === "eastern" || key === "east") return { tz: "America/New_York", label: "ET" };
if (key === "central" || key === "midwest")
return { tz: "America/Chicago", label: "CT" };
if (key === "mountain") return { tz: "America/Denver", label: "MT" };
if (key === "pacific" || key === "west")
return { tz: "America/Los_Angeles", label: "PT" };

// Abbreviations: "ET", "PST", etc.
const alias = ALIAS_TO_IANA[key.replace(/\./g, "")];
if (alias) return { tz: alias.tz, label: alias.label };

// Power user path: accept IANA directly
if (isValidIanaZone(v)) return { tz: v };

return null;
Expand Down Expand Up @@ -227,6 +284,21 @@ async function getTzOrNull(
return getUserTimezone({ userId, guildId, scope });
}

function shortHint(): string {
return [
"Examples:",
'`/timezone set zone:"US Eastern"`',
"`/timezone set zone:ET`",
"`/timezone set zone:America/New_York`",
"",
"Common zones: US Eastern, US Central, US Mountain, US Pacific, UTC, UK, Central Europe, India, Japan, Australia East",
].join("\n");
}

/* -------------------------------------------------------------------------- */
/* Handlers */
/* -------------------------------------------------------------------------- */

async function handleSet(interaction: ChatInputCommandInteraction): Promise<void> {
const raw = interaction.options.getString("zone", true);
const guildFlag = interaction.options.getBoolean("guild") ?? false;
Expand All @@ -235,7 +307,7 @@ async function handleSet(interaction: ChatInputCommandInteraction): Promise<void
const normalized = normalizeZoneInput(raw);
if (!normalized) {
await interaction.editReply(
"I could not understand that timezone. Use an IANA zone like `America/New_York` or an alias like `EST`, `CST`, `PST`.",
["I could not understand that timezone.", "", shortHint()].join("\n"),
);
return;
}
Expand All @@ -258,7 +330,7 @@ async function handleSet(interaction: ChatInputCommandInteraction): Promise<void
"Saved your timezone.",
"",
`Zone: ${saved.timezone}${saved.label ? ` (${saved.label})` : ""}`,
`Now: ${local} ${offset}`,
`Now: ${local} | ${offset}`,
`Scope: ${scope === "guild" ? "this server" : "global"}`,
].join("\n"),
);
Expand All @@ -270,9 +342,7 @@ async function handleShow(interaction: ChatInputCommandInteraction): Promise<voi

const tz = await getTzOrNull(interaction, interaction.user.id, scope);
if (!tz) {
await interaction.editReply(
"No timezone saved yet. Use `/timezone set zone:America/New_York` (or `EST`, `CST`, `PST`).",
);
await interaction.editReply(["No timezone saved yet.", "", shortHint()].join("\n"));
return;
}

Expand All @@ -284,7 +354,7 @@ async function handleShow(interaction: ChatInputCommandInteraction): Promise<voi
[
"Your timezone:",
`Zone: ${tz.timezone}${tz.label ? ` (${tz.label})` : ""}`,
`Now: ${local} ${offset}`,
`Now: ${local} | ${offset}`,
`Scope: ${scope === "guild" ? "this server" : "global"}`,
].join("\n"),
);
Expand Down Expand Up @@ -313,7 +383,9 @@ async function handleCompare(interaction: ChatInputCommandInteraction): Promise<

if (!a) {
await interaction.editReply(
"You have no timezone saved. Set it with `/timezone set zone:America/New_York` first.",
["You have no timezone saved.", "Run `/timezone set` first.", "", shortHint()].join(
"\n",
),
);
return;
}
Expand All @@ -338,8 +410,8 @@ async function handleCompare(interaction: ChatInputCommandInteraction): Promise<
[
"Timezone compare:",
"",
`${interaction.user.username}: ${aNow} (${a.timezone}) ${aOff}`,
`${target.username}: ${bNow} (${b.timezone}) ${bOff}`,
`${interaction.user.username}: ${aNow} (${a.timezone}) | ${aOff}`,
`${target.username}: ${bNow} (${b.timezone}) | ${bOff}`,
"",
`${target.username} is ${rel}.`,
].join("\n"),
Expand All @@ -350,6 +422,7 @@ function parseTimeString(raw: string): { hours: number; minutes: number } | null
const s = raw.trim().toLowerCase();
if (!s) return null;

// Match "19:30" or "7:30pm" or "7pm"
const m = s.match(/^(\d{1,2})(?::(\d{2}))?\s*(am|pm)?$/i);
if (!m) return null;

Expand Down Expand Up @@ -409,7 +482,7 @@ async function handleConvert(interaction: ChatInputCommandInteraction): Promise<
const toNorm = normalizeZoneInput(rawTo);
if (!toNorm) {
await interaction.editReply(
"I could not understand the **to** timezone. Use an IANA zone like `America/Los_Angeles` or `PST`.",
["I could not understand the **to** timezone.", "", shortHint()].join("\n"),
);
return;
}
Expand All @@ -421,7 +494,7 @@ async function handleConvert(interaction: ChatInputCommandInteraction): Promise<
const fromNorm = normalizeZoneInput(rawFrom);
if (!fromNorm) {
await interaction.editReply(
"I could not understand the **from** timezone. Use an IANA zone like `America/New_York` or `EST`.",
["I could not understand the **from** timezone.", "", shortHint()].join("\n"),
);
return;
}
Expand Down Expand Up @@ -461,8 +534,8 @@ async function handleConvert(interaction: ChatInputCommandInteraction): Promise<
[
"Time conversion:",
"",
`From: ${fromTime} (${fromTz}${fromLabel ? `, ${fromLabel}` : ""}) ${fromOff}`,
`To: ${toTime} (${toNorm.tz}${toNorm.label ? `, ${toNorm.label}` : ""}) ${toOff}`,
`From: ${fromTime} (${fromTz}${fromLabel ? `, ${fromLabel}` : ""}) | ${fromOff}`,
`To: ${toTime} (${toNorm.tz}${toNorm.label ? `, ${toNorm.label}` : ""}) | ${toOff}`,
"",
`That is ${rel}.`,
].join("\n"),
Expand Down