3 Commits
Author SHA1 Message Date
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
florian.wildeandClaude Opus 5 7b60667633 feat(sync): geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab
CI / pruefen (push) Canceled after 0s
Behebt florian.wilde/nebula#1.

Der Rueckweg "Issue geschlossen -> Aufgabe erledigt" gab es seit v1.7.0
schon, aber nur ueber den Webhook. Ist keiner eingerichtet - und am
Repository war keiner eingetragen -, blieb er ganz aus. Damit hing die
Funktion an einer Voraussetzung, die man ihr von aussen nicht ansieht.

Der Abgleich fragt jetzt den Zustand verknuepfter Issues nach und hakt ab,
was in Git geschlossen ist. Beides ergaenzt sich: der Webhook wirkt sofort,
der Abgleich verlaesslich.

Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen
fuehrt, werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht - die
Liste ist gedeckelt, aus dem Fehlen einer Nummer folgt nichts. Deshalb ein
Deckel von 25 Nachfragen je Durchgang.

**Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
Nebula aus fehlgeschlagen ist; ein symmetrischer Abgleich wuerde dann eine
bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
Webhook vorbehalten, der ein echtes "reopened" sieht. Ein geloeschtes Issue
ist ebenfalls kein erledigtes.

Nebenbei eine Unstimmigkeit aus v1.7.0 geradegezogen: der Webhook setzte nur
den Status und liess aus, was die Oberflaeche tut - naechste Fassung einer
wiederkehrenden Aufgabe, Eintrag im Verlauf. Beide Wege gehen jetzt durch
dieselbe Funktion.

