From f84539d5ab7ed24f76c1789a4a2f316e2c008183 Mon Sep 17 00:00:00 2001 From: sarah Date: Mon, 3 Aug 2026 18:57:16 +0200 Subject: [PATCH] feat: add count-up Stopwatch module with persistence in SQLite --- AGENTS-BOT.md | 1 + README.md | 7 + src/commands/utility/stopwatch.ts | 270 ++++++++++++++++++++++++++++++ src/structures/Database.ts | 14 ++ 4 files changed, 292 insertions(+) create mode 100644 src/commands/utility/stopwatch.ts diff --git a/AGENTS-BOT.md b/AGENTS-BOT.md index c58c08d..9f55775 100644 --- a/AGENTS-BOT.md +++ b/AGENTS-BOT.md @@ -21,6 +21,7 @@ The bot uses a **Modular Command & Event Loading** pattern with **ESM (ECMAScrip 12. **Role Selection System:** Self-service role assignment via select menus with support for exclusive categories and max-role limits. 13. **Cat & CatGIF System:** TheCatAPI integration for random cat images and GIFs (`CatManager`). 14. **Centralized Event Logger:** Kapsel event logs into `EventLogger.sendLog()` to eliminate duplicated logging logic across event files. +15. **Stopwatch System:** Persistent count-up timer tracking stored in SQLite (`stopwatches` table) calculating elapsed time dynamically on query. ## 📁 Project Structure diff --git a/README.md b/README.md index dc99735..7745eca 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,13 @@ npm run deploy - `/timer listall` – Alle Timer (Mod) - `/timer help` – Timer-Hilfe +### Stoppuhr (Hochzählender Timer) +- `/stopwatch start ` – Startet eine neue Stoppuhr mit Namen +- `/stopwatch status [name]` – Abgelaufene Zeit abfragen +- `/stopwatch stop ` – Stoppuhr beenden & Laufzeit ausgeben +- `/stopwatch list` – Aktive Stoppuhren auflisten +- `/stopwatch help` – Stoppuhr-Hilfe + ### Trigger (Auto-Antworten) - `/trigger add ` – Trigger hinzufügen (Mod) - `/trigger remove ` – Trigger entfernen (Mod) diff --git a/src/commands/utility/stopwatch.ts b/src/commands/utility/stopwatch.ts new file mode 100644 index 0000000..a8e94e5 --- /dev/null +++ b/src/commands/utility/stopwatch.ts @@ -0,0 +1,270 @@ +import { SlashCommandBuilder, EmbedBuilder } from 'discord.js'; +import { Command } from '../../structures/Command.js'; +import { ExtendedClient } from '../../structures/ExtendedClient.js'; + +function formatDuration(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const days = Math.floor(totalSeconds / 86400); + const hours = Math.floor((totalSeconds % 86400) / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + const parts: string[] = []; + if (days > 0) parts.push(`${days}d`); + if (hours > 0) parts.push(`${hours}h`); + if (minutes > 0) parts.push(`${minutes}m`); + parts.push(`${seconds}s`); + + return parts.join(' '); +} + +const command: Command = { + data: new SlashCommandBuilder() + .setName('stopwatch') + .setDescription('Hochzählende Stoppuhren verwalten.') + .addSubcommand(subcommand => + subcommand + .setName('start') + .setDescription('Startet eine neue Stoppuhr.') + .addStringOption(option => + option.setName('name') + .setDescription('Der Name der Stoppuhr (z.B. "Gaming", "Lernen")') + .setRequired(true))) + .addSubcommand(subcommand => + subcommand + .setName('status') + .setDescription('Zeigt den aktuellen Stand einer oder aller Stoppuhren an.') + .addStringOption(option => + option.setName('name') + .setDescription('Der Name der Stoppuhr (optional)') + .setRequired(false))) + .addSubcommand(subcommand => + subcommand + .setName('stop') + .setDescription('Stoppt eine laufende Stoppuhr.') + .addStringOption(option => + option.setName('name') + .setDescription('Der Name der zu stoppenden Stoppuhr') + .setRequired(true))) + .addSubcommand(subcommand => + subcommand + .setName('list') + .setDescription('Listet alle deine aktiven Stoppuhren auf.')) + .addSubcommand(subcommand => + subcommand + .setName('help') + .setDescription('Zeigt Hilfe zu den Stoppuhr-Befehlen an.')) + .setDMPermission(true), + category: 'Public', + async execute(interaction: any) { + const client = interaction.client as ExtendedClient; + const subcommand = interaction.options.getSubcommand(); + const guildId = interaction.guildId; + const userId = interaction.user.id; + const channelId = interaction.channelId; + const DB = client.DB; + + if (subcommand === 'help') { + const embed = new EmbedBuilder() + .setTitle('⏱️ Stoppuhr Hilfe') + .setColor(0x3498db) + .setDescription('Verwalte hochzählende Timer (Stoppuhren).') + .addFields( + { name: '`/stopwatch start `', value: 'Startet eine neue Stoppuhr mit dem angegebenen Namen.', inline: false }, + { name: '`/stopwatch status [name]`', value: 'Zeigt die aktuelle Laufzeit einer oder aller Stoppuhren an.', inline: false }, + { name: '`/stopwatch stop `', value: 'Stoppt eine Stoppuhr und gibt die finale Laufzeit aus.', inline: false }, + { name: '`/stopwatch list`', value: 'Listet alle deine aktiven Stoppuhren auf diesem Server auf.', inline: false }, + { name: 'Beispiele', value: '`/stopwatch start name: Zocksession`\n`/stopwatch status name: Zocksession`\n`/stopwatch stop name: Zocksession`', inline: false } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + if (subcommand === 'start') { + const name = interaction.options.getString('name')!.trim(); + + const existing: any = DB.get( + 'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?', + guildId, + userId, + name + ); + + if (existing) { + const startTime = new Date(existing.start_time); + const unixTs = Math.floor(startTime.getTime() / 1000); + await interaction.reply({ + content: `⚠️ Du hast bereits eine Stoppuhr namens **"${name}"** laufen (gestartet ).\nNutze \`/stopwatch status name:${name}\` oder \`/stopwatch stop name:${name}\`.`, + ephemeral: true + }); + return; + } + + DB.run( + 'INSERT INTO stopwatches (guild_id, channel_id, user_id, name, start_time) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)', + guildId, + channelId, + userId, + name + ); + + const nowUnix = Math.floor(Date.now() / 1000); + + const embed = new EmbedBuilder() + .setTitle(`⏱️ Stoppuhr "${name}" gestartet`) + .setColor(0x2ecc71) + .addFields( + { name: 'Name', value: name, inline: true }, + { name: 'Gestartet um', value: ` ()`, inline: true } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + if (subcommand === 'status') { + const name = interaction.options.getString('name')?.trim(); + + if (name) { + const sw: any = DB.get( + 'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?', + guildId, + userId, + name + ); + + if (!sw) { + await interaction.reply({ + content: `❌ Keine aktive Stoppuhr namens **"${name}"** gefunden.`, + ephemeral: true + }); + return; + } + + const startTime = new Date(sw.start_time); + const elapsedMs = Date.now() - startTime.getTime(); + const unixTs = Math.floor(startTime.getTime() / 1000); + + const embed = new EmbedBuilder() + .setTitle(`⏱️ Stoppuhr Status: "${sw.name}"`) + .setColor(0x3498db) + .addFields( + { name: 'Aktuelle Laufzeit', value: `**${formatDuration(elapsedMs)}**`, inline: true }, + { name: 'Gestartet', value: ``, inline: true }, + { name: 'Startzeitpunkt', value: ``, inline: false } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } else { + const stopwatches: any[] = DB.all( + 'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? ORDER BY start_time ASC', + guildId, + userId + ); + + if (stopwatches.length === 0) { + await interaction.reply({ + content: 'ℹ️ Du hast aktuell keine aktiven Stoppuhren.', + ephemeral: true + }); + return; + } + + const now = Date.now(); + const listStr = stopwatches.map((sw: any) => { + const startTime = new Date(sw.start_time); + const elapsedMs = now - startTime.getTime(); + const unixTs = Math.floor(startTime.getTime() / 1000); + return `• **${sw.name}**: **${formatDuration(elapsedMs)}** (seit )`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setTitle(`⏱️ Deine aktiven Stoppuhren (${stopwatches.length})`) + .setColor(0x3498db) + .setDescription(listStr) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + } + + if (subcommand === 'stop') { + const name = interaction.options.getString('name')!.trim(); + + const sw: any = DB.get( + 'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? AND name = ?', + guildId, + userId, + name + ); + + if (!sw) { + await interaction.reply({ + content: `❌ Keine aktive Stoppuhr namens **"${name}"** gefunden.`, + ephemeral: true + }); + return; + } + + const startTime = new Date(sw.start_time); + const elapsedMs = Date.now() - startTime.getTime(); + const startUnix = Math.floor(startTime.getTime() / 1000); + const endUnix = Math.floor(Date.now() / 1000); + + DB.run('DELETE FROM stopwatches WHERE id = ?', sw.id); + + const embed = new EmbedBuilder() + .setTitle(`⏹️ Stoppuhr "${name}" beendet`) + .setColor(0xe74c3c) + .addFields( + { name: 'Gesamtlaufzeit', value: `**${formatDuration(elapsedMs)}**`, inline: true }, + { name: 'Gestartet', value: ``, inline: false }, + { name: 'Beendet um', value: ``, inline: false } + ) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + return; + } + + if (subcommand === 'list') { + const stopwatches: any[] = DB.all( + 'SELECT * FROM stopwatches WHERE guild_id = ? AND user_id = ? ORDER BY start_time ASC', + guildId, + userId + ); + + if (stopwatches.length === 0) { + await interaction.reply({ + content: 'ℹ️ Du hast aktuell keine aktiven Stoppuhren.', + ephemeral: true + }); + return; + } + + const now = Date.now(); + const listStr = stopwatches.map((sw: any) => { + const startTime = new Date(sw.start_time); + const elapsedMs = now - startTime.getTime(); + const unixTs = Math.floor(startTime.getTime() / 1000); + return `• **${sw.name}**: **${formatDuration(elapsedMs)}** (gestartet )`; + }).join('\n'); + + const embed = new EmbedBuilder() + .setTitle(`⏱️ Aktive Stoppuhren (${stopwatches.length})`) + .setColor(0x3498db) + .setDescription(listStr) + .setTimestamp(); + + await interaction.reply({ embeds: [embed], ephemeral: true }); + } + }, +}; + +export default command; diff --git a/src/structures/Database.ts b/src/structures/Database.ts index 1215567..a30b5a1 100644 --- a/src/structures/Database.ts +++ b/src/structures/Database.ts @@ -151,10 +151,24 @@ export class DB { ) `).run(); + // Stopwatches (Count-up Timers) + db.prepare(` + CREATE TABLE IF NOT EXISTS stopwatches ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT NOT NULL, + channel_id TEXT NOT NULL, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + start_time DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(guild_id, user_id, name) + ) + `).run(); + // Performance Indexes db.prepare('CREATE INDEX IF NOT EXISTS idx_mod_logs_guild_user ON mod_logs(guild_id, user_id, action)').run(); db.prepare('CREATE INDEX IF NOT EXISTS idx_reminders_target ON reminders(target_time)').run(); db.prepare('CREATE INDEX IF NOT EXISTS idx_twitch_monitors_guild ON twitch_monitors(guild_id)').run(); + db.prepare('CREATE INDEX IF NOT EXISTS idx_stopwatches_guild_user ON stopwatches(guild_id, user_id)').run(); // Migration logic const columns = db.prepare("PRAGMA table_info(guild_settings)").all() as any[];