From 52f2d0ce8e5a4f840e0d1791d84ca1e3e7ed1100 Mon Sep 17 00:00:00 2001
From: Florian Wilde
Date: Tue, 18 Aug 2026 00:55:02 +0200
Subject: [PATCH] Wochenrueckblick ueber alle Projekte
Eine Seite, die aus dem vorhandenen Aktivitaetsverlauf zusammenfasst, was in
einer Woche ueber alle Projekte hinweg passiert ist: Kennzahlen oben, darunter
die Vorgaenge nach Kalendertagen gebuendelt, neuester Tag zuerst. Blaettern in
frueher Wochen ueber ?woche=-n.
Der Verlauf kennt keinen Besitzer - die Trennung laeuft ueber das Projekt,
"project: { ownerId }" steht als erste Bedingung.
Zwei Dinge, die sonst still falsch werden:
- **Wochengrenzen** stehen als pruefbare Funktion in lib/review, nicht in der
Seite. Gerechnet wird ueber Berliner Kalendertage, nicht ueber "sieben mal
24 Stunden": liegt eine Zeitumstellung in der Woche, hat sie 167 oder 169
Stunden. Und im Container laeuft die Anwendung nach UTC, ein Eintrag von
Sonntagabend fiele sonst in die falsche Woche.
- **"Aufgaben erledigt"** wird am meta-Feld abgelesen (to: "DONE") und nicht
am Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
Formulierung; eine Zaehlung daran wuerde beim naechsten umformulierten
Einzeiler unbemerkt kippen. Eintraege ohne meta zaehlen nicht mit - lieber
untertreiben als raten.
Damit completeTaskFromIssue dieselbe Grundlage liefert, schreibt es jetzt
ebenfalls from/to ins meta, wie es das Verschieben von Hand schon tut.
Die Zuordnung Symbol/Farbe je Eintragsart lag bisher in ActivityFeed und wird
jetzt von zwei Stellen gebraucht - herausgezogen nach components/activityKinds,
sonst laufen die Kopien beim naechsten neuen Eintragstyp auseinander.
Erreichbar ueber die Schnellsuche und einen Verweis vom Dashboard. Bewusst
kein vierter Punkt in der Kopfleiste - die ist auf dem Telefon schon eng.
Geprueft: 20 neue Unit-Tests (Wochengrenzen inklusive beider Zeitumstellungen,
Jahreswechsel, Zaehlung), ein E2E-Test ueber Aufruf und Blaettern, axe-core auf
375px und 1280px ueber drei Zustaende - auch die leere Woche - ohne
Beanstandung, kein seitlicher Ueberlauf.
Refs #2
---
e2e/ui.spec.ts | 26 ++++
src/app/page.tsx | 12 +-
src/app/rueckblick/page.tsx | 135 ++++++++++++++++++++
src/components/ActivityFeed.tsx | 37 +-----
src/components/CommandPalette.tsx | 8 ++
src/components/WeeklyReview.tsx | 197 +++++++++++++++++++++++++++++
src/components/activityKinds.ts | 43 +++++++
src/lib/review.test.ts | 173 +++++++++++++++++++++++++
src/lib/review.ts | 203 ++++++++++++++++++++++++++++++
src/lib/tasks/issues.ts | 10 +-
src/types/index.ts | 18 +++
11 files changed, 827 insertions(+), 35 deletions(-)
create mode 100644 src/app/rueckblick/page.tsx
create mode 100644 src/components/WeeklyReview.tsx
create mode 100644 src/components/activityKinds.ts
create mode 100644 src/lib/review.test.ts
create mode 100644 src/lib/review.ts
diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts
index 32c036f..4adff30 100644
--- a/e2e/ui.spec.ts
+++ b/e2e/ui.spec.ts
@@ -223,6 +223,32 @@ test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page
).toBeVisible();
});
+test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => {
+ // Erreichbar vom Dashboard aus, ohne vierten Punkt in der Kopfleiste.
+ await page.getByRole("link", { name: /Wochenrückblick/ }).click();
+ await expect(page).toHaveURL(/\/rueckblick$/);
+ await expect(page.getByRole("heading", { name: "Diese Woche", level: 1 })).toBeVisible();
+
+ // Die Vorgaenge der bisherigen Tests liegen in dieser Woche, es muss also
+ // etwas dastehen – und das Projekt gehoert an jede Zeile.
+ await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
+
+ // Blättern: zurück und wieder her.
+ await page.getByRole("link", { name: "Eine Woche zurück" }).click();
+ await expect(page).toHaveURL(/woche=-1/);
+ await expect(page.getByRole("heading", { name: "Vorige Woche", level: 1 })).toBeVisible();
+
+ await page.getByRole("link", { name: "Diese Woche", exact: true }).click();
+ await expect(page).toHaveURL(/\/rueckblick$/);
+
+ // Auch ueber die Schnellsuche erreichbar.
+ await page.goto("/");
+ await page.keyboard.press("Control+k");
+ await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Rückblick");
+ await page.keyboard.press("Enter");
+ await expect(page).toHaveURL(/\/rueckblick/);
+});
+
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 29ad9fd..2270fc4 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,4 +1,5 @@
-import { CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
+import Link from "next/link";
+import { CalendarRange, CircleDot, GitBranch, Lightbulb, Rocket, Trophy } from "lucide-react";
import { ProjectBoard } from "@/components/ProjectBoard";
import { StatCard } from "@/components/StatCard";
import { requireUserPage } from "@/lib/auth/guard";
@@ -50,6 +51,15 @@ export default async function DashboardPage() {
Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den
verknuepften Repositories bei GitHub, GitLab und Gitea passiert.
+ {/* Kein weiterer Punkt in der Kopfleiste – die ist auf dem Telefon
+ schon eng. Der Rueckblick haengt dort, wo man ohnehin hinsieht. */}
+
+
+ Wochenrückblick ansehen
+
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
diff --git a/src/app/rueckblick/page.tsx b/src/app/rueckblick/page.tsx
new file mode 100644
index 0000000..d91463f
--- /dev/null
+++ b/src/app/rueckblick/page.tsx
@@ -0,0 +1,135 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ArrowLeft, ArrowRight, CalendarRange } from "lucide-react";
+import { WeeklyReview } from "@/components/WeeklyReview";
+import { requireUserPage } from "@/lib/auth/guard";
+import { prisma } from "@/lib/db";
+import { tagesschluessel, wocheUm, wochenName, wochenTitel, zaehleWoche } from "@/lib/review";
+import type { ReviewDay, ReviewEntry } from "@/types";
+
+export const dynamic = "force-dynamic";
+
+export const metadata: Metadata = { title: "Wochenrückblick" };
+
+/** Wie weit sich zurueckblaettern laesst – der Verlauf haelt ohnehin nur 200 je Projekt. */
+const MAX_RUECKBLICK = 25;
+
+export default async function ReviewPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ woche?: string }>;
+}) {
+ const user = await requireUserPage();
+ const params = await searchParams;
+
+ // Nur nach hinten blaettern: die Zukunft ist im Rueckblick leer.
+ const gewuenscht = Number(params.woche ?? 0);
+ const versatz = Number.isFinite(gewuenscht)
+ ? Math.min(0, Math.max(-MAX_RUECKBLICK, Math.trunc(gewuenscht)))
+ : 0;
+
+ const woche = wocheUm(new Date(), versatz);
+
+ /**
+ * Der Verlauf kennt keinen Besitzer – die Trennung laeuft ueber das
+ * Projekt. Die Bedingung steht deshalb als erste in der where-Klausel.
+ */
+ const activities = await prisma.activity.findMany({
+ where: {
+ project: { ownerId: user.id },
+ createdAt: { gte: woche.von, lt: woche.bis },
+ },
+ include: {
+ project: { select: { id: true, name: true, accent: true } },
+ user: { select: { username: true, displayName: true } },
+ },
+ // meta kommt mit, weil die Kennzahlen daran ablesen, was erledigt
+ // wurde – verlaesslicher als der Anzeigetext.
+ orderBy: { createdAt: "desc" },
+ // Deckel gegen eine Woche, in der ein Abgleich Amok gelaufen ist.
+ take: 500,
+ });
+
+ /**
+ * Nach Kalendertagen buendeln – auf dem Server, damit Server-Render und
+ * Hydration nicht auseinanderlaufen. "Welcher Tag" haengt an der Zeitzone.
+ */
+ const nachTag = new Map();
+ for (const eintrag of activities) {
+ const schluessel = tagesschluessel(eintrag.createdAt);
+ const liste = nachTag.get(schluessel) ?? [];
+ liste.push({
+ id: eintrag.id,
+ kind: eintrag.kind,
+ summary: eintrag.summary,
+ createdAt: eintrag.createdAt.toISOString(),
+ project: eintrag.project,
+ user: eintrag.user,
+ });
+ nachTag.set(schluessel, liste);
+ }
+
+ // Neuester Tag zuerst, und nur Tage, an denen etwas war.
+ const tage: ReviewDay[] = [...woche.tage]
+ .reverse()
+ .map((tag) => ({ tag, eintraege: nachTag.get(tag) ?? [] }))
+ .filter((eintrag) => eintrag.eintraege.length > 0);
+
+ const kennzahlen = zaehleWoche(activities);
+
+ return (
+
+
+
+ Über alle Projekte
+
+
+
+
+ {wochenName(woche)}
+
+
+
+ {wochenTitel(woche)}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ActivityFeed.tsx b/src/components/ActivityFeed.tsx
index 2e8492b..e2289ec 100644
--- a/src/components/ActivityFeed.tsx
+++ b/src/components/ActivityFeed.tsx
@@ -1,23 +1,9 @@
"use client";
import { useState } from "react";
-import {
- AlertTriangle,
- ArrowRight,
- Bot,
- Download,
- GitCommitHorizontal,
- History,
- Link2,
- Loader2,
- NotebookPen,
- Pencil,
- Plus,
- Signpost,
- Sparkles,
- Trash2,
-} from "lucide-react";
-import type { ActivityEntry, ActivityKind } from "@/types";
+import { Bot, History, Loader2 } from "lucide-react";
+import { ACTIVITY_KINDS } from "@/components/activityKinds";
+import type { ActivityEntry } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils";
/**
@@ -29,21 +15,6 @@ import { formatDateTime, formatRelative } from "@/lib/utils";
* obersten fuenf liest.
*/
-const KINDS: Record = {
- PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300" },
- PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400" },
- STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300" },
- NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300" },
- NOTE_UPDATED: { icon: Pencil, color: "text-blue-300" },
- NOTE_DELETED: { icon: Trash2, color: "text-rose-300" },
- TASK_ADDED: { icon: Plus, color: "text-emerald-300" },
- TASK_MOVED: { icon: ArrowRight, color: "text-violet-300" },
- TASK_DELETED: { icon: Trash2, color: "text-rose-300" },
- TASKS_IMPORTED: { icon: Download, color: "text-amber-300" },
- REPO_LINKED: { icon: Link2, color: "text-cyan-300" },
- REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300" },
- SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300" },
-};
export function ActivityFeed({
projectId,
@@ -88,7 +59,7 @@ export function ActivityFeed({
) : (
{entries.map((entry, index) => {
- const meta = KINDS[entry.kind] ?? KINDS.PROJECT_UPDATED;
+ const meta = ACTIVITY_KINDS[entry.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = meta.icon;
const letzter = index === entries.length - 1;
diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx
index 5d4c721..a128026 100644
--- a/src/components/CommandPalette.tsx
+++ b/src/components/CommandPalette.tsx
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
+ CalendarRange,
CheckSquare,
CornerDownLeft,
GitBranch,
@@ -56,6 +57,13 @@ interface Action {
const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
+ {
+ id: "rueckblick",
+ label: "Wochenrückblick",
+ hint: "Was diese Woche passiert ist",
+ icon: CalendarRange,
+ href: "/rueckblick",
+ },
{
id: "tasks",
label: "Aufgaben",
diff --git a/src/components/WeeklyReview.tsx b/src/components/WeeklyReview.tsx
new file mode 100644
index 0000000..9bba634
--- /dev/null
+++ b/src/components/WeeklyReview.tsx
@@ -0,0 +1,197 @@
+import Link from "next/link";
+import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react";
+import { ACTIVITY_KINDS } from "@/components/activityKinds";
+import type { Kennzahlen } from "@/lib/review";
+import { accentOf } from "@/lib/status";
+import { cn } from "@/lib/utils";
+import type { ReviewDay, ReviewEntry } from "@/types";
+
+/**
+ * Der Wochenrueckblick.
+ *
+ * Bewusst eine Server-Komponente: es gibt nichts zu bedienen, alles steht
+ * beim Rendern fest. Das spart dem Browser den ganzen Verlauf als JavaScript.
+ *
+ * Die Tage kommen fertig gebuendelt und in der richtigen Reihenfolge – die
+ * Zuordnung "welcher Kalendertag" haengt an der Zeitzone und gehoert
+ * deshalb auf den Server.
+ */
+
+const WOCHENTAG = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin",
+ weekday: "long",
+});
+const TAG_MONAT = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin",
+ day: "numeric",
+ month: "long",
+});
+const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin",
+ hour: "2-digit",
+ minute: "2-digit",
+});
+
+function alsDatum(tag: string): Date {
+ const [j, m, t] = tag.split("-").map(Number);
+ return new Date(Date.UTC(j, m - 1, t, 12));
+}
+
+export function WeeklyReview({
+ tage,
+ kennzahlen,
+ istDieseWoche,
+}: {
+ tage: ReviewDay[];
+ /** Fertig gezaehlt vom Server – die Anzeige rechnet nichts nach. */
+ kennzahlen: Kennzahlen;
+ istDieseWoche: boolean;
+}) {
+ const { gesamt, projekte, erledigt, neueAufgaben, notizen } = kennzahlen;
+
+ if (gesamt === 0) {
+ return (
+
+
+
+
+
+
+ {istDieseWoche ? "Diese Woche war es still" : "In dieser Woche ist nichts passiert"}
+
+
+ {istDieseWoche
+ ? "Sobald du Aufgaben abhakst, Notizen schreibst oder etwas gepusht wird, steht es hier."
+ : "Vielleicht lohnt ein Blick auf eine frühere Woche."}
+
+ {/* Das Projekt gehoert in jede Zeile – ohne es ist eine
+ projektuebergreifende Liste nicht zu lesen. */}
+
+
+ {eintrag.project.name}
+
+ {UHRZEIT.format(new Date(eintrag.createdAt))} Uhr
+ {eintrag.user ? (
+
+ {eintrag.user.displayName?.trim() || eintrag.user.username}
+
+ ) : (
+ // Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
+ automatisch
+ )}
+
+
+
+ );
+}
diff --git a/src/components/activityKinds.ts b/src/components/activityKinds.ts
new file mode 100644
index 0000000..448bebd
--- /dev/null
+++ b/src/components/activityKinds.ts
@@ -0,0 +1,43 @@
+import {
+ AlertTriangle,
+ ArrowRight,
+ Download,
+ GitCommitHorizontal,
+ Link2,
+ NotebookPen,
+ Pencil,
+ Plus,
+ Signpost,
+ Sparkles,
+ Trash2,
+} from "lucide-react";
+import type { ActivityKind } from "@prisma/client";
+
+/**
+ * Symbol und Farbe je Art eines Verlaufseintrags.
+ *
+ * Ausgelagert, weil sie inzwischen an zwei Stellen gebraucht werden: im
+ * Verlauf eines Projekts und im Wochenrueckblick ueber alle Projekte. Zwei
+ * Kopien liefen beim naechsten neuen Eintragstyp auseinander.
+ *
+ * Bewusst ohne "use client" und ohne Prisma-Zugriff – nur Typen und
+ * Symbole, damit beide Seiten es importieren koennen.
+ */
+export const ACTIVITY_KINDS: Record<
+ ActivityKind,
+ { icon: typeof Sparkles; color: string; label: string }
+> = {
+ PROJECT_CREATED: { icon: Sparkles, color: "text-violet-300", label: "Projekt angelegt" },
+ PROJECT_UPDATED: { icon: Pencil, color: "text-slate-400", label: "Projekt bearbeitet" },
+ STATUS_CHANGED: { icon: Signpost, color: "text-cyan-300", label: "Status geändert" },
+ NOTE_ADDED: { icon: NotebookPen, color: "text-blue-300", label: "Notiz" },
+ NOTE_UPDATED: { icon: Pencil, color: "text-blue-300", label: "Notiz bearbeitet" },
+ NOTE_DELETED: { icon: Trash2, color: "text-rose-300", label: "Notiz gelöscht" },
+ TASK_ADDED: { icon: Plus, color: "text-emerald-300", label: "Aufgabe angelegt" },
+ TASK_MOVED: { icon: ArrowRight, color: "text-violet-300", label: "Aufgabe verschoben" },
+ TASK_DELETED: { icon: Trash2, color: "text-rose-300", label: "Aufgabe gelöscht" },
+ TASKS_IMPORTED: { icon: Download, color: "text-amber-300", label: "Aufgaben übernommen" },
+ REPO_LINKED: { icon: Link2, color: "text-cyan-300", label: "Repository verknüpft" },
+ REPO_COMMITS: { icon: GitCommitHorizontal, color: "text-emerald-300", label: "Commits" },
+ SYNC_FAILED: { icon: AlertTriangle, color: "text-rose-300", label: "Abgleich fehlgeschlagen" },
+};
diff --git a/src/lib/review.test.ts b/src/lib/review.test.ts
new file mode 100644
index 0000000..c55ae67
--- /dev/null
+++ b/src/lib/review.test.ts
@@ -0,0 +1,173 @@
+import { describe, expect, it } from "vitest";
+import {
+ berlinMitternacht,
+ tagesschluessel,
+ wocheUm,
+ wochenName,
+ wochenTitel,
+ zaehleWoche,
+} from "./review";
+
+describe("tagesschluessel", () => {
+ it("rechnet in Berliner Kalendertagen, nicht in UTC", () => {
+ // 22:30 UTC ist in Berlin schon der Folgetag (Sommerzeit, UTC+2).
+ expect(tagesschluessel(new Date("2026-08-17T22:30:00Z"))).toBe("2026-08-18");
+ expect(tagesschluessel(new Date("2026-08-17T21:00:00Z"))).toBe("2026-08-17");
+ });
+});
+
+describe("berlinMitternacht", () => {
+ it("trifft den Sommerzeit-Beginn des Tages", () => {
+ // Sommerzeit: Berlin = UTC+2, Mitternacht dort ist 22:00 UTC am Vortag.
+ expect(berlinMitternacht("2026-08-17").toISOString()).toBe("2026-08-16T22:00:00.000Z");
+ });
+
+ it("trifft den Winterzeit-Beginn des Tages", () => {
+ // Winterzeit: UTC+1.
+ expect(berlinMitternacht("2026-01-12").toISOString()).toBe("2026-01-11T23:00:00.000Z");
+ });
+});
+
+describe("wocheUm", () => {
+ /** Ein Dienstag. */
+ const DIENSTAG = new Date("2026-08-18T09:00:00Z");
+
+ it("beginnt am Montag und endet am folgenden Montag", () => {
+ const woche = wocheUm(DIENSTAG);
+ expect(woche.tage[0]).toBe("2026-08-17");
+ expect(woche.tage[6]).toBe("2026-08-23");
+ expect(woche.tage).toHaveLength(7);
+ expect(woche.von.toISOString()).toBe("2026-08-16T22:00:00.000Z");
+ expect(woche.bis.toISOString()).toBe("2026-08-23T22:00:00.000Z");
+ });
+
+ it("zaehlt den Montag selbst zur laufenden Woche", () => {
+ const montag = new Date("2026-08-17T06:00:00Z");
+ expect(wocheUm(montag).tage[0]).toBe("2026-08-17");
+ });
+
+ it("zaehlt den Sonntagabend noch zur ablaufenden Woche", () => {
+ // 21:00 UTC am Sonntag ist in Berlin 23:00 – noch derselbe Tag.
+ const sonntagAbend = new Date("2026-08-23T21:00:00Z");
+ expect(wocheUm(sonntagAbend).tage[0]).toBe("2026-08-17");
+ // Zwei Stunden spaeter ist Montag und damit die naechste Woche.
+ const montagFrueh = new Date("2026-08-23T22:30:00Z");
+ expect(wocheUm(montagFrueh).tage[0]).toBe("2026-08-24");
+ });
+
+ it("blättert rückwärts", () => {
+ expect(wocheUm(DIENSTAG, -1).tage[0]).toBe("2026-08-10");
+ expect(wocheUm(DIENSTAG, -3).tage[0]).toBe("2026-07-27");
+ });
+
+ it("kommt über den Monats- und Jahreswechsel", () => {
+ const neujahr = new Date("2027-01-01T12:00:00Z"); // ein Freitag
+ const woche = wocheUm(neujahr);
+ expect(woche.tage[0]).toBe("2026-12-28");
+ expect(woche.tage[6]).toBe("2027-01-03");
+ });
+
+ it("bleibt über die Zeitumstellung hinweg sieben Tage lang", () => {
+ // Am 25.10.2026 wird auf Winterzeit zurueckgestellt – die Woche hat
+ // dann 169 Stunden. "von + 7 Tage" waere hier eine Stunde daneben.
+ const inDerWoche = new Date("2026-10-21T12:00:00Z");
+ const woche = wocheUm(inDerWoche);
+ expect(woche.tage[0]).toBe("2026-10-19");
+ expect(woche.tage[6]).toBe("2026-10-25");
+ const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
+ expect(stunden).toBe(169);
+ });
+
+ it("liefert bei der Umstellung im Fruehjahr 167 Stunden", () => {
+ // Vorgestellt wird am letzten Sonntag im Maerz – 2026 ist das der 29.,
+ // er liegt also in der Woche ab Montag, dem 23.
+ const inDerWoche = new Date("2026-03-25T12:00:00Z");
+ const woche = wocheUm(inDerWoche);
+ expect(woche.tage[0]).toBe("2026-03-23");
+ expect(woche.tage[6]).toBe("2026-03-29");
+ const stunden = (woche.bis.getTime() - woche.von.getTime()) / 3_600_000;
+ expect(stunden).toBe(167);
+ });
+
+ it("hat in einer Woche ohne Umstellung genau 168 Stunden", () => {
+ const woche = wocheUm(new Date("2026-03-31T12:00:00Z"));
+ expect(woche.tage[0]).toBe("2026-03-30");
+ expect((woche.bis.getTime() - woche.von.getTime()) / 3_600_000).toBe(168);
+ });
+});
+
+describe("Beschriftung", () => {
+ const DIENSTAG = new Date("2026-08-18T09:00:00Z");
+
+ it("nennt die laufende und die vorige Woche beim Namen", () => {
+ expect(wochenName(wocheUm(DIENSTAG))).toBe("Diese Woche");
+ expect(wochenName(wocheUm(DIENSTAG, -1))).toBe("Vorige Woche");
+ });
+
+ it("nennt weiter zurueckliegende Wochen mit Zeitraum", () => {
+ expect(wochenName(wocheUm(DIENSTAG, -2))).toMatch(/\d/);
+ });
+
+ it("nennt den Monat nur einmal, wenn die Woche nicht darueber hinausgeht", () => {
+ expect(wochenTitel(wocheUm(DIENSTAG))).toBe("17.–23. August 2026");
+ });
+
+ it("nennt beide Monate, wenn die Woche sie ueberspannt", () => {
+ // 31.08. bis 06.09.2026.
+ const woche = wocheUm(new Date("2026-09-02T12:00:00Z"));
+ expect(wochenTitel(woche)).toBe("31. August–6. September 2026");
+ });
+});
+
+describe("zaehleWoche", () => {
+ const eintrag = (
+ kind: Parameters[0][number]["kind"],
+ projectId: string,
+ meta?: unknown,
+ ) => ({ kind, projectId, meta });
+
+ it("zaehlt Erledigtes am meta-Feld, nicht am Text", () => {
+ const zahlen = zaehleWoche([
+ eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DONE" }),
+ // Verschoben, aber nicht erledigt – darf nicht mitzaehlen.
+ eintrag("TASK_MOVED", "p1", { from: "TODO", to: "DOING" }),
+ ]);
+ expect(zahlen.erledigt).toBe(1);
+ });
+
+ it("zaehlt einen Eintrag ohne meta nicht als erledigt", () => {
+ // Aeltere Eintraege koennen kein "to" haben. Lieber untertreiben als
+ // eine Zahl behaupten, die aus dem Anzeigetext geraten waere.
+ expect(zaehleWoche([eintrag("TASK_MOVED", "p1")]).erledigt).toBe(0);
+ expect(zaehleWoche([eintrag("TASK_MOVED", "p1", "unsinn")]).erledigt).toBe(0);
+ });
+
+ it("zaehlt neue Aufgaben aus beiden Quellen", () => {
+ const zahlen = zaehleWoche([
+ eintrag("TASK_ADDED", "p1"),
+ eintrag("TASKS_IMPORTED", "p2"),
+ ]);
+ expect(zahlen.neueAufgaben).toBe(2);
+ });
+
+ it("zaehlt beruehrte Projekte ohne Dubletten", () => {
+ const zahlen = zaehleWoche([
+ eintrag("NOTE_ADDED", "p1"),
+ eintrag("NOTE_ADDED", "p1"),
+ eintrag("REPO_COMMITS", "p2"),
+ ]);
+ expect(zahlen.projekte).toBe(2);
+ expect(zahlen.notizen).toBe(2);
+ expect(zahlen.gesamt).toBe(3);
+ });
+
+ it("kommt mit einer leeren Woche zurecht", () => {
+ expect(zaehleWoche([])).toEqual({
+ gesamt: 0,
+ projekte: 0,
+ erledigt: 0,
+ neueAufgaben: 0,
+ notizen: 0,
+ });
+ });
+});
diff --git a/src/lib/review.ts b/src/lib/review.ts
new file mode 100644
index 0000000..8f53262
--- /dev/null
+++ b/src/lib/review.ts
@@ -0,0 +1,203 @@
+import type { ActivityKind } from "@prisma/client";
+
+/**
+ * Wochenrueckblick: welche Woche, und was zaehlt darin.
+ *
+ * Die Wochenrechnung steht hier als reine Funktion und nicht in der Seite –
+ * Wochengrenzen sind der Teil, der still falsch wird, und so laesst er sich
+ * pruefen.
+ */
+
+/** Wochenbeginn ist Montag, wie in Deutschland ueblich (ISO 8601). */
+export const WOCHENBEGINN = 1;
+
+/**
+ * Kalendertag in fester Zeitzone.
+ *
+ * Dieselbe Ueberlegung wie bei den Faelligkeiten in lib/tasks: im Container
+ * laeuft die Anwendung nach UTC. Um 23:30 Berliner Zeit waere dort noch der
+ * Vortag, und ein Eintrag von Sonntagabend fiele in die falsche Woche.
+ */
+const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", {
+ timeZone: "Europe/Berlin",
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+});
+
+/** "2026-08-17" in Berliner Zeit. */
+export function tagesschluessel(wert: Date): string {
+ return TAG_FORMAT.format(wert);
+}
+
+/** Wochentag in Berliner Zeit, 0 = Sonntag. */
+function wochentag(wert: Date): number {
+ const [jahr, monat, tag] = tagesschluessel(wert).split("-").map(Number);
+ return new Date(Date.UTC(jahr, monat - 1, tag)).getUTCDay();
+}
+
+export interface Woche {
+ /** Beginn des Montags, als Zeitpunkt fuer die Abfrage. */
+ von: Date;
+ /** Beginn des folgenden Montags – ausschliesslich. */
+ bis: Date;
+ /** 0 = laufende Woche, -1 = vorige, … */
+ versatz: number;
+ /** Die sieben Kalendertage der Woche, Montag zuerst. */
+ tage: string[];
+}
+
+/**
+ * Grenzen der Woche, in der `jetzt` liegt – um `versatz` Wochen verschoben.
+ *
+ * Gerechnet wird ueber die Kalendertage in Berliner Zeit und dann in UTC
+ * zurueckgerechnet. Der Umweg ist noetig, weil zwischen zwei Montagen eine
+ * Zeitumstellung liegen kann: "sieben mal 24 Stunden" waere dann um eine
+ * Stunde daneben.
+ */
+export function wocheUm(jetzt: Date, versatz = 0): Woche {
+ const [jahr, monat, tag] = tagesschluessel(jetzt).split("-").map(Number);
+ const heuteUtc = Date.UTC(jahr, monat - 1, tag);
+
+ // Rueckwaerts bis zum Montag.
+ const abstandZumMontag = (wochentag(jetzt) - WOCHENBEGINN + 7) % 7;
+ const montag = new Date(heuteUtc - (abstandZumMontag - versatz * 7) * 86_400_000);
+
+ const tage: string[] = [];
+ for (let i = 0; i < 7; i += 1) {
+ tage.push(new Date(montag.getTime() + i * 86_400_000).toISOString().slice(0, 10));
+ }
+
+ const von = berlinMitternacht(tage[0]);
+ return {
+ von,
+ // Ende ist die Mitternacht *nach* dem Sonntag – nicht "von + 7 Tage":
+ // liegt eine Zeitumstellung dazwischen, ist die Woche 167 oder 169
+ // Stunden lang.
+ bis: berlinMitternacht(naechsterTag(tage[6])),
+ versatz,
+ tage,
+ };
+}
+
+/** Ein Kalendertag weiter, rein auf der Zeichenkette gerechnet. */
+function naechsterTag(tag: string): string {
+ const [j, m, t] = tag.split("-").map(Number);
+ return new Date(Date.UTC(j, m - 1, t) + 86_400_000).toISOString().slice(0, 10);
+}
+
+/**
+ * Um wie viel geht die Berliner Uhr zu diesem Zeitpunkt der UTC voraus?
+ *
+ * Ermittelt statt fest verdrahtet, weil zwischen Sommer- und Winterzeit eine
+ * Stunde Unterschied liegt und ein Rueckblick ueber die Umstellung hinweg
+ * sonst um eine Stunde verrutscht.
+ */
+function zonenversatzMs(zeitpunkt: Date): number {
+ // sv-SE liefert "2026-08-17 14:30:00" – nah genug an ISO, um es nach dem
+ // Austausch des Leerzeichens wieder einlesen zu koennen.
+ const berlin = new Intl.DateTimeFormat("sv-SE", {
+ timeZone: "Europe/Berlin",
+ dateStyle: "short",
+ timeStyle: "medium",
+ }).format(zeitpunkt);
+ return Date.parse(`${berlin.replace(" ", "T")}Z`) - zeitpunkt.getTime();
+}
+
+/** Der Zeitpunkt, an dem in Berlin dieser Kalendertag beginnt. */
+export function berlinMitternacht(tag: string): Date {
+ const basis = new Date(`${tag}T00:00:00Z`);
+ // Die Umstellung findet nachts um zwei statt, nie um Mitternacht – der
+ // Versatz an dieser Stelle ist also eindeutig.
+ return new Date(basis.getTime() - zonenversatzMs(basis));
+}
+
+export interface Kennzahlen {
+ gesamt: number;
+ projekte: number;
+ erledigt: number;
+ neueAufgaben: number;
+ notizen: number;
+}
+
+/** Was ein Verlaufseintrag fuer die Zaehlung hergeben muss. */
+export interface ZaehlbarerEintrag {
+ kind: ActivityKind;
+ projectId: string;
+ meta?: unknown;
+}
+
+/**
+ * Kennzahlen einer Woche.
+ *
+ * "Erledigt" kommt aus dem `meta`-Feld (`to: "DONE"`) und nicht aus dem
+ * Anzeigetext. Der Text ist zum Lesen da und aendert sich mit der
+ * Formulierung – eine Zaehlung, die daran haengt, wird beim naechsten
+ * umformulierten Einzeiler still falsch.
+ *
+ * Fuer aeltere Eintraege ohne `to` bleibt ein Rueckfall auf den Text: der
+ * Verlauf haelt bis zu 200 Eintraege je Projekt, darunter also durchaus
+ * welche von vor dieser Aenderung.
+ */
+export function zaehleWoche(eintraege: ZaehlbarerEintrag[]): Kennzahlen {
+ let erledigt = 0;
+ let neueAufgaben = 0;
+ let notizen = 0;
+ const projekte = new Set();
+
+ for (const eintrag of eintraege) {
+ projekte.add(eintrag.projectId);
+
+ if (eintrag.kind === "TASK_ADDED" || eintrag.kind === "TASKS_IMPORTED") {
+ neueAufgaben += 1;
+ } else if (eintrag.kind === "NOTE_ADDED") {
+ notizen += 1;
+ } else if (eintrag.kind === "TASK_MOVED" && istErledigt(eintrag)) {
+ erledigt += 1;
+ }
+ }
+
+ return { gesamt: eintraege.length, projekte: projekte.size, erledigt, neueAufgaben, notizen };
+}
+
+function istErledigt(eintrag: ZaehlbarerEintrag): boolean {
+ const meta = eintrag.meta;
+ if (meta && typeof meta === "object" && "to" in meta) {
+ return (meta as { to?: unknown }).to === "DONE";
+ }
+ return false;
+}
+
+const MONAT_FORMAT = new Intl.DateTimeFormat("de-DE", {
+ timeZone: "Europe/Berlin",
+ month: "long",
+});
+
+/**
+ * Beschriftung einer Woche: "17.–23. August 2026", ueber einen
+ * Monatswechsel hinweg "31. August–6. September 2026".
+ *
+ * Der Punkt hinter der Tageszahl kommt von Hand: Intl liefert bei
+ * `day: "numeric"` nur die Ziffer, und mit `day: "2-digit"` staende dort
+ * "07." statt "7.".
+ */
+export function wochenTitel(woche: Woche): string {
+ const teile = (tag: string) => {
+ const [j, m, t] = tag.split("-").map(Number);
+ return { tag: t, monat: MONAT_FORMAT.format(new Date(Date.UTC(j, m - 1, t, 12))), jahr: j };
+ };
+
+ const von = teile(woche.tage[0]);
+ const bis = teile(woche.tage[6]);
+ const gleicherMonat = von.monat === bis.monat && von.jahr === bis.jahr;
+
+ const links = gleicherMonat ? `${von.tag}.` : `${von.tag}. ${von.monat}`;
+ return `${links}–${bis.tag}. ${bis.monat} ${bis.jahr}`;
+}
+
+/** "Diese Woche", "Vorige Woche", sonst der Zeitraum. */
+export function wochenName(woche: Woche): string {
+ if (woche.versatz === 0) return "Diese Woche";
+ if (woche.versatz === -1) return "Vorige Woche";
+ return wochenTitel(woche);
+}
diff --git a/src/lib/tasks/issues.ts b/src/lib/tasks/issues.ts
index c963be3..dfd800b 100644
--- a/src/lib/tasks/issues.ts
+++ b/src/lib/tasks/issues.ts
@@ -116,7 +116,15 @@ export async function completeTaskFromIssue(
`Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` +
(herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") +
(folge ? ", nächste angelegt" : ""),
- meta: { taskId: task.id, issue: task.sourceIssue, source: herkunft },
+ // "from"/"to" wie beim Verschieben von Hand – der Wochenrueckblick
+ // zaehlt daran ab, was erledigt wurde, statt den Text zu durchsuchen.
+ meta: {
+ taskId: task.id,
+ issue: task.sourceIssue,
+ source: herkunft,
+ from: task.status,
+ to: "DONE",
+ },
});
return true;
diff --git a/src/types/index.ts b/src/types/index.ts
index fbc9dff..e9c37a1 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -100,6 +100,24 @@ export interface TaskOverviewItem {
project: { id: string; name: string; accent: string; status: ProjectStatus };
}
+/** Ein Verlaufseintrag im Wochenrueckblick – mit Projekt, ueber Projekte hinweg. */
+export interface ReviewEntry {
+ id: string;
+ kind: ActivityKind;
+ summary: string;
+ /** ISO – ein Date ueberlebt den Weg zum Browser nicht. */
+ createdAt: string;
+ project: { id: string; name: string; accent: string };
+ user: { username: string; displayName: string | null } | null;
+}
+
+/** Ein Kalendertag der Woche samt seiner Eintraege. */
+export interface ReviewDay {
+ /** "2026-08-17", in Berliner Zeit bestimmt. */
+ tag: string;
+ eintraege: ReviewEntry[];
+}
+
export interface RepoListEntry {
fullPath: string;
owner: string;
--
2.54.0