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()