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."} +

    +
    +
    + ); + } + + return ( +
    + {/* Kennzahlen */} +
    + + + + +
    + + {notizen > 0 ? ( +

    + + Dazu {notizen} {notizen === 1 ? "neue Notiz" : "neue Notizen"}. +

    + ) : null} + + {/* Verlauf nach Tagen */} +
    + {tage.map((tag) => { + const datum = alsDatum(tag.tag); + return ( +
    +
    +

    + {WOCHENTAG.format(datum)} +

    + {TAG_MONAT.format(datum)} + + {tag.eintraege.length} + + +
    + +
      + {tag.eintraege.map((eintrag) => ( + + ))} +
    +
    + ); + })} +
    +
    + ); +} + +function Kennzahl({ + label, + wert, + icon: Icon, + color, +}: { + label: string; + wert: number; + icon: typeof CalendarRange; + color: string; +}) { + return ( +
    + + ); +} + +function Zeile({ eintrag }: { eintrag: ReviewEntry }) { + const art = ACTIVITY_KINDS[eintrag.kind]; + const Icon = art.icon; + const accent = accentOf(eintrag.project.accent); + + return ( +
  1. + + + + +
    +

    {eintrag.summary}

    +
    + {/* Das Projekt gehoert in jede Zeile – ohne es ist eine + projektuebergreifende Liste nicht zu lesen. */} + +
    +
    +
  2. + ); +} 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;