7 Commits
Author SHA1 Message Date
florian.wildeandClaude Opus 5 ce224bc2a4 chore(release): v1.10.0
CI / pruefen (pull_request) Canceled after 0s
Seit v1.9.0 sind drei Features nach main gegangen, ohne dass die
Versionsnummer mitgezogen waere: Wochenrueckblick (#2), globale Zeitleiste
(#3) und die Suche ueber Aufgaben (#4).

Genau dieselbe Luecke wie vor v1.6.0. Der Test in changelog.test.ts prueft,
dass die oberste Fassung im Verlauf zur package.json passt - er greift aber
nicht, wenn beide gemeinsam zurueckhaengen. Ein Test, der das faende,
muesste den Verlauf gegen die Git-Historie halten; das waere mehr Maschinerie
als der Fehler wert, solange die Freigabe ohnehin von Hand laeuft.

Der Verlauf wird in der Anwendung angezeigt (Knopf an der Versionsnummer in
der Fusszeile), deshalb steht dort jetzt, was dazugekommen ist. Nachgesehen
auf 375px und 1280px: Eintraege vollstaendig, keine Beanstandung von
axe-core.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 07:44:16 +02:00
florian.wilde a264eb0dce Merge #4: Suche über Aufgaben
CI / pruefen (push) Canceled after 0s
2026-08-18 05:37:54 +00:00
florian.wilde afe57c4428 Suche ueber Aufgaben
CI / pruefen (pull_request) Canceled after 0s
Zwei Luecken, die im Issue zusammen stehen.

**Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und
Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld
deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im
Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine
zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen.

Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von
letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter
"Erledigte einblenden" gilt nur ohne Suchbegriff.

**Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei,
nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt,
Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit
auch hier - "Rechnung" findet "Rechnungen".

Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der
Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst
greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen
Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe -
Gegenprobe kommt leer heraus.

Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen
haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als
eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und
Schnellsuche -, zeigen sie jetzt.

Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen
die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes
inbegriffen, Stammformen greifen, fremde Konten finden nichts.

Refs #4
2026-08-18 03:39:02 +02:00
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
24 changed files with 1719 additions and 59 deletions
+106
View File
@@ -763,6 +763,112 @@ 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("Volltextsuche findet auch Aufgaben", async ({ request, playwright }) => {
await login(request, ADMIN);
const projekt = (
await (await request.post("/api/projects", { data: { name: "Suchprojekt" } })).json()
).project;
for (const task of [
{
title: "Dockerfile aufraeumen",
description: "Die Schichten im Container neu ordnen, damit der Bau schneller wird.",
},
{ title: "Rechnungen exportieren", description: "Monatlicher Export als CSV." },
{ title: "Erledigtes mit Container", description: "Auch das soll gefunden werden.", status: "DONE" },
{ title: "Ohne Bezug", description: "Voellig anderer Inhalt." },
]) {
expect((await request.post(`/api/projects/${projekt.id}/tasks`, { data: task })).status()).toBe(
201,
);
}
// Titel und Beschreibung werden durchsucht.
const container = await (await request.get("/api/search?q=Container")).json();
const titel = container.tasks.map((treffer: { title: string }) => treffer.title);
expect(titel).toContain("Dockerfile aufraeumen");
// Erledigtes bleibt drin – wer sucht, sucht oft gerade das Abgehakte.
expect(titel).toContain("Erledigtes mit Container");
expect(titel).not.toContain("Ohne Bezug");
// Deutsche Stammformen: "Rechnung" findet "Rechnungen".
const rechnung = await (await request.get("/api/search?q=Rechnung")).json();
expect(rechnung.tasks.map((t: { title: string }) => t.title)).toContain(
"Rechnungen exportieren",
);
// Das Projekt taucht in projectIds auf, damit das Brett es einblendet.
expect(container.projectIds).toContain(projekt.id);
// Zu kurze Eingaben liefern leere Listen statt Unsinn.
const kurz = await (await request.get("/api/search?q=a")).json();
expect(kurz.tasks).toEqual([]);
expect(kurz.hits).toEqual([]);
// Und ein fremdes Konto findet nichts davon.
const fremd = await playwright.request.newContext({
baseURL: test.info().project.use.baseURL,
});
await login(fremd, ZWEITER);
expect((await (await fremd.get("/api/search?q=Container")).json()).tasks).toHaveLength(0);
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
+70 -1
View File
@@ -223,6 +223,75 @@ 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, /).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, /).fill("Verlauf");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/verlauf/);
});
test("Aufgabendashboard laesst sich durchsuchen", async ({ page }) => {
await page.getByRole("link", { name: "Aufgaben", exact: true }).click();
await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible();
const suchfeld = page.getByLabel("Aufgaben durchsuchen");
await expect(suchfeld).toBeVisible();
// Aus den frueheren Tests gibt es "Erste Aufgabe" und "Zweite Aufgabe".
await suchfeld.fill("Erste");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
await expect(page.getByRole("listitem").filter({ hasText: "Zweite Aufgabe" })).toBeHidden();
// Ohne Treffer erklaert der Leerzustand, worin gesucht wurde.
await suchfeld.fill("gibtesnichtxyz");
await expect(page.getByText("Keine Treffer")).toBeVisible();
await expect(page.getByText(/Titel, Beschreibung, Labels und Projektname/)).toBeVisible();
// Zuruecksetzen bringt alles wieder.
await page.getByRole("button", { name: "Suche zuruecksetzen" }).click();
await expect(suchfeld).toHaveValue("");
await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible();
});
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
await page.keyboard.press("Control+k");
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
@@ -230,7 +299,7 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
// Ohne Eingabe stehen Projekte und Bereiche bereit.
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig");
await page.getByPlaceholder(/^Projekt, /).fill("Zaunkoenig");
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
// Enter springt zum ersten Treffer.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "nebula",
"version": "1.9.0",
"version": "1.10.0",
"private": true,
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
"scripts": {
@@ -0,0 +1,20 @@
-- Volltextsuche ueber Aufgaben.
--
-- Wie Note_fulltext_idx ein Ausdrucksindex: Prisma kann so etwas im Schema
-- nicht abbilden und kennt ihn deshalb nicht. Nachgeprueft mit
-- "prisma migrate diff" gegen das Schema - es kommt "empty migration"
-- heraus, der Index wird also weder angelegt noch geloescht. Trotzdem beim
-- Erzeugen kuenftiger Migrationen kurz hinsehen.
--
-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer:
-- Postgres berechnet to_tsvector dann je Zeile.
--
-- Der Ausdruck muss Zeichen fuer Zeichen dem in src/app/api/search/route.ts
-- entsprechen, sonst greift der Index nicht. Die zweistellige Form von
-- to_tsvector mit fester Konfiguration ist IMMUTABLE und damit indizierbar.
--
-- Anders als bei der Notiz ist auch die Beschreibung optional, deshalb steht
-- coalesce um beide Spalten.
CREATE INDEX "Task_fulltext_idx" ON "Task"
USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || coalesce("description", '')));
+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,
});
}
+58 -7
View File
@@ -12,16 +12,30 @@ interface Hit {
snippet: string;
}
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
/** Ausschnitt aus der Beschreibung; leer, wenn nur der Titel passte. */
snippet: string;
}
/**
* Volltextsuche ueber Notizen.
* Volltextsuche ueber Notizen und Aufgaben.
*
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt:
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank.
* Name, Kurzbeschreibung und Tags ab. Notizen und Aufgaben liegen dort nicht
* vor – die mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser
* Endpunkt: Postgres sucht mit seiner deutschen Textsuche direkt in der
* Datenbank.
*
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
*
* Das Feld heisst weiterhin "hits" und meint die Notizen – umbenennen haette
* nur Aufrufer gebrochen, ohne etwas zu verbessern. Aufgaben kommen als
* eigenes Feld dazu.
*/
export async function GET(request: Request) {
const auth = await requireUserApi();
@@ -29,7 +43,7 @@ export async function GET(request: Request) {
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
if (query.length < 2) {
return NextResponse.json({ query, hits: [], projectIds: [] });
return NextResponse.json({ query, hits: [], tasks: [], projectIds: [] });
}
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
@@ -57,11 +71,48 @@ export async function GET(request: Request) {
LIMIT 50
`;
/**
* Dieselbe Rechnung fuer Aufgaben.
*
* Der Ausdruck in to_tsvector muss Zeichen fuer Zeichen dem in der
* Migration entsprechen (Task_fulltext_idx), sonst greift der Index nicht
* und Postgres rechnet ihn je Zeile neu.
*
* Erledigtes bleibt drin: wer sucht, sucht oft gerade das, was er letzte
* Woche abgehakt hat.
*/
const tasks = await prisma.$queryRaw<TaskHit[]>`
SELECT
t."projectId" AS "projectId",
t."id" AS "taskId",
t."title" AS "title",
t."status"::text AS "status",
ts_headline(
'german',
coalesce(t."description", ''),
websearch_to_tsquery('german', ${query}),
'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1'
) AS "snippet"
FROM "Task" t
JOIN "Project" p ON p."id" = t."projectId"
WHERE p."ownerId" = ${auth.user.id}
AND to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", ''))
@@ websearch_to_tsquery('german', ${query})
ORDER BY ts_rank(
to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", '')),
websearch_to_tsquery('german', ${query})
) DESC
LIMIT 50
`;
return NextResponse.json({
query,
hits,
tasks,
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
// Board einzublenden.
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))),
// Board einzublenden – jetzt aus beiden Quellen.
projectIds: Array.from(
new Set([...hits.map((hit) => hit.projectId), ...tasks.map((hit) => hit.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 { 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>
);
}
+64 -5
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,
@@ -16,8 +18,9 @@ import {
Settings,
UserRound,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import type { ProjectStatus, TaskStatus } from "@prisma/client";
import { statusOf } from "@/lib/status";
import { TASK_STATUS_META } from "@/lib/tasks";
import { cn } from "@/lib/utils";
/**
@@ -38,6 +41,14 @@ interface ProjectHit {
tags: string[];
}
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
snippet: string;
}
interface NoteHit {
projectId: string;
noteId: string;
@@ -56,6 +67,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",
@@ -91,7 +116,8 @@ const ACTIONS: Action[] = [
type Entry =
| { kind: "action"; key: string; action: Action }
| { kind: "project"; key: string; project: ProjectHit }
| { kind: "note"; key: string; note: NoteHit; projectName: string };
| { kind: "note"; key: string; note: NoteHit; projectName: string }
| { kind: "task"; key: string; task: TaskHit; projectName: string };
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const router = useRouter();
@@ -100,6 +126,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const [query, setQuery] = useState("");
const [projects, setProjects] = useState<ProjectHit[] | null>(null);
const [notes, setNotes] = useState<NoteHit[]>([]);
const [aufgaben, setAufgaben] = useState<TaskHit[]>([]);
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(null);
@@ -138,6 +165,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const needle = query.trim();
if (needle.length < 2) {
setNotes([]);
setAufgaben([]);
return;
}
const controller = new AbortController();
@@ -147,8 +175,9 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] };
setNotes(body.hits ?? []);
setAufgaben(body.tasks ?? []);
} catch {
// Abbruch beim Weitertippen ist der Normalfall.
}
@@ -163,6 +192,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
if (!open) {
setQuery("");
setNotes([]);
setAufgaben([]);
setActive(0);
}
}, [open]);
@@ -202,6 +232,17 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
for (const project of passendeProjekte.slice(0, 8)) {
result.push({ kind: "project", key: `p-${project.id}`, project });
}
// Aufgaben vor Notizen: wer tippt, sucht haeufiger etwas zu Tun als
// etwas Notiertes.
for (const treffer of aufgaben.slice(0, 5)) {
result.push({
kind: "task",
key: `t-${treffer.taskId}`,
task: treffer,
projectName:
(projects ?? []).find((project) => project.id === treffer.projectId)?.name ?? "Projekt",
});
}
for (const hit of notes.slice(0, 5)) {
result.push({
kind: "note",
@@ -215,7 +256,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
result.push({ kind: "action", key: `a-${action.id}`, action });
}
return result;
}, [query, projects, notes, isAdmin]);
}, [query, projects, notes, aufgaben, isAdmin]);
useEffect(() => {
setActive((current) => Math.min(current, Math.max(0, entries.length - 1)));
@@ -226,6 +267,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
setOpen(false);
if (entry.kind === "action") router.push(entry.action.href);
else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`);
else if (entry.kind === "task") router.push(`/projects/${entry.task.projectId}`);
else router.push(`/projects/${entry.note.projectId}`);
},
[router],
@@ -276,7 +318,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={onInputKey}
placeholder="Projekt, Notiz oder Bereich…"
placeholder="Projekt, Aufgabe, Notiz oder Bereich…"
className="w-full bg-transparent text-sm text-slate-100 outline-none placeholder:text-slate-600"
/>
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin text-slate-600" /> : null}
@@ -330,6 +372,23 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
{statusOf(entry.project.status).short}
</span>
</>
) : entry.kind === "task" ? (
<>
<CheckSquare className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300/70" />
<span className="min-w-0">
<span className="block truncate text-sm text-slate-200">
{entry.task.title}
<span className="ml-1.5 text-[11px] text-slate-500">
in {entry.projectName}
</span>
</span>
<span className="block truncate text-[11px] text-slate-500">
{entry.task.snippet.replace(/\[\[|\]\]/g, "").trim() ||
TASK_STATUS_META[entry.task.status as TaskStatus]?.label ||
"Aufgabe"}
</span>
</span>
</>
) : (
<>
<NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" />
+76 -5
View File
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import {
CheckSquare,
Columns3,
Database,
Download,
@@ -16,7 +17,7 @@ import {
Upload,
X,
} from "lucide-react";
import type { ProjectStatus } from "@prisma/client";
import type { ProjectStatus, TaskStatus } from "@prisma/client";
import { BulkBar } from "@/components/BulkBar";
import { ImportDialog } from "@/components/ImportDialog";
import { KanbanBoard } from "@/components/KanbanBoard";
@@ -25,9 +26,18 @@ import { ProjectDialog } from "@/components/ProjectDialog";
import { ProjectList } from "@/components/ProjectList";
import { Popover } from "@/components/ui/Popover";
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
import { TASK_STATUS_META } from "@/lib/tasks";
import { cn } from "@/lib/utils";
import type { ConnectionSafe, ProjectListItem } from "@/types";
interface TaskHit {
projectId: string;
taskId: string;
title: string;
status: string;
snippet: string;
}
interface NoteHit {
projectId: string;
noteId: string;
@@ -135,6 +145,7 @@ export function ProjectBoard({
const [dataMenuOpen, setDataMenuOpen] = useState(false);
const dataButtonRef = useRef<HTMLButtonElement>(null);
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
const [taskHits, setTaskHits] = useState<TaskHit[]>([]);
const [selection, setSelection] = useState<Set<string>>(new Set());
/**
@@ -147,6 +158,7 @@ export function ProjectBoard({
const needle = query.trim();
if (needle.length < 2) {
setNoteHits([]);
setTaskHits([]);
return;
}
@@ -157,8 +169,9 @@ export function ProjectBoard({
signal: controller.signal,
});
if (!response.ok) return;
const body = (await response.json()) as { hits: NoteHit[] };
const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] };
setNoteHits(body.hits ?? []);
setTaskHits(body.tasks ?? []);
} catch {
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
}
@@ -171,8 +184,12 @@ export function ProjectBoard({
}, [query]);
const noteHitProjectIds = useMemo(
() => new Set(noteHits.map((hit) => hit.projectId)),
[noteHits],
() =>
new Set([
...noteHits.map((hit) => hit.projectId),
...taskHits.map((hit) => hit.projectId),
]),
[noteHits, taskHits],
);
const counts = useMemo(() => {
@@ -200,7 +217,7 @@ export function ProjectBoard({
project.repositories.some((repo) =>
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
) ||
// Treffer in Notizen kommen vom Server dazu.
// Treffer in Notizen und Aufgaben kommen vom Server dazu.
noteHitProjectIds.has(project.id)
);
});
@@ -434,6 +451,60 @@ export function ProjectBoard({
</div>
</div>
{/* Treffer in Aufgaben */}
{taskHits.length > 0 ? (
<div className="glass fade-up p-4">
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
<CheckSquare className="h-3.5 w-3.5 text-emerald-300" />
Treffer in Aufgaben
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
{taskHits.length}
</span>
</h2>
<ul className="space-y-1">
{taskHits.slice(0, 8).map((hit) => {
const project = projects.find((entry) => entry.id === hit.projectId);
const status = TASK_STATUS_META[hit.status as TaskStatus];
return (
<li key={hit.taskId}>
<Link
href={`/projects/${hit.projectId}`}
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
>
<span className="flex flex-wrap items-baseline gap-x-2">
<span className="text-sm text-slate-200">{hit.title}</span>
<span className="text-[11px] text-slate-500">
· {project?.name ?? "Projekt"}
</span>
{status ? (
<span
className="inline-flex items-center gap-1 text-[11px]"
style={{ color: status.color }}
>
{status.label}
</span>
) : null}
</span>
{/* Nur wenn die Beschreibung etwas beitraegt – passte
allein der Titel, ist der Ausschnitt leer. */}
{hit.snippet.trim() ? (
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
{highlight(hit.snippet)}
</span>
) : null}
</Link>
</li>
);
})}
</ul>
{taskHits.length > 8 ? (
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
… und {taskHits.length - 8} weitere
</p>
) : null}
</div>
) : null}
{/* Treffer in Notizen */}
{noteHits.length > 0 ? (
<div className="glass fade-up p-4">
+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" },
};
+53 -5
View File
@@ -10,7 +10,9 @@ import {
Loader2,
Plus,
Repeat,
Search,
Square,
X,
} from "lucide-react";
import type { TaskStatus } from "@prisma/client";
import { QuickCapture } from "@/components/QuickCapture";
@@ -42,6 +44,7 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
const router = useRouter();
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState("");
const [suche, setSuche] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false);
const [laufend, setLaufend] = useState<Set<string>>(new Set());
@@ -63,16 +66,34 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
}, [tasks]);
const sichtbar = useMemo(() => {
const nadel = suche.toLowerCase().trim();
return tasks.filter((task) => {
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
if (!zeigeErledigte && task.status === "DONE" && !statusFilter.includes("DONE")) {
// Wer allerdings sucht, sucht auch in Erledigtem – sonst findet man
// die Aufgabe von letzter Woche nicht wieder.
if (
!zeigeErledigte &&
!nadel &&
task.status === "DONE" &&
!statusFilter.includes("DONE")
) {
return false;
}
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
if (projektFilter && task.project.id !== projektFilter) return false;
return true;
if (!nadel) return true;
// Die Aufgaben liegen hier ohnehin alle vor – das filtert ohne
// zusaetzliche Abfrage und ohne Verzoegerung beim Tippen.
return (
task.title.toLowerCase().includes(nadel) ||
(task.description ?? "").toLowerCase().includes(nadel) ||
task.labels.some((label) => label.toLowerCase().includes(nadel)) ||
task.project.name.toLowerCase().includes(nadel)
);
});
}, [tasks, statusFilter, projektFilter, zeigeErledigte]);
}, [tasks, statusFilter, projektFilter, zeigeErledigte, suche]);
const faecher = useMemo(() => {
return DUE_BUCKET_ORDER.map((bucket) => ({
@@ -155,6 +176,27 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
{/* Steuerleiste */}
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
<div className="relative">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<input
className="field pl-9"
placeholder="Aufgaben durchsuchen – Titel, Beschreibung, Labels…"
value={suche}
onChange={(event) => setSuche(event.target.value)}
aria-label="Aufgaben durchsuchen"
/>
{suche ? (
<button
type="button"
onClick={() => setSuche("")}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
aria-label="Suche zuruecksetzen"
>
<X className="h-3.5 w-3.5" />
</button>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
{TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status];
@@ -220,12 +262,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"}
{tasks.length === 0
? "Noch keine Aufgaben"
: suche.trim()
? "Keine Treffer"
: "Nichts offen"}
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
{tasks.length === 0
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
: "Alles abgehakt, was zu den Filtern passt."}
: suche.trim()
? `Keine Aufgabe passt zu „${suche.trim()}“ – gesucht wird in Titel, Beschreibung, Labels und Projektname.`
: "Alles abgehakt, was zu den Filtern passt."}
</p>
</div>
</div>
+35
View File
@@ -41,6 +41,41 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
};
export const CHANGELOG: Release[] = [
{
version: "1.10.0",
date: "2026-08-18",
headline: "Rückblick, Zeitleiste und eine Suche, die auch Aufgaben findet.",
changes: [
{
kind: "neu",
text:
"Wochenrückblick über alle Projekte: Kennzahlen der Woche und darunter die " +
"Vorgänge nach Tagen gebündelt. Blättern in frühere Wochen inbegriffen. " +
"Erreichbar über die Schnellsuche und einen Verweis vom Dashboard.",
},
{
kind: "neu",
text:
"Globale Zeitleiste: derselbe Verlauf, aber fortlaufend und ohne Wochengrenze, " +
"mit Nachladen älterer Einträge. Geblättert wird per Cursor — bei mehreren " +
"Einträgen im selben Augenblick verliert oder verdoppelt das keine Zeilen.",
},
{
kind: "neu",
text:
"Das Aufgabendashboard hat ein Suchfeld über Titel, Beschreibung, Labels und " +
"Projektname. Wer sucht, sucht dabei auch in Erledigtem — sonst findet man die " +
"Aufgabe von letzter Woche nicht wieder.",
},
{
kind: "besser",
text:
"Die Volltextsuche kannte bisher nur Notizen und findet jetzt auch Aufgaben — " +
"mit denselben deutschen Stammformen: „Rechnung“ findet „Rechnungen“. Treffer " +
"erscheinen im Projektbrett und in der Schnellsuche.",
},
],
},
{
version: "1.9.0",
date: "2026-08-17",
+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;