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;