const { SlashCommandBuilder, EmbedBuilder, AttachmentBuilder } = require('discord.js'); const axios = require('axios').default; const { HOUSEHOLD_IDS } = require('../../core/constants.js'); const CHART_COLORS = { [HOUSEHOLD_IDS.MINZ]: '#4e79a7', [HOUSEHOLD_IDS.MIFFY]: '#f28e2b', }; const DISPLAY_NAMES = { [HOUSEHOLD_IDS.MINZ]: 'Minz', [HOUSEHOLD_IDS.MIFFY]: 'Miffy', }; module.exports = { category: 'utility', global: true, data: new SlashCommandBuilder() .setName('budget') .setDescription('The thing') .addSubcommand(subcommand => subcommand .setName('set') .setDescription('Set weekly budget') .addIntegerOption(option => option.setName('amount') .setRequired(true) .setDescription('Amount as int')) .addBooleanOption(option => option.setName('override') .setRequired(false) .setDescription('Override budget of current period')) ) .addSubcommand(subcommand => subcommand .setName('view') .setDescription('View your grocery spendings for the current or previous week') .addUserOption(option => option.setName('user') .setRequired(false) .setDescription('User to view budget for')) .addBooleanOption(option => option .setName('lastweek') .setDescription('Show spendings from the previous budget cycle instead of the current one') .setRequired(false) ) ) .addSubcommand(subcommand => subcommand .setName('stats') .setDescription('View detailed spending and savings statistics') .addUserOption(option => option.setName('user') .setRequired(false) .setDescription('User to view stats for')) ) .addSubcommand(subcommand => subcommand .setName('chart') .setDescription('Render a bar chart comparing Minz and Miffy spendings over time') .addIntegerOption(option => option.setName('weeks') .setRequired(false) .setDescription('Number of recent weeks to include (default 8)')) ), async execute(interaction) { let db = await interaction.client.localDB; let targetUser = interaction.options.getUser('user') ?? interaction.user; let discordId = interaction.member.id; let allowed = [HOUSEHOLD_IDS.MIFFY, HOUSEHOLD_IDS.MINZ].includes(discordId); if (!allowed) { await interaction.reply('This command may only be invoked by Miffy!'); return; } switch (interaction.options.getSubcommand()) { case 'set': let override = interaction.options.getBoolean('override') ?? false; let budget = interaction.options.get('amount').value; db.prepare(`INSERT OR REPLACE INTO bot_config (id, weekly_budget) VALUES (1, ?)`).run(budget); let overrideApplied = true; if (override) { const result = db.prepare(` UPDATE weekly_budgets SET budget_amount = ? WHERE id = ( SELECT id FROM weekly_budgets WHERE date('now', 'localtime') BETWEEN start_date AND end_date ORDER BY id DESC LIMIT 1 )`).run(budget); overrideApplied = result.changes > 0; } const overrideNote = override && !overrideApplied ? " (no active budget period to override this week)" : ""; await interaction.reply(`Budget set for ${targetUser.username} to ${Number(budget).toFixed(2)}.${overrideNote}`); break; case 'view': const lastWeek = interaction.options.getBoolean('lastweek') ?? false; const currentBudget = db.prepare(` SELECT id, budget_amount, exchange_rate, start_date, end_date FROM weekly_budgets WHERE ${lastWeek ? "end_date < date('now', 'localtime')" : "date('now', 'localtime') BETWEEN start_date AND end_date"} ORDER BY start_date DESC, created_at DESC LIMIT 1 `).get(); if (!currentBudget) { await interaction.reply("No budget period found."); return; } const userSpendings = db.prepare(` SELECT amount, message_raw FROM grocery_spendings WHERE budget_id = ? AND discord_id = ? `).all(currentBudget.id, targetUser.id); const totalSpentEur = userSpendings.reduce((acc, s) => { return acc + s.amount; }, 0); const remainingEur = currentBudget.budget_amount - totalSpentEur; const remainingSek = remainingEur * currentBudget.exchange_rate; const todayTimestamp = Math.floor(Date.now() / 1000); const endTimestamp = Math.floor(new Date(`${currentBudget.end_date}T23:59:59`).getTime() / 1000); const budgetEmbed = new EmbedBuilder() .setColor(remainingEur > 0 ? 0x00ff00 : 0xff0000) .setTitle(`${targetUser.globalName ?? targetUser.username} Budget as of `) .setDescription(`Period: \`${currentBudget.start_date}\` to \`${currentBudget.end_date}\`\nReset `) .addFields( { name: 'Spent', value: `${totalSpentEur.toFixed(2)} €\n${(totalSpentEur * currentBudget.exchange_rate).toFixed(2)} kr`, inline: true }, { name: 'Remaining', value: `${remainingEur.toFixed(2)} €\n${remainingSek.toFixed(2)} kr`, inline: true }, ) .setFooter({ text: `Exchange rate this week: 1 € = ${currentBudget.exchange_rate.toFixed(2)} kr` }) .setTimestamp(); await interaction.reply({ embeds: [budgetEmbed] }); break case 'stats': const allBudgets = db.prepare(` SELECT id, budget_amount, exchange_rate, start_date, end_date FROM weekly_budgets ORDER BY start_date DESC LIMIT 4 `).all().reverse(); let statsDescription = ""; let cumulativeSavingsEur = 0; for (const budgetPeriod of allBudgets) { const periodSpendings = db.prepare(` SELECT amount FROM grocery_spendings WHERE budget_id = ? AND discord_id = ? `).all(budgetPeriod.id, targetUser.id); const totalSpentInPeriodEur = periodSpendings.reduce((acc, s) => acc + s.amount, 0); const savedInPeriodEur = Number(budgetPeriod.budget_amount) - Number(totalSpentInPeriodEur); cumulativeSavingsEur += savedInPeriodEur; statsDescription += `**Period: ${budgetPeriod.start_date} - ${budgetPeriod.end_date}**\n`; statsDescription += `Budget: ${budgetPeriod.budget_amount.toFixed(2)}€\n`; statsDescription += `Spent: ${totalSpentInPeriodEur.toFixed(2)}€ (${(totalSpentInPeriodEur * budgetPeriod.exchange_rate).toFixed(2)} kr)\n`; statsDescription += `Saved: ${savedInPeriodEur.toFixed(2)}€ (${(savedInPeriodEur * budgetPeriod.exchange_rate).toFixed(2)} kr)\n`; statsDescription += `Cumulative: ${cumulativeSavingsEur.toFixed(2)}€\n\n`; } if (allBudgets.length === 0) { statsDescription = "No budget periods on record."; } const statsEmbed = new EmbedBuilder() .setColor(0x0099ff) .setTitle(`${targetUser.globalName ?? targetUser.username}'s Budget Statistics`) .setDescription(statsDescription) .setTimestamp(); await interaction.reply({ embeds: [statsEmbed] }); break; case 'chart': { const weeks = Math.min(Math.max(interaction.options.getInteger('weeks') ?? 8, 1), 52); const chartPeriods = db.prepare(` SELECT id, start_date, end_date FROM weekly_budgets ORDER BY start_date DESC LIMIT ? `).all(weeks).reverse(); if (chartPeriods.length === 0) { await interaction.reply("No budget periods on record."); return; } await interaction.deferReply(); const spentByUserAndPeriod = db.prepare(` SELECT COALESCE(SUM(amount), 0) as total FROM grocery_spendings WHERE budget_id = ? AND discord_id = ? `); const labels = chartPeriods.map(period => period.start_date.slice(5)); const datasets = [HOUSEHOLD_IDS.MINZ, HOUSEHOLD_IDS.MIFFY].map(userId => ({ label: DISPLAY_NAMES[userId], backgroundColor: CHART_COLORS[userId], data: chartPeriods.map(period => spentByUserAndPeriod.get(period.id, userId).total), })); const chartConfig = { type: 'bar', data: { labels, datasets }, options: { title: { display: true, text: 'Weekly Grocery Spending (€)' }, scales: { yAxes: [{ ticks: { beginAtZero: true }, scaleLabel: { display: true, labelString: '€' } }], }, }, }; try { const response = await axios.post('https://quickchart.io/chart', { chart: chartConfig, width: 800, height: 400, backgroundColor: 'white', }, { responseType: 'arraybuffer' }); const attachment = new AttachmentBuilder(Buffer.from(response.data), { name: 'budget-chart.png' }); const chartEmbed = new EmbedBuilder() .setColor(0x0099ff) .setTitle('Weekly Grocery Spending') .setDescription(`Last ${chartPeriods.length} week(s): \`${chartPeriods[0].start_date}\` to \`${chartPeriods[chartPeriods.length - 1].end_date}\``) .setImage('attachment://budget-chart.png') .setTimestamp(); await interaction.editReply({ embeds: [chartEmbed], files: [attachment] }); } catch (error) { console.error('[budget chart] Failed to render chart:', error); await interaction.editReply("Failed to render the chart, sorry!"); } break; } default: await interaction.reply(`Sub mismatch. Switching on ${interaction.options.getSubcommand()}`); break; } }, };