Geprueft: 232 Unit-Tests, 31 E2E-Tests. Der neue E2E-Test laeuft als
einziger wirklich nach draussen - er haengt eine Aufgabe an ein seit Jahren
geschlossenes Issue und prueft, dass der Abgleich sie abhakt. Ist GitHub
nicht erreichbar, ueberspringt er sich statt rot zu werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 12:20:53 +02:00
21 changed files with 1151 additions and 43 deletions
+26 -1
View File
@@ -529,7 +529,32 @@ soll jemand gewollt haben. Danach wandert der Zustand allerdings mit — erledig
das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe das Issue, ein Zurückziehen öffnet es wieder. Schlägt der Aufruf fehl, zieht die Aufgabe
trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern. trotzdem um; ein nicht erreichbares Git-System darf das Abhaken nicht verhindern.
**Git → Nebula** über Webhooks, siehe unten. **Git → Nebula** auf zwei Wegen, die sich ergänzen:
- **Webhook** — sofort, sobald das Issue geschlossen wird. Setzt voraus, dass die Instanz
von außen erreichbar ist und der Haken eingetragen wurde (siehe 4.7).
- **Beim Abgleich** — verzögert, aber verlässlich. Der Abgleich fragt den Zustand
verknüpfter Issues nach und hakt ab, was in Git geschlossen ist. Damit funktioniert der
Rückweg auch ohne Webhook, ohne öffentliche Erreichbarkeit und selbst dann, wenn ein
Haken einmal ausfällt.
Gespart wird, wo es geht: Nummern, die der gerade geholte Snapshot als offen führt,
werden nicht einzeln nachgefragt. Umgekehrt gilt das nicht — die Liste der offenen
Issues ist gedeckelt, aus dem Fehlen einer Nummer folgt also nichts. Pro Durchgang
fragt der Abgleich höchstens 25 Issues nach.
**Der Abgleich hakt nur ab, er nimmt nie zurück.** Er sieht Zustände, keine Ereignisse:
ein offenes Issue kann auch bedeuten, dass das Schließen von Nebula aus fehlgeschlagen
ist. Ein symmetrischer Abgleich würde dann eine bewusst erledigte Aufgabe wieder
aufmachen. Das Zurücknehmen bleibt dem Webhook vorbehalten, der ein echtes „reopened“
sieht.
Ein gelöschtes Issue ist kein erledigtes — die Aufgabe bleibt stehen.
In beiden Fällen bewirkt ein geschlossenes Issue dasselbe wie ein Haken in der
Oberfläche: bei einer wiederkehrenden Aufgabe entsteht die nächste Fassung, und der
Vorgang landet im Projektverlauf (ohne Benutzer — das hat nicht jemand in Nebula getan,
sondern das Git-System gemeldet).
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt **Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
`Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach `Offen · In Arbeit · Blockiert · Erledigt`, ein Issue nur offen oder geschlossen. Nach
+82
View File
@@ -1,4 +1,13 @@
import { expect, test, type APIRequestContext } from "@playwright/test"; import { expect, test, type APIRequestContext } from "@playwright/test";
import { PrismaClient } from "@prisma/client";
/**
* Dieselbe Datenbank wie global-setup.ts. Gebraucht fuer den einen Fall, in
* dem ein Zustand herzustellen ist, den die API bewusst nicht anbietet.
*/
const DATABASE_URL =
process.env.E2E_DATABASE_URL ??
"postgresql://nebula:nebula@localhost:5432/nebula_e2e_test?schema=public";
/** /**
* Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware, * Tests ueber HTTP gegen die laufende Anwendung – also durch Middleware,
@@ -325,6 +334,79 @@ test("Die Anmeldung verraet nicht, welche Konten es gibt", async ({ request, pla
await request.delete(`/api/admin/users/${id}`); await request.delete(`/api/admin/users/${id}`);
}); });
test("Abgleich hakt Aufgaben ab, deren Issue geschlossen wurde", async ({ request }) => {
await login(request, ADMIN);
// Ein oeffentliches Repository mit sicher geschlossenen Issues. Der
// Abgleich laeuft anonym gegen die GitHub-API – kein Token noetig.
const projekt = (
await (
await request.post("/api/projects", {
data: { name: "Rueckweg", repoUrl: "https://github.com/vercel/swr" },
})
).json()
).project;
const repoId = projekt.repositories[0].id;
const angelegt = await request.post(`/api/projects/${projekt.id}/tasks`, {
data: { title: "Haengt an einem geschlossenen Issue" },
});
const taskId = (await angelegt.json()).task.id;
/**
* Die Verknuepfung direkt in der Datenbank setzen.
*
* Ueber die API gibt es dafuer keinen Weg, und das ist richtig so: "als
* Issue anlegen" wuerde ein neues Issue in einem fremden Repository
* erzeugen. Ein Endpunkt nur fuer Tests waere eine Hintertuer im
* Produktionscode – dafuer greift der Test lieber selbst zur Datenbank.
*
* Issue 1 von vercel/swr ist seit Jahren geschlossen.
*/
const prisma = new PrismaClient({
datasources: { db: { url: DATABASE_URL } },
});
try {
await prisma.task.update({
where: { id: taskId },
data: { sourceRepositoryId: repoId, sourceIssue: 1 },
});
} finally {
await prisma.$disconnect();
}
const vorher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const alsAngelegt = vorher.project.tasks.find(
(t: { id: string }) => t.id === taskId,
);
expect(alsAngelegt.sourceIssue).toBe(1);
expect(alsAngelegt.status).toBe("TODO");
// Abgleich anstossen – er fragt den Zustand des Issues nach.
const sync = await request.post(`/api/repositories/${repoId}/sync`);
const syncBody = await sync.json();
/**
* Dieser Test greift als einziger wirklich nach draussen. Ist GitHub nicht
* erreichbar oder das anonyme Rate-Limit ausgeschoepft, sagt er das statt
* rot zu werden – ein fremder Dienst darf die Testreihe nicht bestimmen.
*/
if (!sync.ok()) {
test.skip(true, `GitHub nicht erreichbar: ${syncBody.message ?? sync.status()}`);
return;
}
const nachher = await (await request.get(`/api/projects/${projekt.id}`)).json();
const abgehakt = nachher.project.tasks.find((t: { id: string }) => t.id === taskId);
expect(abgehakt.status).toBe("DONE");
expect(abgehakt.doneAt).not.toBeNull();
// Und ein zweiter Lauf aendert nichts mehr – abgehakt bleibt abgehakt,
// ohne dass erneut nachgefragt wird.
const nochmal = await request.post(`/api/repositories/${repoId}/sync`);
expect((await nochmal.json()).message ?? "").not.toMatch(/abgehakt/);
});
test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => { test("Webhook: Signatur entscheidet, sonst passiert nichts", async ({ request, playwright }) => {
await login(request, ADMIN); await login(request, ADMIN);
+26
View File
@@ -223,6 +223,32 @@ test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page
).toBeVisible(); ).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 }) => { test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k"); await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nebula", "name": "nebula",
"version": "1.8.0", "version": "1.9.0",
"private": true, "private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)", "description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": { "scripts": {
+12 -6
View File
@@ -3,7 +3,7 @@ import { tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { rateLimit } from "@/lib/security/rateLimit"; import { rateLimit } from "@/lib/security/rateLimit";
import { syncRepository } from "@/lib/sync"; import { syncRepository } from "@/lib/sync";
import { statusForIssueState } from "@/lib/tasks/issues"; import { completeTaskFromIssue, statusForIssueState } from "@/lib/tasks/issues";
import { refreshDerivedProgress } from "@/lib/tasks"; import { refreshDerivedProgress } from "@/lib/tasks";
import { parseEvent, verifySignature } from "@/lib/webhooks/verify"; import { parseEvent, verifySignature } from "@/lib/webhooks/verify";
@@ -118,13 +118,19 @@ export async function POST(request: Request, { params }: Params) {
} }
const ziel = statusForIssueState(ereignis.open, task.status); const ziel = statusForIssueState(ereignis.open, task.status);
if (ziel !== task.status) {
if (ziel === "DONE" && task.status !== "DONE") {
// Ueber die gemeinsame Funktion, damit ein geschlossenes Issue
// dasselbe bewirkt wie ein Haken in der Oberflaeche: naechste Fassung
// einer wiederkehrenden Aufgabe und ein Eintrag im Verlauf.
await completeTaskFromIssue(task.id, "webhook");
} else if (ziel !== task.status) {
// Wieder geoeffnet. Das darf nur der Webhook, weil hier ein echtes
// "reopened" vorliegt – der Abgleich sieht nur Zustaende und wuerde
// sonst eine bewusst erledigte Aufgabe aufmachen.
await prisma.task.update({ await prisma.task.update({
where: { id: task.id }, where: { id: task.id },
data: { data: { status: ziel, doneAt: null },
status: ziel,
doneAt: ziel === "DONE" ? new Date() : null,
},
}); });
await refreshDerivedProgress(task.projectId); await refreshDerivedProgress(task.projectId);
} }
+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 { ProjectBoard } from "@/components/ProjectBoard";
import { StatCard } from "@/components/StatCard"; import { StatCard } from "@/components/StatCard";
import { requireUserPage } from "@/lib/auth/guard"; 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 Ideen festhalten, Status pflegen, Notizen sammeln – und live sehen, was in den
verknuepften Repositories bei GitHub, GitLab und Gitea passiert. verknuepften Repositories bei GitHub, GitLab und Gitea passiert.
</p> </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> </header>
{/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf {/* Zwei Spalten schon auf dem Telefon: einspaltig fuellten die fuenf
+135
View File
@@ -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<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}
</nav>
</div>
</header>
<WeeklyReview
tage={tage}
kennzahlen={kennzahlen}
istDieseWoche={versatz === 0}
/>
</div>
);
}
+4 -33
View File
@@ -1,23 +1,9 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { import { Bot, History, Loader2 } from "lucide-react";
AlertTriangle, import { ACTIVITY_KINDS } from "@/components/activityKinds";
ArrowRight, import type { ActivityEntry } from "@/types";
Bot,
Download,
GitCommitHorizontal,
History,
Link2,
Loader2,
NotebookPen,
Pencil,
Plus,
Signpost,
Sparkles,
Trash2,
} from "lucide-react";
import type { ActivityEntry, ActivityKind } from "@/types";
import { formatDateTime, formatRelative } from "@/lib/utils"; import { formatDateTime, formatRelative } from "@/lib/utils";
/** /**
@@ -29,21 +15,6 @@ import { formatDateTime, formatRelative } from "@/lib/utils";
* obersten fuenf liest. * 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({ export function ActivityFeed({
projectId, projectId,
@@ -88,7 +59,7 @@ export function ActivityFeed({
) : ( ) : (
<ol className="mt-4 space-y-0"> <ol className="mt-4 space-y-0">
{entries.map((entry, index) => { {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 Icon = meta.icon;
const letzter = index === entries.length - 1; const letzter = index === entries.length - 1;
+8
View File
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
Bell, Bell,
CalendarRange,
CheckSquare, CheckSquare,
CornerDownLeft, CornerDownLeft,
GitBranch, GitBranch,
@@ -56,6 +57,13 @@ interface Action {
const ACTIONS: Action[] = [ const ACTIONS: Action[] = [
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" }, { 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", id: "tasks",
label: "Aufgaben", label: "Aufgaben",
+197
View File
@@ -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 (
<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 */}
<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">{TAG_MONAT.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) => (
<Zeile key={eintrag.id} eintrag={eintrag} />
))}
</ul>
</section>
);
})}
</div>
</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>
);
}
function Zeile({ eintrag }: { eintrag: ReviewEntry }) {
const art = ACTIVITY_KINDS[eintrag.kind];
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>
);
}
+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" },
};
+21
View File
@@ -41,6 +41,27 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
}; };
export const CHANGELOG: Release[] = [ export const CHANGELOG: Release[] = [
{
version: "1.9.0",
date: "2026-08-17",
headline: "Geschlossene Issues haken ihre Aufgabe auch ohne Webhook ab.",
changes: [
{
kind: "neu",
text:
"Der Abgleich prüft jetzt den Zustand verknüpfter Issues: was in Git geschlossen " +
"wurde, wird in Nebula abgehakt. Bisher konnte das nur der Webhook — war keiner " +
"eingerichtet, blieb der Rückweg ganz aus.",
},
{
kind: "besser",
text:
"Ein über Git geschlossenes Issue bewirkt jetzt dasselbe wie ein Haken in der " +
"Oberfläche: bei wiederkehrenden Aufgaben entsteht die nächste Fassung, und der " +
"Vorgang steht im Projektverlauf.",
},
],
},
{ {
version: "1.8.0", version: "1.8.0",
date: "2026-08-15", date: "2026-08-15",
+9
View File
@@ -71,6 +71,15 @@ export class GiteaClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
const version = await getJsonSafe<{ version?: string }>( const version = await getJsonSafe<{ version?: string }>(
+9
View File
@@ -70,6 +70,15 @@ export class GitHubClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.repoBase(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
+10
View File
@@ -70,6 +70,16 @@ export class GitLabClient implements GitClient {
); );
} }
async getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null> {
const issue = await getJson<{ state?: string } | null>(
`${this.apiBase}/projects/${this.projectPath(ref)}/issues/${number}`,
{ headers: this.headers(), allow404: true },
);
if (!issue?.state) return null;
// GitLab nennt den offenen Zustand "opened".
return issue.state === "closed" ? "closed" : "open";
}
async check(): Promise<ConnectionCheck> { async check(): Promise<ConnectionCheck> {
try { try {
if (!this.token) { if (!this.token) {
+11
View File
@@ -190,6 +190,17 @@ export interface GitClient {
* geoeffnet – daher der Schalter statt zweier Methoden. * geoeffnet – daher der Schalter statt zweier Methoden.
*/ */
setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>; setIssueState(ref: RepoRef, number: number, open: boolean): Promise<void>;
/**
* Liest den Zustand eines einzelnen Issues.
*
* Gebraucht fuer den Abgleich: der Snapshot fuehrt nur die *offenen*
* Issues und ist ausserdem gedeckelt – aus dem Fehlen einer Nummer laesst
* sich deshalb nicht schliessen, dass sie geschlossen wurde. Sie koennte
* ebenso geloescht oder einfach jenseits des Deckels sein.
*
* null heisst: gibt es nicht (mehr).
*/
getIssueState(ref: RepoRef, number: number): Promise<"open" | "closed" | null>;
/** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */ /** Repos, auf die der Token Zugriff hat (fuer die Auswahl beim Verknuepfen). */
listRepos(query?: string): Promise<RepoListItem[]>; listRepos(query?: string): Promise<RepoListItem[]>;
/** Vollstaendiger Stand eines Repositories. */ /** Vollstaendiger Stand eines Repositories. */
+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);
}
+22 -1
View File
@@ -5,6 +5,7 @@ import { clientForConnection, createClient, parseRepoUrl } from "@/lib/git";
import type { GitClient } from "@/lib/git/types"; import type { GitClient } from "@/lib/git/types";
import { notifyAfterSync } from "@/lib/notify"; import { notifyAfterSync } from "@/lib/notify";
import { repoTitle } from "@/lib/repositories"; import { repoTitle } from "@/lib/repositories";
import { reconcileLinkedIssues } from "@/lib/tasks/issues";
import type { RepoSnapshotData } from "@/lib/git/types"; import type { RepoSnapshotData } from "@/lib/git/types";
export interface SyncResult { export interface SyncResult {
@@ -62,11 +63,31 @@ export async function syncRepository(repositoryId: string): Promise<SyncResult>
// README hineingeschrieben werden koennen. // README hineingeschrieben werden koennen.
await maybeRefreshCalendar(repository.id, client, ref); await maybeRefreshCalendar(repository.id, client, ref);
await maybeRefreshReadme(repository.id, client, ref); await maybeRefreshReadme(repository.id, client, ref);
/**
* Verknuepfte Aufgaben nachziehen: was in Git geschlossen wurde, wird
* hier abgehakt. Der Webhook macht das sofort – ist keiner eingerichtet
* oder faellt er aus, greift der Abgleich.
*
* Die offenen Issues aus dem gerade geholten Snapshot gehen mit hinein:
* fuer jede Nummer, die dort steht, ist die Nachfrage gespart.
*/
const abgehakt = await reconcileLinkedIssues(
repository.id,
client,
ref,
data.issues.map((issue) => issue.number),
);
// Nach dem Speichern, damit die Meldung den neuen Stand sieht. // Nach dem Speichern, damit die Meldung den neuen Stand sieht.
await notifyAfterSync(repository.id); await notifyAfterSync(repository.id);
return { return {
ok: true, ok: true,
message: `Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.`, message:
`Snapshot aktualisiert: ${data.branches.length} Branches, ${data.tags.length} Tags.` +
(abgehakt > 0
? ` ${abgehakt} Aufgabe(n) abgehakt, weil das Issue geschlossen wurde.`
: ""),
fetchedAt: new Date().toISOString(), fetchedAt: new Date().toISOString(),
}; };
} catch (error) { } catch (error) {
+130
View File
@@ -1,6 +1,9 @@
import type { TaskStatus } from "@prisma/client"; import type { TaskStatus } from "@prisma/client";
import { recordActivity, shorten } from "@/lib/activity";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { clientForConnection } from "@/lib/git"; import { clientForConnection } from "@/lib/git";
import type { GitClient, RepoRef } from "@/lib/git/types";
import { nextPosition, refreshDerivedProgress, spawnRecurrence } from "@/lib/tasks";
/** /**
* Die Bruecke zwischen Nebula-Aufgabe und Git-Issue. * Die Bruecke zwischen Nebula-Aufgabe und Git-Issue.
@@ -68,3 +71,130 @@ export async function mirrorStatusToIssue(
return false; return false;
} }
} }
/**
* Hakt eine Aufgabe ab, weil ihr Issue geschlossen wurde.
*
* Bewusst an einer Stelle fuer beide Wege – Webhook und Abgleich. Vorher
* setzte der Webhook nur den Status und liess dabei aus, was die Oberflaeche
* tut: die naechste Fassung einer wiederkehrenden Aufgabe anlegen und den
* Vorgang in den Verlauf schreiben.
*
* Gibt false zurueck, wenn nichts zu tun war (Aufgabe fehlt oder ist schon
* erledigt) – der Aufrufer kann daran zaehlen, was er bewirkt hat.
*/
export async function completeTaskFromIssue(
taskId: string,
herkunft: "webhook" | "abgleich",
): Promise<boolean> {
const task = await prisma.task.findUnique({
where: { id: taskId },
select: { id: true, title: true, status: true, projectId: true, sourceIssue: true },
});
if (!task || task.status === "DONE") return false;
await prisma.task.update({
where: { id: task.id },
data: {
status: "DONE",
doneAt: new Date(),
position: await nextPosition(task.projectId, "DONE"),
},
});
// Wie beim Abhaken von Hand: eine wiederkehrende Aufgabe erzeugt ihre
// naechste Fassung.
const folge = await spawnRecurrence(task.id);
await refreshDerivedProgress(task.projectId);
await recordActivity({
projectId: task.projectId,
// Kein userId: das hat nicht ein Mensch in Nebula getan, sondern das
// Git-System gemeldet.
kind: "TASK_MOVED",
summary:
`Issue #${task.sourceIssue} geschlossen → „${shorten(task.title, 40)}“ erledigt` +
(herkunft === "abgleich" ? " (beim Abgleich bemerkt)" : "") +
(folge ? ", nächste angelegt" : ""),
// "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;
}
/** Wie viele Issues ein Abgleich hoechstens einzeln nachfragt. */
const MAX_NACHFRAGEN = 25;
/**
* Gleicht beim Abgleich die verknuepften Issues ab: was in Git geschlossen
* ist, wird in Nebula abgehakt.
*
* Warum zusaetzlich zum Webhook: der Webhook wirkt sofort, setzt aber voraus,
* dass die Instanz von aussen erreichbar ist und der Haken eingetragen wurde.
* Ist er das nicht – oder faellt er einmal aus –, bliebe der Rueckweg sonst
* ganz aus. Hier kommt er verzoegert, aber verlaesslich.
*
* **Nur abhaken, nie zuruecknehmen.** Der Abgleich sieht Zustaende, keine
* Ereignisse. Ein offenes Issue kann auch bedeuten, dass das Schliessen von
* Nebula aus fehlgeschlagen ist – dann wuerde ein symmetrischer Abgleich eine
* bewusst erledigte Aufgabe wieder aufmachen. Das Zuruecknehmen bleibt dem
* Webhook vorbehalten, der ein echtes "reopened" sieht.
*
* Fehler werden geschluckt: ein nicht erreichbares Issue darf den Abgleich
* des Repositories nicht scheitern lassen.
*/
export async function reconcileLinkedIssues(
repositoryId: string,
client: GitClient,
ref: RepoRef,
/** Nummern, die der Snapshot gerade als offen gemeldet hat. */
offeneNummern: number[] = [],
): Promise<number> {
const offen = new Set(offeneNummern);
const kandidaten = await prisma.task.findMany({
where: {
sourceRepositoryId: repositoryId,
sourceIssue: { not: null },
status: { not: "DONE" },
},
select: { id: true, sourceIssue: true },
orderBy: { createdAt: "asc" },
});
let abgehakt = 0;
let nachfragen = 0;
for (const task of kandidaten) {
const nummer = task.sourceIssue;
if (nummer === null) continue;
// Steht die Nummer in der Liste der offenen Issues, ist nichts zu tun –
// und der Aufruf gespart. Umgekehrt gilt das nicht: die Liste ist
// gedeckelt, aus dem Fehlen folgt also nichts.
if (offen.has(nummer)) continue;
if (nachfragen >= MAX_NACHFRAGEN) break;
nachfragen += 1;
try {
const zustand = await client.getIssueState(ref, nummer);
// null heisst "gibt es nicht mehr" – geloescht ist nicht erledigt,
// also bleibt die Aufgabe stehen.
if (zustand !== "closed") continue;
if (await completeTaskFromIssue(task.id, "abgleich")) abgehakt += 1;
} catch {
// Nichts erreichbar, nichts geaendert – der naechste Lauf versucht es.
}
}
return abgehakt;
}
+18
View File
@@ -100,6 +100,24 @@ export interface TaskOverviewItem {
project: { id: string; name: string; accent: string; status: ProjectStatus }; 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 { export interface RepoListEntry {
fullPath: string; fullPath: string;
owner: string; owner: string;