4 Commits
Author SHA1 Message Date
florian.wilde 0ef0b3d5ca Merge #3: Globale Zeitleiste
CI / pruefen (push) Canceled after 0s
2026-08-18 01:12:55 +00:00
florian.wilde 9cdd51fba8 Globale Zeitleiste ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
Eine Seite /verlauf, die den Aktivitaetsverlauf fortlaufend ueber alle
Projekte zeigt - ohne Wochengrenze, nach Kalendertagen gegliedert, mit
Nachladen aelterer Eintraege. Erste Seite serverseitig, alles Weitere ueber
/api/activity.

Geblaettert wird per Cursor ueber (createdAt, id), nicht per skip. Der
Verlauf schreibt mehrere Eintraege im selben Augenblick - beim Uebernehmen
von Issues entstehen zehn Zeilen mit identischem Zeitstempel. Ein Offset
ueber eine solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal,
und zwar lautlos. Der zweite Zweig der Bedingung (gleicher Zeitpunkt,
kleinere ID) faengt genau das ab.

Geprueft gegen 144 Eintraege mit absichtlichen Bloecken gleicher Zeitstempel:
15 Seiten, 144 Zeilen, keine Dublette, streng absteigend.

Die Bedingung selbst steht als pruefbare Funktion in lib/timeline, samt
Deckelung der Seitengroesse und Abweisen unbrauchbarer Cursor - eine ID aus
der Abfragezeichenkette geht nicht ungeprueft in die Abfrage.

Damit Rueckblick und Zeitleiste wirklich gleich aussehen, sind Zeile und
Tagesgruppen nach components/ActivityRow gewandert. Bewusst ohne "use
client" und ohne Hooks, damit beides sowohl aus der Server-Komponente
(Rueckblick) als auch aus der Client-Komponente (Zeitleiste) heraus
benutzbar ist. Die Buendelung im Browser ist unbedenklich, weil
tagesschluessel eine feste Zeitzone benutzt - Server und Browser kommen auf
denselben Kalendertag.

Erreichbar ueber die Schnellsuche und vom Wochenrueckblick aus.

Geprueft: 9 neue Unit-Tests zur Cursor-Logik, ein E2E-Test, der die ganze
Liste in Dreierseiten durchblaettert und gegen einen einzelnen grossen
Aufruf haelt, ein E2E-Test fuer die Seite, axe-core auf 375px und 1280px
je vor und nach dem Nachladen ohne Beanstandung.

