diff --git a/src/commands/causes.ts b/src/commands/causes.ts index 1474612..909747f 100644 --- a/src/commands/causes.ts +++ b/src/commands/causes.ts @@ -6,76 +6,11 @@ import type { Command } from "workers-discord"; import getStats from "../util/stats"; import checkDate from "../util/check"; -import { bold, italic, money, number } from "../util/format"; +import getNow from "../util/now"; +import { bold, italic, number } from "../util/format"; +import causesBreakdown from "../util/causes"; import type { CtxWithEnv } from "../env"; -const sluggify = (str: string) => - str - // Remove anything that isn't a word character or space - .replace(/[^\w\s]+/g, "") - // Convert spaces to case changes for camel case - .replace(/\s+\S/g, (match) => match.trim().toUpperCase()) - // Ensure first character is lowercase for camel case - .replace(/^./, (match) => match.toLowerCase()); - -const hash = (str: string) => { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = (hash << 5) - hash + str.charCodeAt(i); - hash |= 0; - } - return hash; -}; - -const hearts = [ - ":blue_heart:", - ":green_heart:", - ":purple_heart:", - ":yellow_heart:", - ":heart:", -]; - -const heart = (str: string) => hearts[Math.abs(hash(str)) % hearts.length]; - -const data: Record = { - autistica: { - emote: "<:Autistica:1159097680753066035>", - }, - campaignAgainstLivingMiserablyCALM: { - emote: "<:CALM:1159097678957920286>", - }, - comicRelief: { - emote: "<:ComicRelief:1159097684750250044>", - }, - coppafeel: { - emote: "<:CoppaFeel:1160890771273162832>", - }, - galop: { - emote: "<:Galop:1160890850075742309>", - }, - movember: { - emote: "<:Movember:1160891203986927627>", - }, - helloWorld: { - emote: "<:HelloWorld:1160884733283147866>", - }, - justdiggit: { - emote: "<:Justdiggit:1160889813461913632>", - }, - royalNationalInstituteOfBlindPeopleRNIB: { - emote: "<:RNIB:1160891327635017820>", - }, - warChild: { - emote: "<:WarChild:1159097686394425385>", - }, - wallaceGromitsGrandAppeal: { - emote: "<:GrandAppeal:1160891082972868608>", - }, - whaleAndDolphinConservation: { - emote: "<:WDC:1159097675304669214>", - }, -}; - const causesCommand: Command = { name: "causes", description: @@ -88,17 +23,14 @@ const causesCommand: Command = { // Check if Jingle Jam is running const start = new Date(stats.event.start); const check = checkDate(start); - // TODO: Re-enable this check once testing is done - // if (check) return edit({ content: check }); + if (check) return edit({ content: check }); // Check if Jingle Jam has finished const end = new Date(stats.event.end); if (isNaN(+end)) throw new Error("Invalid end date"); // Time since launch - // TODO: Switch back to using the current time once testing is done - // const now = new Date(); - const now = end; + const now = getNow(); const ended = now >= end; await edit({ @@ -107,20 +39,7 @@ const causesCommand: Command = { ended ? "supported" : "is supporting" } ${bold(number(stats.causes.length))} amazing causes:`, "", - ...stats.causes.map((cause) => { - const slug = sluggify(cause.name); - const { name, emote } = data[slug] ?? {}; - - return bold( - `[${emote || heart(cause.name)} ${ - name || cause.name - }](${cause.url}): ${money( - "£", - cause.raised.yogscast + - cause.raised.fundraisers, - )}`, - ); - }), + causesBreakdown(stats), "", `:heart: Thank you for supporting some wonderful causes! ${ ended diff --git a/src/commands/stats.ts b/src/commands/stats.ts index 80eb4d0..ab08789 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -6,6 +6,7 @@ import type { Command } from "workers-discord"; import getStats from "../util/stats"; import checkDate from "../util/check"; +import getNow from "../util/now"; import { bold, date, italic, money, number } from "../util/format"; import type { CtxWithEnv } from "../env"; @@ -21,17 +22,14 @@ const statsCommand: Command = { // Check if Jingle Jam is running const start = new Date(stats.event.start); const check = checkDate(start); - // TODO: Re-enable this check once testing is done - // if (check) return edit({ content: check }); + if (check) return edit({ content: check }); // Check if Jingle Jam has finished const end = new Date(stats.event.end); if (isNaN(+end)) throw new Error("Invalid end date"); // Time since launch - // TODO: Switch back to using the current time once testing is done - // const now = new Date(); - const now = end; + const now = getNow(); const ended = now >= end; const timeSinceLaunch = Math.min( now.getTime() - start.getTime(), diff --git a/src/commands/total.ts b/src/commands/total.ts index c8d2096..fef1a8a 100644 --- a/src/commands/total.ts +++ b/src/commands/total.ts @@ -6,6 +6,7 @@ import type { Command } from "workers-discord"; import getStats from "../util/stats"; import checkDate from "../util/check"; +import getNow from "../util/now"; import { bold, italic, money, number } from "../util/format"; import type { CtxWithEnv } from "../env"; @@ -21,17 +22,14 @@ const totalCommand: Command = { // Check if Jingle Jam is running const start = new Date(stats.event.start); const check = checkDate(start); - // TODO: Re-enable this check once testing is done - // if (check) return edit({ content: check }); + if (check) return edit({ content: check }); // Check if Jingle Jam has finished const end = new Date(stats.event.end); if (isNaN(+end)) throw new Error("Invalid end date"); // Time since launch - // TODO: Switch back to using the current time once testing is done - // const now = new Date(); - const now = end; + const now = getNow(); const ended = now >= end; // Format some stats diff --git a/src/scheduled/milestone.ts b/src/scheduled/milestone.ts index 1a400c6..b52b05d 100644 --- a/src/scheduled/milestone.ts +++ b/src/scheduled/milestone.ts @@ -49,8 +49,8 @@ const milestoneScheduled = async ( `# :tada: ${money("£", recentMilestone, false)}`, "", `Jingle Jam ${stats.event.year} just hit a new milestone, with ${totalRaised} raised so far through the Yogscast and fundraisers.`, + ` There have already been ${collections} games collections claimed, and our ${countFundraisers} fundraisers have raised ${totalFundraisers}!`, "", - `There have already been ${collections} games collections claimed, and our ${countFundraisers} fundraisers have raised ${totalFundraisers}!`, ":heart: Thank you for supporting some wonderful causes! Get involved and grab the collection at ", ].join("\n"); ctx.waitUntil( diff --git a/src/scheduled/summary.ts b/src/scheduled/summary.ts index 1c98f68..7016388 100644 --- a/src/scheduled/summary.ts +++ b/src/scheduled/summary.ts @@ -1,17 +1,25 @@ +import getNow from "../util/now"; import checkDate from "../util/check"; import getStats from "../util/stats"; import sendWebhook from "../util/webhook"; +import { bold, italic, money, number, timeSince } from "../util/format"; +import causesBreakdown from "../util/causes"; import type { Env } from "../env"; // Aim to post at 23:00 UTC every day const target = () => { - // TODO: Switch back to using the current time once testing is done - // const now = new Date(); - const now = new Date("2023-12-02T23:15:00.000Z"); + const now = getNow(); now.setUTCHours(23, 0, 0, 0); return now; }; +// Check the end, but allow for posting a final summary within 12 hours of the end +const checkEnd = (end: Date) => { + const offset = new Date(end); + offset.setHours(offset.getHours() + 12); + return getNow() > offset; +}; + const summaryScheduled = async ( event: ScheduledController, env: Env, @@ -25,46 +33,70 @@ const summaryScheduled = async ( // Get the target, and short-circuit if we're not there yet const targetSummary = target(); - // TODO: Switch back to using the current time once testing is done - // const now = new Date(); - const now = new Date("2023-12-02T23:15:00.000Z"); - console.log(now, targetSummary); - if (now.getTime() < targetSummary.getTime()) return; + const now = getNow(); + if (now < targetSummary) return; // Check when we last posted a summary, and don't post if it was after the current target const lastSummary = new Date((await env.STORE.get("lastSummary")) || 0); - if (lastSummary.getTime() >= targetSummary.getTime()) return; + if (lastSummary >= targetSummary) return; // Get the stats, and check if Jingle Jam is running const stats = await getStats(env.STATS_API_ENDPOINT); const start = new Date(stats.event.start); - // TODO: Re-enable this check once testing is done - // if (checkDate(start)) return; + if (checkDate(start)) return; - // Check the end, but less precisely, to allow for posting a final summary after the event + // Check the end, allowing for a final post after the end const end = new Date(stats.event.end); if (isNaN(+end)) throw new Error("Invalid end date"); - end.setMinutes(end.getMinutes() + 23 * 60 + 59); - if (now.getTime() > end.getTime()) return; + if (checkEnd(end)) return; // Format some stats const daysSinceLaunch = Math.ceil( Math.max((now.getTime() - start.getTime()) / 1000 / 60 / 60 / 24, 1), ); + const ended = now >= end; + const timeElapsed = italic(timeSince(start, ended ? end : now)); + const timeRemaining = ended ? null : italic(timeSince(now, end)); + const totalRaised = bold( + money("£", stats.raised.yogscast + stats.raised.fundraisers), + ); + const collections = bold(number(stats.collections.redeemed)); + const fundraisers = bold(number(stats.campaigns.count - 1)); - // TODO: Send the webhooks, in the background, with errors logged to the console - // const content = [ - // bold(`:snowflake: Jinlge Jam ${stats.event.year} Day ${daysSinceLaunch} Summary`), - // "", - // ":heart: Thank you for supporting some wonderful causes! Get involved at ", - // ].join("\n"); - // ctx.waitUntil( - // Promise.all( - // webhooks.map((webhook) => - // sendWebhook(webhook, { content }).catch(console.error), - // ), - // ), - // ); + // Send the webhooks, in the background, with errors logged to the console + const content = [ + `# :snowflake: Jingle Jam ${stats.event.year} Day ${daysSinceLaunch} Summary`, + "", + `:money_with_wings: ${ + ended ? "We" : "We've" + } raised a total of ${totalRaised} for charity over the last ${timeElapsed} during Jingle Jam ${ + stats.event.year + }${ended ? "!" : " so far!"}`, + `:package: There ${ + ended ? "were" : "have already been" + } ${collections} games collections redeemed, and ${fundraisers} fundraisers ${ + ended ? "joined" : "have joined" + } to raise money for charity.`, + "", + causesBreakdown(stats), + "", + `:heart: Thank you for supporting some wonderful causes! ${ + timeRemaining + ? `\n:arrow_right: There ${ + /^\D*1 /.test(timeRemaining) ? "is" : "are" + } still ${timeRemaining} remaining to get involved and grab the collection at ` + : `We look forward to seeing you again for Jingle Jam ${ + stats.event.year + 1 + }.` + }`, + ].join("\n"); + ctx.waitUntil( + Promise.all( + webhooks.map((webhook) => + sendWebhook(webhook, { content }).catch(console.error), + ), + ), + ); // Update the last summary we posted await env.STORE.put("lastSummary", now.toISOString()); diff --git a/src/util/causes.ts b/src/util/causes.ts new file mode 100644 index 0000000..a099c8c --- /dev/null +++ b/src/util/causes.ts @@ -0,0 +1,88 @@ +import { bold, money } from "./format"; +import type { Stats } from "./stats"; + +const sluggify = (str: string) => + str + // Remove anything that isn't a word character or space + .replace(/[^\w\s]+/g, "") + // Convert spaces to case changes for camel case + .replace(/\s+\S/g, (match) => match.trim().toUpperCase()) + // Ensure first character is lowercase for camel case + .replace(/^./, (match) => match.toLowerCase()); + +const hash = (str: string) => { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return hash; +}; + +const hearts = [ + ":blue_heart:", + ":green_heart:", + ":purple_heart:", + ":yellow_heart:", + ":heart:", +]; + +const heart = (str: string) => hearts[Math.abs(hash(str)) % hearts.length]; + +const data: Record = { + autistica: { + emote: "<:Autistica:1159097680753066035>", + }, + campaignAgainstLivingMiserablyCALM: { + emote: "<:CALM:1159097678957920286>", + }, + comicRelief: { + emote: "<:ComicRelief:1159097684750250044>", + }, + coppafeel: { + emote: "<:CoppaFeel:1160890771273162832>", + }, + galop: { + emote: "<:Galop:1160890850075742309>", + }, + movember: { + emote: "<:Movember:1160891203986927627>", + }, + helloWorld: { + emote: "<:HelloWorld:1160884733283147866>", + }, + justdiggit: { + emote: "<:Justdiggit:1160889813461913632>", + }, + royalNationalInstituteOfBlindPeopleRNIB: { + emote: "<:RNIB:1160891327635017820>", + }, + warChild: { + emote: "<:WarChild:1159097686394425385>", + }, + wallaceGromitsGrandAppeal: { + emote: "<:GrandAppeal:1160891082972868608>", + }, + whaleAndDolphinConservation: { + emote: "<:WDC:1159097675304669214>", + }, +}; + +const causesBreakdown = (stats: Stats) => + stats.causes + .map((cause) => { + const slug = sluggify(cause.name); + const { name, emote } = data[slug] ?? {}; + + return bold( + `[${emote || heart(cause.name)} ${name || cause.name}](${ + cause.url + }): ${money( + "£", + cause.raised.yogscast + cause.raised.fundraisers, + )}`, + ); + }) + .join("\n"); + +export default causesBreakdown; diff --git a/src/util/check.ts b/src/util/check.ts index 35e489a..fa1d913 100644 --- a/src/util/check.ts +++ b/src/util/check.ts @@ -1,3 +1,4 @@ +import getNow from "./now"; import { date, time } from "./format"; const checkDate = (start: Date) => { @@ -5,10 +6,15 @@ const checkDate = (start: Date) => { if (isNaN(+start)) throw new Error("Invalid start date"); // If the date is in the past, we're fine - if (start < new Date()) return null; + const now = getNow(); + if (start < now) return null; // If we're within the same day, show the time - if (start.getDate() === new Date().getDate()) { + if ( + start.getUTCFullYear() === now.getUTCFullYear() && + start.getUTCMonth() === now.getUTCMonth() && + start.getUTCDate() === now.getUTCDate() + ) { return `Jingle Jam hasn't launched yet! Get ready to raise money for some awesome causes and grab the games collection when it goes live at ${time( start, )}.`; diff --git a/src/util/format.ts b/src/util/format.ts index 3621e81..dd0c861 100644 --- a/src/util/format.ts +++ b/src/util/format.ts @@ -27,6 +27,49 @@ export const time = (date: Date, seconds = false) => }), }); +export const plural = (number: number, singular: string, plural: string) => + `${number} ${number === 1 ? singular : plural}`; + +export const timeSince = ( + start: Date, + end: Date, + minutes = false, + seconds = false, +) => { + const diff = Math.abs(end.getTime() - start.getTime()); + const secs = Math.floor(diff / 1000); + const mins = seconds + ? Math.floor(diff / 1000 / 60) + : Math.round(diff / 1000 / 60); + const hours = + minutes || seconds + ? Math.floor(diff / 1000 / 60 / 60) + : Math.round(diff / 1000 / 60 / 60); + const days = Math.floor(diff / 1000 / 60 / 60 / 24); + + const parts = [ + // Only show days if there are days + days > 0 && plural(days, "day", "days"), + // Only show hours if there are hours, seconds, or minutes + hours > 0 && + (hours % 24 !== 0 || + (minutes && mins % 60 !== 0) || + (seconds && secs % 60 !== 0)) && + plural(hours % 24, "hour", "hours"), + // Only show minutes if there are minutes or seconds + minutes && + mins > 0 && + (mins % 60 !== 0 || (seconds && secs % 60 !== 0)) && + plural(mins % 60, "minute", "minutes"), + // Only show seconds if there are seconds + seconds && secs > 0 && plural(secs % 60, "second", "seconds"), + ].filter(Boolean); + + // Format as a well-formed list + if (parts.length < 3) return parts.join(" and "); + return `${parts.slice(0, -1).join(", ")}, and ${parts.slice(-1)}`; +}; + export const number = ( number: number, decimals: number | undefined = undefined, diff --git a/src/util/now.ts b/src/util/now.ts new file mode 100644 index 0000000..d5cacec --- /dev/null +++ b/src/util/now.ts @@ -0,0 +1,5 @@ +// Util to allow for easy overriding when testing +// TODO: switch back to new Date() when we're done testing +const getNow = () => new Date("2023-12-01T23:15:00.000Z"); + +export default getNow; diff --git a/src/util/stats.ts b/src/util/stats.ts index 13d03a8..ce00a93 100644 --- a/src/util/stats.ts +++ b/src/util/stats.ts @@ -1,4 +1,4 @@ -interface Stats { +export interface Stats { date: string; event: { year: number; diff --git a/src/util/webhook.ts b/src/util/webhook.ts index 8bec81c..f5a9a9d 100644 --- a/src/util/webhook.ts +++ b/src/util/webhook.ts @@ -1,4 +1,4 @@ -import { RESTPostAPIWebhookWithTokenJSONBody } from "discord-api-types/rest"; +import type { RESTPostAPIWebhookWithTokenJSONBody } from "discord-api-types/rest"; const sendWebhook = async ( hook: string,