This commit was merged in pull request #7.
This commit is contained in:
@@ -817,6 +817,58 @@ test("Verlauf blaettert per Cursor, ohne Zeilen zu verlieren", async ({ request,
|
|||||||
await fremd.dispose();
|
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 }) => {
|
test("Der Bildoptimierer ist zu", async ({ playwright }) => {
|
||||||
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
|
// /_next/image reicht jedes Bild durch sharp und damit durch libvips, fuer
|
||||||
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
|
// das es regelmaessig Schwachstellen gibt. Nebula benutzt next/image
|
||||||
|
|||||||
+26
-3
@@ -244,7 +244,7 @@ test("Wochenrueckblick zeigt, was diese Woche passiert ist", async ({ page }) =>
|
|||||||
// Auch ueber die Schnellsuche erreichbar.
|
// Auch ueber die Schnellsuche erreichbar.
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.keyboard.press("Control+k");
|
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 page.keyboard.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/rueckblick/);
|
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.
|
// Auch ueber die Schnellsuche erreichbar.
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.keyboard.press("Control+k");
|
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 page.keyboard.press("Enter");
|
||||||
await expect(page).toHaveURL(/\/verlauf/);
|
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 }) => {
|
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();
|
||||||
@@ -276,7 +299,7 @@ test("Schnellsuche findet Projekt und Notiz", async ({ page }) => {
|
|||||||
// Ohne Eingabe stehen Projekte und Bereiche bereit.
|
// Ohne Eingabe stehen Projekte und Bereiche bereit.
|
||||||
await expect(page.getByRole("dialog").getByText("Oberflaechentest")).toBeVisible();
|
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();
|
await expect(page.getByRole("dialog").getByText(/in Oberflaechentest/)).toBeVisible();
|
||||||
|
|
||||||
// Enter springt zum ersten Treffer.
|
// Enter springt zum ersten Treffer.
|
||||||
|
|||||||
@@ -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", '')));
|
||||||
@@ -12,16 +12,30 @@ interface Hit {
|
|||||||
snippet: string;
|
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
|
* Die Suche im Board arbeitet auf den bereits geladenen Projekten und deckt
|
||||||
* Name, Kurzbeschreibung und Tags ab. Notizen liegen dort nicht vor – die
|
* Name, Kurzbeschreibung und Tags ab. Notizen und Aufgaben liegen dort nicht
|
||||||
* mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser Endpunkt:
|
* vor – die mitzuladen wuerde die Seite unnoetig aufblaehen. Deshalb dieser
|
||||||
* Postgres sucht mit seiner deutschen Textsuche direkt in der Datenbank.
|
* Endpunkt: Postgres sucht mit seiner deutschen Textsuche direkt in der
|
||||||
|
* Datenbank.
|
||||||
*
|
*
|
||||||
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
|
* ts_headline liefert gleich den passenden Ausschnitt mit; die Hervorhebung
|
||||||
* kommt als [[…]] zurueck, damit die Oberflaeche kein HTML einbetten muss.
|
* 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) {
|
export async function GET(request: Request) {
|
||||||
const auth = await requireUserApi();
|
const auth = await requireUserApi();
|
||||||
@@ -29,7 +43,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
|
const query = (new URL(request.url).searchParams.get("q") ?? "").trim();
|
||||||
if (query.length < 2) {
|
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
|
// websearch_to_tsquery versteht Eingaben wie "docker -compose" oder
|
||||||
@@ -57,11 +71,48 @@ export async function GET(request: Request) {
|
|||||||
LIMIT 50
|
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<TaskHit[]>`
|
||||||
|
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({
|
return NextResponse.json({
|
||||||
query,
|
query,
|
||||||
hits,
|
hits,
|
||||||
|
tasks,
|
||||||
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
|
// Die Oberflaeche braucht vor allem die betroffenen Projekte, um sie im
|
||||||
// Board einzublenden.
|
// Board einzublenden – jetzt aus beiden Quellen.
|
||||||
projectIds: Array.from(new Set(hits.map((hit) => hit.projectId))),
|
projectIds: Array.from(
|
||||||
|
new Set([...hits.map((hit) => hit.projectId), ...tasks.map((hit) => hit.projectId)]),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
UserRound,
|
UserRound,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus, TaskStatus } from "@prisma/client";
|
||||||
import { statusOf } from "@/lib/status";
|
import { statusOf } from "@/lib/status";
|
||||||
|
import { TASK_STATUS_META } from "@/lib/tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,14 @@ interface ProjectHit {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskHit {
|
||||||
|
projectId: string;
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface NoteHit {
|
interface NoteHit {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
noteId: string;
|
noteId: string;
|
||||||
@@ -107,7 +116,8 @@ const ACTIONS: Action[] = [
|
|||||||
type Entry =
|
type Entry =
|
||||||
| { kind: "action"; key: string; action: Action }
|
| { kind: "action"; key: string; action: Action }
|
||||||
| { kind: "project"; key: string; project: ProjectHit }
|
| { 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 }) {
|
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -116,6 +126,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [projects, setProjects] = useState<ProjectHit[] | null>(null);
|
const [projects, setProjects] = useState<ProjectHit[] | null>(null);
|
||||||
const [notes, setNotes] = useState<NoteHit[]>([]);
|
const [notes, setNotes] = useState<NoteHit[]>([]);
|
||||||
|
const [aufgaben, setAufgaben] = useState<TaskHit[]>([]);
|
||||||
const [active, setActive] = useState(0);
|
const [active, setActive] = useState(0);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -154,6 +165,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
if (needle.length < 2) {
|
if (needle.length < 2) {
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
|
setAufgaben([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@@ -163,8 +175,9 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) return;
|
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 ?? []);
|
setNotes(body.hits ?? []);
|
||||||
|
setAufgaben(body.tasks ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
// Abbruch beim Weitertippen ist der Normalfall.
|
// Abbruch beim Weitertippen ist der Normalfall.
|
||||||
}
|
}
|
||||||
@@ -179,6 +192,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
setQuery("");
|
setQuery("");
|
||||||
setNotes([]);
|
setNotes([]);
|
||||||
|
setAufgaben([]);
|
||||||
setActive(0);
|
setActive(0);
|
||||||
}
|
}
|
||||||
}, [open]);
|
}, [open]);
|
||||||
@@ -218,6 +232,17 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
for (const project of passendeProjekte.slice(0, 8)) {
|
for (const project of passendeProjekte.slice(0, 8)) {
|
||||||
result.push({ kind: "project", key: `p-${project.id}`, project });
|
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)) {
|
for (const hit of notes.slice(0, 5)) {
|
||||||
result.push({
|
result.push({
|
||||||
kind: "note",
|
kind: "note",
|
||||||
@@ -231,7 +256,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
result.push({ kind: "action", key: `a-${action.id}`, action });
|
result.push({ kind: "action", key: `a-${action.id}`, action });
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}, [query, projects, notes, isAdmin]);
|
}, [query, projects, notes, aufgaben, isAdmin]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActive((current) => Math.min(current, Math.max(0, entries.length - 1)));
|
setActive((current) => Math.min(current, Math.max(0, entries.length - 1)));
|
||||||
@@ -242,6 +267,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
setOpen(false);
|
setOpen(false);
|
||||||
if (entry.kind === "action") router.push(entry.action.href);
|
if (entry.kind === "action") router.push(entry.action.href);
|
||||||
else if (entry.kind === "project") router.push(`/projects/${entry.project.id}`);
|
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}`);
|
else router.push(`/projects/${entry.note.projectId}`);
|
||||||
},
|
},
|
||||||
[router],
|
[router],
|
||||||
@@ -292,7 +318,7 @@ export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
|
|||||||
value={query}
|
value={query}
|
||||||
onChange={(event) => setQuery(event.target.value)}
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
onKeyDown={onInputKey}
|
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"
|
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}
|
{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}
|
{statusOf(entry.project.status).short}
|
||||||
</span>
|
</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" />
|
<NotebookPen className="mt-0.5 h-4 w-4 shrink-0 text-violet-300/70" />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
|
CheckSquare,
|
||||||
Columns3,
|
Columns3,
|
||||||
Database,
|
Database,
|
||||||
Download,
|
Download,
|
||||||
@@ -16,7 +17,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ProjectStatus } from "@prisma/client";
|
import type { ProjectStatus, TaskStatus } from "@prisma/client";
|
||||||
import { BulkBar } from "@/components/BulkBar";
|
import { BulkBar } from "@/components/BulkBar";
|
||||||
import { ImportDialog } from "@/components/ImportDialog";
|
import { ImportDialog } from "@/components/ImportDialog";
|
||||||
import { KanbanBoard } from "@/components/KanbanBoard";
|
import { KanbanBoard } from "@/components/KanbanBoard";
|
||||||
@@ -25,9 +26,18 @@ import { ProjectDialog } from "@/components/ProjectDialog";
|
|||||||
import { ProjectList } from "@/components/ProjectList";
|
import { ProjectList } from "@/components/ProjectList";
|
||||||
import { Popover } from "@/components/ui/Popover";
|
import { Popover } from "@/components/ui/Popover";
|
||||||
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
import { STATUS_META, STATUS_ORDER } from "@/lib/status";
|
||||||
|
import { TASK_STATUS_META } from "@/lib/tasks";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
import type { ConnectionSafe, ProjectListItem } from "@/types";
|
||||||
|
|
||||||
|
interface TaskHit {
|
||||||
|
projectId: string;
|
||||||
|
taskId: string;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
snippet: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface NoteHit {
|
interface NoteHit {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
noteId: string;
|
noteId: string;
|
||||||
@@ -135,6 +145,7 @@ export function ProjectBoard({
|
|||||||
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
const [dataMenuOpen, setDataMenuOpen] = useState(false);
|
||||||
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
const dataButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
|
const [noteHits, setNoteHits] = useState<NoteHit[]>([]);
|
||||||
|
const [taskHits, setTaskHits] = useState<TaskHit[]>([]);
|
||||||
const [selection, setSelection] = useState<Set<string>>(new Set());
|
const [selection, setSelection] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -147,6 +158,7 @@ export function ProjectBoard({
|
|||||||
const needle = query.trim();
|
const needle = query.trim();
|
||||||
if (needle.length < 2) {
|
if (needle.length < 2) {
|
||||||
setNoteHits([]);
|
setNoteHits([]);
|
||||||
|
setTaskHits([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,8 +169,9 @@ export function ProjectBoard({
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (!response.ok) return;
|
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 ?? []);
|
setNoteHits(body.hits ?? []);
|
||||||
|
setTaskHits(body.tasks ?? []);
|
||||||
} catch {
|
} catch {
|
||||||
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
|
// Abgebrochene Anfragen sind der Normalfall beim Tippen.
|
||||||
}
|
}
|
||||||
@@ -171,8 +184,12 @@ export function ProjectBoard({
|
|||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const noteHitProjectIds = useMemo(
|
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(() => {
|
const counts = useMemo(() => {
|
||||||
@@ -200,7 +217,7 @@ export function ProjectBoard({
|
|||||||
project.repositories.some((repo) =>
|
project.repositories.some((repo) =>
|
||||||
(repo.snapshot?.fullName ?? "").toLowerCase().includes(needle),
|
(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)
|
noteHitProjectIds.has(project.id)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -434,6 +451,60 @@ export function ProjectBoard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Treffer in Aufgaben */}
|
||||||
|
{taskHits.length > 0 ? (
|
||||||
|
<div className="glass fade-up p-4">
|
||||||
|
<h2 className="mb-2.5 flex items-center gap-2 text-[11px] font-semibold uppercase tracking-wider text-slate-400">
|
||||||
|
<CheckSquare className="h-3.5 w-3.5 text-emerald-300" />
|
||||||
|
Treffer in Aufgaben
|
||||||
|
<span className="rounded-full bg-white/6 px-2 py-0.5 tabular-nums text-slate-400">
|
||||||
|
{taskHits.length}
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{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 (
|
||||||
|
<li key={hit.taskId}>
|
||||||
|
<Link
|
||||||
|
href={`/projects/${hit.projectId}`}
|
||||||
|
className="block rounded-lg px-2.5 py-2 transition-colors hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<span className="flex flex-wrap items-baseline gap-x-2">
|
||||||
|
<span className="text-sm text-slate-200">{hit.title}</span>
|
||||||
|
<span className="text-[11px] text-slate-500">
|
||||||
|
· {project?.name ?? "Projekt"}
|
||||||
|
</span>
|
||||||
|
{status ? (
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1 text-[11px]"
|
||||||
|
style={{ color: status.color }}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{/* Nur wenn die Beschreibung etwas beitraegt – passte
|
||||||
|
allein der Titel, ist der Ausschnitt leer. */}
|
||||||
|
{hit.snippet.trim() ? (
|
||||||
|
<span className="mt-0.5 block text-xs leading-snug text-slate-400">
|
||||||
|
{highlight(hit.snippet)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
{taskHits.length > 8 ? (
|
||||||
|
<p className="mt-2 px-2.5 text-[11px] text-slate-600">
|
||||||
|
… und {taskHits.length - 8} weitere
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Treffer in Notizen */}
|
{/* Treffer in Notizen */}
|
||||||
{noteHits.length > 0 ? (
|
{noteHits.length > 0 ? (
|
||||||
<div className="glass fade-up p-4">
|
<div className="glass fade-up p-4">
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
Repeat,
|
Repeat,
|
||||||
|
Search,
|
||||||
Square,
|
Square,
|
||||||
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { TaskStatus } from "@prisma/client";
|
import type { TaskStatus } from "@prisma/client";
|
||||||
import { QuickCapture } from "@/components/QuickCapture";
|
import { QuickCapture } from "@/components/QuickCapture";
|
||||||
@@ -42,6 +44,7 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
|
const [statusFilter, setStatusFilter] = useState<TaskStatus[]>([]);
|
||||||
const [projektFilter, setProjektFilter] = useState("");
|
const [projektFilter, setProjektFilter] = useState("");
|
||||||
|
const [suche, setSuche] = useState("");
|
||||||
const [zeigeErledigte, setZeigeErledigte] = useState(false);
|
const [zeigeErledigte, setZeigeErledigte] = useState(false);
|
||||||
const [erfassenOffen, setErfassenOffen] = useState(false);
|
const [erfassenOffen, setErfassenOffen] = useState(false);
|
||||||
const [laufend, setLaufend] = useState<Set<string>>(new Set());
|
const [laufend, setLaufend] = useState<Set<string>>(new Set());
|
||||||
@@ -63,16 +66,34 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
const sichtbar = useMemo(() => {
|
const sichtbar = useMemo(() => {
|
||||||
|
const nadel = suche.toLowerCase().trim();
|
||||||
|
|
||||||
return tasks.filter((task) => {
|
return tasks.filter((task) => {
|
||||||
// Erledigtes fuellt die Liste sonst mit dem, was gerade nicht ansteht.
|
// 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;
|
return false;
|
||||||
}
|
}
|
||||||
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
|
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) return false;
|
||||||
if (projektFilter && task.project.id !== projektFilter) 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(() => {
|
const faecher = useMemo(() => {
|
||||||
return DUE_BUCKET_ORDER.map((bucket) => ({
|
return DUE_BUCKET_ORDER.map((bucket) => ({
|
||||||
@@ -155,6 +176,27 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
|
|
||||||
{/* Steuerleiste */}
|
{/* Steuerleiste */}
|
||||||
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
|
<div className="glass flex flex-col gap-3 p-3 sm:p-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||||
|
<input
|
||||||
|
className="field pl-9"
|
||||||
|
placeholder="Aufgaben durchsuchen – Titel, Beschreibung, Labels…"
|
||||||
|
value={suche}
|
||||||
|
onChange={(event) => setSuche(event.target.value)}
|
||||||
|
aria-label="Aufgaben durchsuchen"
|
||||||
|
/>
|
||||||
|
{suche ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSuche("")}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-1 text-slate-500 hover:text-white"
|
||||||
|
aria-label="Suche zuruecksetzen"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{TASK_STATUS_ORDER.map((status) => {
|
{TASK_STATUS_ORDER.map((status) => {
|
||||||
const meta = TASK_STATUS_META[status];
|
const meta = TASK_STATUS_META[status];
|
||||||
@@ -220,12 +262,18 @@ export function TaskOverview({ tasks }: { tasks: TaskOverviewItem[] }) {
|
|||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold text-white">
|
<h2 className="text-lg font-semibold text-white">
|
||||||
{tasks.length === 0 ? "Noch keine Aufgaben" : "Nichts offen"}
|
{tasks.length === 0
|
||||||
|
? "Noch keine Aufgaben"
|
||||||
|
: suche.trim()
|
||||||
|
? "Keine Treffer"
|
||||||
|
: "Nichts offen"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 max-w-md text-sm text-slate-400">
|
<p className="mt-1 max-w-md text-sm text-slate-400">
|
||||||
{tasks.length === 0
|
{tasks.length === 0
|
||||||
? "Aufgaben entstehen im Projekt oder hier über „Neue Aufgabe“."
|
? "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."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user