feat(tasks): Schnellerfassung und Aufgaben ueber alle Projekte
CI / pruefen (push) Canceled after 0s
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:
+58
-1
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 <TaskOverview tasks={items} />;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLInputElement>(null);
|
||||
|
||||
const [modus, setModus] = useState<Modus>(initialMode);
|
||||
const [titel, setTitel] = useState("");
|
||||
const [projekte, setProjekte] = useState<ProjektHit[]>([]);
|
||||
const [projektId, setProjektId] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [fehler, setFehler] = useState<string | null>(null);
|
||||
const [angelegt, setAngelegt] = useState<Angelegt[]>([]);
|
||||
|
||||
// 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 (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width="md"
|
||||
title="Schnell erfassen"
|
||||
description="Ein Feld genuegt. Beschreibung, Status und Termin lassen sich spaeter nachziehen."
|
||||
>
|
||||
<form onSubmit={absenden} className="space-y-4">
|
||||
<div className="flex overflow-hidden rounded-xl border border-white/9" role="group">
|
||||
{(
|
||||
[
|
||||
{ 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 }) => (
|
||||
<button
|
||||
key={wert}
|
||||
type="button"
|
||||
onClick={() => setModus(wert)}
|
||||
aria-pressed={modus === wert}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-center gap-2 px-3 py-2.5 text-sm transition-colors",
|
||||
modus === wert ? "bg-white/10 text-white" : "text-slate-400 hover:text-white",
|
||||
)}
|
||||
title={hinweis}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="field-label" htmlFor="quick-title">
|
||||
{modus === "idee" ? "Wie heisst die Idee?" : "Was ist zu tun?"}
|
||||
</label>
|
||||
<input
|
||||
id="quick-title"
|
||||
ref={eingabeRef}
|
||||
className="field"
|
||||
value={titel}
|
||||
onChange={(event) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{modus === "aufgabe" ? (
|
||||
<div>
|
||||
<label className="field-label" htmlFor="quick-project">
|
||||
Projekt
|
||||
</label>
|
||||
{projekte.length > 0 ? (
|
||||
<select
|
||||
id="quick-project"
|
||||
className="field"
|
||||
value={projektId}
|
||||
onChange={(event) => setProjektId(event.target.value)}
|
||||
>
|
||||
{projekte.map((projekt) => (
|
||||
<option key={projekt.id} value={projekt.id}>
|
||||
{projekt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
Noch kein Projekt vorhanden – leg zuerst eine Idee an.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
|
||||
{angelegt.length > 0 ? (
|
||||
<div className="rounded-lg border border-emerald-500/25 bg-emerald-500/8 px-3 py-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-emerald-300/80">
|
||||
Angelegt
|
||||
</p>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{angelegt.slice(0, 5).map((eintrag, index) => (
|
||||
<li key={`${eintrag.href}-${index}`} className="truncate text-sm">
|
||||
<Link
|
||||
href={eintrag.href}
|
||||
onClick={onClose}
|
||||
className="text-emerald-100 underline-offset-2 hover:underline"
|
||||
>
|
||||
{eintrag.titel}
|
||||
</Link>
|
||||
<span className="ml-1.5 text-[11px] text-emerald-300/60">
|
||||
{eintrag.art === "idee" ? "Projekt" : "Aufgabe"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-1">
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
{angelegt.length > 0 ? "Fertig" : "Abbrechen"}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={busy || !titel.trim() || (modus === "aufgabe" && !projektId)}
|
||||
>
|
||||
{busy ? <Loader2 className="h-4 w-4 animate-spin" /> : <Zap className="h-4 w-4" />}
|
||||
Anlegen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
|
||||
{appName}
|
||||
</span>
|
||||
{/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die
|
||||
Zeile wert ist – dort bleibt nur der Name stehen. */}
|
||||
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 xs:block">
|
||||
{/* 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. */}
|
||||
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 sm:block">
|
||||
{tagline}
|
||||
</span>
|
||||
</span>
|
||||
@@ -81,7 +87,8 @@ export function TopNav({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<QuickCaptureButton />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
</>
|
||||
@@ -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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(true)}
|
||||
className="btn btn-ghost px-2.5 py-1.5 sm:px-3"
|
||||
// Unterhalb von sm bleibt nur das Symbol – ohne Beschriftung waere
|
||||
// der Knopf fuer einen Screenreader namenlos.
|
||||
aria-label="Schnell erfassen"
|
||||
title="Schnell erfassen – Idee oder Aufgabe"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
<span className="hidden lg:inline">Erfassen</span>
|
||||
</button>
|
||||
|
||||
<QuickCapture open={open} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SessionUser }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<DueBucket, { label: string; color: string }> = {
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user