From afe57c44280f0dabfbaa030a6a78342c355d220e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Tue, 18 Aug 2026 03:39:02 +0200 Subject: [PATCH] Suche ueber Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Luecken, die im Issue zusammen stehen. **Im Aufgabendashboard fehlte ein Suchfeld.** Es gab Filter nach Status und Projekt, aber keine Moeglichkeit, nach einem Wort zu suchen. Das Suchfeld deckt Titel, Beschreibung, Labels und Projektname ab. Gefiltert wird im Browser - die Aufgaben liegen dort ohnehin alle vor, es braucht also keine zusaetzliche Abfrage und es gibt keine Verzoegerung beim Tippen. Wer sucht, sucht dabei auch in Erledigtem: sonst faende man die Aufgabe von letzter Woche nicht wieder, obwohl man genau sie meint. Der Schalter "Erledigte einblenden" gilt nur ohne Suchbegriff. **Die Volltextsuche kannte nur Notizen.** Task ist jetzt ebenfalls dabei, nach demselben Muster: deutsche Textsuche, ts_headline fuer den Ausschnitt, Hervorhebung als [[…]] statt als HTML. Deutsche Stammformen greifen damit auch hier - "Rechnung" findet "Rechnungen". Dazu ein Ausdrucksindex Task_fulltext_idx wie Note_fulltext_idx. Der Ausdruck muss Zeichen fuer Zeichen dem in der Abfrage entsprechen, sonst greift er nicht; nachgeprueft mit EXPLAIN, der Plan zeigt einen Bitmap Index Scan darauf. "prisma migrate diff" laesst ihn in Ruhe - Gegenprobe kommt leer heraus. Das Antwortfeld heisst weiterhin "hits" und meint die Notizen. Umbenennen haette nur Aufrufer gebrochen, ohne etwas zu verbessern; Aufgaben kommen als eigenes Feld dazu. Beide Stellen, die Treffer anzeigen - Projektbrett und Schnellsuche -, zeigen sie jetzt. Geprueft: 261 Unit-Tests, 36 E2E-Tests. Die Volltextsuche zusaetzlich gegen die laufende Anwendung: Treffer in Titel und Beschreibung, Erledigtes inbegriffen, Stammformen greifen, fremde Konten finden nichts. Refs #4 --- e2e/api.spec.ts | 52 ++++++++++++ e2e/ui.spec.ts | 29 ++++++- .../migration.sql | 20 +++++ src/app/api/search/route.ts | 65 +++++++++++++-- src/components/CommandPalette.tsx | 53 ++++++++++-- src/components/ProjectBoard.tsx | 81 +++++++++++++++++-- src/components/tasks/TaskOverview.tsx | 58 +++++++++++-- 7 files changed, 333 insertions(+), 25 deletions(-) create mode 100644 prisma/migrations/20260818000000_task_fulltext/migration.sql diff --git a/e2e/api.spec.ts b/e2e/api.spec.ts index fdd4a7c..cb6b8fb 100644 --- a/e2e/api.spec.ts +++ b/e2e/api.spec.ts @@ -817,6 +817,58 @@ test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request, await fremd.dispose(); }); +test("Volltextsuche findet auch Aufgaben", async ({ request, playwright }) => { + await login(request, ADMIN); + + const projekt = ( + await (await request.post("/api/projects", { data: { name: "Suchprojekt" } })).json() + ).project; + + for (const task of [ + { + title: "Dockerfile aufraeumen", + description: "Die Schichten im Container neu ordnen, damit der Bau schneller wird.", + }, + { title: "Rechnungen exportieren", description: "Monatlicher Export als CSV." }, + { title: "Erledigtes mit Container", description: "Auch das soll gefunden werden.", status: "DONE" }, + { title: "Ohne Bezug", description: "Voellig anderer Inhalt." }, + ]) { + expect((await request.post(`/api/projects/${projekt.id}/tasks`, { data: task })).status()).toBe( + 201, + ); + } + + // Titel und Beschreibung werden durchsucht. + const container = await (await request.get("/api/search?q=Container")).json(); + const titel = container.tasks.map((treffer: { title: string }) => treffer.title); + expect(titel).toContain("Dockerfile aufraeumen"); + // Erledigtes bleibt drin – wer sucht, sucht oft gerade das Abgehakte. + expect(titel).toContain("Erledigtes mit Container"); + expect(titel).not.toContain("Ohne Bezug"); + + // Deutsche Stammformen: "Rechnung" findet "Rechnungen". + const rechnung = await (await request.get("/api/search?q=Rechnung")).json(); + expect(rechnung.tasks.map((t: { title: string }) => t.title)).toContain( + "Rechnungen exportieren", + ); + + // Das Projekt taucht in projectIds auf, damit das Brett es einblendet. + expect(container.projectIds).toContain(projekt.id); + + // Zu kurze Eingaben liefern leere Listen statt Unsinn. + const kurz = await (await request.get("/api/search?q=a")).json(); + expect(kurz.tasks).toEqual([]); + expect(kurz.hits).toEqual([]); + + // Und ein fremdes Konto findet nichts davon. + const fremd = await playwright.request.newContext({ + baseURL: test.info().project.use.baseURL, + }); + await login(fremd, ZWEITER); + expect((await (await fremd.get("/api/search?q=Container")).json()).tasks).toHaveLength(0); + await fremd.dispose(); +}); + test("Der Bildoptimierer ist zu", async ({ playwright }) => { // /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer // das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image diff --git a/e2e/ui.spec.ts b/e2e/ui.spec.ts index c08c6b2..20bbbfd 100644 --- a/e2e/ui.spec.ts +++ b/e2e/ui.spec.ts @@ -244,7 +244,7 @@ test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) => // Auch ueber die Schnellsuche erreichbar. await page.goto("/"); await page.keyboard.press("Control+k"); - await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Rückblick"); + await page.getByPlaceholder(/^Projekt, /).fill("Rückblick"); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/rueckblick/); }); @@ -264,11 +264,34 @@ test("Globale Zeitleiste zeigt den Verlauf ueber alle Projekte", async ({ page } // Auch ueber die Schnellsuche erreichbar. await page.goto("/"); await page.keyboard.press("Control+k"); - await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Verlauf"); + await page.getByPlaceholder(/^Projekt, /).fill("Verlauf"); await page.keyboard.press("Enter"); await expect(page).toHaveURL(/\/verlauf/); }); +test("Aufgabendashboard laesst sich durchsuchen", async ({ page }) => { + await page.getByRole("link", { name: "Aufgaben", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Aufgaben", level: 1 })).toBeVisible(); + + const suchfeld = page.getByLabel("Aufgaben durchsuchen"); + await expect(suchfeld).toBeVisible(); + + // Aus den frueheren Tests gibt es "Erste Aufgabe" und "Zweite Aufgabe". + await suchfeld.fill("Erste"); + await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible(); + await expect(page.getByRole("listitem").filter({ hasText: "Zweite Aufgabe" })).toBeHidden(); + + // Ohne Treffer erklaert der Leerzustand, worin gesucht wurde. + await suchfeld.fill("gibtesnichtxyz"); + await expect(page.getByText("Keine Treffer")).toBeVisible(); + await expect(page.getByText(/Titel, Beschreibung, Labels und Projektname/)).toBeVisible(); + + // Zuruecksetzen bringt alles wieder. + await page.getByRole("button", { name: "Suche zuruecksetzen" }).click(); + await expect(suchfeld).toHaveValue(""); + await expect(page.getByRole("listitem").filter({ hasText: "Erste Aufgabe" })).toBeVisible(); +}); + test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { await page.keyboard.press("Control+k"); await expect(page.getByRole("dialog", { name: "Schnellsuche" })).toBeVisible(); @@ -276,7 +299,7 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => { // Ohne Eingabe stehen Projekte und Bereiche bereit. await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible(); - await page.getByPlaceholder(/Projekt, Notiz oder Bereich/).fill("Zaunkoenig"); + await page.getByPlaceholder(/^Projekt, /).fill("Zaunkoenig"); await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible(); // Enter springt zum ersten Treffer. diff --git a/prisma/migrations/20260818000000_task_fulltext/migration.sql b/prisma/migrations/20260818000000_task_fulltext/migration.sql new file mode 100644 index 0000000..0e257e1 --- /dev/null +++ b/prisma/migrations/20260818000000_task_fulltext/migration.sql @@ -0,0 +1,20 @@ +-- Volltextsuche ueber Aufgaben. +-- +-- Wie Note_fulltext_idx ein Ausdrucksindex: Prisma kann so etwas im Schema +-- nicht abbilden und kennt ihn deshalb nicht. Nachgeprueft mit +-- "prisma migrate diff" gegen das Schema - es kommt "empty migration" +-- heraus, der Index wird also weder angelegt noch geloescht. Trotzdem beim +-- Erzeugen kuenftiger Migrationen kurz hinsehen. +-- +-- Faellt er doch einmal weg, funktioniert die Suche weiter, nur langsamer: +-- Postgres berechnet to_tsvector dann je Zeile. +-- +-- Der Ausdruck muss Zeichen fuer Zeichen dem in src/app/api/search/route.ts +-- entsprechen, sonst greift der Index nicht. Die zweistellige Form von +-- to_tsvector mit fester Konfiguration ist IMMUTABLE und damit indizierbar. +-- +-- Anders als bei der Notiz ist auch die Beschreibung optional, deshalb steht +-- coalesce um beide Spalten. + +CREATE INDEX "Task_fulltext_idx" ON "Task" + USING GIN (to_tsvector('german', coalesce("title", '') || ' ' || coalesce("description", ''))); diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts index 960c636..4b23f8e 100644 --- a/src/app/api/search/route.ts +++ b/src/app/api/search/route.ts @@ -12,16 +12,30 @@ interface Hit { snippet: string; } +interface TaskHit { + projectId: string; + taskId: string; + title: string; + status: string; + /** Ausschnitt aus der Beschreibung; leer, wenn nur der Titel passte. */ + snippet: string; +} + /** - * Volltextsuche ueber Notizen. + * Volltextsuche ueber Notizen und Aufgaben. * * Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt - * Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die - * mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt: - * Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank. + * Name, Kurzbeschreibung und Tags ab. Notizen und Aufgaben liegen dort nicht + * vor – die mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser + * Endpunkt: Postgres sucht mit seiner deutschen Textsuche direkt in der + * Datenbank. * * ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung * kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss. + * + * Das Feld heisst weiterhin "hits" und meint die Notizen – umbenennen haette + * nur Aufrufer gebrochen, ohne etwas zu verbessern. Aufgaben kommen als + * eigenes Feld dazu. */ export async function GET(request: Request) { const auth = await requireUserApi(); @@ -29,7 +43,7 @@ export async function GET(request: Request) { const query = (new URL(request.url).searchParams.get("q") ?? "").trim(); if (query.length < 2) { - return NextResponse.json({ query, hits: [], projectIds: [] }); + return NextResponse.json({ query, hits: [], tasks: [], projectIds: [] }); } // websearch_to_tsquery versteht Eingaben wie "docker -compose" oder @@ -57,11 +71,48 @@ export async function GET(request: Request) { LIMIT 50 `; + /** + * Dieselbe Rechnung fuer Aufgaben. + * + * Der Ausdruck in to_tsvector muss Zeichen fuer Zeichen dem in der + * Migration entsprechen (Task_fulltext_idx), sonst greift der Index nicht + * und Postgres rechnet ihn je Zeile neu. + * + * Erledigtes bleibt drin: wer sucht, sucht oft gerade das, was er letzte + * Woche abgehakt hat. + */ + const tasks = await prisma.$queryRaw` + SELECT + t."projectId" AS "projectId", + t."id" AS "taskId", + t."title" AS "title", + t."status"::text AS "status", + ts_headline( + 'german', + coalesce(t."description", ''), + websearch_to_tsquery('german', ${query}), + 'StartSel=[[, StopSel=]], MaxWords=18, MinWords=6, ShortWord=2, MaxFragments=1' + ) AS "snippet" + FROM "Task" t + JOIN "Project" p ON p."id" = t."projectId" + WHERE p."ownerId" = ${auth.user.id} + AND to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", '')) + @@ websearch_to_tsquery('german', ${query}) + ORDER BY ts_rank( + to_tsvector('german', coalesce(t."title", '') || ' ' || coalesce(t."description", '')), + websearch_to_tsquery('german', ${query}) + ) DESC + LIMIT 50 + `; + return NextResponse.json({ query, hits, + tasks, // Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im - // Board einzublenden. - projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))), + // Board einzublenden – jetzt aus beiden Quellen. + projectIds: Array.from( + new Set([...hits.map((hit) => hit.projectId), ...tasks.map((hit) => hit.projectId)]), + ), }); } diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx index f7af258..84e56b2 100644 --- a/src/components/CommandPalette.tsx +++ b/src/components/CommandPalette.tsx @@ -18,8 +18,9 @@ import { Settings, UserRound, } from "lucide-react"; -import type { ProjectStatus } from "@prisma/client"; +import type { ProjectStatus, TaskStatus } from "@prisma/client"; import { statusOf } from "@/lib/status"; +import { TASK_STATUS_META } from "@/lib/tasks"; import { cn } from "@/lib/utils"; /** @@ -40,6 +41,14 @@ interface ProjectHit { tags: string[]; } +interface TaskHit { + projectId: string; + taskId: string; + title: string; + status: string; + snippet: string; +} + interface NoteHit { projectId: string; noteId: string; @@ -107,7 +116,8 @@ const ACTIONS: Action[] = [ type Entry = | { kind: "action"; key: string; action: Action } | { kind: "project"; key: string; project: ProjectHit } - | { kind: "note"; key: string; note: NoteHit; projectName: string }; + | { kind: "note"; key: string; note: NoteHit; projectName: string } + | { kind: "task"; key: string; task: TaskHit; projectName: string }; export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { const router = useRouter(); @@ -116,6 +126,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { const [query, setQuery] = useState(""); const [projects, setProjects] = useState(null); const [notes, setNotes] = useState([]); + const [aufgaben, setAufgaben] = useState([]); const [active, setActive] = useState(0); const [loading, setLoading] = useState(false); const listRef = useRef(null); @@ -154,6 +165,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { const needle = query.trim(); if (needle.length < 2) { setNotes([]); + setAufgaben([]); return; } const controller = new AbortController(); @@ -163,8 +175,9 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { signal: controller.signal, }); if (!response.ok) return; - const body = (await response.json()) as { hits: NoteHit[] }; + const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] }; setNotes(body.hits ?? []); + setAufgaben(body.tasks ?? []); } catch { // Abbruch beim Weitertippen ist der Normalfall. } @@ -179,6 +192,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { if (!open) { setQuery(""); setNotes([]); + setAufgaben([]); setActive(0); } }, [open]); @@ -218,6 +232,17 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { for (const project of passendeProjekte.slice(0, 8)) { result.push({ kind: "project", key: `p-${project.id}`, project }); } + // Aufgaben vor Notizen: wer tippt, sucht haeufiger etwas zu Tun als + // etwas Notiertes. + for (const treffer of aufgaben.slice(0, 5)) { + result.push({ + kind: "task", + key: `t-${treffer.taskId}`, + task: treffer, + projectName: + (projects ?? []).find((project) => project.id === treffer.projectId)?.name ?? "Projekt", + }); + } for (const hit of notes.slice(0, 5)) { result.push({ kind: "note", @@ -231,7 +256,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { result.push({ kind: "action", key: `a-${action.id}`, action }); } return result; - }, [query, projects, notes, isAdmin]); + }, [query, projects, notes, aufgaben, isAdmin]); useEffect(() => { setActive((current) => Math.min(current, Math.max(0, entries.length - 1))); @@ -242,6 +267,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { setOpen(false); if (entry.kind === "action") router.push(entry.action.href); else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`); + else if (entry.kind === "task") router.push(`/projects/${entry.task.projectId}`); else router.push(`/projects/${entry.note.projectId}`); }, [router], @@ -292,7 +318,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { value={query} onChange={(event) => setQuery(event.target.value)} onKeyDown={onInputKey} - placeholder="Projekt, Notiz oder Bereich…" + placeholder="Projekt, Aufgabe, Notiz oder Bereich…" className="w-full bg-transparent text-sm text-slate-100 outline-none placeholder:text-slate-600" /> {loading ? : null} @@ -346,6 +372,23 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) { {statusOf(entry.project.status).short} + ) : entry.kind === "task" ? ( + <> + + + + {entry.task.title} + + in {entry.projectName} + + + + {entry.task.snippet.replace(/\[\[|\]\]/g, "").trim() || + TASK_STATUS_META[entry.task.status as TaskStatus]?.label || + "Aufgabe"} + + + ) : ( <> diff --git a/src/components/ProjectBoard.tsx b/src/components/ProjectBoard.tsx index 961eaf2..d0ec910 100644 --- a/src/components/ProjectBoard.tsx +++ b/src/components/ProjectBoard.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { + CheckSquare, Columns3, Database, Download, @@ -16,7 +17,7 @@ import { Upload, X, } from "lucide-react"; -import type { ProjectStatus } from "@prisma/client"; +import type { ProjectStatus, TaskStatus } from "@prisma/client"; import { BulkBar } from "@/components/BulkBar"; import { ImportDialog } from "@/components/ImportDialog"; import { KanbanBoard } from "@/components/KanbanBoard"; @@ -25,9 +26,18 @@ import { ProjectDialog } from "@/components/ProjectDialog"; import { ProjectList } from "@/components/ProjectList"; import { Popover } from "@/components/ui/Popover"; import { STATUS_META, STATUS_ORDER } from "@/lib/status"; +import { TASK_STATUS_META } from "@/lib/tasks"; import { cn } from "@/lib/utils"; import type { ConnectionSafe, ProjectListItem } from "@/types"; +interface TaskHit { + projectId: string; + taskId: string; + title: string; + status: string; + snippet: string; +} + interface NoteHit { projectId: string; noteId: string; @@ -135,6 +145,7 @@ export function ProjectBoard({ const [dataMenuOpen, setDataMenuOpen] = useState(false); const dataButtonRef = useRef(null); const [noteHits, setNoteHits] = useState([]); + const [taskHits, setTaskHits] = useState([]); const [selection, setSelection] = useState>(new Set()); /** @@ -147,6 +158,7 @@ export function ProjectBoard({ const needle = query.trim(); if (needle.length < 2) { setNoteHits([]); + setTaskHits([]); return; } @@ -157,8 +169,9 @@ export function ProjectBoard({ signal: controller.signal, }); if (!response.ok) return; - const body = (await response.json()) as { hits: NoteHit[] }; + const body = (await response.json()) as { hits: NoteHit[]; tasks: TaskHit[] }; setNoteHits(body.hits ?? []); + setTaskHits(body.tasks ?? []); } catch { // Abgebrochene Anfragen sind der Normalfall beim Tippen. } @@ -171,8 +184,12 @@ export function ProjectBoard({ }, [query]); const noteHitProjectIds = useMemo( - () => new Set(noteHits.map((hit) => hit.projectId)), - [noteHits], + () => + new Set([ + ...noteHits.map((hit) => hit.projectId), + ...taskHits.map((hit) => hit.projectId), + ]), + [noteHits, taskHits], ); const counts = useMemo(() => { @@ -200,7 +217,7 @@ export function ProjectBoard({ project.repositories.some((repo) => (repo.snapshot?.fullName ?? "").toLowerCase().includes(needle), ) || - // Treffer in Notizen kommen vom Server dazu. + // Treffer in Notizen und Aufgaben kommen vom Server dazu. noteHitProjectIds.has(project.id) ); }); @@ -434,6 +451,60 @@ export function ProjectBoard({ + {/* Treffer in Aufgaben */} + {taskHits.length > 0 ? ( +
+