Refs #3
2026-08-18 01:54:50 +02:00
florian.wilde 5713facc75 Merge #2: Wochenrückblick
CI / pruefen (push) Canceled after 0s
2026-08-17 23:27:19 +00:00
florian.wilde 52f2d0ce8e Wochenrueckblick ueber alle Projekte
CI / pruefen (pull_request) Canceled after 0s
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
2026-08-18 00:55:02 +02:00
18 changed files with 1352 additions and 35 deletions
+54
View File
@@ -763,6 +763,60 @@ test("Projektvorlagen: aus Projekt sichern und anwenden", async ({ request, play
expect((await request.delete(`/api/templates/${vorlage.id}`)).ok()).toBeTruthy();
});
test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, playwright }) => {
await login(request, ADMIN);
// Die bisherigen Tests haben reichlich Verlauf erzeugt. Erst alles auf
// einmal holen, dann in kleinen Seiten – beides muss dieselbe Menge sein.
const alles = await (await request.get("/api/activity?limit=100")).json();
expect(alles.activities.length).toBeGreaterThan(5);
const ids: string[] = [];
let cursor: { vor: string; id: string } | null = null;
for (let seite = 0; seite < 60; seite += 1) {
const query = cursor
? `?limit=3&vor=${encodeURIComponent(cursor.vor)}&id=${encodeURIComponent(cursor.id)}`
: "?limit=3";
const antwort = await request.get(`/api/activity${query}`);
expect(antwort.ok()).toBeTruthy();
const seiteBody = await antwort.json();
ids.push(...seiteBody.activities.map((eintrag: { id: string }) => eintrag.id));
if (!seiteBody.weitere) break;
cursor = seiteBody.naechster;
expect(cursor).not.toBeNull();
}
// Der eigentliche Punkt: keine Dubletten und keine Luecken, auch wenn
// mehrere Eintraege denselben Zeitstempel tragen.
expect(new Set(ids).size).toBe(ids.length);
expect(ids.length).toBe(alles.activities.length);
// Absteigend sortiert.
const zeiten: number[] = alles.activities.map((eintrag: { createdAt: string }) =>
Date.parse(eintrag.createdAt),
);
for (let i = 1; i < zeiten.length; i += 1) {
expect(zeiten[i]).toBeLessThanOrEqual(zeiten[i - 1]);
}
// Unbrauchbare Cursor fangen von vorn an, statt zu scheitern.
const kaputt = await request.get("/api/activity?vor=keindatum&id=x");
expect(kaputt.ok()).toBeTruthy();
expect((await kaputt.json()).activities.length).toBeGreaterThan(0);
// Und ein fremdes Konto sieht ausschliesslich seinen eigenen Verlauf.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
const fremderVerlauf = await (await fremd.get("/api/activity")).json();
const eigeneIds = new Set(ids);
for (const eintrag of fremderVerlauf.activities) {
expect(eigeneIds.has(eintrag.id)).toBe(false);
}
await fremd.dispose();
});
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
+46
View File
@@ -223,6 +223,52 @@ 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("Globale Zeitleiste zeigt den Verlauf ueber alle Projekte", async ({ page }) => {
// Vom Wochenrueckblick aus verlinkt.
await page.goto("/rueckblick");
await page.getByRole("link", { name: /Ganzer Verlauf/ }).click();
await expect(page).toHaveURL(/\/verlauf$/);
await expect(page.getByRole("heading", { name: "Verlauf", level: 1 })).toBeVisible();
// Eintraege mit Projektbezug – ohne den ist eine uebergreifende Liste
// nicht zu lesen.
await expect(page.getByRole("link", { name: "Oberflaechentest" }).first()).toBeVisible();
await expect(page.getByText(/Vorgänge geladen|Vorgang geladen/)).toBeVisible();
// Auch ueber die Schnellsuche erreichbar.
await page.goto("/");
await page.keyboard.press("Control+k");
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Verlauf");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/verlauf/);
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { requireUserApi } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import {
SEITEN_ORDNUNG,
cursorAus,
seitenBedingung,
seitenGroesse,
} from "@/lib/timeline";
export const dynamic = "force-dynamic";
/**
* Der Verlauf ueber alle Projekte, neueste Eintraege zuerst.
*
* Geblaettert wird per Cursor (`vor` = Zeitpunkt, `id` = Eintrag), nicht per
* Offset – siehe lib/timeline. Zurueck kommt der Cursor fuer die naechste
* Seite gleich mit, damit der Aufrufer ihn nicht selbst zusammensetzen muss.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
if (!auth.ok) return auth.response;
const params = new URL(request.url).searchParams;
const cursor = cursorAus(params.get("vor"), params.get("id"));
const take = seitenGroesse(params.get("limit"));
/**
* Einen mehr holen als angefragt: daran laesst sich ablesen, ob es
* weitergeht, ohne dafuer zaehlen zu muessen.
*/
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(auth.user.id, cursor),
orderBy: SEITEN_ORDNUNG,
take: take + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > take;
const activities = weitere ? gefunden.slice(0, take) : gefunden;
const letzter = activities[activities.length - 1];
return NextResponse.json({
activities: activities.map((eintrag) => ({
...eintrag,
createdAt: eintrag.createdAt.toISOString(),
})),
weitere,
naechster: weitere && letzter ? { vor: letzter.createdAt.toISOString(), id: letzter.id } : null,
});
}
+11 -1
View File
@@ -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.
</p>
{/* Kein weiterer Punkt in der Kopfleiste – die ist auf dem Telefon
schon eng. Der Rueckblick haengt dort, wo man ohnehin hinsieht. */}
<Link
href="/rueckblick"
className="mt-3 inline-flex items-center gap-1.5 text-sm text-violet-300 underline-offset-2 hover:underline"
>
<CalendarRange className="h-4 w-4" />
Wochenrückblick ansehen
</Link>
</header>
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
+139
View File
@@ -0,0 +1,139 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ArrowLeft, ArrowRight, CalendarRange, History } 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<string, ReviewEntry[]>();
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 (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">
{wochenName(woche)}
</h1>
<p className="mt-1 flex items-center gap-1.5 text-sm text-slate-400">
<CalendarRange className="h-3.5 w-3.5" />
{wochenTitel(woche)}
</p>
</div>
<nav className="flex items-center gap-2" aria-label="Woche wählen">
<Link
href={`/rueckblick?woche=${versatz - 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche zurück"
// Am Ende des Zeitraums bleibt der Verweis stehen, wird aber
// wirkungslos – die Seite klemmt den Wert ohnehin.
aria-disabled={versatz <= -MAX_RUECKBLICK}
>
<ArrowLeft className="h-3.5 w-3.5" />
Früher
</Link>
{versatz < 0 ? (
<Link
href={`/rueckblick?woche=${versatz + 1}`}
className="btn btn-ghost px-2.5 py-1.5 text-xs"
aria-label="Eine Woche vor"
>
Später
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
{versatz !== 0 ? (
<Link href="/rueckblick" className="btn btn-ghost px-2.5 py-1.5 text-xs">
Diese Woche
</Link>
) : null}
<Link href="/verlauf" className="btn btn-ghost px-2.5 py-1.5 text-xs">
<History className="h-3.5 w-3.5" />
Ganzer Verlauf
</Link>
</nav>
</div>
</header>
<WeeklyReview
tage={tage}
kennzahlen={kennzahlen}
istDieseWoche={versatz === 0}
/>
</div>
);
}
+77
View File
@@ -0,0 +1,77 @@
import type { Metadata } from "next";
import Link from "next/link";
import { CalendarRange, History } from "lucide-react";
import { Timeline } from "@/components/Timeline";
import { requireUserPage } from "@/lib/auth/guard";
import { prisma } from "@/lib/db";
import { SEITE, SEITEN_ORDNUNG, seitenBedingung } from "@/lib/timeline";
import type { ReviewEntry } from "@/types";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Verlauf" };
/**
* Der Verlauf ueber alle Projekte, fortlaufend.
*
* Die erste Seite kommt serverseitig mit, damit sofort etwas dasteht; alles
* Weitere holt der Knopf per Cursor nach. Anders als der Wochenrueckblick
* hat diese Seite keine Grenze und kein Fazit – sie laeuft zurueck, solange
* es Eintraege gibt.
*/
export default async function TimelinePage() {
const user = await requireUserPage();
const gefunden = await prisma.activity.findMany({
where: seitenBedingung(user.id, null),
orderBy: SEITEN_ORDNUNG,
// Einen mehr, um zu wissen, ob es weitergeht.
take: SEITE + 1,
select: {
id: true,
kind: true,
summary: true,
createdAt: true,
project: { select: { id: true, name: true, accent: true } },
user: { select: { username: true, displayName: true } },
},
});
const weitere = gefunden.length > SEITE;
const erste: ReviewEntry[] = (weitere ? gefunden.slice(0, SEITE) : gefunden).map((eintrag) => ({
id: eintrag.id,
kind: eintrag.kind,
summary: eintrag.summary,
createdAt: eintrag.createdAt.toISOString(),
project: eintrag.project,
user: eintrag.user,
}));
return (
<div className="space-y-5">
<header className="fade-up pt-4">
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
<History className="h-6 w-6 text-violet-300" />
Verlauf
</h1>
<p className="mt-1 text-sm text-slate-400">
Alles, was passiert ist – vom Neuesten rückwärts.
</p>
</div>
<Link href="/rueckblick" className="btn btn-ghost px-3 py-1.5 text-xs">
<CalendarRange className="h-3.5 w-3.5" />
Wochenrückblick
</Link>
</div>
</header>
<Timeline erste={erste} weitere={weitere} />
</div>
);
}
+4 -33
View File
@@ -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<ActivityKind, { icon: typeof Sparkles; color: string }> = {
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({
) : (
<ol className="mt-4 space-y-0">
{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;
+126
View File
@@ -0,0 +1,126 @@
import Link from "next/link";
import { ACTIVITY_KINDS } from "@/components/activityKinds";
import { accentOf } from "@/lib/status";
import { cn } from "@/lib/utils";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Darstellung von Verlaufseintraegen – eine Zeile und die Gruppierung nach
* Kalendertagen.
*
* Bewusst ohne "use client" und ohne Hooks: so laesst sich beides sowohl aus
* einer Server-Komponente (Wochenrueckblick) als auch aus einer
* Client-Komponente (Zeitleiste mit Nachladen) heraus benutzen. Zwei Kopien
* derselben Zeile waeren beim naechsten Feinschliff auseinandergelaufen.
*
* Alle Formate mit fester Zeitzone – dieselbe Ueberlegung wie in lib/utils:
* sonst weichen Server-Render und Hydration voneinander ab.
*/
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 TAG_MONAT_JAHR = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
day: "numeric",
month: "long",
year: "numeric",
});
const UHRZEIT = new Intl.DateTimeFormat("de-DE", {
timeZone: "Europe/Berlin",
hour: "2-digit",
minute: "2-digit",
});
/** "2026-08-17" → ein Zeitpunkt mittags, damit die Zeitzone nichts verschiebt. */
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 ActivityDayGroups({
tage,
/** Bei Tagen aus anderen Jahren gehoert die Jahreszahl dazu. */
mitJahr = false,
}: {
tage: ReviewDay[];
mitJahr?: boolean;
}) {
const format = mitJahr ? TAG_MONAT_JAHR : TAG_MONAT;
return (
<div className="space-y-6">
{tage.map((tag) => {
const datum = alsDatum(tag.tag);
return (
<section key={tag.tag}>
<div className="mb-2.5 flex flex-wrap items-baseline gap-x-2.5 gap-y-1">
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{WOCHENTAG.format(datum)}
</h2>
<span className="text-xs text-slate-500">{format.format(datum)}</span>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{tag.eintraege.length}
</span>
<span className="h-px flex-1 bg-gradient-to-r from-white/10 to-transparent" />
</div>
<ul className="glass divide-y divide-white/6 overflow-hidden">
{tag.eintraege.map((eintrag) => (
<ActivityRow key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
);
}
export function ActivityRow({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind] ?? ACTIVITY_KINDS.PROJECT_UPDATED;
const Icon = art.icon;
const accent = accentOf(eintrag.project.accent);
return (
<li className="flex items-start gap-2.5 px-3 py-2.5 sm:gap-3 sm:px-4">
<span className={cn("mt-0.5 shrink-0", art.color)} title={art.label}>
<Icon className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<p className="text-sm leading-snug text-slate-200">{eintrag.summary}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-slate-500">
{/* Das Projekt gehoert in jede Zeile – ohne es ist eine
projektuebergreifende Liste nicht zu lesen. */}
<Link
href={`/projects/${eintrag.project.id}`}
className="inline-flex max-w-[12rem] items-center gap-1.5 truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
<span
className="h-1.5 w-1.5 shrink-0 rounded-full"
style={{ backgroundColor: accent.from }}
aria-hidden="true"
/>
{eintrag.project.name}
</Link>
<span className="tabular-nums">{UHRZEIT.format(new Date(eintrag.createdAt))} Uhr</span>
{eintrag.user ? (
<span className="hidden xs:inline">
{eintrag.user.displayName?.trim() || eintrag.user.username}
</span>
) : (
// Ohne Benutzer: das war der Zeitgeber oder ein Webhook.
<span className="hidden text-slate-600 xs:inline">automatisch</span>
)}
</div>
</div>
</li>
);
}
+16
View File
@@ -5,9 +5,11 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation";
import {
Bell,
CalendarRange,
CheckSquare,
CornerDownLeft,
GitBranch,
History,
LayoutDashboard,
Loader2,
NotebookPen,
@@ -56,6 +58,20 @@ 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: "verlauf",
label: "Verlauf",
hint: "Alles, was passiert ist – über alle Projekte",
icon: History,
href: "/verlauf",
},
{
id: "tasks",
label: "Aufgaben",
+126
View File
@@ -0,0 +1,126 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, History, Loader2 } from "lucide-react";
import { ActivityDayGroups } from "@/components/ActivityRow";
import { tagesschluessel } from "@/lib/review";
import type { ReviewDay, ReviewEntry } from "@/types";
/**
* Die fortlaufende Zeitleiste.
*
* Client-Komponente, weil hier nachgeladen wird – das ist der Unterschied
* zum Wochenrueckblick, der komplett auf dem Server entsteht.
*
* Die Buendelung nach Tagen passiert hier im Browser. Das ist unbedenklich,
* weil tagesschluessel eine feste Zeitzone benutzt: Server und Browser kommen
* damit auf denselben Kalendertag, und die erste Seite hydriert ohne
* Abweichung.
*/
export function Timeline({
erste,
weitere: gabWeitere,
}: {
erste: ReviewEntry[];
weitere: boolean;
}) {
const [eintraege, setEintraege] = useState(erste);
const [weitere, setWeitere] = useState(gabWeitere);
const [laedt, setLaedt] = useState(false);
const [fehler, setFehler] = useState<string | null>(null);
const tage: ReviewDay[] = useMemo(() => {
// Map behaelt die Einfuegereihenfolge – die Eintraege kommen schon
// absteigend, also stimmt die Reihenfolge der Tage von selbst.
const nachTag = new Map<string, ReviewEntry[]>();
for (const eintrag of eintraege) {
const schluessel = tagesschluessel(new Date(eintrag.createdAt));
const liste = nachTag.get(schluessel) ?? [];
liste.push(eintrag);
nachTag.set(schluessel, liste);
}
return [...nachTag.entries()].map(([tag, liste]) => ({ tag, eintraege: liste }));
}, [eintraege]);
async function mehr() {
const letzter = eintraege[eintraege.length - 1];
if (!letzter) return;
setLaedt(true);
setFehler(null);
try {
const antwort = await fetch(
`/api/activity?vor=${encodeURIComponent(letzter.createdAt)}&id=${encodeURIComponent(letzter.id)}`,
);
if (!antwort.ok) throw new Error("Verlauf konnte nicht geladen werden.");
const nutzlast = (await antwort.json()) as {
activities: ReviewEntry[];
weitere: boolean;
};
/**
* Gegen Dubletten absichern, auch wenn der Cursor sie ausschliesst:
* ein zweimal gedrueckter Knopf oder ein wiederholter Aufruf soll
* keine Zeile verdoppeln, und React braucht eindeutige Schluessel.
*/
setEintraege((bisher) => {
const bekannt = new Set(bisher.map((eintrag) => eintrag.id));
return [...bisher, ...nutzlast.activities.filter((eintrag) => !bekannt.has(eintrag.id))];
});
setWeitere(nutzlast.weitere);
} catch (gefangen) {
setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen));
} finally {
setLaedt(false);
}
}
if (eintraege.length === 0) {
return (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<History className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">Noch kein Verlauf</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
Sobald du ein Projekt anlegst, Aufgaben verschiebst oder Notizen schreibst, steht es
hier.
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Die Jahreszahl gehoert dazu: eine Zeitleiste reicht weit zurueck,
und "3. Januar" allein sagt dann nicht genug. */}
<ActivityDayGroups tage={tage} mitJahr />
{fehler ? (
<p className="rounded-lg border border-rose-500/30 bg-rose-500/10 px-3 py-2 text-sm text-rose-200">
{fehler}
</p>
) : null}
<div className="flex flex-col items-center gap-2 pt-1">
{weitere ? (
<button type="button" className="btn btn-ghost" onClick={mehr} disabled={laedt}>
{laedt ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ChevronDown className="h-4 w-4" />
)}
Ältere laden
</button>
) : (
<p className="text-xs text-slate-600">Das war alles.</p>
)}
<p className="text-[11px] text-slate-600 tabular-nums">
{eintraege.length} {eintraege.length === 1 ? "Vorgang" : "Vorgänge"} geladen
</p>
</div>
</div>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { CalendarRange, CheckSquare, FolderGit2, NotebookPen, Plus } from "lucide-react";
import { ActivityDayGroups } from "@/components/ActivityRow";
import type { Kennzahlen } from "@/lib/review";
import type { ReviewDay } 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.
*/
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 (
<div className="glass fade-up flex flex-col items-center justify-center gap-4 px-6 py-16 text-center">
<span className="grid h-14 w-14 place-items-center rounded-2xl bg-gradient-to-br from-violet-500/25 to-cyan-400/20 text-violet-200">
<CalendarRange className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{istDieseWoche ? "Diese Woche war es still" : "In dieser Woche ist nichts passiert"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{istDieseWoche
? "Sobald du Aufgaben abhakst, Notizen schreibst oder etwas gepusht wird, steht es hier."
: "Vielleicht lohnt ein Blick auf eine frühere Woche."}
</p>
</div>
</div>
);
}
return (
<div className="space-y-5">
{/* Kennzahlen */}
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<Kennzahl label="Vorgänge" wert={gesamt} icon={CalendarRange} color="#a78bfa" />
<Kennzahl label="Projekte berührt" wert={projekte} icon={FolderGit2} color="#38bdf8" />
<Kennzahl label="Aufgaben erledigt" wert={erledigt} icon={CheckSquare} color="#34d399" />
<Kennzahl label="Neue Aufgaben" wert={neueAufgaben} icon={Plus} color="#fbbf24" />
</div>
{notizen > 0 ? (
<p className="flex items-center gap-1.5 px-1 text-xs text-slate-500">
<NotebookPen className="h-3.5 w-3.5" />
Dazu {notizen} {notizen === 1 ? "neue Notiz" : "neue Notizen"}.
</p>
) : null}
{/* Verlauf nach Tagen – dieselbe Darstellung wie in der Zeitleiste. */}
<ActivityDayGroups tage={tage} />
</div>
);
}
function Kennzahl({
label,
wert,
icon: Icon,
color,
}: {
label: string;
wert: number;
icon: typeof CalendarRange;
color: string;
}) {
return (
<div className="glass relative overflow-hidden p-3 sm:p-4">
<div
className="pointer-events-none absolute -right-6 -top-8 h-24 w-24 rounded-full opacity-25 blur-2xl"
style={{ backgroundColor: color }}
aria-hidden="true"
/>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase leading-tight tracking-[0.14em] text-slate-500">
{label}
</p>
<p className="mt-1.5 text-2xl font-semibold tabular-nums text-white sm:text-3xl">
{wert}
</p>
</div>
<span
className="grid h-8 w-8 shrink-0 place-items-center rounded-xl sm:h-9 sm:w-9"
style={{ backgroundColor: `${color}1f`, color }}
>
<Icon className="h-4 w-4" />
</span>
</div>
</div>
);
}
+43
View File
@@ -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" },
};
+173
View File
@@ -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<typeof zaehleWoche>[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,
});
});
});
+203
View File
@@ -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<string>();
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);
}
+9 -1
View File
@@ -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;
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { SEITE, SEITE_MAX, cursorAus, seitenBedingung, seitenGroesse } from "./timeline";
const ID = "cmstxnrzs00011cx4nd5tiqbt";
describe("cursorAus", () => {
it("liest Zeitpunkt und ID", () => {
const cursor = cursorAus("2026-08-17T22:30:00.000Z", ID);
expect(cursor?.id).toBe(ID);
expect(cursor?.createdAt.toISOString()).toBe("2026-08-17T22:30:00.000Z");
});
it("faengt ohne Cursor von vorn an", () => {
expect(cursorAus(null, null)).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", null)).toBeNull();
expect(cursorAus(null, ID)).toBeNull();
});
it("weist Unbrauchbares ab, statt es in die Abfrage zu geben", () => {
expect(cursorAus("kein datum", ID)).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", "kurz")).toBeNull();
expect(cursorAus("2026-08-17T22:30:00.000Z", "'; DROP TABLE x --")).toBeNull();
});
});
describe("seitenBedingung", () => {
it("filtert immer auf den Besitzer", () => {
expect(seitenBedingung("u1", null)).toEqual({ project: { ownerId: "u1" } });
});
it("behaelt den Besitzer auch mit Cursor", () => {
const wo = seitenBedingung("u1", { createdAt: new Date("2026-08-17T22:30:00Z"), id: ID });
expect(wo.project).toEqual({ ownerId: "u1" });
});
it("fasst gleichzeitige Eintraege ueber die ID auseinander", () => {
// Der eigentliche Zweck: beim Uebernehmen von Issues entstehen mehrere
// Zeilen mit identischem createdAt. Ohne den zweiten Zweig wuerde die
// naechste Seite sie ueberspringen oder wiederholen.
const zeitpunkt = new Date("2026-08-17T22:30:00Z");
const wo = seitenBedingung("u1", { createdAt: zeitpunkt, id: ID });
expect(wo.OR).toEqual([
{ createdAt: { lt: zeitpunkt } },
{ createdAt: zeitpunkt, id: { lt: ID } },
]);
});
});
describe("seitenGroesse", () => {
it("nimmt die Vorgabe, wenn nichts angefragt wurde", () => {
expect(seitenGroesse(null)).toBe(SEITE);
expect(seitenGroesse("unsinn")).toBe(SEITE);
});
it("deckelt nach oben und unten", () => {
expect(seitenGroesse("1000")).toBe(SEITE_MAX);
expect(seitenGroesse("0")).toBe(1);
expect(seitenGroesse("-5")).toBe(1);
});
it("nimmt einen gueltigen Wert an", () => {
expect(seitenGroesse("25")).toBe(25);
});
});
+77
View File
@@ -0,0 +1,77 @@
import type { Prisma } from "@prisma/client";
/**
* Fortlaufender Verlauf ueber alle Projekte.
*
* Geblaettert wird per Cursor, nicht per `skip`. Der Verlauf schreibt
* mehrere Eintraege im selben Augenblick – beim Uebernehmen von Issues etwa
* entstehen zehn Zeilen mit identischem `createdAt`. Ein Offset ueber eine
* solche Grenze hinweg ueberspringt Zeilen oder zeigt sie zweimal, und zwar
* lautlos. Der Cursor aus (createdAt, id) ist dagegen eindeutig.
*/
/** Wie viele Eintraege eine Seite hat. */
export const SEITE = 40;
/** Deckel gegen einen Aufrufer, der sich alles auf einmal holen will. */
export const SEITE_MAX = 100;
export interface Cursor {
/** Zeitpunkt des letzten gezeigten Eintrags. */
createdAt: Date;
/** Seine ID – entscheidet bei gleichem Zeitpunkt. */
id: string;
}
/**
* Liest einen Cursor aus Abfrageparametern.
*
* Gibt null zurueck, wenn nichts oder Unbrauchbares ankommt – dann faengt
* die Seite eben von vorn an. Ein Fehler waere hier die schlechtere Antwort:
* ein abgeschnittener Verweis soll die Seite nicht sperren.
*/
export function cursorAus(vor: string | null, id: string | null): Cursor | null {
if (!vor || !id) return null;
const zeitpunkt = new Date(vor);
if (Number.isNaN(zeitpunkt.getTime())) return null;
// Die ID ist eine cuid; alles andere weisen wir ab, statt es in die
// Abfrage zu geben.
if (!/^[a-z0-9]{20,32}$/i.test(id)) return null;
return { createdAt: zeitpunkt, id };
}
/**
* Die where-Bedingung fuer eine Seite.
*
* Besitzer zuerst – wie ueberall im Projekt, damit fremde Datensaetze gar
* nicht erst getroffen werden. Der Verlauf selbst kennt keinen Besitzer, die
* Bedingung laeuft deshalb ueber das Projekt.
*
* Der Cursor bedeutet "aelter als der letzte gezeigte Eintrag": entweder
* frueher, oder gleichzeitig und mit kleinerer ID. Genau so sortiert auch
* die Abfrage, weshalb beides zusammenpasst.
*/
export function seitenBedingung(ownerId: string, cursor: Cursor | null): Prisma.ActivityWhereInput {
const basis: Prisma.ActivityWhereInput = { project: { ownerId } };
if (!cursor) return basis;
return {
...basis,
OR: [
{ createdAt: { lt: cursor.createdAt } },
{ createdAt: cursor.createdAt, id: { lt: cursor.id } },
],
};
}
/** Sortierung, die zur Cursor-Bedingung passt. Beide muessen gleich sein. */
export const SEITEN_ORDNUNG: Prisma.ActivityOrderByWithRelationInput[] = [
{ createdAt: "desc" },
{ id: "desc" },
];
/** Begrenzt eine gewuenschte Seitengroesse auf das Erlaubte. */
export function seitenGroesse(roh: string | null): number {
const angefragt = roh === null ? Number.NaN : Number(roh);
if (!Number.isFinite(angefragt)) return SEITE;
return Math.min(Math.max(Math.trunc(angefragt), 1), SEITE_MAX);
}
+18
View File
@@ -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;