feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
CI / pruefen (push) Canceled after 0s

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:33:16 +02:00
co-authored by Claude Opus 5
parent fcc903601a
commit 5ae1b6ed06
10 changed files with 994 additions and 5 deletions
+370
View File
@@ -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<TaskStatus[]>([]);
const [projektFilter, setProjektFilter] = useState("");
const [zeigeErledigte, setZeigeErledigte] = useState(false);
const [erfassenOffen, setErfassenOffen] = useState(false);
const [laufend, setLaufend] = useState<Set<string>>(new Set());
const projekte = useMemo(() => {
const map = new Map<string, string>();
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<TaskStatus, number>();
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 (
<div className="space-y-5">
<header className="fade-up flex flex-wrap items-end justify-between gap-3 pt-4">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.3em] text-violet-300/70">
Über alle Projekte
</p>
<h1 className="mt-2 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Aufgaben
</h1>
<p className="mt-1.5 text-sm text-slate-400">
{offen} offen
{ueberfaellig > 0 ? (
<>
{" · "}
<span className="text-rose-300">{ueberfaellig} überfällig</span>
</>
) : null}
</p>
</div>
<button
type="button"
className="btn btn-primary"
onClick={() => setErfassenOffen(true)}
aria-label="Neue Aufgabe"
>
<Plus className="h-4 w-4" />
<span className="hidden sm:inline">Neue Aufgabe</span>
</button>
</header>
{/* Steuerleiste */}
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
<div className="flex flex-wrap items-center gap-2">
{TASK_STATUS_ORDER.map((status) => {
const meta = TASK_STATUS_META[status];
const aktiv = statusFilter.includes(status);
return (
<button
key={status}
type="button"
onClick={() => toggleStatus(status)}
aria-pressed={aktiv}
className={cn(
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs ring-1 ring-inset transition-all",
aktiv
? meta.className
: "text-slate-400 ring-white/10 hover:bg-white/5 hover:text-slate-200",
)}
>
<span
className="h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: meta.color, opacity: aktiv ? 1 : 0.55 }}
/>
{meta.label}
<span className="tabular-nums text-slate-500">{zaehler.get(status) ?? 0}</span>
</button>
);
})}
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<label className="sr-only" htmlFor="task-project-filter">
Projekt
</label>
<select
id="task-project-filter"
className="field w-auto min-w-0 sm:min-w-[14rem] sm:flex-none"
value={projektFilter}
onChange={(event) => setProjektFilter(event.target.value)}
>
<option value="">Alle Projekte</option>
{projekte.map((projekt) => (
<option key={projekt.id} value={projekt.id}>
{projekt.name}
</option>
))}
</select>
<label className="flex cursor-pointer items-center gap-2 text-xs text-slate-500 sm:ml-auto">
<input
type="checkbox"
checked={zeigeErledigte}
onChange={(event) => setZeigeErledigte(event.target.checked)}
className="h-4 w-4 accent-violet-500"
/>
Erledigte einblenden
</label>
</div>
</div>
{faecher.length === 0 ? (
<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">
<CheckSquare className="h-6 w-6" />
</span>
<div>
<h2 className="text-lg font-semibold text-white">
{tasks.length === 0 ? "Noch keine Aufgaben" : "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."}
</p>
</div>
</div>
) : (
<div className="space-y-6">
{faecher.map((fach) => {
const meta = DUE_BUCKET_META[fach.bucket];
return (
<section key={fach.bucket}>
<div className="mb-2.5 flex items-center gap-2.5">
<span
className="accent-dot h-2 w-2 rounded-full"
style={{ backgroundColor: meta.color, color: meta.color }}
/>
<h2 className="text-sm font-semibold uppercase tracking-wider text-slate-300">
{meta.label}
</h2>
<span className="rounded-full bg-white/6 px-2 py-0.5 text-[11px] tabular-nums text-slate-400">
{fach.items.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">
{fach.items.map((task) => (
<TaskRow
key={task.id}
task={task}
busy={laufend.has(task.id)}
onToggle={() => abhaken(task)}
/>
))}
</ul>
</section>
);
})}
</div>
)}
<QuickCapture
open={erfassenOffen}
onClose={() => setErfassenOffen(false)}
initialMode="aufgabe"
/>
</div>
);
}
function TaskRow({
task,
busy,
onToggle,
}: {
task: TaskOverviewItem;
busy: boolean;
onToggle: () => void;
}) {
const meta = TASK_STATUS_META[task.status];
const erledigt = task.status === "DONE";
return (
<li className="flex items-center gap-2.5 px-3 py-2.5 transition-colors hover:bg-white/4 sm:gap-3 sm:px-4">
<button
type="button"
onClick={onToggle}
disabled={busy}
aria-label={erledigt ? `„${task.title}“ wieder öffnen` : `„${task.title}“ abhaken`}
className="shrink-0 rounded-lg p-1.5 text-slate-500 transition-colors hover:text-emerald-300 disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : erledigt ? (
<CheckSquare className="h-4 w-4 text-emerald-300" />
) : (
<Square className="h-4 w-4" />
)}
</button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className={cn(
"truncate text-sm",
erledigt ? "text-slate-500 line-through" : "text-slate-100",
)}
>
{task.title}
</span>
{task.recurrence ? (
<Repeat
className="h-3 w-3 shrink-0 text-slate-500"
aria-label={RECURRENCE_META[task.recurrence].label}
/>
) : null}
{task.sourceUrl ? (
<a
href={task.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="shrink-0 text-slate-500 transition-colors hover:text-violet-300"
aria-label="Zugehöriges Issue öffnen"
>
<ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
<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/${task.project.id}`}
className="max-w-[10rem] truncate text-slate-400 underline-offset-2 hover:text-violet-300 hover:underline"
>
{task.project.name}
</Link>
<span
className={cn("inline-flex items-center gap-1 rounded-full px-1.5 ring-1 ring-inset", meta.className)}
>
{meta.label}
</span>
{task.dueDate ? (
<span
className={cn(
"inline-flex shrink-0 items-center gap-1",
task.bucket === "overdue" && !erledigt && "text-rose-300",
)}
>
<CalendarClock className="h-3 w-3" />
{formatDate(task.dueDate)}
</span>
) : null}
{task.labels.slice(0, 2).map((label) => (
<span key={label} className="hidden rounded bg-white/5 px-1.5 text-slate-400 xs:inline">
#{label}
</span>
))}
</div>
</div>
</li>
);
}