diff --git a/package.json b/package.json index 40886dd0..4ca437bc 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev": "pnpm --filter backend dev", "build": "pnpm --recursive build", "test": "pnpm --recursive test", + "test:unit": "pnpm --recursive test:unit", + "test:integration": "vitest run tests/integration", "lint": "pnpm --recursive lint", "lint:fix": "pnpm --recursive lint:fix", "format": "pnpm --recursive format", @@ -26,8 +28,11 @@ "husky": "^9.1.7", "lint-staged": "^17.3.0", "prettier": "^3.9.6", - "typescript-eslint": "^8.65.0", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "typescript-eslint": "^8.65.0" + }, + "dependencies": { + "date-fns": "^4.4.0" }, "pnpm": { "overrides": { diff --git a/packages/backend/src/adapters/repositories/PlayedSoundsRepository.ts b/packages/backend/src/adapters/repositories/PlayedSoundsRepository.ts index 6e6147d0..b86d94a4 100644 --- a/packages/backend/src/adapters/repositories/PlayedSoundsRepository.ts +++ b/packages/backend/src/adapters/repositories/PlayedSoundsRepository.ts @@ -9,11 +9,19 @@ export type SoundPlayCount = { readonly playCount: number; }; +export type SoundPlayedDates = { + readonly id: number; + readonly name: string; + readonly latestDate: Date; + readonly oldestDate: Date; +}; + export type PlayedSoundsRepository = { addPlayedSound(data: CreatePlayedSoundData): Promise; getSoundPlayCount(soundId: number, userId?: string, year?: number): Promise; getSoundPlayCounts(limit?: number, userId?: string, year?: number): Promise; + getSoundPlayedDates(userId?: string, year?: number): Promise; }; const transformPlayedSound = (dbSoundPlay: Selectable): PlayedSound => { @@ -68,9 +76,25 @@ export const createPlayedSoundsRepository = (db: Kysely): PlayedSoundsReposi return rows.map(r => ({ id: r.sound_id, name: r.name, playCount: Number(r.play_count) })); }; + const getSoundPlayedDates = async (userId?: string, year?: number): Promise => { + const rows = await db + .selectFrom("played_sounds as ps") + .innerJoin("sounds as s", "s.id", "ps.sound_id") + .select(["s.id", "s.name", db.fn.max("played_at").as("latest_date"), db.fn.min("played_at").as("oldest_date")]) + .$if(userId !== undefined, qb => qb.where("ps.user_id", "=", userId!)) + .$if(year !== undefined, qb => qb.where(sql`extract(year from ps.played_at)`, "=", year!)) + .groupBy(["s.id", "s.name"]) + .orderBy("latest_date", "desc") + .orderBy("name", "asc") + .execute(); + + return rows.map(r => ({ id: r.id, name: r.name, oldestDate: r.oldest_date, latestDate: r.latest_date })); + }; + return { addPlayedSound, getSoundPlayCount, getSoundPlayCounts, + getSoundPlayedDates, }; }; diff --git a/packages/backend/src/application/commands/AudioCommands.ts b/packages/backend/src/application/commands/AudioCommands.ts index e1a2a65d..8b947939 100644 --- a/packages/backend/src/application/commands/AudioCommands.ts +++ b/packages/backend/src/application/commands/AudioCommands.ts @@ -108,7 +108,7 @@ export const createAudioCommands = ({ soundService, discordChatService, commandC } const response = `Available sounds:\n${sounds.map(sound => `- ${sound}`).join("\n")}`; - await discordChatService.replyToInteraction(interaction, response, true); + await discordChatService.replyToInteraction(interaction, response, { ephemeral: true }); }, getAutocompleteChoices: commandChoicesService.getAutocompleteChoices, }; diff --git a/packages/backend/src/application/commands/BannedFeaturesCommands.ts b/packages/backend/src/application/commands/BannedFeaturesCommands.ts index 6dfce432..958bd7c4 100644 --- a/packages/backend/src/application/commands/BannedFeaturesCommands.ts +++ b/packages/backend/src/application/commands/BannedFeaturesCommands.ts @@ -85,7 +85,7 @@ export const createBannedFeaturesCommands = ({ bannedFeaturesRepository, discord ]); const response = `\`\`\`\nBanned Features and Users\n${"-".repeat(30)}\n${lines.join("\n")}\n\`\`\``; - await discordChatService.replyToInteraction(interaction, response, true); + await discordChatService.replyToInteraction(interaction, response, { ephemeral: true }); }, }; diff --git a/packages/backend/src/application/commands/PlayedSoundsCommands.ts b/packages/backend/src/application/commands/PlayedSoundsCommands.ts index 58627af0..6f88e9d7 100644 --- a/packages/backend/src/application/commands/PlayedSoundsCommands.ts +++ b/packages/backend/src/application/commands/PlayedSoundsCommands.ts @@ -1,7 +1,8 @@ import type { PlayedSoundsRepository } from "@adapters/repositories/PlayedSoundsRepository.js"; import type { SoundRepository } from "@adapters/repositories/SoundRepository.js"; import type { CommandChoicesService } from "@core/services/CommandChoicesService.js"; -import { type DiscordChatService, MESSAGE_LENGTH_LIMIT } from "@core/services/DiscordChatService.js"; +import { type DiscordChatService } from "@core/services/DiscordChatService.js"; +import { format } from "date-fns"; import { type ChatInputCommandInteraction, MessageFlags, SlashCommandBuilder } from "discord.js"; import type { Command } from "./Commands.js"; @@ -72,13 +73,39 @@ export const createPlayedSoundsCommands = ({ soundRepository, playedSoundsReposi ); const response = `${year ? `${year} ` : ""}Played Sound Counts\n-------------------\nRank Count Name\n${result.join(`\n`)}`; - const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```"; - await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`); + await discordChatService.replyToInteraction(interaction, response, { backticks: true }); + }, + }; + + const fmt = (date: Date) => format(date, "MMM d, yyyy h:mm:ss a").padEnd(32); + + const soundPlayedDates: Command = { + data: new SlashCommandBuilder() + .setName("sound-played-dates") + .setDescription("Returns the first and latest dates each sound was played") + .addUserOption(option => option.setName("user").setDescription("Optional user to filter by").setRequired(false)) + .addNumberOption(option => option.setName("year").setDescription("Optional year to filter by").setRequired(false)), + execute: async (interaction: ChatInputCommandInteraction) => { + const options = interaction.options; + const user = options.getUser("user") ?? undefined; + const year = options.getNumber("year") ?? undefined; + + const dates = await playedSoundsRepository.getSoundPlayedDates(user?.id, year); + if (dates.length === 0) { + await interaction.reply(`No played sounds ${year ? `for ${year}` : ""}`); + return; + } + + const result = dates.map(d => `${d.name.padEnd(14)}${fmt(d.latestDate)}${fmt(d.oldestDate)}`); + + const response = `Name Newest Played Date Oldest Played Date\n${"-".repeat(64)}\n${result.join(`\n`)}`; + await discordChatService.replyToInteraction(interaction, response, { backticks: true }); }, }; return { "sound-play-count": soundPlayCount, "sound-play-counts": soundPlayCounts, + "sound-played-dates": soundPlayedDates, }; }; diff --git a/packages/backend/src/application/commands/ReactionCommands.ts b/packages/backend/src/application/commands/ReactionCommands.ts index 9d0b4a42..37d7aa19 100644 --- a/packages/backend/src/application/commands/ReactionCommands.ts +++ b/packages/backend/src/application/commands/ReactionCommands.ts @@ -1,6 +1,6 @@ import { KarmaEmoteNames } from "@adapters/repositories/ReactionEmoteRepository.js"; import type { ReactionRepository } from "@adapters/repositories/ReactionRepository.js"; -import { type DiscordChatService, MESSAGE_LENGTH_LIMIT } from "@core/services/DiscordChatService.js"; +import { type DiscordChatService } from "@core/services/DiscordChatService.js"; import { formatEmoji } from "@core/utils/emojiUtils.js"; import { getJumpUrl } from "@core/utils/messageUtils.js"; import { type ChatInputCommandInteraction, GuildMember, MessageFlags, Role, SlashCommandBuilder, userMention } from "discord.js"; @@ -138,8 +138,7 @@ export const createReactionCommands = ({ reactionRepository, discordChatService ); const response = `${year ? `${year} ` : ""}Emote Leaderboard (Top ${limit})\n-------------------------------\nRank Count Emote\n${result.join(`\n`)}`; - const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```"; - await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`); + await discordChatService.replyToInteraction(interaction, response, { backticks: true }); }, }; @@ -176,8 +175,7 @@ export const createReactionCommands = ({ reactionRepository, discordChatService ); const response = `${year ? `${year} ` : ""}Karma Leaderboard\n------------------------\nRank Karma User\n${result.join(`\n`)}`; - const enclosingChars = response.length > MESSAGE_LENGTH_LIMIT ? "" : "```"; - await discordChatService.replyToInteraction(interaction, `${enclosingChars}${response}${enclosingChars}`); + await discordChatService.replyToInteraction(interaction, response, { backticks: true }); }, }; diff --git a/packages/backend/src/application/commands/SoundTagCommands.ts b/packages/backend/src/application/commands/SoundTagCommands.ts index e1fdbf8e..173b290f 100644 --- a/packages/backend/src/application/commands/SoundTagCommands.ts +++ b/packages/backend/src/application/commands/SoundTagCommands.ts @@ -61,7 +61,7 @@ export const createSoundTagCommands = ({ soundTagService, discordChatService, co } const response = `Available tags:\n${tags.map(tag => `- ${tag.name}`).join("\n")}`; - await discordChatService.replyToInteraction(interaction, response, true); + await discordChatService.replyToInteraction(interaction, response, { ephemeral: true }); }, }; diff --git a/packages/backend/src/core/services/DiscordChatService.ts b/packages/backend/src/core/services/DiscordChatService.ts index 4db070a7..02bbb91f 100644 --- a/packages/backend/src/core/services/DiscordChatService.ts +++ b/packages/backend/src/core/services/DiscordChatService.ts @@ -5,14 +5,25 @@ export const MESSAGE_LENGTH_LIMIT = 2000; export type SendMode = "split" | "file"; +//Extra optional params passed to replyToInteraction and followUpToInteraction. +export type ResponseOptions = { + //If the response is only visible to the invoker of the interaction. + ephemeral?: boolean; + + //If backticks for Markdown code blocks should automatically be added around the content (unless we're in file mode). + backticks?: boolean; +}; + +const DefaultResponseOptions: ResponseOptions = { ephemeral: false, backticks: false }; + export type DiscordChatService = { readonly hasBeenPinged: (latestMessage: Message) => boolean; readonly replaceUserRoleAndChannelMentions: (message: Message) => Promise; readonly sendTypingIndicator: (abortSignal: AbortSignal, channel: TextChannel) => Promise; readonly formatMessageContent: (content: string, sendMode?: SendMode) => MessageCreateOptions[]; readonly sendMessage: (content: string, channel: TextChannel, sendMode?: SendMode) => Promise; - readonly replyToInteraction: (interaction: ChatInputCommandInteraction, content: string, ephemeral?: boolean) => Promise; - readonly followUpToInteraction: (interaction: ChatInputCommandInteraction, content: string, ephemeral?: boolean) => Promise; + readonly replyToInteraction: (interaction: ChatInputCommandInteraction, content: string, options?: ResponseOptions) => Promise; + readonly followUpToInteraction: (interaction: ChatInputCommandInteraction, content: string, options?: ResponseOptions) => Promise; }; export type DiscordChatServiceDeps = { @@ -78,21 +89,26 @@ export const createDiscordChatService = ({ config }: DiscordChatServiceDeps): Di } } - //Calls reply on the interaction, sending the result back as a file if content is too long. - async function replyToInteraction(interaction: ChatInputCommandInteraction, content: string, ephemeral = false) { + //Formats the response back for Discord interactions. + function formatResponseContent(content: string, options = DefaultResponseOptions) { + const { ephemeral, backticks } = options; const mode = content.length > MESSAGE_LENGTH_LIMIT ? "file" : "split"; - const result = formatMessageContent(content, mode)[0]!; - const formatted: InteractionReplyOptions = { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined }; + const enclosingChars = mode === "split" && backticks ? "```" : ""; + //There will never be more than 1 since interactions don't support more than 1 message. + const result = formatMessageContent(`${enclosingChars}${content}${enclosingChars}`, mode)[0]!; + return { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined } satisfies InteractionReplyOptions; + } + + //Calls reply on the interaction, sending the result back as a file if content is too long. + async function replyToInteraction(interaction: ChatInputCommandInteraction, content: string, options = DefaultResponseOptions) { + const formatted = formatResponseContent(content, options); await interaction.reply(formatted); } //Calls followUp on the interaction, sending the result back as a file if content is too long. - async function followUpToInteraction(interaction: ChatInputCommandInteraction, content: string, ephemeral = false) { - const mode = content.length > MESSAGE_LENGTH_LIMIT ? "file" : "split"; - const result = formatMessageContent(content, mode)[0]!; - const formatted: InteractionReplyOptions = { ...result, flags: ephemeral ? MessageFlags.Ephemeral : undefined }; - + async function followUpToInteraction(interaction: ChatInputCommandInteraction, content: string, options = DefaultResponseOptions) { + const formatted = formatResponseContent(content, options); await interaction.followUp(formatted); } diff --git a/packages/backend/tests/unit/playedSounds/playedSounds.test.ts b/packages/backend/tests/unit/playedSounds/playedSounds.test.ts index dd7168a0..a0bb1171 100644 --- a/packages/backend/tests/unit/playedSounds/playedSounds.test.ts +++ b/packages/backend/tests/unit/playedSounds/playedSounds.test.ts @@ -19,6 +19,8 @@ const setUpTest = async () => { return { db, users, sounds, soundPlays }; }; +const source = "Command"; + describe.concurrent("getSoundPlayCounts", () => { test("returns an empty array when there are no plays", async () => { const { soundPlays } = await setUpTest(); @@ -27,7 +29,6 @@ describe.concurrent("getSoundPlayCounts", () => { }); test("orders sounds by play count descending", async () => { - const source = "Command"; const { soundPlays } = await setUpTest(); await soundPlays.addPlayedSound({ userId: "111", soundId: 1, source }); @@ -41,7 +42,6 @@ describe.concurrent("getSoundPlayCounts", () => { }); test("respects the limit parameter", async () => { - const source = "VoiceEvent"; const { soundPlays } = await setUpTest(); await soundPlays.addPlayedSound({ userId: "111", soundId: 1, source }); @@ -53,7 +53,6 @@ describe.concurrent("getSoundPlayCounts", () => { }); test("only counts plays from the given year", async () => { - const source = "Command"; const { db, soundPlays } = await setUpTest(); await db @@ -79,7 +78,6 @@ describe.concurrent("getSoundPlayCount", () => { }); test("returns the correct count after multiple plays", async () => { - const source = "Command"; const { soundPlays } = await setUpTest(); for (const userId of ["111", "222", "333"]) { @@ -91,7 +89,6 @@ describe.concurrent("getSoundPlayCount", () => { }); test("only counts plays from the given year", async () => { - const source = "Command"; const { db, soundPlays } = await setUpTest(); await db @@ -108,7 +105,6 @@ describe.concurrent("getSoundPlayCount", () => { }); test("only counts plays from the given user", async () => { - const source = "Command"; const { soundPlays } = await setUpTest(); await soundPlays.addPlayedSound({ userId: "111", soundId: 1, source }); @@ -120,7 +116,6 @@ describe.concurrent("getSoundPlayCount", () => { }); test("combines the year and user filters", async () => { - const source = "Command"; const { db, soundPlays } = await setUpTest(); await db @@ -136,3 +131,190 @@ describe.concurrent("getSoundPlayCount", () => { expect(count).toBe(1); }); }); + +describe.concurrent("getSoundPlayedDates", () => { + test("returns an empty array when there are no plays", async () => { + const { soundPlays } = await setUpTest(); + + const dates = await soundPlays.getSoundPlayedDates(); + expect(dates).toEqual([]); + }); + + test("returns the oldest and newest played_at for a sound with multiple plays", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-06-15T00:00:00Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-03-10T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(); + expect(dates).toHaveLength(1); + expect(dates[0]).toMatchObject({ id: 1, oldestDate: new Date("2025-01-01T00:00:00Z"), latestDate: new Date("2025-06-15T00:00:00Z") }); + }); + + test("excludes sounds that have never been played", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values({ user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(); + expect(dates).toHaveLength(1); + expect(dates[0]!.id).toBe(1); + }); + + test("orders sounds by newest played_at descending", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }, + { user_id: "111", sound_id: 2, source, played_at: new Date("2025-06-15T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(); + expect(dates.map(d => d.id)).toEqual([2, 1]); + }); + + test("breaks ties in latest_date by ordering names ascending", async () => { + const { db, soundPlays } = await setUpTest(); + const tiedDate = new Date("2025-01-01T00:00:00Z"); + + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 3, source, played_at: tiedDate }, // "bones" + { user_id: "111", sound_id: 1, source, played_at: tiedDate }, // "airhorn" + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(); + expect(dates.map(d => d.name)).toEqual(["airhorn", "bones"]); + }); + + test("filters to only the given userId when provided", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }, + { user_id: "222", sound_id: 2, source, played_at: new Date("2025-02-01T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates("111"); + expect(dates).toHaveLength(1); + expect(dates[0]!.id).toBe(1); + }); + + test("aggregates oldest/newest only from the given userId's plays", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + // user 111's plays of sound 1 span Feb - Mar + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-02-01T00:00:00Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-03-01T00:00:00Z") }, + // user 222 also played sound 1, outside that range - should be excluded when filtering to 111 + { user_id: "222", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }, + { user_id: "222", sound_id: 1, source, played_at: new Date("2025-12-01T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates("111"); + expect(dates).toHaveLength(1); + expect(dates[0]).toMatchObject({ id: 1, oldestDate: new Date("2025-02-01T00:00:00Z"), latestDate: new Date("2025-03-01T00:00:00Z") }); + }); + + test("returns an empty array when the given userId has no plays", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values({ user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates("999"); + expect(dates).toEqual([]); + }); + + test("filters to only the given year when provided", async () => { + const { db, soundPlays } = await setUpTest(); + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 1, source, played_at: new Date("2024-06-01T00:00:00Z") }, + { user_id: "111", sound_id: 2, source, played_at: new Date("2025-06-01T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(undefined, 2025); + expect(dates).toHaveLength(1); + expect(dates[0]!.id).toBe(2); + }); + + test("aggregates oldest/newest only from plays within the given year", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + { user_id: "111", sound_id: 1, source, played_at: new Date("2024-12-31T23:59:59Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-15T00:00:00Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-11-20T00:00:00Z") }, + { user_id: "111", sound_id: 1, source, played_at: new Date("2026-01-01T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(undefined, 2025); + expect(dates).toHaveLength(1); + expect(dates[0]).toMatchObject({ + id: 1, + oldestDate: new Date("2025-01-15T00:00:00Z"), + latestDate: new Date("2025-11-20T00:00:00Z"), + }); + }); + + test("returns an empty array when no plays fall in the given year", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values({ user_id: "111", sound_id: 1, source, played_at: new Date("2025-01-01T00:00:00Z") }) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates(undefined, 1999); + expect(dates).toEqual([]); + }); + + test("combines userId and year filters together", async () => { + const { db, soundPlays } = await setUpTest(); + + await db + .insertInto("played_sounds") + .values([ + // matches both filters + { user_id: "111", sound_id: 1, source, played_at: new Date("2025-05-01T00:00:00Z") }, + // wrong user, right year + { user_id: "222", sound_id: 2, source, played_at: new Date("2025-05-01T00:00:00Z") }, + // right user, wrong year + { user_id: "111", sound_id: 3, source, played_at: new Date("2024-05-01T00:00:00Z") }, + ]) + .execute(); + + const dates = await soundPlays.getSoundPlayedDates("111", 2025); + expect(dates).toHaveLength(1); + expect(dates[0]!.id).toBe(1); + }); +}); diff --git a/packages/backend/tests/utils/createMinimalTestBot.ts b/packages/backend/tests/utils/createMinimalTestBot.ts index 45ee01f5..9549ea2a 100644 --- a/packages/backend/tests/utils/createMinimalTestBot.ts +++ b/packages/backend/tests/utils/createMinimalTestBot.ts @@ -143,6 +143,7 @@ export async function createMinimalTestBot(config: Config, schemaName: string, o addPlayedSound: vi.fn().mockResolvedValue({}), getSoundPlayCount: vi.fn().mockResolvedValue({}), getSoundPlayCounts: vi.fn().mockResolvedValue({}), + getSoundPlayedDates: vi.fn().mockResolvedValue({}), }); const createStubVoiceEventSoundsRepository = (): VoiceEventSoundsRepository => ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0db87e0c..30f2bb0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,10 @@ overrides: importers: .: + dependencies: + date-fns: + specifier: ^4.4.0 + version: 4.4.0 devDependencies: "@eslint/js": specifier: ^10.0.1 @@ -1223,6 +1227,9 @@ packages: resolution: { integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== } engines: { node: ">= 12" } + date-fns@4.4.0: + resolution: { integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w== } + debug@2.6.9: resolution: { integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== } peerDependencies: @@ -3677,6 +3684,8 @@ snapshots: data-uri-to-buffer@4.0.1: {} + date-fns@4.4.0: {} + debug@2.6.9: dependencies: ms: 2.0.0