diff --git a/docker-compose.yml b/docker-compose.yml index f7ed9a1..e7bf984 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: SESSION_SECRET: ${SESSION_SECRET} ADMIN_DISCORD_ID: ${ADMIN_DISCORD_ID} PUBLIC_URL: ${PUBLIC_URL:-https://bot.d4rkst3r.de} + # Lokale Zeitzone — wichtig für den Wochen-Rückblick (sonntags 20:00) und Datumsangaben + TZ: ${TZ:-Europe/Berlin} DB_PATH: /app/data/ecobot.db # HTTP-Endpoint für Gitea-Webhooks + Webinterface # NPM leitet bot.d4rkst3r.de → host.docker.internal:3080 diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index d54ccf9..3bf58ad 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -167,6 +167,20 @@ export default function Settings({ me }) { ))} +
+ +
+
+ +
{/* Commit-Feed */} diff --git a/src/bot/weekly-recap.js b/src/bot/weekly-recap.js new file mode 100644 index 0000000..c6a507b --- /dev/null +++ b/src/bot/weekly-recap.js @@ -0,0 +1,87 @@ +// Wochen-Rückblick: sonntags 20:00 (Container-TZ, siehe compose) postet der Bot +// eine Zusammenfassung der Woche in den Devlog-Kanal. +import { EmbedBuilder } from 'discord.js'; +import { weeklyStats, getSetting, setSetting } from '../db.js'; +import { devlogChannelId } from '../runtime-settings.js'; +import { config } from '../config.js'; + +const BRAND_YELLOW = 0xf5c518; +const CHECK_INTERVAL_MS = 5 * 60 * 1000; + +/** Balken-Zeile: `Mo 21.07 ████████ 12` */ +function buildBars(perDay) { + const byDay = new Map(perDay.map((r) => [r.day, r.n])); + const rows = []; + let max = 1; + // Letzte 7 Tage, älteste zuerst + for (let i = 6; i >= 0; i--) { + const date = new Date(Date.now() - i * 86400000); + const key = date.toISOString().slice(0, 10); + const n = byDay.get(key) ?? 0; + max = Math.max(max, n); + rows.push({ date, n }); + } + const dayFmt = new Intl.DateTimeFormat('de-DE', { weekday: 'short' }); + const dateFmt = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }); + return rows + .map((r) => { + const bar = '█'.repeat(Math.round((r.n / max) * 14)) || '·'; + return `${dayFmt.format(r.date).padEnd(3)}${dateFmt.format(r.date)} ${bar} ${r.n}`; + }) + .join('\n'); +} + +/** Rückblick-Embed bauen und in den Devlog-Kanal posten */ +export async function postWeeklyRecap(client) { + const channelId = devlogChannelId(); + if (!channelId) throw new Error('Kein Devlog-Kanal konfiguriert'); + + const channel = await client.channels.fetch(channelId); + if (!channel?.isTextBased()) throw new Error('Devlog-Kanal nicht gefunden'); + + const stats = weeklyStats(); + const totalCommits = stats.perDay.reduce((sum, r) => sum + r.n, 0); + const repoLine = stats.topRepos + .map((r) => `**${r.repo.split('/').pop()}** (${r.n})`) + .join(' · '); + + const embed = new EmbedBuilder() + .setColor(BRAND_YELLOW) + .setTitle('📊 Wochen-Rückblick') + .setURL(`${config.publicUrl}/devlogs`) + .setDescription( + `\`\`\`\n${buildBars(stats.perDay)}\n\`\`\`` + ) + .addFields( + { name: 'Commits', value: String(totalCommits), inline: true }, + { name: 'Devlogs', value: String(stats.devlogs), inline: true }, + ...(repoLine ? [{ name: 'Aktive Projekte', value: repoLine, inline: false }] : []) + ) + .setFooter({ text: 'D4RKST3R // WEEKLY' }) + .setTimestamp(); + + await channel.send({ embeds: [embed] }); +} + +/** Scheduler starten: prüft alle 5 Minuten, ob Sonntag ≥ 20:00 und noch nicht gepostet */ +export function scheduleWeeklyRecap(client) { + const check = async () => { + if (getSetting('weekly_recap_enabled') === '0') return; + + const now = new Date(); + if (now.getDay() !== 0 || now.getHours() < 20) return; // Sonntag ab 20:00 (lokale TZ) + + const today = now.toISOString().slice(0, 10); + if (getSetting('last_weekly_recap') === today) return; + + try { + await postWeeklyRecap(client); + setSetting('last_weekly_recap', today); + console.log('[weekly] Wochen-Rückblick gepostet'); + } catch (error) { + console.error('[weekly] Rückblick fehlgeschlagen:', error); + } + }; + setInterval(check, CHECK_INTERVAL_MS); + check(); +} diff --git a/src/db.js b/src/db.js index 9215c67..c4c430d 100644 --- a/src/db.js +++ b/src/db.js @@ -211,6 +211,28 @@ export function listReleases(limit, offset) { return { items: selectReleases.all(limit, offset), total: countReleasesStmt.get().n }; } +/** Zahlen der letzten 7 Tage für den Wochen-Rückblick */ +export function weeklyStats() { + const perDay = db + .prepare(` + SELECT date(committed_at) AS day, count(*) AS n + FROM commits WHERE committed_at >= datetime('now', '-7 days') + GROUP BY day + `) + .all(); + const topRepos = db + .prepare(` + SELECT repo, count(*) AS n + FROM commits WHERE committed_at >= datetime('now', '-7 days') + GROUP BY repo ORDER BY n DESC LIMIT 3 + `) + .all(); + const devlogs = db + .prepare(`SELECT count(*) AS n FROM devlogs WHERE posted_at >= datetime('now', '-7 days')`) + .get().n; + return { perDay, topRepos, devlogs }; +} + /** Statistiken fürs Status-Panel der Settings-Seite */ export function archiveStats() { return { diff --git a/src/index.js b/src/index.js index 8e9e7e7..77d3f0c 100644 --- a/src/index.js +++ b/src/index.js @@ -1,6 +1,7 @@ -// Einstiegspunkt — startet Discord-Bot und Webserver (Webhooks) +// Einstiegspunkt — startet Discord-Bot, Webserver und den Wochen-Rückblick-Scheduler import { startBot } from './bot/client.js'; import { startWebServer } from './web/server.js'; +import { scheduleWeeklyRecap } from './bot/weekly-recap.js'; process.on('unhandledRejection', (error) => { console.error('[main] Unhandled Rejection:', error); @@ -9,6 +10,7 @@ process.on('unhandledRejection', (error) => { try { const client = await startBot(); await startWebServer(client); + scheduleWeeklyRecap(client); } catch (error) { console.error('[main] Start fehlgeschlagen:', error); process.exit(1); diff --git a/src/web/api.js b/src/web/api.js index e376871..e55e704 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -138,6 +138,7 @@ ${rssItems} commit_feed_enabled: getSetting('commit_feed_enabled') !== '0', commit_branch_filter: getSetting('commit_branch_filter') ?? '', ignored_repos: getSetting('ignored_repos') ?? '', + weekly_recap_enabled: getSetting('weekly_recap_enabled') !== '0', }; } @@ -193,6 +194,9 @@ ${rssItems} if (body.commit_feed_enabled !== undefined) { setSetting('commit_feed_enabled', body.commit_feed_enabled ? '1' : '0'); } + if (body.weekly_recap_enabled !== undefined) { + setSetting('weekly_recap_enabled', body.weekly_recap_enabled ? '1' : '0'); + } for (const key of ['commit_branch_filter', 'ignored_repos']) { if (body[key] !== undefined) { setSetting(key, String(body[key]).trim()); @@ -208,6 +212,18 @@ ${rssItems} if (requireAdmin(request, reply)) return; const target = request.params.target; + + // Sonderfall: echten Wochen-Rückblick sofort posten + if (target === 'recap') { + try { + const { postWeeklyRecap } = await import('../bot/weekly-recap.js'); + await postWeeklyRecap(client); + return { ok: true }; + } catch (error) { + request.log.error({ err: error }, 'Wochen-Rückblick-Test fehlgeschlagen'); + return reply.code(502).send({ error: 'Senden fehlgeschlagen' }); + } + } const channelId = target === 'commit' ? commitChannelId() : target === 'devlog' ? devlogChannelId()