+ + Treffer in Aufgaben + + {taskHits.length} + +

+
    + {taskHits.slice(0, 8).map((hit) => { + const project = projects.find((entry) => entry.id === hit.projectId); + const status = TASK_STATUS_META[hit.status as TaskStatus]; + return ( +
  • + + + {hit.title} + + · {project?.name ?? "Projekt"} + + {status ? ( + + {status.label} + + ) : null} + + {/* Nur wenn die Beschreibung etwas beitraegt – passte + allein der Titel, ist der Ausschnitt leer. */} + {hit.snippet.trim() ? ( + + {highlight(hit.snippet)} + + ) : null} + +
  • + ); + })} +
+ {taskHits.length > 8 ? ( +

+ … und {taskHits.length - 8} weitere +

+ ) : null} +
+ ) : null} + {/* Treffer in Notizen */} {noteHits.length > 0 ? (
diff --git a/src/components/tasks/TaskOverview.tsx b/src/components/tasks/TaskOverview.tsx index a76fe3c..91ad1ed 100644 --- a/src/components/tasks/TaskOverview.tsx +++ b/src/components/tasks/TaskOverview.tsx @@ -10,7 +10,9 @@ import { Loader2, Plus, Repeat, + Search, Square, + X, } from "lucide-react"; import type { TaskStatus } from "@prisma/client"; import { QuickCapture } from "@/components/QuickCapture"; @@ -42,6 +44,7 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) { const router = useRouter(); const [statusFilter, setStatusFilter] = useState([]); const [projektFilter, setProjektFilter] = useState(""); + const [suche, setSuche] = useState(""); const [zeigeErledigte, setZeigeErledigte] = useState(false); const [erfassenOffen, setErfassenOffen] = useState(false); const [laufend, setLaufend] = useState>(new Set()); @@ -63,16 +66,34 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) { }, [tasks]); const sichtbar = useMemo(() => { + const nadel = suche.toLowerCase().trim(); + return tasks.filter((task) => { // Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht. - if (!zeigeErledigte && task.status === "DONE" && !statusFilter.includes("DONE")) { + // Wer allerdings sucht, sucht auch in Erledigtem – sonst findet man + // die Aufgabe von letzter Woche nicht wieder. + if ( + !zeigeErledigte && + !nadel && + 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; + if (!nadel) return true; + + // Die Aufgaben liegen hier ohnehin alle vor – das filtert ohne + // zusaetzliche Abfrage und ohne Verzoegerung beim Tippen. + return ( + task.title.toLowerCase().includes(nadel) || + (task.description ?? "").toLowerCase().includes(nadel) || + task.labels.some((label) => label.toLowerCase().includes(nadel)) || + task.project.name.toLowerCase().includes(nadel) + ); }); - }, [tasks, statusFilter, projektFilter, zeigeErledigte]); + }, [tasks, statusFilter, projektFilter, zeigeErledigte, suche]); const faecher = useMemo(() => { return DUE_BUCKET_ORDER.map((bucket) => ({ @@ -155,6 +176,27 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) { {/* Steuerleiste */}
+
+ + setSuche(event.target.value)} + aria-label="Aufgaben durchsuchen" + /> + {suche ? ( + + ) : null} +
+
{TASK_STATUS_ORDER.map((status) => { const meta = TASK_STATUS_META[status]; @@ -220,12 +262,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {

- {tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"} + {tasks.length === 0 + ? "Noch keine Aufgaben" + : suche.trim() + ? "Keine Treffer" + : "Nichts offen"}

{tasks.length === 0 ? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“." - : "Alles abgehakt, was zu den Filtern passt."} + : suche.trim() + ? `Keine Aufgabe passt zu „${suche.trim()}“ – gesucht wird in Titel, Beschreibung, Labels und Projektname.` + : "Alles abgehakt, was zu den Filtern passt."}

-- 2.54.0