From d7c18d1b061202f932f7e4006e51f0b5b7ecf89c Mon Sep 17 00:00:00 2001 From: WatDuhHekBro <44940783+WatDuhHekBro@users.noreply.github.com> Date: Sun, 24 Jan 2021 22:46:48 -0600 Subject: [PATCH] Fixed time setup accounting for differences in day --- src/commands/utilities/time.ts | 202 ++++++++++++++++++++++++--------- src/core/lib.ts | 53 +++++---- 2 files changed, 184 insertions(+), 71 deletions(-) diff --git a/src/commands/utilities/time.ts b/src/commands/utilities/time.ts index de4fe12..8cdecc1 100644 --- a/src/commands/utilities/time.ts +++ b/src/commands/utilities/time.ts @@ -4,8 +4,10 @@ import {User} from "discord.js"; import moment from "moment"; const DATE_FORMAT = "D MMMM YYYY"; +const DOW_FORMAT = "dddd"; const TIME_FORMAT = "HH:mm:ss"; type DST = "na" | "eu" | "sh"; +const TIME_EMBED_COLOR = 0x191970; const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = { na: "North America", @@ -16,12 +18,12 @@ const DAYLIGHT_SAVINGS_REGIONS: {[region in DST]: string} = { const DST_NOTE_INFO = `*Note: To make things simple, the way the bot will handle specific points in time when switching Daylight Savings is just to switch at UTC 00:00, ignoring local timezones. After all, there's no need to get this down to the exact hour.* North America -- Starts: 2nd Sunday March -- Ends: 1st Sunday November +- Starts: 2nd Sunday of March +- Ends: 1st Sunday of November Europe -- Starts: Last Sunday March -- Ends: Last Sunday October +- Starts: Last Sunday of March +- Ends: Last Sunday of October Southern Hemisphere - Starts: 1st Sunday of October @@ -30,12 +32,12 @@ Southern Hemisphere const DST_NOTE_SETUP = `Which daylight savings region most closely matches your own? North America (1️⃣) -- Starts: 2nd Sunday March -- Ends: 1st Sunday November +- Starts: 2nd Sunday of March +- Ends: 1st Sunday of November Europe (2️⃣) -- Starts: Last Sunday March -- Ends: Last Sunday October +- Starts: Last Sunday of March +- Ends: Last Sunday of October Southern Hemisphere (3️⃣) - Starts: 1st Sunday of October @@ -99,6 +101,7 @@ function hasDaylightSavings(region: DST) { function getTimeEmbed(user: User) { const {timezone, daylightSavingsRegion} = Storage.getUser(user.id); let localDate = "N/A"; + let dayOfWeek = "N/A"; let localTime = "N/A"; let timezoneOffset = "N/A"; @@ -107,13 +110,14 @@ function getTimeEmbed(user: User) { const daylightTimezone = timezone + daylightSavingsOffset; const now = moment().utcOffset(daylightTimezone * 60); localDate = now.format(DATE_FORMAT); + dayOfWeek = now.format(DOW_FORMAT); localTime = now.format(TIME_FORMAT); - timezoneOffset = daylightTimezone > 0 ? `+${daylightTimezone}` : daylightTimezone.toString(); + timezoneOffset = daylightTimezone >= 0 ? `+${daylightTimezone}` : daylightTimezone.toString(); } const embed = { embed: { - color: 0x000080, + color: TIME_EMBED_COLOR, author: { name: user.username, icon_url: user.displayAvatarURL({ @@ -126,12 +130,16 @@ function getTimeEmbed(user: User) { name: "Local Date", value: localDate }, + { + name: "Day of the Week", + value: dayOfWeek + }, { name: "Local Time", value: localTime }, { - name: timezone !== null ? "Current Timezone Offset" : "Timezone Offset", + name: daylightSavingsRegion !== null ? "Current Timezone Offset" : "Timezone Offset", value: timezoneOffset }, { @@ -160,14 +168,19 @@ function getTimeEmbed(user: User) { export default new Command({ description: "Show others what time it is for you.", + aliases: ["tz"], async run({channel, author}) { channel.send(getTimeEmbed(author)); }, subcommands: { + // Welcome to callback hell. We hope you enjoy your stay here! setup: new Command({ description: "Registers your timezone information for the bot.", - async run({author, channel, ask, askYesOrNo, askMultipleChoice, prompt}) { + async run({author, channel, ask, askYesOrNo, askMultipleChoice}) { const profile = Storage.getUser(author.id); + profile.timezone = null; + profile.daylightSavingsRegion = null; + let hour: number; ask( await channel.send( @@ -175,60 +188,141 @@ export default new Command({ ), author.id, (reply) => { - const hour = parseInt(reply); + hour = parseInt(reply); if (isNaN(hour)) { return false; } - const isValidHour = hour >= 0 && hour <= 23; - - if (isValidHour) { - const date = new Date(); - profile.timezone = hour - date.getUTCHours(); - } - - return isValidHour; + return hour >= 0 && hour <= 23; }, async () => { - askYesOrNo( - await channel.send("Does your timezone change based on daylight savings?"), - author.id, - async (hasDST) => { - const finalize = () => { - Storage.save(); - channel.send( - "You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.", - getTimeEmbed(author) - ); - }; + // You need to also take into account whether or not it's the same day in UTC or not. + // The problem this setup avoids is messing up timezones by 24 hours. + // For example, the old logic was just (hour - hourUTC). When I setup my timezone (UTC-6) at 18:00, it was UTC 00:00. + // That means that that formula was doing (18 - 0) getting me UTC+18 instead of UTC-6 because that naive formula didn't take into account a difference in days. - if (hasDST) { - const finalizeDST = (region: DST) => { - profile.daylightSavingsRegion = region; + // (day * 24 + hour) - (day * 24 + hour) + // Since the timezones will be restricted to -12 to +14, you'll be given three options. + // The end of the month should be calculated automatically, you should have enough information at that point. - // If daylight savings is active, subtract the timezone offset by one to store the standard time. - if (hasDaylightSavings(region)) { - (profile.timezone as number)--; - } + // But after mapping out the edge cases, I figured out that you can safely gather accurate information just based on whether the UTC day matches the user's day. + // 21:xx (-12, -d) -- 09:xx (+0, 0d) -- 23:xx (+14, 0d) + // 22:xx (-12, -d) -- 10:xx (+0, 0d) -- 00:xx (+14, +d) + // 23:xx (-12, -d) -- 11:xx (+0, 0d) -- 01:xx (+14, +d) + // 00:xx (-12, 0d) -- 12:xx (+0, 0d) -- 02:xx (+14, +d) - finalize(); - }; + // For example, 23:xx (-12) - 01:xx (+14) shows that even the edge cases of UTC-12 and UTC+14 cannot overlap, so the dataset can be reduced to a yes or no option. + // - 23:xx same day = +0, 23:xx diff day = -1 + // - 00:xx same day = +0, 00:xx diff day = +1 + // - 01:xx same day = +0, 01:xx diff day = +1 - askMultipleChoice(await channel.send(DST_NOTE_SETUP), author.id, [ - () => finalizeDST("na"), - () => finalizeDST("eu"), - () => finalizeDST("sh") - ]); - } else { - finalize(); + // First, create a tuple list matching each possible hour-dayOffset-timezoneOffset combination. In the above example, it'd go something like this: + // [[23, -1, -12], [0, 0, -11], ..., [23, 0, 12], [0, 1, 13], [1, 1, 14]] + // Then just find the matching one by filtering through dayOffset (equals zero or not), then the hour from user input. + // Remember that you don't know where the difference in day might be at this point, so you can't do (hour - hourUTC) safely. + // In terms of the equation, you're missing a variable in (--> day <-- * 24 + hour) - (day * 24 + hour). That's what the loop is for. + + // Now you might be seeing a problem with setting this up at the end/beginning of a month, but that actually isn't a problem. + // Let's say that it's 00:xx of the first UTC day of a month. hourSumUTC = 24 + // UTC-12 --> hourSumLowerBound (hourSumUTC - 12) = 12 + // UTC+14 --> hourSumUpperBound (hourSumUTC + 14) = 38 + // Remember, the nice thing about making these bounds relative to the UTC hour sum is that there can't be any conflicts even at the edges of months. + // And remember that we aren't using this question: (day * 24 + hour) - (day * 24 + hour). We're drawing from a list which does not store its data in absolute terms. + // That means there's no 31st, 1st, 2nd, it's -1, 0, +1. I just need to make sure to calculate an offset to subtract from the hour sums. + + const date = new Date(); // e.g. 2021-05-01 @ 05:00 + const day = date.getUTCDate(); // e.g. 1 + const hourSumUTC = day * 24 + date.getUTCHours(); // e.g. 29 + const timezoneTupleList: [number, number, number][] = []; + const uniques: number[] = []; // only for temporary use + const duplicates = []; + + // Setup the tuple list in a separate block. + for (let timezoneOffset = -12; timezoneOffset <= 14; timezoneOffset++) { + const hourSum = hourSumUTC + timezoneOffset; // e.g. 23, UTC-6 (17 to 43) + const hour = hourSum % 24; // e.g. 23 + // This works because you get the # of days w/o hours minus UTC days without hours. + // Since it's all relative to UTC, it'll end up being -1, 0, or 1. + const dayOffset = Math.floor(hourSum / 24) - day; // e.g. -1 + timezoneTupleList.push([hour, dayOffset, timezoneOffset]); + + if (uniques.includes(hour)) { + duplicates.push(hour); + } else { + uniques.push(hour); + } + } + + // I calculate the list beforehand and check for duplicates to reduce unnecessary asking. + if (duplicates.includes(hour)) { + const isSameDay = await askYesOrNo( + await channel.send( + `Is the current day of the month the ${moment().utc().format("Do")} for you?` + ), + author.id + ); + + // Filter through isSameDay (aka !hasDayOffset) then hour from user-generated input. + // isSameDay is checked first to reduce the amount of conditionals per loop. + if (isSameDay) { + for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) { + if (dayOffset === 0 && hour === hourPoint) { + profile.timezone = timezoneOffset; + } + } + } else { + for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) { + if (dayOffset !== 0 && hour === hourPoint) { + profile.timezone = timezoneOffset; + } } } + } else { + // If it's a unique hour, just search through the tuple list and find the matching entry. + for (const [hourPoint, dayOffset, timezoneOffset] of timezoneTupleList) { + if (hour === hourPoint) { + profile.timezone = timezoneOffset; + } + } + } + + // I should note that error handling should be added sometime because await throws an exception on Promise.reject. + const hasDST = await askYesOrNo( + await channel.send("Does your timezone change based on daylight savings?"), + author.id ); + + const finalize = () => { + Storage.save(); + channel.send( + "You've finished setting up your timezone! Just check to see if this looks right, and if it doesn't, run this setup again.", + getTimeEmbed(author) + ); + }; + + if (hasDST) { + const finalizeDST = (region: DST) => { + profile.daylightSavingsRegion = region; + + // If daylight savings is active, subtract the timezone offset by one to store the standard time. + if (hasDaylightSavings(region)) { + profile.timezone!--; + } + + finalize(); + }; + + askMultipleChoice(await channel.send(DST_NOTE_SETUP), author.id, [ + () => finalizeDST("na"), + () => finalizeDST("eu"), + () => finalizeDST("sh") + ]); + } else { + finalize(); + } }, - () => { - return "you need to enter in a valid integer between 0 to 23"; - } + () => "you need to enter in a valid integer between 0 to 23" ); } }), @@ -256,12 +350,16 @@ export default new Command({ channel.send({ embed: { - color: 0x000080, + color: TIME_EMBED_COLOR, fields: [ { name: "Local Date", value: time.format(DATE_FORMAT) }, + { + name: "Day of the Week", + value: time.format(DOW_FORMAT) + }, { name: "Local Time", value: time.format(TIME_FORMAT) diff --git a/src/core/lib.ts b/src/core/lib.ts index f8efea6..42852d9 100644 --- a/src/core/lib.ts +++ b/src/core/lib.ts @@ -46,7 +46,7 @@ export interface CommonLibrary { onReject: () => string, timeout?: number ) => void; - askYesOrNo: (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout?: number) => void; + askYesOrNo: (message: Message, senderID: string, timeout?: number) => Promise; askMultipleChoice: ( message: Message, senderID: string, @@ -196,6 +196,8 @@ export function updateGlobalEmoteRegistry(): void { FileManager.write("emote-registry", data, true); } +// Maybe promisify this section to reduce the potential for creating callback hell? Especially if multiple questions in a row are being asked. + // Pagination function that allows for customization via a callback. // Define your own pages outside the function because this only manages the actual turning of pages. $.paginate = async ( @@ -315,36 +317,49 @@ $.ask = async ( }, timeout); }; -$.askYesOrNo = async (message: Message, senderID: string, onSuccess: (condition: boolean) => void, timeout = 30000) => { - let isDeleted = false; +$.askYesOrNo = (message: Message, senderID: string, timeout = 30000): Promise => { + return new Promise(async (resolve, reject) => { + let isDeleted = false; - await message.react("✅"); - message.react("❌"); - await message.awaitReactions( - (reaction, user) => { - if (user.id === senderID) { - const isCheckReacted = reaction.emoji.name === "✅"; + await message.react("✅"); + message.react("❌"); + await message.awaitReactions( + (reaction, user) => { + if (user.id === senderID) { + const isCheckReacted = reaction.emoji.name === "✅"; - if (isCheckReacted || reaction.emoji.name === "❌") { - onSuccess(isCheckReacted); - isDeleted = true; - message.delete(); + if (isCheckReacted || reaction.emoji.name === "❌") { + resolve(isCheckReacted); + isDeleted = true; + message.delete(); + } } - } - return false; - }, - {time: timeout} - ); + return false; + }, + {time: timeout} + ); - if (!isDeleted) message.delete(); + if (!isDeleted) { + message.delete(); + reject("Prompt timed out."); + } + }); }; // This MUST be split into an array. These emojis are made up of several characters each, adding up to 29 in length. const multiNumbers = ["1️⃣", "2️⃣", "3️⃣", "4️⃣", "5️⃣", "6️⃣", "7️⃣", "8️⃣", "9️⃣", "🔟"]; // This will bring up an option to let the user choose between one option out of many. +// This definitely needs a single callback alternative, because using the numerical version isn't actually that uncommon of a pattern. $.askMultipleChoice = async (message: Message, senderID: string, callbackStack: (() => void)[], timeout = 90000) => { + if (callbackStack.length > multiNumbers.length) { + message.channel.send( + `\`ERROR: The amount of callbacks in "askMultipleChoice" must not exceed the total amount of allowed options (${multiNumbers.length})!\`` + ); + return; + } + let isDeleted = false; for (let i = 0; i < callbackStack.length; i++) {