From 5ae1b6ed069cc063719234d4acdbfd38cd2c0240 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Fri, 14 Aug 2026 23:33:16 +0200 Subject: [PATCH] feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte Zwei Wege, die zusammengehoeren: etwas festhalten, ohne den Rechner aufzuklappen, und danach einen Ort haben, an dem alles zusammenlaeuft. **Schnellerfassung.** Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig - als Idee entsteht ein Projekt im Status IDEA, als Aufgabe eine Aufgabe im gewaehlten Projekt. Beide Schemas kommen mit einem einzigen Feld aus, an der API war deshalb nichts zu aendern. Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine Idee notiert, hat meistens gleich die naechste; ihn jedes Mal neu zu oeffnen waere genau die Huerde, die hier wegsoll. Das zuletzt gewaehlte Projekt wird gemerkt. Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts - dort waere er auf schmalen Anzeigen mit der Auswahlleiste kollidiert, die beim Markieren von Projekten einfaehrt. **Aufgabenuebersicht.** /tasks sammelt jede Aufgabe aus jedem Projekt. Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett". Abhaken geht direkt aus der Liste; den Rest - Zeitpunkt, abgeleiteter Fortschritt, naechste Fassung wiederkehrender Aufgaben - erledigt der bestehende Endpunkt. Zwei Entscheidungen, die im Code begruendet sind: - Gerechnet wird auf Kalendertage in fester Zeitzone, nicht auf Stunden und nicht nach Serverzeit. Eine um 9 Uhr faellige Aufgabe ist um 17 Uhr nicht ueberfaellig, sondern immer noch "heute". Und im Container laeuft die Anwendung nach UTC - ohne feste Zeitzone kippte die Einteilung abends um einen Tag. Das Fach bestimmt der Server, sonst liefen Server-Render und Hydration auseinander. - Bewusst nicht per Ziehen sortierbar. Die Position gilt innerhalb ihrer Spalte in ihrem Projekt und waere hier ohne Bedeutung; eine Reihenfolge anzubieten, die nirgends ankommt, waere ein leeres Versprechen. Die Aufgabe kennt keinen Besitzer - die Trennung laeuft ueber das Projekt, "project: { ownerId }" steht als einzige und erste Bedingung. Nebenbei: der Untertitel in der Kopfleiste brach mit dem dritten Navigationspunkt auf 375px mitten im Wort ab und bleibt unterhalb von sm jetzt weg. Geprueft: 200 Unit-Tests (davon 7 neue zu dueBucket, inklusive des UTC-Falls), 27 E2E-Tests mit zwei neuen Wegen, axe-core auf 375px ueber Uebersicht und beide Zustaende der Schnellerfassung ohne Beanstandung. Co-Authored-By: Claude Opus 5 (1M context) --- FEATURES.md | 59 +++- e2e/ui.spec.ts | 52 ++++ src/app/tasks/page.tsx | 51 ++++ src/components/CommandPalette.tsx | 8 + src/components/QuickCapture.tsx | 282 ++++++++++++++++++++ src/components/TopNav.tsx | 44 ++- src/components/tasks/TaskOverview.tsx | 370 ++++++++++++++++++++++++++ src/lib/tasks.test.ts | 48 ++++ src/lib/tasks.ts | 62 +++++ src/types/index.ts | 23 ++ 10 files changed, 994 insertions(+), 5 deletions(-) create mode 100644 src/app/tasks/page.tsx create mode 100644 src/components/QuickCapture.tsx create mode 100644 src/components/tasks/TaskOverview.tsx diff --git a/FEATURES.md b/FEATURES.md index 6ceecf1..0e673e8 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -109,8 +109,25 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei ## 2. Dashboard +### Schnellerfassung + +Ein Blitzsymbol in der Kopfleiste, auf jeder Seite erreichbar. Ein Feld, Enter, fertig — +als **Idee** entsteht ein Projekt im Status `IDEA`, als **Aufgabe** eine Aufgabe im +gewählten Projekt (`TODO`). Alles Weitere setzen die Vorgaben des Schemas. + +Der Dialog **bleibt nach dem Anlegen offen** und leert nur das Feld. Wer eine Idee +notiert, hat meistens gleich die nächste; ihn jedes Mal neu zu öffnen wäre genau die +Hürde, die hier wegsoll. Angelegtes wird darunter mit Link bestätigt. Das zuletzt +gewählte Projekt wird gemerkt. + +Der Einstieg sitzt in der Kopfleiste und nicht als schwebender Knopf unten rechts — +dort wäre er auf schmalen Anzeigen mit der Auswahlleiste kollidiert. + +### Überblick + - **Kennzahlen** oben: Anzahl Projekte mit Ø-Fortschritt, offene Ideen, aktive - Entwicklungen, fertige Projekte, verknüpfte Repositories. + Entwicklungen, fertige Projekte, verknüpfte Repositories. Auf dem Telefon zweispaltig, + damit die Projekte nicht erst nach dem zweiten Wischen beginnen. - **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad. - **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar. - **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität — @@ -273,6 +290,46 @@ statt verschoben. --- +## 2b. Aufgaben über alle Projekte + +Die Seite `/tasks` sammelt jede Aufgabe aus jedem Projekt an einer Stelle. Erreichbar +über die Kopfleiste und die Schnellsuche. + +**Geordnet wird nach Fälligkeit, nicht nach Spalte.** Projektübergreifend lautet die +Frage „was ist überfällig", nicht „was steht wo im Brett". Die Fächer: + +| Fach | Inhalt | +| --- | --- | +| **Überfällig** | Termin liegt vor heute | +| **Heute** | Termin ist der heutige Kalendertag | +| **Diese Woche** | in den nächsten sieben Tagen | +| **Später** | alles danach | +| **Ohne Termin** | kein Fälligkeitsdatum gesetzt | + +Gerechnet wird auf **Kalendertage in fester Zeitzone** (Europe/Berlin), nicht auf +Stunden und nicht nach Serverzeit. Eine heute um 9 Uhr fällige Aufgabe ist um 17 Uhr +nicht überfällig, sondern immer noch „heute" — sonst wanderte die halbe Liste im Lauf +des Tages nach oben. Und im Container läuft die Anwendung nach UTC; ohne feste Zeitzone +kippte die Einteilung abends um einen Tag. Das Fach bestimmt der **Server**, damit +Server-Render und Hydration nicht auseinanderlaufen. + +- **Filter** nach Status (mit Zähler) und Projekt; Erledigtes ist ausgeblendet und + einzeln zuschaltbar. +- **Abhaken direkt aus der Liste.** Der Endpunkt erledigt dabei den Rest: Zeitpunkt, + abgeleiteter Projektfortschritt und — bei wiederkehrenden Aufgaben — die nächste + Fassung. +- Jede Zeile nennt ihr **Projekt** und verlinkt es; ohne das ist eine übergreifende + Liste nicht zu lesen. Stammt die Aufgabe aus einem Git-Issue, führt ein Symbol dorthin. + +**Bewusst nicht sortierbar per Ziehen.** Die Position einer Aufgabe gilt innerhalb ihrer +Spalte in ihrem Projekt; hier wäre sie ohne Bedeutung. Eine Reihenfolge anzubieten, die +nirgends ankommt, wäre ein Versprechen, das die Seite nicht halten kann. + +Die Aufgabe kennt keinen Besitzer — die Trennung läuft über das Projekt +(`project: { ownerId }` als einzige und erste Bedingung). + +--- + ## 3. Notizen - **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text. diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index e6fb6dc..011ba29 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -131,6 +131,58 @@ test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page } expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth); }); +test("Schnellerfassung legt Idee und Aufgabe mit einem Feld an", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 667 }); + + await page.getByRole("button", { name: "Schnell erfassen" }).click(); + const dialog = page.getByRole("dialog"); + await expect(dialog).toBeVisible(); + + // Idee: nur der Name, alles andere kommt aus den Vorgaben des Schemas. + await page.fill("#quick-title", "Aus der Bahn notiert"); + await dialog.getByRole("button", { name: "Anlegen" }).click(); + + // Der Dialog bleibt offen und bestaetigt – das ist der Punkt der + // Schnellerfassung: die naechste Idee soll ohne Umweg hineingehen. + await expect(dialog.getByText("Angelegt")).toBeVisible(); + await expect(dialog.getByRole("link", { name: "Aus der Bahn notiert" })).toBeVisible(); + await expect(page.locator("#quick-title")).toHaveValue(""); + + // Aufgabe: derselbe Dialog, ein Feld mehr. + await dialog.getByRole("button", { name: "Aufgabe", exact: true }).click(); + await page.fill("#quick-title", "Unterwegs eingefallen"); + await expect(page.locator("#quick-project")).toBeVisible(); + await dialog.getByRole("button", { name: "Anlegen" }).click(); + await expect(dialog.getByRole("link", { name: "Unterwegs eingefallen" })).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(dialog).toBeHidden(); + + // Die Idee steht als Projekt im Dashboard. + await page.goto("/"); + await expect(page.getByText("Aus der Bahn notiert")).toBeVisible(); +}); + +test("Aufgabenuebersicht sammelt ueber alle Projekte und hakt ab", async ({ page }) => { + await page.getByRole("link", { name: "Aufgaben", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible(); + + // Die zuvor per Schnellerfassung angelegte Aufgabe taucht hier auf, + // obwohl sie in einem anderen Projekt haengt. + const zeile = page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }); + await expect(zeile).toBeVisible(); + + // Abhaken aus der Uebersicht heraus. + await zeile.getByRole("button", { name: /abhaken/ }).click(); + // Ohne "Erledigte einblenden" verschwindet sie damit aus der Liste. + await expect(zeile).toBeHidden(); + + await page.getByLabel("Erledigte einblenden").check(); + await expect( + page.getByRole("listitem").filter({ hasText: "Unterwegs eingefallen" }), + ).toBeVisible(); +}); + test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); diff --git a/src/app/tasks/page.tsx b/src/app/tasks/page.tsx new file mode 100644 index 0000000..d55f417 --- /dev/null +++ b/src/app/tasks/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import { TaskOverview } from "@/components/tasks/TaskOverview"; +import { requireUserPage } from "@/lib/auth/guard"; +import { prisma } from "@/lib/db"; +import { dueBucket } from "@/lib/tasks"; +import type { TaskOverviewItem } from "@/types"; + +export const dynamic = "force-dynamic"; + +export const metadata: Metadata = { title: "Aufgaben" }; + +export default async function TasksPage() { + const user = await requireUserPage(); + + /** + * Die Aufgabe kennt keinen Besitzer – die Trennung laeuft ueber das + * Projekt. Die Bedingung steht deshalb als einzige und erste in der + * where-Klausel; eine fremde Aufgabe kann hier gar nicht auftauchen. + */ + const tasks = await prisma.task.findMany({ + where: { project: { ownerId: user.id } }, + include: { + project: { select: { id: true, name: true, accent: true, status: true } }, + }, + // NULL-Termine sortiert Postgres bei ASC ans Ende – genau richtig, + // Aufgaben ohne Termin gehoeren nicht nach oben. + orderBy: [{ dueDate: "asc" }, { position: "asc" }], + }); + + /** + * Das Fach wird auf dem Server bestimmt, nicht im Browser. "Heute" haengt + * an der Uhr, und zwei verschiedene Antworten zwischen Server-Render und + * Hydration waeren genau der Fehler, den die feste Zeitzone in lib/utils + * sonst vermeidet. + */ + const jetzt = new Date(); + const items: TaskOverviewItem[] = tasks.map((task) => ({ + id: task.id, + title: task.title, + description: task.description, + status: task.status, + dueDate: task.dueDate ? task.dueDate.toISOString() : null, + labels: task.labels, + recurrence: task.recurrence, + sourceUrl: task.sourceUrl, + bucket: dueBucket(task.dueDate, jetzt), + project: task.project, + })); + + return ; +} diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index 91acd79..5d4c721 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { Bell, + CheckSquare, CornerDownLeft, GitBranch, LayoutDashboard, @@ -55,6 +56,13 @@ interface Action { const ACTIONS: Action[] = [ { id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" }, + { + id: "tasks", + label: "Aufgaben", + hint: "Über alle Projekte, nach Fälligkeit", + icon: CheckSquare, + href: "/tasks", + }, { id: "connections", label: "Git-Verbindungen", diff --git a/src/components/QuickCapture.tsx b/src/components/QuickCapture.tsx new file mode 100644 index 0000000..ece8282 --- /dev/null +++ b/src/components/QuickCapture.tsx @@ -0,0 +1,282 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { CheckSquare, Lightbulb, Loader2, Zap } from "lucide-react"; +import { Modal } from "@/components/ui/Modal"; +import { cn } from "@/lib/utils"; + +/** + * Schnellerfassung: ein Feld, Enter, fertig. + * + * „Ideen festhalten" ist der erste Satz auf dem Dashboard – der Weg dorthin + * fuehrte aber ueber einen Dialog mit sieben Feldern. Unterwegs auf dem + * Telefon ist das der Unterschied zwischen „schreib ich schnell auf" und + * „mach ich spaeter", und spaeter heisst gar nicht. + * + * Deshalb bleibt hier alles weg, was sich nachziehen laesst: Projekte + * entstehen als Idee, Aufgaben als offen. Beschreibung, Status, Termin und + * der ganze Rest kommen am Rechner dazu, wenn ueberhaupt. + * + * Der Dialog bleibt nach dem Anlegen offen und leert nur das Feld. Wer eine + * Idee notiert, hat meistens gleich die naechste – ihn jedes Mal neu zu + * oeffnen waere genau die Huerde, die hier wegsoll. + */ + +interface ProjektHit { + id: string; + name: string; +} + +/** Zuletzt gewaehltes Projekt, damit die Aufgabenerfassung nicht jedes Mal fragt. */ +const PROJEKT_SCHLUESSEL = "nebula.quickcapture.projekt"; + +type Modus = "idee" | "aufgabe"; + +interface Angelegt { + art: Modus; + titel: string; + href: string; +} + +export function QuickCapture({ + open, + onClose, + initialMode = "idee", +}: { + open: boolean; + onClose: () => void; + /** Aus der Aufgabenuebersicht heraus faengt der Dialog bei "aufgabe" an. */ + initialMode?: Modus; +}) { + const router = useRouter(); + const eingabeRef = useRef(null); + + const [modus, setModus] = useState(initialMode); + const [titel, setTitel] = useState(""); + const [projekte, setProjekte] = useState([]); + const [projektId, setProjektId] = useState(""); + const [busy, setBusy] = useState(false); + const [fehler, setFehler] = useState(null); + const [angelegt, setAngelegt] = useState([]); + + // Beim Oeffnen zuruecksetzen – ein Dialog, der noch die Eingabe von + // vorgestern zeigt, ist schlimmer als einer, der leer ist. + useEffect(() => { + if (!open) return; + setTitel(""); + setFehler(null); + setAngelegt([]); + setModus(initialMode); + }, [open, initialMode]); + + /** + * Die Projektliste wird erst gebraucht, wenn jemand eine Aufgabe erfasst. + * Sie beim Oeffnen zu holen waere eine Abfrage fuer den haeufigeren Fall, + * in dem sie niemand ansieht. + */ + useEffect(() => { + if (!open || modus !== "aufgabe" || projekte.length > 0) return; + + let abgebrochen = false; + (async () => { + try { + const antwort = await fetch("/api/projects"); + if (!antwort.ok) return; + const daten = (await antwort.json()) as { projects?: ProjektHit[] }; + if (abgebrochen) return; + + const liste = (daten.projects ?? []).map(({ id, name }) => ({ id, name })); + setProjekte(liste); + + const gemerkt = window.localStorage.getItem(PROJEKT_SCHLUESSEL); + const gueltig = gemerkt && liste.some((eintrag) => eintrag.id === gemerkt); + setProjektId(gueltig ? gemerkt : (liste[0]?.id ?? "")); + } catch { + // Ohne Liste bleibt der Waehler leer; der Hinweis darunter erklaert es. + } + })(); + + return () => { + abgebrochen = true; + }; + }, [open, modus, projekte.length]); + + async function absenden(event: React.FormEvent) { + event.preventDefault(); + const wert = titel.trim(); + if (!wert || busy) return; + + setBusy(true); + setFehler(null); + + try { + if (modus === "idee") { + const antwort = await fetch("/api/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + // Nur der Name. Alles andere setzt das Schema: Status IDEA, + // Prioritaet normal, Fortschritt 0. + body: JSON.stringify({ name: wert }), + }); + const nutzlast = await antwort.json().catch(() => ({})); + if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen."); + + setAngelegt((bisher) => [ + { art: "idee", titel: wert, href: `/projects/${nutzlast.project.id}` }, + ...bisher, + ]); + } else { + if (!projektId) throw new Error("Kein Projekt gewaehlt."); + const antwort = await fetch(`/api/projects/${projektId}/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title: wert }), + }); + const nutzlast = await antwort.json().catch(() => ({})); + if (!antwort.ok) throw new Error(nutzlast.error ?? "Anlegen fehlgeschlagen."); + + window.localStorage.setItem(PROJEKT_SCHLUESSEL, projektId); + setAngelegt((bisher) => [ + { art: "aufgabe", titel: wert, href: `/projects/${projektId}` }, + ...bisher, + ]); + } + + setTitel(""); + eingabeRef.current?.focus(); + router.refresh(); + } catch (gefangen) { + setFehler(gefangen instanceof Error ? gefangen.message : String(gefangen)); + } finally { + setBusy(false); + } + } + + return ( + +
+
+ {( + [ + { wert: "idee", label: "Idee", hinweis: "Neues Projekt", icon: Lightbulb }, + { wert: "aufgabe", label: "Aufgabe", hinweis: "In einem Projekt", icon: CheckSquare }, + ] as const + ).map(({ wert, label, hinweis, icon: Icon }) => ( + + ))} +
+ +
+ + setTitel(event.target.value)} + placeholder={modus === "idee" ? "z. B. Pixelgarten" : "z. B. Favicon austauschen"} + maxLength={modus === "idee" ? 120 : 200} + required + autoFocus + // enterKeyHint stellt auf dem Telefon die Absendetaste um – sonst + // steht dort „Weiter", obwohl es nichts weiter gibt. + enterKeyHint="done" + /> +
+ + {modus === "aufgabe" ? ( +
+ + {projekte.length > 0 ? ( + + ) : ( +

+ Noch kein Projekt vorhanden – leg zuerst eine Idee an. +

+ )} +
+ ) : null} + + {fehler ? ( +

+ {fehler} +

+ ) : null} + + {angelegt.length > 0 ? ( +
+

+ Angelegt +

+
    + {angelegt.slice(0, 5).map((eintrag, index) => ( +
  • + + {eintrag.titel} + + + {eintrag.art === "idee" ? "Projekt" : "Aufgabe"} + +
  • + ))} +
+
+ ) : null} + +
+ + +
+
+
+ ); +} diff --git a/src/components/TopNav.tsx b/src/components/TopNav.tsx index 5322dc3..df70a61 100644 --- a/src/components/TopNav.tsx +++ b/src/components/TopNav.tsx @@ -4,6 +4,7 @@ import { useRef, useState } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { + CheckSquare, GitBranch, LayoutDashboard, LogOut, @@ -11,13 +12,16 @@ import { ShieldCheck, Sparkles, UserRound, + Zap, } from "lucide-react"; +import { QuickCapture } from "@/components/QuickCapture"; import { Popover } from "@/components/ui/Popover"; import type { SessionUser } from "@/lib/auth/session"; import { cn } from "@/lib/utils"; const LINKS = [ { href: "/", label: "Dashboard", icon: LayoutDashboard }, + { href: "/tasks", label: "Aufgaben", icon: CheckSquare }, { href: "/connections", label: "Git-Verbindungen", icon: GitBranch }, ]; @@ -43,9 +47,11 @@ export function TopNav({ {appName} - {/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die - Zeile wert ist – dort bleibt nur der Name stehen. */} - + {/* Der weite Zeichenabstand kostet mehr Platz, als die Zeile wert + ist. Seit die Aufgaben als dritter Punkt danebenstehen, brach + sie auf 375px mitten im Wort ab – unterhalb von sm bleibt + deshalb nur der Name. */} + {tagline} @@ -81,7 +87,8 @@ export function TopNav({ })} -
+
+
@@ -91,6 +98,35 @@ export function TopNav({ ); } +/** + * Der Einstieg in die Schnellerfassung sitzt in der Kopfleiste, nicht als + * schwebender Knopf unten rechts: dort waere er auf schmalen Anzeigen mit + * der Auswahlleiste kollidiert, die beim Markieren von Projekten einfaehrt. + * In der Kopfleiste ist er auf jeder Seite erreichbar. + */ +function QuickCaptureButton() { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(false)} /> + + ); +} + function UserMenu({ user }: { user: SessionUser }) { const router = useRouter(); const [open, setOpen] = useState(false); diff --git a/src/components/tasks/TaskOverview.tsx b/src/components/tasks/TaskOverview.tsx new file mode 100644 index 0000000..218afe5 --- /dev/null +++ b/src/components/tasks/TaskOverview.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + CalendarClock, + CheckSquare, + ExternalLink, + Loader2, + Plus, + Repeat, + Square, +} from "lucide-react"; +import type { TaskStatus } from "@prisma/client"; +import { QuickCapture } from "@/components/QuickCapture"; +import { + DUE_BUCKET_META, + DUE_BUCKET_ORDER, + RECURRENCE_META, + TASK_STATUS_META, + TASK_STATUS_ORDER, +} from "@/lib/tasks"; +import { cn, formatDate } from "@/lib/utils"; +import type { TaskOverviewItem } from "@/types"; + +/** Wo Filter und Schalter im Browser liegen. */ +const ANSICHT_SCHLUESSEL = "nebula.tasks"; + +/** + * Aufgaben ueber alle Projekte hinweg. + * + * Geordnet wird nach Faelligkeit, nicht nach Spalte: projektuebergreifend + * lautet die Frage "was ist ueberfaellig", nicht "was steht wo im Brett". + * Aus demselben Grund laesst sich hier bewusst **nichts ziehen** – die + * Position einer Aufgabe gilt innerhalb ihrer Spalte in ihrem Projekt und + * waere hier ohne Bedeutung. Eine Reihenfolge anzubieten, die nirgends + * ankommt, waere ein Versprechen, das die Seite nicht halten kann. + */ +export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) { + const router = useRouter(); + const [statusFilter, setStatusFilter] = useState([]); + const [projektFilter, setProjektFilter] = useState(""); + const [zeigeErledigte, setZeigeErledigte] = useState(false); + const [erfassenOffen, setErfassenOffen] = useState(false); + const [laufend, setLaufend] = useState>(new Set()); + + const projekte = useMemo(() => { + const map = new Map(); + for (const task of tasks) map.set(task.project.id, task.project.name); + return [...map.entries()] + .map(([id, name]) => ({ id, name })) + .sort((a, b) => a.name.localeCompare(b.name, "de")); + }, [tasks]); + + const zaehler = useMemo(() => { + const map = new Map(); + for (const task of tasks) map.set(task.status, (map.get(task.status) ?? 0) + 1); + return map; + }, [tasks]); + + const sichtbar = useMemo(() => { + return tasks.filter((task) => { + // Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht. + if (!zeigeErledigte && 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; + }); + }, [tasks, statusFilter, projektFilter, zeigeErledigte]); + + const faecher = useMemo(() => { + return DUE_BUCKET_ORDER.map((bucket) => ({ + bucket, + items: sichtbar.filter((task) => task.bucket === bucket), + })).filter((fach) => fach.items.length > 0); + }, [sichtbar]); + + const offen = sichtbar.filter((task) => task.status !== "DONE").length; + const ueberfaellig = sichtbar.filter( + (task) => task.bucket === "overdue" && task.status !== "DONE", + ).length; + + function toggleStatus(status: TaskStatus) { + setStatusFilter((current) => + current.includes(status) + ? current.filter((entry) => entry !== status) + : [...current, status], + ); + } + + /** + * Abhaken direkt aus der Uebersicht. Der Endpunkt erledigt dabei den Rest: + * doneAt, abgeleiteter Fortschritt und – bei wiederkehrenden Aufgaben – + * die naechste Fassung. + */ + async function abhaken(task: TaskOverviewItem) { + const ziel: TaskStatus = task.status === "DONE" ? "TODO" : "DONE"; + setLaufend((current) => new Set(current).add(task.id)); + try { + const antwort = await fetch(`/api/tasks/${task.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: ziel }), + }); + if (!antwort.ok) throw new Error("fehlgeschlagen"); + router.refresh(); + } catch { + // Beim naechsten Laden steht wieder der echte Stand da. + } finally { + setLaufend((current) => { + const next = new Set(current); + next.delete(task.id); + return next; + }); + } + } + + return ( +
+
+
+

+ Über alle Projekte +

+

+ Aufgaben +

+

+ {offen} offen + {ueberfaellig > 0 ? ( + <> + {" · "} + {ueberfaellig} überfällig + + ) : null} +

+
+ + +
+ + {/* Steuerleiste */} +
+
+ {TASK_STATUS_ORDER.map((status) => { + const meta = TASK_STATUS_META[status]; + const aktiv = statusFilter.includes(status); + return ( + + ); + })} +
+ +
+ + + + +
+
+ + {faecher.length === 0 ? ( +
+ + + +
+

+ {tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"} +

+

+ {tasks.length === 0 + ? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“." + : "Alles abgehakt, was zu den Filtern passt."} +

+
+
+ ) : ( +
+ {faecher.map((fach) => { + const meta = DUE_BUCKET_META[fach.bucket]; + return ( +
+
+ +

+ {meta.label} +

+ + {fach.items.length} + + +
+ +
    + {fach.items.map((task) => ( + abhaken(task)} + /> + ))} +
+
+ ); + })} +
+ )} + + setErfassenOffen(false)} + initialMode="aufgabe" + /> +
+ ); +} + +function TaskRow({ + task, + busy, + onToggle, +}: { + task: TaskOverviewItem; + busy: boolean; + onToggle: () => void; +}) { + const meta = TASK_STATUS_META[task.status]; + const erledigt = task.status === "DONE"; + + return ( +
  • + + +
    +
    + + {task.title} + + {task.recurrence ? ( + + ) : null} + {task.sourceUrl ? ( + + + + ) : null} +
    + +
    + {/* Das Projekt gehoert in jede Zeile – ohne es ist eine + projektuebergreifende Liste nicht zu lesen. */} + + {task.project.name} + + + + {meta.label} + + + {task.dueDate ? ( + + + {formatDate(task.dueDate)} + + ) : null} + + {task.labels.slice(0, 2).map((label) => ( + + #{label} + + ))} +
    +
    +
  • + ); +} diff --git a/src/lib/tasks.test.ts b/src/lib/tasks.test.ts index 3de6efc..34f438f 100644 --- a/src/lib/tasks.test.ts +++ b/src/lib/tasks.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from "vitest"; import { + DUE_BUCKET_META, + DUE_BUCKET_ORDER, TASK_STATUS_META, TASK_STATUS_ORDER, + dueBucket, nextDueDate, progressFromTasks, } from "./tasks"; @@ -88,3 +91,48 @@ describe("nextDueDate", () => { expect(Number.isNaN(next.getTime())).toBe(false); }); }); + +describe("dueBucket", () => { + /** 14.08.2026, 17:30 Berliner Zeit – bewusst spaeter Nachmittag. */ + const JETZT = new Date("2026-08-14T15:30:00Z"); + + it("ordnet eine Aufgabe ohne Termin nach hinten", () => { + expect(dueBucket(null, JETZT)).toBe("someday"); + }); + + it("zaehlt den heutigen Tag als heute, auch wenn die Uhrzeit vorbei ist", () => { + // Der Punkt der Funktion: gerechnet wird auf Kalendertage. Um 9 Uhr + // faellig und jetzt ist es halb sechs – die Aufgabe steht trotzdem + // unter "Heute" und nicht unter "Ueberfaellig". + expect(dueBucket(new Date("2026-08-14T07:00:00Z"), JETZT)).toBe("today"); + }); + + it("erkennt den Vortag als ueberfaellig", () => { + expect(dueBucket(new Date("2026-08-13T21:00:00Z"), JETZT)).toBe("overdue"); + }); + + it("fasst die kommenden sieben Tage als diese Woche", () => { + expect(dueBucket(new Date("2026-08-15T06:00:00Z"), JETZT)).toBe("week"); + expect(dueBucket(new Date("2026-08-21T06:00:00Z"), JETZT)).toBe("week"); + }); + + it("schiebt alles danach nach spaeter", () => { + expect(dueBucket(new Date("2026-08-22T06:00:00Z"), JETZT)).toBe("later"); + }); + + it("richtet sich nach Berliner Kalendertagen, nicht nach UTC", () => { + // 21:30 UTC ist in Berlin schon 23:30 desselben Tages – im Container + // laeuft die Anwendung aber nach UTC. Ohne feste Zeitzone kippte die + // Einteilung hier um einen Tag. + const spaetAbends = new Date("2026-08-15T21:30:00Z"); + expect(dueBucket(new Date("2026-08-15T10:00:00Z"), spaetAbends)).toBe("today"); + expect(dueBucket(new Date("2026-08-16T10:00:00Z"), spaetAbends)).toBe("week"); + }); + + it("hat zu jedem Fach eine Beschriftung und eine Farbe", () => { + for (const bucket of DUE_BUCKET_ORDER) { + expect(DUE_BUCKET_META[bucket].label).toBeTruthy(); + expect(DUE_BUCKET_META[bucket].color).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 46535b2..505dab7 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -161,6 +161,68 @@ export async function spawnRecurrence(taskId: string) { }); } +// ── Faelligkeit in der Gesamtuebersicht ────────────────────── + +/** + * Faecher der Aufgabenuebersicht. Anders als im Projektbrett ordnet dort + * nicht die Spalte, sondern der Termin: projektuebergreifend ist "was ist + * ueberfaellig" die Frage, nicht "was steht in welcher Spalte". + */ +export const DUE_BUCKET_ORDER = ["overdue", "today", "week", "later", "someday"] as const; + +export type DueBucket = (typeof DUE_BUCKET_ORDER)[number]; + +export const DUE_BUCKET_META: Record = { + overdue: { label: "Überfällig", color: "#fb7185" }, + today: { label: "Heute", color: "#fbbf24" }, + week: { label: "Diese Woche", color: "#a78bfa" }, + later: { label: "Später", color: "#38bdf8" }, + someday: { label: "Ohne Termin", color: "#8a98b0" }, +}; + +/** + * Kalendertag in fester Zeitzone, als fortlaufende Tagesnummer. + * + * Die Serverzeit taugt dafuer nicht: im Container laeuft die Anwendung nach + * UTC. Um 23:30 Berliner Zeit ist dort noch der Vortag, und eine heute + * faellige Aufgabe stuende unter "Ueberfaellig". Dieselbe Zeitzone benutzt + * auch die Datumsausgabe in lib/utils. + */ +const TAG_FORMAT = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +function tagesnummer(wert: Date): number { + // en-CA liefert "2026-08-14" – und damit etwas, das sich ohne weiteres + // Zerlegen sortieren und rechnen laesst. + const [jahr, monat, tag] = TAG_FORMAT.format(wert).split("-").map(Number); + return Date.UTC(jahr, monat - 1, tag) / 86_400_000; +} + +/** + * In welches Fach eine Aufgabe faellt. + * + * Gerechnet wird auf Kalendertage, nicht auf Stunden: eine Aufgabe, die + * heute um 9 Uhr faellig war, ist um 17 Uhr nicht "ueberfaellig", sondern + * immer noch "heute". Sonst wanderte die halbe Liste im Lauf des Tages + * nach oben. + * + * Bewusst als reine Funktion mit uebergebenem "jetzt" – das macht sie + * pruefbar. + */ +export function dueBucket(dueDate: Date | null, now: Date = new Date()): DueBucket { + if (!dueDate) return "someday"; + + const abstand = tagesnummer(dueDate) - tagesnummer(now); + if (abstand < 0) return "overdue"; + if (abstand === 0) return "today"; + if (abstand <= 7) return "week"; + return "later"; +} + /** Naechste freie Position am Ende einer Spalte. */ export async function nextPosition( projectId: string, diff --git a/src/types/index.ts b/src/types/index.ts index f3ac21f..4fb272b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -4,12 +4,14 @@ import type { Note, Project, ProjectStatus, + Recurrence, RepoSnapshot, Repository, Task, TaskStatus, } from "@prisma/client"; import type { CommitCalendar } from "@/lib/git/calendar"; +import type { DueBucket } from "@/lib/tasks"; import type { BranchInfo, CiInfo, @@ -70,6 +72,27 @@ export interface ProjectDetail extends Project { _count: { notes: number; tasks: number }; } +/** + * Eine Aufgabe in der projektuebergreifenden Uebersicht. + * + * Bewusst kein `extends Task`: die Seite braucht das Projekt dazu, das Fach + * fuer die Faelligkeit kommt vom Server, und Datumsangaben gehen als + * Zeichenkette an den Browser – ein Date ueberlebt die Serialisierung nicht. + */ +export interface TaskOverviewItem { + id: string; + title: string; + description: string | null; + status: TaskStatus; + dueDate: string | null; + labels: string[]; + recurrence: Recurrence | null; + /** Gesetzt, wenn die Aufgabe aus einem Git-Issue stammt. */ + sourceUrl: string | null; + bucket: DueBucket; + project: { id: string; name: string; accent: string; status: ProjectStatus }; +} + export interface RepoListEntry { fullPath: string; owner: string;