diff --git a/config.json b/config.json index 51a29ef4..803cca06 100644 --- a/config.json +++ b/config.json @@ -151,6 +151,7 @@ "rank": "everyone", "leaderboard": "everyone", "profile": "everyone", + "challenge": "everyone", "review": "everyone", "showcase": "everyone" } @@ -206,6 +207,12 @@ "levelThresholds": [100, 300, 600, 1000, 1500, 2500, 4000, 6000, 8500, 12000], "roleRewards": {} }, + "challenges": { + "enabled": false, + "channelId": null, + "postTime": "09:00", + "timezone": "America/New_York" + }, "review": { "enabled": false, "channelId": null, diff --git a/migrations/011_challenges.cjs b/migrations/011_challenges.cjs new file mode 100644 index 00000000..1a53477f --- /dev/null +++ b/migrations/011_challenges.cjs @@ -0,0 +1,35 @@ +/** + * Migration 011 — Daily Coding Challenges + * Creates the challenge_solves table for tracking user solve history. + * + * Uses challenge_date (DATE) as part of the PK so users can re-solve the same + * challenge index when the cycle repeats on a different day. This also enables + * simple date-based consecutive-day streak calculation. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/52 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS challenge_solves ( + guild_id TEXT NOT NULL, + challenge_date DATE NOT NULL, + challenge_index INTEGER NOT NULL, + user_id TEXT NOT NULL, + solved_at TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (guild_id, challenge_date, user_id) + ); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_challenge_solves_guild + ON challenge_solves(guild_id); + `); +} + +module.exports = { up }; diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index 5d009ec2..3ad6d7a2 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -23,6 +23,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'reputation', 'engagement', 'github', + 'challenges', 'review', ]); diff --git a/src/commands/challenge.js b/src/commands/challenge.js new file mode 100644 index 00000000..5b2d3a43 --- /dev/null +++ b/src/commands/challenge.js @@ -0,0 +1,239 @@ +/** + * Challenge Command + * View today's coding challenge, check your streak, or see the leaderboard. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/52 + */ + +import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info } from '../logger.js'; +import { + buildChallengeButtons, + buildChallengeEmbed, + getLocalDateString, + selectTodaysChallenge, +} from '../modules/challengeScheduler.js'; +import { getConfig } from '../modules/config.js'; +import { safeEditReply } from '../utils/safeSend.js'; + +export const data = new SlashCommandBuilder() + .setName('challenge') + .setDescription('Daily coding challenges') + .addSubcommand((sub) => sub.setName('today').setDescription("Show today's coding challenge")) + .addSubcommand((sub) => + sub.setName('streak').setDescription('Show your solve streak and total solves'), + ) + .addSubcommand((sub) => + sub.setName('leaderboard').setDescription('Top 10 solvers this week and all-time'), + ); + +/** + * Execute the /challenge command. + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +export async function execute(interaction) { + await interaction.deferReply({ ephemeral: false }); + + const subcommand = interaction.options.getSubcommand(); + const config = getConfig(interaction.guildId); + const challengesCfg = config.challenges ?? {}; + + if (!challengesCfg.enabled) { + await safeEditReply(interaction, { + content: '❌ Daily coding challenges are not enabled on this server.', + }); + return; + } + + if (subcommand === 'today') { + await handleToday(interaction, challengesCfg); + } else if (subcommand === 'streak') { + await handleStreak(interaction); + } else if (subcommand === 'leaderboard') { + await handleLeaderboard(interaction); + } +} + +/** + * Handle /challenge today + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {Object} challengesCfg + */ +async function handleToday(interaction, challengesCfg) { + const pool = getPool(); + const timezone = challengesCfg.timezone ?? 'America/New_York'; + const now = new Date(); + const { challenge, index, dayNumber } = selectTodaysChallenge(now, timezone); + + // Get current solve count for today's challenge + let solveCount = 0; + if (pool) { + const dateStr = getLocalDateString(now, timezone); + const { rows } = await pool.query( + 'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND challenge_date = $2', + [interaction.guildId, dateStr], + ); + // COUNT(*) returns bigint → pg driver serialises as string; cast explicitly + solveCount = Number(rows[0].total); + } + + const embed = buildChallengeEmbed(challenge, dayNumber, solveCount); + const buttons = buildChallengeButtons(index); + + await safeEditReply(interaction, { embeds: [embed], components: [buttons] }); + + info('/challenge today used', { + userId: interaction.user.id, + guildId: interaction.guildId, + dayNumber, + challengeTitle: challenge.title, + }); +} + +/** + * Handle /challenge streak + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleStreak(interaction) { + const pool = getPool(); + if (!pool) { + await safeEditReply(interaction, { content: '❌ Database unavailable.' }); + return; + } + + const { guildId } = interaction; + const userId = interaction.user.id; + + // Total solves + const { rows: totalRows } = await pool.query( + 'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND user_id = $2', + [guildId, userId], + ); + // COUNT(*) returns bigint → pg driver serialises as string; cast explicitly + const totalSolves = Number(totalRows[0].total); + + // Get all solved dates ordered newest-first to compute consecutive-day streak + const { rows: solvedRows } = await pool.query( + `SELECT challenge_date FROM challenge_solves + WHERE guild_id = $1 AND user_id = $2 + ORDER BY challenge_date DESC`, + [guildId, userId], + ); + + // Compute streak: count consecutive days backwards from today (or yesterday). + // pg returns DATE columns as JS Date objects at UTC midnight. + let streak = 0; + if (solvedRows.length > 0) { + const todayUTC = new Date(); + const todayStr = todayUTC.toISOString().slice(0, 10); + const yesterdayUTC = new Date(todayUTC); + yesterdayUTC.setUTCDate(yesterdayUTC.getUTCDate() - 1); + const yesterdayStr = yesterdayUTC.toISOString().slice(0, 10); + + const dates = solvedRows.map((r) => new Date(r.challenge_date).toISOString().slice(0, 10)); + const mostRecent = dates[0]; + + // Only count a streak if the user solved today or yesterday + if (mostRecent === todayStr || mostRecent === yesterdayStr) { + streak = 1; + for (let i = 1; i < dates.length; i++) { + const prevDate = new Date(dates[i - 1]); + prevDate.setUTCDate(prevDate.getUTCDate() - 1); + if (dates[i] === prevDate.toISOString().slice(0, 10)) { + streak++; + } else { + break; + } + } + } + } + + const embed = new EmbedBuilder() + .setColor(0x5865f2) + .setTitle(`📊 Challenge Stats — ${interaction.user.displayName}`) + .setThumbnail(interaction.user.displayAvatarURL()) + .addFields( + { + name: '🔥 Current Streak', + value: `**${streak}** challenge${streak !== 1 ? 's' : ''}`, + inline: true, + }, + { + name: '✅ Total Solved', + value: `**${totalSolves}** challenge${totalSolves !== 1 ? 's' : ''}`, + inline: true, + }, + ) + .setFooter({ text: 'Keep solving to grow your streak!' }) + .setTimestamp(); + + await safeEditReply(interaction, { embeds: [embed] }); +} + +/** + * Handle /challenge leaderboard + * + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ +async function handleLeaderboard(interaction) { + const pool = getPool(); + if (!pool) { + await safeEditReply(interaction, { content: '❌ Database unavailable.' }); + return; + } + + const { guildId } = interaction; + + // All-time top 10 + const { rows: allTimeRows } = await pool.query( + `SELECT user_id, COUNT(*) AS total + FROM challenge_solves + WHERE guild_id = $1 + GROUP BY user_id + ORDER BY total DESC + LIMIT 10`, + [guildId], + ); + + // This week top 10 (last 7 days) + const { rows: weekRows } = await pool.query( + `SELECT user_id, COUNT(*) AS total + FROM challenge_solves + WHERE guild_id = $1 AND solved_at >= NOW() - INTERVAL '7 days' + GROUP BY user_id + ORDER BY total DESC + LIMIT 10`, + [guildId], + ); + + const medals = ['🥇', '🥈', '🥉']; + + const formatBoard = (rows) => { + if (rows.length === 0) return '_No solves yet — be the first!_'; + return rows + .map((row, i) => { + const prefix = medals[i] ?? `**${i + 1}.**`; + const total = Number(row.total); + return `${prefix} <@${row.user_id}> — **${total}** solve${total !== 1 ? 's' : ''}`; + }) + .join('\n'); + }; + + const embed = new EmbedBuilder() + .setColor(0xfee75c) + .setTitle('🏆 Challenge Leaderboard') + .addFields( + { name: '📅 This Week', value: formatBoard(weekRows) }, + { name: '🌟 All-Time', value: formatBoard(allTimeRows) }, + ) + .setFooter({ text: 'Solve daily challenges to climb the ranks!' }) + .setTimestamp(); + + await safeEditReply(interaction, { embeds: [embed] }); + + info('/challenge leaderboard used', { userId: interaction.user.id, guildId }); +} diff --git a/src/data/challenges.json b/src/data/challenges.json new file mode 100644 index 00000000..178109bc --- /dev/null +++ b/src/data/challenges.json @@ -0,0 +1,398 @@ +[ + { + "title": "Two Sum", + "description": "Given an array of integers `nums` and an integer `target`, return the indices of the two numbers that add up to `target`. You may assume each input has exactly one solution, and you may not use the same element twice.", + "difficulty": "easy", + "hints": [ + "Try using a hash map to store values you've seen so far.", + "For each element, check if `target - element` is already in the map." + ], + "sampleInput": "nums = [2, 7, 11, 15], target = 9", + "sampleOutput": "[0, 1]", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "FizzBuzz", + "description": "Print numbers from 1 to n. For multiples of 3, print \"Fizz\". For multiples of 5, print \"Buzz\". For multiples of both, print \"FizzBuzz\".", + "difficulty": "easy", + "hints": [ + "Check divisibility with the modulo operator `%`.", + "Check for FizzBuzz (divisible by both) before checking individually." + ], + "sampleInput": "n = 15", + "sampleOutput": "1, 2, Fizz, 4, Buzz, Fizz, 7, 8, Fizz, Buzz, 11, Fizz, 13, 14, FizzBuzz", + "languages": ["javascript", "python", "typescript", "go", "rust"] + }, + { + "title": "Palindrome Check", + "description": "Given a string `s`, return `true` if it is a palindrome (reads the same forwards and backwards), and `false` otherwise. Ignore non-alphanumeric characters and treat uppercase and lowercase letters as the same.", + "difficulty": "easy", + "hints": [ + "Strip non-alphanumeric characters and convert to lowercase first.", + "Compare the string with its reverse." + ], + "sampleInput": "s = \"A man, a plan, a canal: Panama\"", + "sampleOutput": "true", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Reverse a String", + "description": "Write a function that reverses a string. The input string is given as an array of characters `s`. You must do this in-place with O(1) extra memory.", + "difficulty": "easy", + "hints": [ + "Use two pointers — one at the start and one at the end.", + "Swap characters at the two pointers, then move them toward each other." + ], + "sampleInput": "s = ['h','e','l','l','o']", + "sampleOutput": "['o','l','l','e','h']", + "languages": ["javascript", "python", "typescript", "java", "c++"] + }, + { + "title": "Fibonacci Sequence", + "description": "Return the nth Fibonacci number. The sequence starts with F(0) = 0, F(1) = 1, and each subsequent number is the sum of the two preceding ones.", + "difficulty": "easy", + "hints": [ + "Start with a simple recursive approach, then think about efficiency.", + "Memoization or an iterative approach avoids redundant calculations." + ], + "sampleInput": "n = 10", + "sampleOutput": "55", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Valid Parentheses", + "description": "Given a string `s` containing only the characters `(`, `)`, `{`, `}`, `[`, and `]`, determine if the input string is valid. A string is valid if every open bracket is closed by the same type of bracket in the correct order.", + "difficulty": "easy", + "hints": [ + "Use a stack data structure.", + "Push opening brackets onto the stack. When you see a closing bracket, check if it matches the top of the stack." + ], + "sampleInput": "s = \"()[{}]\"", + "sampleOutput": "true", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Maximum Subarray", + "description": "Given an integer array `nums`, find the contiguous subarray (containing at least one number) which has the largest sum and return its sum.", + "difficulty": "easy", + "hints": [ + "This is Kadane's Algorithm.", + "Track the current running sum, and reset it to 0 if it goes negative." + ], + "sampleInput": "nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]", + "sampleOutput": "6", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Count Vowels", + "description": "Given a string, return the number of vowels (a, e, i, o, u — both upper and lowercase) in the string.", + "difficulty": "easy", + "hints": [ + "Iterate over each character and check if it's a vowel.", + "Use a Set containing all vowels for O(1) lookup." + ], + "sampleInput": "s = \"Hello, World!\"", + "sampleOutput": "3", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Find the Duplicate", + "description": "Given an array of `n + 1` integers where each integer is between 1 and n inclusive, find the duplicate number. You must not modify the array and use only O(1) extra space.", + "difficulty": "easy", + "hints": [ + "Think of the array as a linked list where index -> value is like a pointer.", + "Floyd's cycle detection algorithm works here." + ], + "sampleInput": "nums = [1, 3, 4, 2, 2]", + "sampleOutput": "2", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Single Number", + "description": "Given a non-empty array of integers `nums`, every element appears twice except for one. Find that single element. Your solution must use O(1) extra memory.", + "difficulty": "easy", + "hints": [ + "XOR of a number with itself is 0.", + "XOR all elements together — the duplicate pairs cancel out." + ], + "sampleInput": "nums = [4, 1, 2, 1, 2]", + "sampleOutput": "4", + "languages": ["javascript", "python", "typescript", "go", "rust"] + }, + { + "title": "Binary Search", + "description": "Given a sorted array of integers `nums` and a target value, return the index of `target` if it exists, or `-1` if it doesn't. Your algorithm must run in O(log n) time.", + "difficulty": "medium", + "hints": [ + "Divide the search space in half each iteration.", + "Maintain `left` and `right` pointers. Compute `mid = Math.floor((left + right) / 2)`." + ], + "sampleInput": "nums = [-1, 0, 3, 5, 9, 12], target = 9", + "sampleOutput": "4", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Merge Two Sorted Lists", + "description": "Merge two sorted linked lists and return the result as a sorted list. The list should be made by splicing together the nodes of the first two lists.", + "difficulty": "medium", + "hints": [ + "Compare the heads of both lists and pick the smaller one.", + "Recursion works elegantly here — or use an iterative approach with a dummy head node." + ], + "sampleInput": "l1 = [1, 2, 4], l2 = [1, 3, 4]", + "sampleOutput": "[1, 1, 2, 3, 4, 4]", + "languages": ["javascript", "python", "typescript", "java"] + }, + { + "title": "Linked List Cycle", + "description": "Given a linked list, determine if it has a cycle in it. Can you solve it using O(1) memory?", + "difficulty": "medium", + "hints": [ + "Use two pointers — a slow one and a fast one.", + "If there's a cycle, the fast pointer will eventually catch the slow pointer (Floyd's algorithm)." + ], + "sampleInput": "head = [3, 2, 0, -4], pos = 1 (tail connects to index 1)", + "sampleOutput": "true", + "languages": ["javascript", "python", "typescript", "java"] + }, + { + "title": "Climbing Stairs", + "description": "You are climbing a staircase. It takes `n` steps to reach the top. Each time you can climb 1 or 2 steps. In how many distinct ways can you climb to the top?", + "difficulty": "medium", + "hints": [ + "This follows the Fibonacci pattern.", + "The number of ways to reach step n = ways to reach (n-1) + ways to reach (n-2)." + ], + "sampleInput": "n = 5", + "sampleOutput": "8", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Number of Islands", + "description": "Given a 2D grid of '1's (land) and '0's (water), count the number of islands. An island is surrounded by water and is formed by connecting adjacent lands horizontally or vertically.", + "difficulty": "medium", + "hints": [ + "Use DFS or BFS starting from each unvisited '1'.", + "Mark visited cells as '0' to avoid counting them twice." + ], + "sampleInput": "grid = [[\"1\",\"1\",\"0\"],[\"0\",\"1\",\"0\"],[\"0\",\"0\",\"1\"]]", + "sampleOutput": "2", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Longest Common Prefix", + "description": "Write a function to find the longest common prefix string amongst an array of strings. If there is no common prefix, return an empty string.", + "difficulty": "medium", + "hints": [ + "Sort the array — the common prefix of the first and last strings is the answer.", + "Or compare character by character across all strings." + ], + "sampleInput": "strs = [\"flower\", \"flow\", \"flight\"]", + "sampleOutput": "\"fl\"", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Product of Array Except Self", + "description": "Given an integer array `nums`, return an array `answer` such that `answer[i]` is equal to the product of all elements of `nums` except `nums[i]`. Solve it in O(n) time without using division.", + "difficulty": "medium", + "hints": [ + "Use prefix products (left pass) and suffix products (right pass).", + "For each index i: answer[i] = prefix_product[i-1] * suffix_product[i+1]." + ], + "sampleInput": "nums = [1, 2, 3, 4]", + "sampleOutput": "[24, 12, 8, 6]", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Rotate Array", + "description": "Given an array, rotate it to the right by `k` steps, where `k` is non-negative. Try to do it in-place with O(1) extra space.", + "difficulty": "medium", + "hints": [ + "Reverse the whole array, then reverse the first k elements, then reverse the rest.", + "Use `k = k % n` to handle cases where k > n." + ], + "sampleInput": "nums = [1, 2, 3, 4, 5, 6, 7], k = 3", + "sampleOutput": "[5, 6, 7, 1, 2, 3, 4]", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Group Anagrams", + "description": "Given an array of strings `strs`, group the anagrams together. Return the groups in any order.", + "difficulty": "medium", + "hints": [ + "Two words are anagrams if their sorted characters are identical.", + "Use a hash map keyed on the sorted string." + ], + "sampleInput": "strs = [\"eat\", \"tea\", \"tan\", \"ate\", \"nat\", \"bat\"]", + "sampleOutput": "[[\"bat\"], [\"nat\", \"tan\"], [\"ate\", \"eat\", \"tea\"]]", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Maximum Depth of Binary Tree", + "description": "Given the root of a binary tree, return its maximum depth. The maximum depth is the number of nodes along the longest path from the root node to the farthest leaf node.", + "difficulty": "medium", + "hints": [ + "Use recursion — the depth of a node is 1 + max(depth(left), depth(right)).", + "BFS level-by-level also works." + ], + "sampleInput": "root = [3, 9, 20, null, null, 15, 7]", + "sampleOutput": "3", + "languages": ["javascript", "python", "typescript", "java"] + }, + { + "title": "Coin Change", + "description": "You are given an integer array `coins` representing coins of different denominations and an integer `amount`. Return the fewest number of coins needed to make up that amount. Return -1 if it cannot be made.", + "difficulty": "medium", + "hints": [ + "Classic dynamic programming problem.", + "Build up a DP array where dp[i] = fewest coins to make amount i." + ], + "sampleInput": "coins = [1, 5, 11], amount = 15", + "sampleOutput": "3", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Longest Substring Without Repeating Characters", + "description": "Given a string `s`, find the length of the longest substring without repeating characters.", + "difficulty": "medium", + "hints": [ + "Use the sliding window technique.", + "Maintain a set of characters in the current window. Expand right, shrink left when a duplicate is found." + ], + "sampleInput": "s = \"abcabcbb\"", + "sampleOutput": "3", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Spiral Matrix", + "description": "Given an `m x n` matrix, return all elements in spiral order (clockwise from the top-left).", + "difficulty": "hard", + "hints": [ + "Track four boundaries: top, bottom, left, right.", + "Traverse right → down → left → up, shrinking the boundaries after each direction." + ], + "sampleInput": "matrix = [[1,2,3],[4,5,6],[7,8,9]]", + "sampleOutput": "[1, 2, 3, 6, 9, 8, 7, 4, 5]", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Word Search", + "description": "Given an `m x n` grid of characters and a string `word`, return true if the word exists in the grid. The word can be constructed from sequentially adjacent cells (horizontally or vertically) where each cell may only be used once.", + "difficulty": "hard", + "hints": [ + "Use backtracking DFS from every cell.", + "Mark cells as visited temporarily (e.g., replace with '#') and restore them after the recursive call." + ], + "sampleInput": "board = [[\"A\",\"B\",\"C\"],[\"S\",\"F\",\"C\"],[\"A\",\"D\",\"E\"]], word = \"ABCCED\"", + "sampleOutput": "true", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "LRU Cache", + "description": "Design a data structure that follows the Least Recently Used (LRU) cache eviction policy. Implement `get(key)` (return -1 if not found) and `put(key, value)` (evict the LRU item if capacity is exceeded). Both operations must be O(1).", + "difficulty": "hard", + "hints": [ + "Combine a hash map with a doubly linked list.", + "The map gives O(1) access; the linked list maintains insertion/access order for eviction." + ], + "sampleInput": "LRUCache(2) → put(1,1) → put(2,2) → get(1) → put(3,3) → get(2) → get(3)", + "sampleOutput": "1, -1, 3", + "languages": ["javascript", "python", "typescript", "java"] + }, + { + "title": "Serialize and Deserialize Binary Tree", + "description": "Design an algorithm to serialize and deserialize a binary tree. Serialization is the process of converting a tree to a string; deserialization is the reverse. There is no restriction on how you do it as long as the pair of functions are inverse of each other.", + "difficulty": "hard", + "hints": [ + "BFS (level-order) traversal is a straightforward approach.", + "Use a sentinel (like 'null') to represent missing children during serialization." + ], + "sampleInput": "root = [1, 2, 3, null, null, 4, 5]", + "sampleOutput": "\"1,2,3,null,null,4,5\"", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Median of Two Sorted Arrays", + "description": "Given two sorted arrays `nums1` and `nums2`, return the median of the two sorted arrays. The solution must run in O(log(m + n)) time.", + "difficulty": "hard", + "hints": [ + "Binary search on the smaller array to find the correct partition.", + "Ensure that elements on the left of the partition in both arrays are ≤ elements on the right." + ], + "sampleInput": "nums1 = [1, 3], nums2 = [2]", + "sampleOutput": "2.0", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Trapping Rain Water", + "description": "Given `n` non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it can trap after raining.", + "difficulty": "hard", + "hints": [ + "For each position, water = min(max_left, max_right) - height.", + "Use two-pointer approach to achieve O(n) time and O(1) space." + ], + "sampleInput": "height = [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]", + "sampleOutput": "6", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Regular Expression Matching", + "description": "Implement regular expression matching with support for `.` (matches any single character) and `*` (matches zero or more of the preceding element). The matching should cover the entire input string.", + "difficulty": "hard", + "hints": [ + "Use dynamic programming with a 2D table dp[i][j] = does s[0..i] match p[0..j]?", + "Handle the `*` case carefully: it can match zero occurrences or extend a match." + ], + "sampleInput": "s = \"aab\", p = \"c*a*b\"", + "sampleOutput": "true", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Alien Dictionary", + "description": "Given a list of words sorted according to rules of an alien language, derive the order of characters in that language. If the order is invalid, return an empty string.", + "difficulty": "hard", + "hints": [ + "Build a directed graph from adjacent word pairs.", + "Use topological sort (Kahn's algorithm or DFS with cycle detection) to derive the order." + ], + "sampleInput": "words = [\"wrt\", \"wrf\", \"er\", \"ett\", \"rftt\"]", + "sampleOutput": "\"wertf\"", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "Sliding Window Maximum", + "description": "Given an integer array `nums` and a sliding window of size `k`, return an array of the maximum value in each window position as the window slides from left to right.", + "difficulty": "hard", + "hints": [ + "Use a monotonic deque (double-ended queue) that stores indices.", + "Remove indices that are out of the window's range from the front; pop smaller values from the back." + ], + "sampleInput": "nums = [1, 3, -1, -3, 5, 3, 6, 7], k = 3", + "sampleOutput": "[3, 3, 5, 5, 6, 7]", + "languages": ["javascript", "python", "typescript", "go"] + }, + { + "title": "Count and Say", + "description": "The count-and-say sequence is defined as: 1 → \"1\", 2 → \"11\", 3 → \"21\", 4 → \"1211\", etc. Each term is generated by reading off the digits of the previous term. Return the nth term.", + "difficulty": "medium", + "hints": [ + "Iterate n-1 times, generating each term from the previous.", + "Group consecutive digits and count their occurrences." + ], + "sampleInput": "n = 4", + "sampleOutput": "\"1211\"", + "languages": ["javascript", "python", "typescript"] + }, + { + "title": "3Sum", + "description": "Given an integer array `nums`, return all the unique triplets [nums[i], nums[j], nums[k]] such that i, j, k are distinct and their sum is zero.", + "difficulty": "medium", + "hints": [ + "Sort the array first.", + "For each element, use a two-pointer approach on the remaining elements to find pairs that sum to the negation of the current element." + ], + "sampleInput": "nums = [-1, 0, 1, 2, -1, -4]", + "sampleOutput": "[[-1, -1, 2], [-1, 0, 1]]", + "languages": ["javascript", "python", "typescript"] + } +] diff --git a/src/modules/challengeScheduler.js b/src/modules/challengeScheduler.js new file mode 100644 index 00000000..e6bdf188 --- /dev/null +++ b/src/modules/challengeScheduler.js @@ -0,0 +1,412 @@ +/** + * Daily Coding Challenge Scheduler + * Automatically posts a new coding challenge every day at a configured time. + * Tracks solve history and handles hint/solve button interactions. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/52 + */ + +import { createRequire } from 'node:module'; +import { ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder } from 'discord.js'; +import { getPool } from '../db.js'; +import { info, error as logError, warn as logWarn } from '../logger.js'; +import { getConfig } from './config.js'; + +const require = createRequire(import.meta.url); +/** @type {Array} */ +const CHALLENGES = require('../data/challenges.json'); + +/** Colour codes by difficulty */ +const DIFFICULTY_COLORS = { + easy: 0x57f287, + medium: 0xfee75c, + hard: 0xed4245, +}; + +/** Emoji prefix by difficulty */ +const DIFFICULTY_EMOJI = { + easy: '🟢', + medium: '🟡', + hard: '🔴', +}; + +/** In-memory map of guildId → last posted date string (YYYY-MM-DD) */ +const lastPostedDate = new Map(); + +/** + * Get the day-of-year (1-indexed) for a given date in a timezone. + * + * @param {Date} now - Current date + * @param {string} timezone - IANA timezone string + * @returns {number} Day of year + */ +export function getDayOfYear(now, timezone) { + // Get the start of the year in the target timezone + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + + const parts = formatter.formatToParts(now); + const year = Number.parseInt(parts.find((p) => p.type === 'year').value, 10); + const month = Number.parseInt(parts.find((p) => p.type === 'month').value, 10); + const day = Number.parseInt(parts.find((p) => p.type === 'day').value, 10); + + const startOfYear = new Date(`${year}-01-01T00:00:00`); + const localDate = new Date( + `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}T00:00:00`, + ); + + const diffMs = localDate.getTime() - startOfYear.getTime(); + return Math.floor(diffMs / (1000 * 60 * 60 * 24)) + 1; +} + +/** + * Get today's date string (YYYY-MM-DD) in the given timezone. + * + * @param {Date} now - Current date + * @param {string} timezone - IANA timezone string + * @returns {string} Date string YYYY-MM-DD + */ +export function getLocalDateString(now, timezone) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); +} + +/** + * Get the current time as HH:MM in the given timezone. + * + * @param {Date} now - Current date + * @param {string} timezone - IANA timezone string + * @returns {string} Time string HH:MM + */ +export function getLocalTimeString(now, timezone) { + return new Intl.DateTimeFormat('en-GB', { + timeZone: timezone, + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(now); +} + +/** + * Select today's challenge based on day-of-year. + * + * @param {Date} now - Current date + * @param {string} timezone - IANA timezone string + * @returns {{ challenge: Object, index: number, dayNumber: number }} + */ +export function selectTodaysChallenge(now, timezone) { + const dayNumber = getDayOfYear(now, timezone); + const index = (dayNumber - 1) % CHALLENGES.length; + return { challenge: CHALLENGES[index], index, dayNumber }; +} + +/** + * Build the challenge embed message. + * + * @param {Object} challenge - Challenge data + * @param {number} dayNumber - Day of year + * @param {number} solveCount - How many users have solved it + * @returns {EmbedBuilder} + */ +export function buildChallengeEmbed(challenge, dayNumber, solveCount = 0) { + const color = DIFFICULTY_COLORS[challenge.difficulty] ?? 0x5865f2; + const emoji = DIFFICULTY_EMOJI[challenge.difficulty] ?? '⚪'; + + return new EmbedBuilder() + .setColor(color) + .setTitle(`🧩 Daily Challenge #${dayNumber} — ${challenge.title}`) + .setDescription(challenge.description) + .addFields( + { + name: 'Difficulty', + value: `${emoji} ${challenge.difficulty.charAt(0).toUpperCase() + challenge.difficulty.slice(1)}`, + inline: true, + }, + { + name: 'Languages', + value: challenge.languages.join(', '), + inline: true, + }, + { + name: 'Sample Input', + value: `\`\`\`\n${challenge.sampleInput}\n\`\`\``, + }, + { + name: 'Sample Output', + value: `\`\`\`\n${challenge.sampleOutput}\n\`\`\``, + }, + ) + .setFooter({ + text: `${solveCount} solver${solveCount !== 1 ? 's' : ''} so far • Click "Mark Solved" when you've got it!`, + }) + .setTimestamp(); +} + +/** + * Build the challenge action row buttons. + * + * @param {number} challengeIndex - The challenge index for button IDs + * @returns {ActionRowBuilder} + */ +export function buildChallengeButtons(challengeIndex) { + return new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`challenge_hint_${challengeIndex}`) + .setLabel('💡 Hint') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`challenge_solve_${challengeIndex}`) + .setLabel('✅ Mark Solved') + .setStyle(ButtonStyle.Success), + ); +} + +/** + * Post the daily challenge to a guild's configured channel. + * + * @param {import('discord.js').Client} client - Discord client + * @param {string} guildId - Guild ID to post for + * @returns {Promise} true if posted successfully + */ +export async function postDailyChallenge(client, guildId) { + const config = getConfig(guildId); + const challengesCfg = config.challenges ?? {}; + + if (!challengesCfg.enabled) return false; + + const channelId = challengesCfg.channelId; + if (!channelId) { + logWarn('Challenge channel not configured', { guildId }); + return false; + } + + const channel = await client.channels.fetch(channelId).catch(() => null); + if (!channel) { + logWarn('Challenge channel not found', { guildId, channelId }); + return false; + } + + const timezone = challengesCfg.timezone ?? 'America/New_York'; + const now = new Date(); + const { challenge, index, dayNumber } = selectTodaysChallenge(now, timezone); + + const embed = buildChallengeEmbed(challenge, dayNumber, 0); + const buttons = buildChallengeButtons(index); + + const message = await channel.send({ embeds: [embed], components: [buttons] }); + + // Create a discussion thread on the message + try { + await message.startThread({ + name: `💬 Challenge #${dayNumber} — ${challenge.title}`, + autoArchiveDuration: 1440, // 24 hours + reason: 'Daily coding challenge discussion', + }); + } catch (err) { + logWarn('Failed to create challenge discussion thread', { + guildId, + messageId: message.id, + error: err.message, + }); + } + + // Track last posted date so we don't double-post + lastPostedDate.set(guildId, getLocalDateString(now, timezone)); + + info('Daily challenge posted', { + guildId, + channelId, + dayNumber, + challengeTitle: challenge.title, + difficulty: challenge.difficulty, + }); + + return true; +} + +/** + * Check if it's time to post the daily challenge for a guild. + * Called from the 60s scheduler poll loop. + * + * @param {import('discord.js').Client} client - Discord client + * @param {string} guildId - Guild ID to check + * @returns {Promise} + */ +export async function checkDailyChallengeForGuild(client, guildId) { + const config = getConfig(guildId); + const challengesCfg = config.challenges ?? {}; + + if (!challengesCfg.enabled) return; + + const timezone = challengesCfg.timezone ?? 'America/New_York'; + const postTime = challengesCfg.postTime ?? '09:00'; + const now = new Date(); + + const currentTime = getLocalTimeString(now, timezone); + const todayStr = getLocalDateString(now, timezone); + + // Check if we've already posted today + if (lastPostedDate.get(guildId) === todayStr) return; + + // Check if it's time to post — use >= so a delayed poll loop doesn't miss the window + if (currentTime < postTime) return; + + try { + await postDailyChallenge(client, guildId); + } catch (err) { + logError('Failed to post daily challenge', { guildId, error: err.message }); + } +} + +/** + * Check all guilds for pending daily challenges. + * Called from the scheduler's 60s polling loop. + * + * @param {import('discord.js').Client} client - Discord client + * @returns {Promise} + */ +export async function checkDailyChallenge(client) { + for (const guild of client.guilds.cache.values()) { + await checkDailyChallengeForGuild(client, guild.id).catch((err) => { + logError('Daily challenge check failed for guild', { + guildId: guild.id, + error: err.message, + }); + }); + } +} + +/** + * Handle the "Mark Solved" button interaction. + * + * @param {import('discord.js').ButtonInteraction} interaction + * @param {number} challengeIndex - Parsed challenge index from customId + * @returns {Promise} + */ +export async function handleSolveButton(interaction, challengeIndex) { + // Bounds validation — prevents out-of-range indices from causing DB errors + if (challengeIndex < 0 || challengeIndex >= CHALLENGES.length) { + return interaction.reply({ content: '❌ Invalid challenge.', flags: 64 }); + } + + const pool = getPool(); + if (!pool) { + await interaction.reply({ content: '❌ Database unavailable.', ephemeral: true }); + return; + } + + const { guildId } = interaction; + const userId = interaction.user.id; + + // Resolve today's date in the guild's configured timezone so the stored date + // matches what the guild considers "today" rather than UTC midnight. + const config = getConfig(guildId); + const timezone = config.challenges?.timezone ?? 'America/New_York'; + const challengeDate = getLocalDateString(new Date(), timezone); + + // Upsert the solve record — PK is (guild_id, challenge_date, user_id) so the + // same user can solve challenge index 0 again when the cycle repeats. + await pool.query( + `INSERT INTO challenge_solves (guild_id, challenge_date, challenge_index, user_id) + VALUES ($1, $2, $3, $4) + ON CONFLICT (guild_id, challenge_date, user_id) DO NOTHING`, + [guildId, challengeDate, challengeIndex, userId], + ); + + // Get total solves for this user in this guild + const { rows: totalRows } = await pool.query( + 'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND user_id = $2', + [guildId, userId], + ); + // COUNT(*) returns bigint → pg driver serialises as string; cast explicitly + const totalSolves = Number(totalRows[0].total); + + // Get total solvers for today's challenge (filter by date, not index) + const { rows: solveRows } = await pool.query( + 'SELECT COUNT(*) AS total FROM challenge_solves WHERE guild_id = $1 AND challenge_date = $2', + [guildId, challengeDate], + ); + const solveCount = Number(solveRows[0].total); + + // Update the embed footer with new solve count + try { + const msg = interaction.message; + if (msg.embeds.length > 0) { + const oldEmbed = msg.embeds[0]; + const updatedEmbed = EmbedBuilder.from(oldEmbed).setFooter({ + text: `${solveCount} solver${solveCount !== 1 ? 's' : ''} so far • Click "Mark Solved" when you've got it!`, + }); + await msg.edit({ embeds: [updatedEmbed], components: msg.components }); + } + } catch (editErr) { + logWarn('Could not update challenge embed after solve', { + messageId: interaction.message.id, + error: editErr.message, + }); + } + + await interaction.reply({ + content: `✅ Marked as solved! You've solved **${totalSolves}** challenge${totalSolves !== 1 ? 's' : ''} total. Nice work! 🎉`, + ephemeral: true, + }); + + info('Challenge solved', { guildId, userId, challengeIndex, totalSolves }); +} + +/** + * Handle the "Hint" button interaction. + * + * @param {import('discord.js').ButtonInteraction} interaction + * @param {number} challengeIndex - Parsed challenge index from customId + * @returns {Promise} + */ +export async function handleHintButton(interaction, challengeIndex) { + const challenge = CHALLENGES[challengeIndex]; + if (!challenge) { + await interaction.reply({ content: '❌ Challenge not found.', ephemeral: true }); + return; + } + + const hints = challenge.hints ?? []; + if (hints.length === 0) { + await interaction.reply({ + content: '🤷 No hints available for this challenge.', + ephemeral: true, + }); + return; + } + + const hintLines = hints.map((h, i) => `**Hint ${i + 1}:** ${h}`).join('\n'); + await interaction.reply({ + content: `💡 **Hints for "${challenge.title}":**\n\n${hintLines}`, + ephemeral: true, + }); +} + +/** + * Get the challenges data array (for use by commands). + * + * @returns {Array} challenges + */ +export function getChallenges() { + return CHALLENGES; +} + +/** + * Start the challenge scheduler — just a no-op now since we plug into + * the existing 60s scheduler loop via checkDailyChallenge. + * Kept for a clean startup log. + * + * @param {import('discord.js').Client} client - Discord client + */ +export function startChallengeScheduler(_client) { + info('Daily challenge scheduler ready (integrated into main poll loop)'); +} diff --git a/src/modules/events.js b/src/modules/events.js index f85e3352..9cfff052 100644 --- a/src/modules/events.js +++ b/src/modules/events.js @@ -12,6 +12,7 @@ import { getUserFriendlyMessage } from '../utils/errors.js'; // safe wrapper applies identically to either target type. import { safeEditReply, safeReply } from '../utils/safeSend.js'; import { handleAfkMentions } from './afkHandler.js'; +import { handleHintButton, handleSolveButton } from './challengeScheduler.js'; import { getConfig } from './config.js'; import { trackMessage, trackReaction } from './engagement.js'; import { checkLinks } from './linkFilter.js'; @@ -493,6 +494,56 @@ export function registerErrorHandlers(client) { } } +/** + * Register an interactionCreate handler for challenge solve and hint buttons. + * Listens for button clicks with customId matching `challenge_solve_` or `challenge_hint_`. + * + * @param {Client} client - Discord client instance + */ +export function registerChallengeButtonHandler(client) { + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isButton()) return; + + const isSolve = interaction.customId.startsWith('challenge_solve_'); + const isHint = interaction.customId.startsWith('challenge_hint_'); + if (!isSolve && !isHint) return; + + const prefix = isSolve ? 'challenge_solve_' : 'challenge_hint_'; + const indexStr = interaction.customId.slice(prefix.length); + const challengeIndex = Number.parseInt(indexStr, 10); + + if (Number.isNaN(challengeIndex)) { + warn('Invalid challenge button customId', { customId: interaction.customId }); + return; + } + + try { + if (isSolve) { + await handleSolveButton(interaction, challengeIndex); + } else { + await handleHintButton(interaction, challengeIndex); + } + } catch (err) { + logError('Challenge button handler failed', { + customId: interaction.customId, + userId: interaction.user?.id, + error: err.message, + }); + + if (!interaction.replied && !interaction.deferred) { + try { + await safeReply(interaction, { + content: '❌ Something went wrong. Please try again.', + ephemeral: true, + }); + } catch { + // Ignore + } + } + } + }); +} + /** * Register all event handlers * @param {Object} client - Discord client @@ -505,6 +556,7 @@ export function registerEventHandlers(client, config, healthMonitor) { registerMessageCreateHandler(client, config, healthMonitor); registerReactionHandlers(client, config); registerPollButtonHandler(client); + registerChallengeButtonHandler(client); registerReviewClaimHandler(client); registerShowcaseButtonHandler(client); registerShowcaseModalHandler(client); diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index db01f1e2..69aabebd 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -8,6 +8,7 @@ import { getPool } from '../db.js'; import { info, error as logError, warn as logWarn } from '../logger.js'; import { safeSend } from '../utils/safeSend.js'; +import { checkDailyChallenge } from './challengeScheduler.js'; import { closeExpiredPolls } from './pollHandler.js'; import { expireStaleReviews } from './reviewHandler.js'; @@ -182,6 +183,9 @@ async function pollScheduledMessages(client) { } // Close expired polls await closeExpiredPolls(client); + + // Check and post daily coding challenges + await checkDailyChallenge(client); // Expire stale review requests await expireStaleReviews(client); } catch (err) { diff --git a/tests/commands/challenge.test.js b/tests/commands/challenge.test.js new file mode 100644 index 00000000..51bd3f10 --- /dev/null +++ b/tests/commands/challenge.test.js @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mock dependencies ─────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +vi.mock('../../src/utils/safeSend.js', () => ({ + safeSend: vi.fn(), + safeReply: (t, opts) => t.reply(opts), + safeEditReply: (t, opts) => t.editReply(opts), +})); + +// Minimal discord.js mock — just enough for the builder chain +vi.mock('discord.js', async () => { + function _chainable() { + const proxy = new Proxy(() => proxy, { + get: () => () => proxy, + apply: () => proxy, + }); + return proxy; + } + + class MockSlashCommandBuilder { + constructor() { + this.name = ''; + this.description = ''; + } + setName(name) { + this.name = name; + return this; + } + setDescription(desc) { + this.description = desc; + return this; + } + addSubcommand(fn) { + const sub = { + setName: () => ({ setDescription: () => ({ addStringOption: () => sub }) }), + }; + fn(sub); + return this; + } + toJSON() { + return { name: this.name, description: this.description }; + } + } + + class MockEmbedBuilder { + constructor() { + this._data = {}; + } + setColor(c) { + this._data.color = c; + return this; + } + setTitle(t) { + this._data.title = t; + return this; + } + setThumbnail(u) { + this._data.thumbnail = u; + return this; + } + setDescription(d) { + this._data.description = d; + return this; + } + addFields(...args) { + this._data.fields = args.flat(); + return this; + } + setFooter(f) { + this._data.footer = f; + return this; + } + setTimestamp() { + return this; + } + toJSON() { + return this._data; + } + } + + return { + SlashCommandBuilder: MockSlashCommandBuilder, + EmbedBuilder: MockEmbedBuilder, + }; +}); + +vi.mock('../../src/modules/challengeScheduler.js', () => ({ + selectTodaysChallenge: vi.fn(), + buildChallengeEmbed: vi.fn(() => ({ mock: 'embed' })), + buildChallengeButtons: vi.fn(() => ({ mock: 'buttons' })), + getChallenges: vi.fn(() => []), + getLocalDateString: vi.fn(() => '2024-01-10'), +})); + +// ─── Imports ───────────────────────────────────────────────────────────────── + +import { execute } from '../../src/commands/challenge.js'; +import { getPool } from '../../src/db.js'; +import { + buildChallengeButtons, + buildChallengeEmbed, + getLocalDateString, + selectTodaysChallenge, +} from '../../src/modules/challengeScheduler.js'; +import { getConfig } from '../../src/modules/config.js'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function makeInteraction(subcommand, guildId = 'guild-1') { + return { + guildId, + options: { + getSubcommand: vi.fn().mockReturnValue(subcommand), + }, + user: { + id: 'user-1', + displayName: 'TestUser', + displayAvatarURL: () => 'https://example.com/avatar.png', + }, + deferReply: vi.fn().mockResolvedValue(undefined), + editReply: vi.fn().mockResolvedValue(undefined), + reply: vi.fn().mockResolvedValue(undefined), + channel: { send: vi.fn().mockResolvedValue({ id: 'msg-1', startThread: vi.fn() }) }, + }; +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('/challenge command', () => { + let mockPool; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [{ total: '3' }] }), + }; + getPool.mockReturnValue(mockPool); + + getConfig.mockReturnValue({ + challenges: { + enabled: true, + channelId: 'ch-123', + postTime: '09:00', + timezone: 'America/New_York', + }, + }); + + selectTodaysChallenge.mockReturnValue({ + challenge: { + title: 'Two Sum', + description: 'Given an array…', + difficulty: 'easy', + hints: ['Use a hash map'], + sampleInput: 'nums = [2, 7]', + sampleOutput: '[0, 1]', + languages: ['javascript'], + }, + index: 0, + dayNumber: 42, + }); + }); + + // ─── Config disabled ────────────────────────────────────────────────────── + + describe('when challenges are disabled', () => { + it('should return an error message', async () => { + getConfig.mockReturnValue({ challenges: { enabled: false } }); + const interaction = makeInteraction('today'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not enabled') }), + ); + }); + + it('should return error when challenges config is absent', async () => { + getConfig.mockReturnValue({}); + const interaction = makeInteraction('today'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('not enabled') }), + ); + }); + }); + + // ─── /challenge today ───────────────────────────────────────────────────── + + describe('/challenge today', () => { + it('should call deferReply then editReply with embed and buttons', async () => { + const interaction = makeInteraction('today'); + await execute(interaction); + expect(interaction.deferReply).toHaveBeenCalledOnce(); + expect(selectTodaysChallenge).toHaveBeenCalledOnce(); + expect(buildChallengeEmbed).toHaveBeenCalledWith(expect.any(Object), 42, 3); + expect(buildChallengeButtons).toHaveBeenCalledWith(0); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array), components: expect.any(Array) }), + ); + }); + + it('should handle missing pool gracefully (solve count = 0)', async () => { + getPool.mockReturnValue(null); + const interaction = makeInteraction('today'); + await execute(interaction); + expect(buildChallengeEmbed).toHaveBeenCalledWith(expect.any(Object), 42, 0); + }); + }); + + // ─── /challenge streak ──────────────────────────────────────────────────── + + describe('/challenge streak', () => { + it('should show streak and total solves', async () => { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + const dayBefore = new Date(today); + dayBefore.setUTCDate(dayBefore.getUTCDate() - 2); + + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '5' }] }) // total solves + .mockResolvedValueOnce({ + rows: [ + { challenge_date: today }, + { challenge_date: yesterday }, + { challenge_date: dayBefore }, + ], + }); // solved dates (consecutive → streak = 3) + + const interaction = makeInteraction('streak'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should return db error message when pool is null', async () => { + getPool.mockReturnValue(null); + const interaction = makeInteraction('streak'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database unavailable') }), + ); + }); + + it('should handle zero solves (no streak)', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: '0' }] }) + .mockResolvedValueOnce({ rows: [] }); + + const interaction = makeInteraction('streak'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should compute streak correctly for non-consecutive dates', async () => { + const today = new Date(); + const weekAgo = new Date(today); + weekAgo.setUTCDate(weekAgo.getUTCDate() - 7); // gap → streak resets after today + + mockPool.query.mockResolvedValueOnce({ rows: [{ total: '2' }] }).mockResolvedValueOnce({ + rows: [ + { challenge_date: today }, + { challenge_date: weekAgo }, // gap → streak breaks at 1 + ], + }); + + const interaction = makeInteraction('streak'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + }); + + // ─── /challenge leaderboard ─────────────────────────────────────────────── + + describe('/challenge leaderboard', () => { + it('should show all-time and weekly leaderboards', async () => { + mockPool.query + .mockResolvedValueOnce({ + rows: [ + { user_id: 'u1', total: '10' }, + { user_id: 'u2', total: '7' }, + ], + }) // all-time + .mockResolvedValueOnce({ rows: [{ user_id: 'u1', total: '3' }] }); // this week + + const interaction = makeInteraction('leaderboard'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should handle empty leaderboards', async () => { + mockPool.query.mockResolvedValueOnce({ rows: [] }).mockResolvedValueOnce({ rows: [] }); + + const interaction = makeInteraction('leaderboard'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ embeds: expect.any(Array) }), + ); + }); + + it('should return error when pool is null', async () => { + getPool.mockReturnValue(null); + const interaction = makeInteraction('leaderboard'); + await execute(interaction); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database unavailable') }), + ); + }); + }); +}); diff --git a/tests/modules/challengeScheduler.test.js b/tests/modules/challengeScheduler.test.js new file mode 100644 index 00000000..d5707ae3 --- /dev/null +++ b/tests/modules/challengeScheduler.test.js @@ -0,0 +1,523 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +// ─── Mock dependencies ─────────────────────────────────────────────────────── + +vi.mock('../../src/db.js', () => ({ + getPool: vi.fn(), +})); + +vi.mock('../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../src/modules/config.js', () => ({ + getConfig: vi.fn(), +})); + +// Minimal discord.js mock +vi.mock('discord.js', () => { + class MockEmbedBuilder { + constructor() { + this._data = {}; + } + setColor(c) { + this._data.color = c; + return this; + } + setTitle(t) { + this._data.title = t; + return this; + } + setDescription(d) { + this._data.description = d; + return this; + } + addFields(...args) { + this._data.fields = args.flat(); + return this; + } + setFooter(f) { + this._data.footer = f; + return this; + } + setTimestamp() { + return this; + } + from(e) { + const b = new MockEmbedBuilder(); + b._data = { ...e.data }; + return b; + } + static from(e) { + const b = new MockEmbedBuilder(); + b._data = { ...(e._data ?? {}) }; + return b; + } + toJSON() { + return this._data; + } + } + + class MockButtonBuilder { + constructor() { + this._data = {}; + } + setCustomId(id) { + this._data.customId = id; + return this; + } + setLabel(l) { + this._data.label = l; + return this; + } + setStyle(s) { + this._data.style = s; + return this; + } + toJSON() { + return this._data; + } + } + + class MockActionRowBuilder { + constructor() { + this._components = []; + } + addComponents(...comps) { + this._components.push(...comps.flat()); + return this; + } + toJSON() { + return { components: this._components.map((c) => c.toJSON?.() ?? c) }; + } + } + + return { + EmbedBuilder: MockEmbedBuilder, + ButtonBuilder: MockButtonBuilder, + ActionRowBuilder: MockActionRowBuilder, + ButtonStyle: { Secondary: 2, Success: 3 }, + }; +}); + +// ─── Imports ───────────────────────────────────────────────────────────────── + +import { getPool } from '../../src/db.js'; +import { + buildChallengeButtons, + buildChallengeEmbed, + checkDailyChallenge, + checkDailyChallengeForGuild, + getDayOfYear, + getLocalDateString, + getLocalTimeString, + handleHintButton, + handleSolveButton, + postDailyChallenge, + selectTodaysChallenge, + startChallengeScheduler, +} from '../../src/modules/challengeScheduler.js'; +import { getConfig } from '../../src/modules/config.js'; + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('challengeScheduler', () => { + let mockPool; + + beforeEach(() => { + vi.clearAllMocks(); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [{ total: '1' }] }), + }; + getPool.mockReturnValue(mockPool); + + getConfig.mockReturnValue({ + challenges: { + enabled: true, + channelId: 'ch-test', + postTime: '09:00', + timezone: 'America/New_York', + }, + }); + }); + + // ─── Time helpers ────────────────────────────────────────────────────────── + + describe('getDayOfYear', () => { + it('should return 1 for Jan 1', () => { + const jan1 = new Date('2024-01-01T12:00:00Z'); + const day = getDayOfYear(jan1, 'UTC'); + expect(day).toBe(1); + }); + + it('should return 365 for Dec 31 in a non-leap year', () => { + const dec31 = new Date('2023-12-31T12:00:00Z'); + const day = getDayOfYear(dec31, 'UTC'); + expect(day).toBe(365); + }); + + it('should return 366 for Dec 31 in a leap year', () => { + const dec31 = new Date('2024-12-31T12:00:00Z'); + const day = getDayOfYear(dec31, 'UTC'); + expect(day).toBe(366); + }); + + it('should handle timezone offset correctly', () => { + // When UTC time is 00:00 Jan 2, New York is still Jan 1 (EST = UTC-5) + const utcJan2 = new Date('2024-01-02T02:00:00Z'); // 9pm Jan 1 EST + const dayNY = getDayOfYear(utcJan2, 'America/New_York'); + expect(dayNY).toBe(1); + }); + }); + + describe('getLocalDateString', () => { + it('should return YYYY-MM-DD format', () => { + const date = new Date('2024-03-15T12:00:00Z'); + const str = getLocalDateString(date, 'UTC'); + expect(str).toBe('2024-03-15'); + }); + }); + + describe('getLocalTimeString', () => { + it('should return HH:MM format', () => { + const date = new Date('2024-01-01T14:30:00Z'); + const str = getLocalTimeString(date, 'UTC'); + expect(str).toBe('14:30'); + }); + }); + + // ─── selectTodaysChallenge ───────────────────────────────────────────────── + + describe('selectTodaysChallenge', () => { + it('should return a challenge object with index and dayNumber', () => { + const now = new Date('2024-01-10T12:00:00Z'); + const result = selectTodaysChallenge(now, 'UTC'); + expect(result).toHaveProperty('challenge'); + expect(result).toHaveProperty('index'); + expect(result).toHaveProperty('dayNumber'); + expect(result.challenge).toHaveProperty('title'); + expect(result.challenge).toHaveProperty('difficulty'); + }); + + it('should cycle through challenges using modulo', () => { + const now = new Date('2024-01-01T12:00:00Z'); + const { index, dayNumber } = selectTodaysChallenge(now, 'UTC'); + // dayNumber = 1, challenges.length = 32, index = (1-1) % 32 = 0 + expect(index).toBe((dayNumber - 1) % 32); + }); + }); + + // ─── buildChallengeEmbed ────────────────────────────────────────────────── + + describe('buildChallengeEmbed', () => { + const challenge = { + title: 'Two Sum', + description: 'Given an array…', + difficulty: 'easy', + hints: ['Use a hash map'], + sampleInput: 'nums = [2, 7]', + sampleOutput: '[0, 1]', + languages: ['javascript'], + }; + + it('should build an embed with correct color for easy', () => { + const embed = buildChallengeEmbed(challenge, 1, 0); + expect(embed._data.color).toBe(0x57f287); + }); + + it('should build an embed with correct color for medium', () => { + const embed = buildChallengeEmbed({ ...challenge, difficulty: 'medium' }, 1, 0); + expect(embed._data.color).toBe(0xfee75c); + }); + + it('should build an embed with correct color for hard', () => { + const embed = buildChallengeEmbed({ ...challenge, difficulty: 'hard' }, 1, 0); + expect(embed._data.color).toBe(0xed4245); + }); + + it('should include the challenge number in the title', () => { + const embed = buildChallengeEmbed(challenge, 42, 0); + expect(embed._data.title).toContain('#42'); + expect(embed._data.title).toContain('Two Sum'); + }); + + it('should include solve count in footer', () => { + const embed = buildChallengeEmbed(challenge, 1, 7); + expect(embed._data.footer.text).toContain('7 solvers'); + }); + + it('should handle unknown difficulty gracefully', () => { + const embed = buildChallengeEmbed({ ...challenge, difficulty: 'unknown' }, 1, 0); + expect(embed._data.color).toBeDefined(); + }); + }); + + // ─── buildChallengeButtons ──────────────────────────────────────────────── + + describe('buildChallengeButtons', () => { + it('should return an action row with hint and solve buttons', () => { + const row = buildChallengeButtons(5); + const json = row.toJSON(); + expect(json.components).toHaveLength(2); + expect(json.components[0].customId).toBe('challenge_hint_5'); + expect(json.components[1].customId).toBe('challenge_solve_5'); + }); + }); + + // ─── postDailyChallenge ─────────────────────────────────────────────────── + + describe('postDailyChallenge', () => { + let mockClient; + let mockMessage; + let mockChannel; + + beforeEach(() => { + mockMessage = { + id: 'msg-1', + startThread: vi.fn().mockResolvedValue({}), + }; + mockChannel = { + send: vi.fn().mockResolvedValue(mockMessage), + }; + mockClient = { + channels: { + fetch: vi.fn().mockResolvedValue(mockChannel), + }, + guilds: { cache: new Map() }, + }; + }); + + it('should return false when challenges are disabled', async () => { + getConfig.mockReturnValue({ challenges: { enabled: false } }); + const result = await postDailyChallenge(mockClient, 'guild-1'); + expect(result).toBe(false); + }); + + it('should return false when no channelId configured', async () => { + getConfig.mockReturnValue({ challenges: { enabled: true, channelId: null } }); + const result = await postDailyChallenge(mockClient, 'guild-1'); + expect(result).toBe(false); + }); + + it('should return false when channel not found', async () => { + mockClient.channels.fetch.mockResolvedValue(null); + const result = await postDailyChallenge(mockClient, 'guild-1'); + expect(result).toBe(false); + }); + + it('should post embed with buttons and create a thread', async () => { + const result = await postDailyChallenge(mockClient, 'guild-1'); + expect(result).toBe(true); + expect(mockChannel.send).toHaveBeenCalledOnce(); + expect(mockMessage.startThread).toHaveBeenCalledOnce(); + }); + + it('should continue when thread creation fails', async () => { + mockMessage.startThread.mockRejectedValue(new Error('Thread error')); + const result = await postDailyChallenge(mockClient, 'guild-1'); + expect(result).toBe(true); + }); + }); + + // ─── checkDailyChallengeForGuild ───────────────────────────────────────── + + describe('checkDailyChallengeForGuild', () => { + let mockClient; + + beforeEach(() => { + const mockMessage = { id: 'msg-1', startThread: vi.fn().mockResolvedValue({}) }; + const mockChannel = { send: vi.fn().mockResolvedValue(mockMessage) }; + mockClient = { + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + guilds: { cache: new Map() }, + }; + }); + + it('should not post when challenges are disabled', async () => { + getConfig.mockReturnValue({ challenges: { enabled: false } }); + await checkDailyChallengeForGuild(mockClient, 'guild-1'); + expect(mockClient.channels.fetch).not.toHaveBeenCalled(); + }); + + it('should not post when current time does not match postTime', async () => { + // postTime is 09:00 but current time will be whatever the test runs at + // We mock getLocalTimeString implicitly — since it uses Intl, we just + // verify that posting only happens when time matches. + // Force postTime to a time that never occurs + getConfig.mockReturnValue({ + challenges: { enabled: true, channelId: 'ch-1', postTime: '99:99', timezone: 'UTC' }, + }); + await checkDailyChallengeForGuild(mockClient, 'guild-1'); + expect(mockClient.channels.fetch).not.toHaveBeenCalled(); + }); + }); + + // ─── checkDailyChallenge (all guilds) ──────────────────────────────────── + + describe('checkDailyChallenge', () => { + it('should iterate all guilds', async () => { + getConfig.mockReturnValue({ challenges: { enabled: false } }); + const mockClient = { + guilds: { + cache: new Map([ + ['g1', { id: 'g1' }], + ['g2', { id: 'g2' }], + ]), + }, + }; + await checkDailyChallenge(mockClient); + // Both guilds checked — no errors thrown + expect(getConfig).toHaveBeenCalledWith('g1'); + expect(getConfig).toHaveBeenCalledWith('g2'); + }); + + it('should not throw when individual guild check fails', async () => { + getConfig.mockImplementation(() => { + throw new Error('Config error'); + }); + const mockClient = { + guilds: { cache: new Map([['g1', { id: 'g1' }]]) }, + }; + await expect(checkDailyChallenge(mockClient)).resolves.toBeUndefined(); + }); + }); + + // ─── Double-post prevention ─────────────────────────────────────────────── + + describe('double-post prevention', () => { + it('should not post twice on the same day', async () => { + // Use a time that will definitely not match (99:99) + getConfig.mockReturnValue({ + challenges: { enabled: true, channelId: 'ch-1', postTime: '99:99', timezone: 'UTC' }, + }); + + const mockMessage = { id: 'msg-1', startThread: vi.fn().mockResolvedValue({}) }; + const mockChannel = { send: vi.fn().mockResolvedValue(mockMessage) }; + const mockClient = { + channels: { fetch: vi.fn().mockResolvedValue(mockChannel) }, + guilds: { cache: new Map() }, + }; + + // Run twice + await checkDailyChallengeForGuild(mockClient, 'double-guild'); + await checkDailyChallengeForGuild(mockClient, 'double-guild'); + + // Should not have posted (time doesn't match) + expect(mockChannel.send).not.toHaveBeenCalled(); + }); + }); + + // ─── handleSolveButton ──────────────────────────────────────────────────── + + describe('handleSolveButton', () => { + let interaction; + + beforeEach(() => { + interaction = { + guildId: 'guild-1', + user: { id: 'user-1' }, + message: { + id: 'msg-1', + embeds: [{ _data: { footer: { text: '0 solvers so far' } } }], + components: [], + edit: vi.fn().mockResolvedValue({}), + }, + reply: vi.fn().mockResolvedValue({}), + }; + + mockPool.query + .mockResolvedValueOnce({ rows: [] }) // INSERT (upsert) + .mockResolvedValueOnce({ rows: [{ total: '5' }] }) // user total solves + .mockResolvedValueOnce({ rows: [{ total: '2' }] }); // challenge solve count + }); + + it('should reject out-of-bounds challenge index', async () => { + await handleSolveButton(interaction, 9999); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Invalid challenge') }), + ); + expect(mockPool.query).not.toHaveBeenCalled(); + }); + + it('should reject negative challenge index', async () => { + await handleSolveButton(interaction, -1); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Invalid challenge') }), + ); + expect(mockPool.query).not.toHaveBeenCalled(); + }); + + it('should record the solve and reply ephemerally', async () => { + await handleSolveButton(interaction, 0); + // INSERT now includes challenge_date as $2 (a YYYY-MM-DD string) + expect(mockPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO challenge_solves'), + ['guild-1', expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/), 0, 'user-1'], + ); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('5'), + ephemeral: true, + }), + ); + }); + + it('should return error when pool is null', async () => { + getPool.mockReturnValue(null); + await handleSolveButton(interaction, 0); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ content: expect.stringContaining('Database unavailable') }), + ); + }); + + it('should handle edit failure gracefully', async () => { + interaction.message.edit.mockRejectedValue(new Error('Edit failed')); + await expect(handleSolveButton(interaction, 0)).resolves.toBeUndefined(); + }); + }); + + // ─── handleHintButton ───────────────────────────────────────────────────── + + describe('handleHintButton', () => { + let interaction; + + beforeEach(() => { + interaction = { + reply: vi.fn().mockResolvedValue({}), + }; + }); + + it('should show all hints ephemerally', async () => { + // index 0 = Two Sum, which has 2 hints + await handleHintButton(interaction, 0); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Hint 1'), + ephemeral: true, + }), + ); + }); + + it('should handle invalid challenge index', async () => { + await handleHintButton(interaction, 9999); + expect(interaction.reply).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('not found'), + ephemeral: true, + }), + ); + }); + }); + + // ─── startChallengeScheduler ────────────────────────────────────────────── + + describe('startChallengeScheduler', () => { + it('should call without throwing', () => { + const mockClient = { guilds: { cache: new Map() } }; + expect(() => startChallengeScheduler(mockClient)).not.toThrow(); + }); + }); +}); diff --git a/web/src/components/dashboard/config-editor.tsx b/web/src/components/dashboard/config-editor.tsx index 1560ee43..f4a296b3 100644 --- a/web/src/components/dashboard/config-editor.tsx +++ b/web/src/components/dashboard/config-editor.tsx @@ -48,7 +48,7 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und function isGuildConfig(data: unknown): data is GuildConfig { if (typeof data !== "object" || data === null || Array.isArray(data)) return false; const obj = data as Record; - const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "showcase", "tldr", "reputation", "afk", "engagement", "github", "review"] as const; + const knownSections = ["ai", "welcome", "spam", "moderation", "triage", "starboard", "permissions", "memory", "help", "announce", "snippet", "poll", "showcase", "tldr", "reputation", "afk", "engagement", "github", "review", "challenges"] as const; const hasKnownSection = knownSections.some((key) => key in obj); if (!hasKnownSection) return false; for (const key of knownSections) { @@ -1375,6 +1375,46 @@ export function ConfigEditor() { + {/* ═══ Daily Coding Challenges ═══ */} + + +
+ Daily Coding Challenges + setDraftConfig((prev) => ({ ...prev, challenges: { ...prev.challenges, enabled: v } } as GuildConfig))} + disabled={saving} + label="Challenges" + /> +
+

Auto-post a daily coding challenge with hint and solve tracking.

+
+ + + +
+
+
+ {/* ═══ GitHub Feed Settings ═══ */}