Suche ueber Aufgaben
CI / pruefen (pull_request) Canceled after 0s

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
This commit is contained in:
2026-08-18 03:39:02 +02:00
parent 0ef0b3d5ca
commit afe57c4428
7 changed files with 333 additions and 25 deletions
+48 -5
View File
@@ -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<ProjectHit[] | null>(null);
const [notes, setNotes] = useState<NoteHit[]>([]);
const [aufgaben, setAufgaben] = useState<TaskHit[]>([]);
const [active, setActive] = useState(0);
const [loading, setLoading] = useState(false);
const listRef = useRef<HTMLDivElement>(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 ? <Loader2 className="h-3.5 w-3.5 animate-spin text-slate-600" /> : null}
@@ -346,6 +372,23 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
{statusOf(entry.project.status).short}
</span>
</>
) : entry.kind === "task" ? (
<>
<CheckSquare className="mt-0.5 h-4 w-4 shrink-0 text-emerald-300/70" />
<span className="min-w-0">
<span className="block truncate text-sm text-slate-200">
{entry.task.title}
<span className="ml-1.5 text-[11px] text-slate-500">
in {entry.projectName}
</span>
</span>
<span className="block truncate text-[11px] text-slate-500">
{entry.task.snippet.replace(/\[\[|\]\]/g, "").trim() ||
TASK_STATUS_META[entry.task.status as TaskStatus]?.label ||
"Aufgabe"}
</span>
</span>
</>
) : (
<>
<NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" />