Compare commits
3
Commits
f3c3383231
...
v1.6.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9202fb65e | ||
|
|
5ae1b6ed06 | ||
|
|
fcc903601a |
+58
-1
@@ -109,8 +109,25 @@ Der Status ist **direkt auf der Projektkarte** umschaltbar, ohne den Detailberei
|
|||||||
|
|
||||||
## 2. Dashboard
|
## 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
|
- **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.
|
- **Volltextsuche** über Name, Kurzbeschreibung, Beschreibung, Tags und Repo-Pfad.
|
||||||
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
|
- **Statusfilter** als Chips mit Trefferzähler, mehrere gleichzeitig kombinierbar.
|
||||||
- **Sortierung** nach zuletzt geändert, Anlagedatum, Name, Fortschritt oder Priorität —
|
- **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
|
## 3. Notizen
|
||||||
|
|
||||||
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
|
- **Beliebig viele Notizen pro Projekt**, mit optionalem Titel und mehrzeiligem Text.
|
||||||
|
|||||||
+62
-1
@@ -11,6 +11,22 @@ Was bereits steht, listet [FEATURES.md](FEATURES.md).
|
|||||||
|
|
||||||
## 1 · Als Nächstes empfohlen
|
## 1 · Als Nächstes empfohlen
|
||||||
|
|
||||||
|
### 1.0 Rest aus v1.6.0: persönliche API-Token `M`
|
||||||
|
|
||||||
|
Schnellerfassung und die projektübergreifende Aufgabenseite sind mit v1.6.0
|
||||||
|
ausgeliefert. Offen bleibt der dritte Teil — siehe 1.3. Er gehört sachlich
|
||||||
|
dazu: erfassen am Telefon, überblicken an einer Stelle, ansprechen per
|
||||||
|
Shortcut.
|
||||||
|
|
||||||
|
Bewusst nicht mitgeliefert wurde ein **Index für die übergreifende
|
||||||
|
Aufgabenabfrage**. `Task` hat kein `ownerId`, die Trennung läuft über
|
||||||
|
`project: { ownerId }`, und `[projectId, status, position]` greift dafür
|
||||||
|
nicht. Bei einigen hundert Aufgaben fällt das nicht auf; wird die Seite
|
||||||
|
träge, ist ein Index auf `dueDate` der erste Griff — und dann eine
|
||||||
|
Migration wert.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### 1.1 Webhooks statt Polling `M`
|
### 1.1 Webhooks statt Polling `M`
|
||||||
|
|
||||||
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
Ein Push aktualisiert den Snapshot sofort, statt bis zum nächsten Durchlauf
|
||||||
@@ -36,6 +52,50 @@ Sitzungen.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 1.4 v1.7.0 „Aufgaben und Issues" `M` + `M`
|
||||||
|
|
||||||
|
Nachverfolgbarkeit zwischen Nebula-Aufgabe und Git. Die Zuordnung steht im
|
||||||
|
Schema bereits: `sourceIssue`, `sourceUrl`, `sourceRepositoryId` samt
|
||||||
|
`@@unique`. Die Richtung **Git → Nebula** gibt es seit v0.9.0
|
||||||
|
(`tasks/from-issues`); es fehlt die Gegenrichtung.
|
||||||
|
|
||||||
|
**Kein Widerspruch zu „Bewusst nicht".** Ausgeschlossen sind dort *eigene
|
||||||
|
Git-Operationen* — klonen, committen, pushen. Issues laufen über die
|
||||||
|
HTTP-API der Provider, dieselbe Schicht wie alles andere (`lib/git/http.ts`,
|
||||||
|
SSRF-Schutz inklusive). Kein Klon, kein Arbeitsverzeichnis.
|
||||||
|
|
||||||
|
Warum Issues und nicht eine Datei im Repo: **dort verlinkt Git ohnehin.**
|
||||||
|
Ein `fixes #12` in der Commit-Nachricht stellt die Verbindung von selbst
|
||||||
|
her — die Kette Aufgabe → Commit → PR gibt es geschenkt, statt sie zu bauen.
|
||||||
|
Damit erledigt sich auch „Aufgabe ↔ Commit verknüpfen" aus Abschnitt 3.
|
||||||
|
|
||||||
|
**Stufe 1 — eine Richtung, ausdrücklich** `M`
|
||||||
|
Ein Knopf „als Issue anlegen" an der Aufgabe. Nummer und URL wandern in die
|
||||||
|
vorhandenen Felder. Kein Automatismus: was nach außen geht, wird
|
||||||
|
angestoßen, nicht nebenbei mitgeschrieben.
|
||||||
|
|
||||||
|
**Stufe 2 — Rückweg über Webhooks** `M`
|
||||||
|
Setzt 1.1 voraus. Geschlossenes Issue → Aufgabe nach `DONE`. Erst damit ist
|
||||||
|
„synchron" ehrlich, ohne im Minutentakt fremde APIs zu fragen.
|
||||||
|
|
||||||
|
**Die Zustände passen nicht aufeinander, und das bleibt so.** Nebula kennt
|
||||||
|
`TODO/DOING/BLOCKED/DONE`, ein Issue nur offen/geschlossen. Ein Rundlauf
|
||||||
|
verliert also `DOING` und `BLOCKED` — bewusst hingenommen: die feine
|
||||||
|
Abstufung bleibt in Nebula, nach Git geht nur offen/zu. Labels wie
|
||||||
|
`nebula:doing` wären die Alternative, würden aber den Issue-Tracker mit
|
||||||
|
Zuständen zumüllen, die dort niemand pflegt.
|
||||||
|
|
||||||
|
**Echte beidseitige Synchronisation mit Konfliktauflösung ist nicht
|
||||||
|
vorgesehen.** Das ist der klassische Ort für stillen Datenverlust, und für
|
||||||
|
ein persönliches Dashboard steht der Aufwand in keinem Verhältnis.
|
||||||
|
|
||||||
|
Für Projekte ohne Issue-Tracker wäre eine `.nebula/tasks.md` über die
|
||||||
|
Contents-API denkbar — aber nur als **einseitiger Spiegel**, nicht als
|
||||||
|
Synchronisation: zwei Schreiber auf einer Datei, und jede Statusänderung
|
||||||
|
ein Commit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 2 · Größere Brocken
|
## 2 · Größere Brocken
|
||||||
|
|
||||||
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
|
### 2.1 Passwort-Zurücksetzen per E-Mail `M`
|
||||||
@@ -58,7 +118,7 @@ Voraussetzung, falls du das Projekt veröffentlichen willst.
|
|||||||
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
|
| Globale Zeitleiste | `S` | Derselbe Verlauf, aber projektübergreifend als eigene Seite |
|
||||||
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
|
| Suche über Aufgaben | `S` | Die Volltextsuche deckt bisher nur Notizen ab |
|
||||||
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
|
| Suchtreffer in Notizen hervorheben | `S` | In der Notiz selbst, nicht nur in der Trefferliste |
|
||||||
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt |
|
| Aufgabe ↔ Commit verknüpfen | `M` | Enthält ein Commit `#5`, wird die übernommene Aufgabe verlinkt — fällt weitgehend mit 1.4 ab |
|
||||||
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
|
| Tastaturkürzel-Übersicht mit `?` | `S` | Es gibt inzwischen genug davon |
|
||||||
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
| Hell/Dunkel umschaltbar | `M` | Aktuell nur dunkel |
|
||||||
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
| Eigenes Logo und Farbe | `S` | Ergänzt `APP_NAME` |
|
||||||
@@ -111,6 +171,7 @@ Damit klar ist, was **nicht** kommt und warum:
|
|||||||
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
|
| **v1.3.0** Wiederkehrende Aufgaben, Barrierefreiheit nach WCAG 2.1 AA | ✓ |
|
||||||
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
| **v1.4.0** README-Vorschau auf der Projektseite | ✓ |
|
||||||
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
|
| **v1.5.0** Sicherheitsdurchsicht, Server-Anleitung und Update-Skript | ✓ |
|
||||||
|
| **v1.6.0** Mobile Ansicht, Listenansicht, Schnellerfassung, Aufgaben über alle Projekte | ✓ |
|
||||||
|
|
||||||
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
Zur Entscheidung beim Export: Git-Tokens gehen **nicht** mit. Die Verbindung wird nur
|
||||||
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
beschrieben, das Token ist nach dem Import neu zu hinterlegen. Snapshots bleiben
|
||||||
|
|||||||
@@ -131,6 +131,58 @@ test("Listenansicht auf Telefonbreite ohne seitliches Scrollen", async ({ page }
|
|||||||
expect(kasten!.x + kasten!.width).toBeLessThanOrEqual(masse.clientWidth);
|
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 }) => {
|
test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
||||||
await page.keyboard.press("Control+k");
|
await page.keyboard.press("Control+k");
|
||||||
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible();
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nebula",
|
"name": "nebula",
|
||||||
"version": "1.5.0",
|
"version": "1.6.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
"description": "Nebula – Dashboard fuer Vibe-Coding Projekte mit Git-Anbindung (GitHub, GitLab, Gitea)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -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 { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
|
CheckSquare,
|
||||||
CornerDownLeft,
|
CornerDownLeft,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -55,6 +56,13 @@ interface Action {
|
|||||||
|
|
||||||
const ACTIONS: Action[] = [
|
const ACTIONS: Action[] = [
|
||||||
{ id: "dashboard", label: "Dashboard", hint: "Alle Projekte", icon: LayoutDashboard, href: "/" },
|
{ 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",
|
id: "connections",
|
||||||
label: "Git-Verbindungen",
|
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 Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
|
CheckSquare,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LogOut,
|
LogOut,
|
||||||
@@ -11,13 +12,16 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
UserRound,
|
UserRound,
|
||||||
|
Zap,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { QuickCapture } from "@/components/QuickCapture";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
import { Popover } from "@/components/ui/Popover";
|
||||||
import type { SessionUser } from "@/lib/auth/session";
|
import type { SessionUser } from "@/lib/auth/session";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/tasks", label: "Aufgaben", icon: CheckSquare },
|
||||||
{ href: "/connections", label: "Git-Verbindungen", icon: GitBranch },
|
{ 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">
|
<span className="text-gradient truncate text-lg font-semibold tracking-tight">
|
||||||
{appName}
|
{appName}
|
||||||
</span>
|
</span>
|
||||||
{/* Der weite Zeichenabstand kostet auf 375px mehr Platz, als die
|
{/* Der weite Zeichenabstand kostet mehr Platz, als die Zeile wert
|
||||||
Zeile wert ist – dort bleibt nur der Name stehen. */}
|
ist. Seit die Aufgaben als dritter Punkt danebenstehen, brach
|
||||||
<span className="mt-0.5 hidden truncate text-[10px] uppercase tracking-[0.2em] text-slate-500 xs:block">
|
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}
|
{tagline}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -81,7 +87,8 @@ export function TopNav({
|
|||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="ml-auto">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<QuickCaptureButton />
|
||||||
<UserMenu user={user} />
|
<UserMenu user={user} />
|
||||||
</div>
|
</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 }) {
|
function UserMenu({ user }: { user: SessionUser }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [open, setOpen] = useState(false);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,62 @@ export const KIND_META: Record<ChangeKind, { label: string; className: string }>
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const CHANGELOG: Release[] = [
|
export const CHANGELOG: Release[] = [
|
||||||
|
{
|
||||||
|
version: "1.6.0",
|
||||||
|
date: "2026-08-14",
|
||||||
|
headline: "Unterwegs erfassen — und eine Oberfläche, die aufs Telefon passt.",
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Auf dem Telefon war das Dashboard breiter als der Bildschirm: die Steuerleiste " +
|
||||||
|
"konnte nicht umbrechen und schob den Knopf „Neues Projekt“ aus dem Bild. " +
|
||||||
|
"Dasselbe im Projektkopf, wo „Löschen“ außerhalb lag. Beide Zeilen brechen " +
|
||||||
|
"jetzt um.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Schnellerfassung: ein Blitzsymbol in der Kopfleiste, auf jeder Seite " +
|
||||||
|
"erreichbar. Ein Feld, Enter, fertig — als Idee entsteht ein Projekt, als " +
|
||||||
|
"Aufgabe eine Aufgabe im gewählten Projekt. Der Dialog bleibt danach offen, " +
|
||||||
|
"weil auf eine Idee meist gleich die nächste folgt.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Eine Aufgabenseite über alle Projekte hinweg, geordnet nach Fälligkeit: " +
|
||||||
|
"überfällig, heute, diese Woche, später, ohne Termin. Abhaken geht direkt " +
|
||||||
|
"aus der Liste.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "neu",
|
||||||
|
text:
|
||||||
|
"Vierte Ansicht im Dashboard: eine kompakte Liste. Auf dem Telefon passen " +
|
||||||
|
"damit fünf Projekte auf einen Bildschirm statt auf vier.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "besser",
|
||||||
|
text:
|
||||||
|
"Eingabefelder sind auf Berührungsgeräten 16 Pixel groß — darunter zoomt " +
|
||||||
|
"iOS Safari beim Hineintippen die Seite heran und kehrt nicht von selbst " +
|
||||||
|
"zurück. Knöpfe und der Fortschrittsregler sind mit dem Finger zu treffen.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Die Verweise in der Kopfleiste und „Neues Projekt“ sind auf schmalen " +
|
||||||
|
"Anzeigen reine Symbole und hießen für einen Screenreader gar nichts. " +
|
||||||
|
"Jetzt tragen sie eine Beschriftung.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "behoben",
|
||||||
|
text:
|
||||||
|
"Das Tag-Formular der Auswahlleiste war breiter als sein Menü und wurde " +
|
||||||
|
"beschnitten — auf jeder Bildschirmbreite.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
version: "1.5.0",
|
version: "1.5.0",
|
||||||
date: "2026-07-27",
|
date: "2026-07-27",
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
DUE_BUCKET_META,
|
||||||
|
DUE_BUCKET_ORDER,
|
||||||
TASK_STATUS_META,
|
TASK_STATUS_META,
|
||||||
TASK_STATUS_ORDER,
|
TASK_STATUS_ORDER,
|
||||||
|
dueBucket,
|
||||||
nextDueDate,
|
nextDueDate,
|
||||||
progressFromTasks,
|
progressFromTasks,
|
||||||
} from "./tasks";
|
} from "./tasks";
|
||||||
@@ -88,3 +91,48 @@ describe("nextDueDate", () => {
|
|||||||
expect(Number.isNaN(next.getTime())).toBe(false);
|
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. */
|
/** Naechste freie Position am Ende einer Spalte. */
|
||||||
export async function nextPosition(
|
export async function nextPosition(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type {
|
|||||||
Note,
|
Note,
|
||||||
Project,
|
Project,
|
||||||
ProjectStatus,
|
ProjectStatus,
|
||||||
|
Recurrence,
|
||||||
RepoSnapshot,
|
RepoSnapshot,
|
||||||
Repository,
|
Repository,
|
||||||
Task,
|
Task,
|
||||||
TaskStatus,
|
TaskStatus,
|
||||||
} from "@prisma/client";
|
} from "@prisma/client";
|
||||||
import type { CommitCalendar } from "@/lib/git/calendar";
|
import type { CommitCalendar } from "@/lib/git/calendar";
|
||||||
|
import type { DueBucket } from "@/lib/tasks";
|
||||||
import type {
|
import type {
|
||||||
BranchInfo,
|
BranchInfo,
|
||||||
CiInfo,
|
CiInfo,
|
||||||
@@ -70,6 +72,27 @@ export interface ProjectDetail extends Project {
|
|||||||
_count: { notes: number; tasks: number };
|
_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 {
|
export interface RepoListEntry {
|
||||||
fullPath: string;
|
fullPath: string;
|
||||||
owner: string;
|
owner: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user