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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@
"rank": "everyone",
"leaderboard": "everyone",
"profile": "everyone",
"challenge": "everyone",
"review": "everyone",
"showcase": "everyone"
}
Expand Down Expand Up @@ -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,
Expand Down
35 changes: 35 additions & 0 deletions migrations/011_challenges.cjs
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 1 addition & 0 deletions src/api/utils/configAllowlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const SAFE_CONFIG_KEYS = new Set([
'reputation',
'engagement',
'github',
'challenges',
'review',
]);

Expand Down
239 changes: 239 additions & 0 deletions src/commands/challenge.js
Original file line number Diff line number Diff line change
@@ -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 });
}
Loading
Loading