Compare commits

...
2 Commits
Author SHA1 Message Date
florian.wilde f86a328b3c Merge branch 'phase/040-tags-sichtbar'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:43:55 +02:00
florian.wildeandClaude Opus 5 b2664de62f feat(hosts): Schlagworte und Notizen sichtbar machen (#040)
Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:43:55 +02:00
25 changed files with 243 additions and 9 deletions
+8
View File
@@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.23.0] – 2026-10-03
### Hinzugefügt
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
## [0.22.2] – 2026-10-03 ## [0.22.2] – 2026-10-03
### Behoben ### Behoben
+6 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 21 h 03 min **Gesamt:** 21 h 43 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -45,6 +45,7 @@
| #037 Rabatt je Position | 0 h 55 min | | #037 Rabatt je Position | 0 h 55 min |
| #038 Überbreite auf dem Handy | 0 h 40 min | | #038 Überbreite auf dem Handy | 0 h 40 min |
| #039 Zugang zur Laufzeit | 0 h 30 min | | #039 Zugang zur Laufzeit | 0 h 30 min |
| #040 Schlagworte sichtbar | 0 h 40 min |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -67,7 +68,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend ## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.22.2 **Version:** 0.23.0
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -204,6 +205,9 @@
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen - Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen - Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
### Nächster Schritt ### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline - #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023) - Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+13
View File
@@ -192,6 +192,19 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst. deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
### Gleichnamige Hosts auseinanderhalten
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
Host-Detail steht die Notiz oben bei den Stammdaten.
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
--- ---
## 5. Kunden und Standorte pflegen ## 5. Kunden und Standorte pflegen
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 278 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 177 KiB

+26
View File
@@ -0,0 +1,26 @@
# #040 Schlagworte und Notizen sichtbar machen
**Status:** erledigt
**Meilenstein:** v0.23.0
**Labels:** ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
## Ausgangslage
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
## Behebung
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
Notiztext; die Trefferzeile nennt die Schlagworte.
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
## Entscheidung
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
+2 -1
View File
@@ -43,4 +43,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min | | [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min | | [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min | | [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
| | **Summe** | | | **163 h** | **21 h 03 min** | | [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
| | **Summe** | | | **165 h** | **21 h 43 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.22.2", "version": "0.23.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+21
View File
@@ -108,6 +108,27 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2> <h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div> <div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{meldetNicht ? <KeineMeldungBadge /> : null} {meldetNicht ? <KeineMeldungBadge /> : null}
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
Hosts ist das die Stelle, an der man sie braucht (#040). */}
{host.tags.length > 0 ? (
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
{host.notizen ? (
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
{host.notizen}
</p>
) : null}
</div> </div>
<Zeile bezeichnung="Kunde"> <Zeile bezeichnung="Kunde">
+24 -1
View File
@@ -68,6 +68,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = { const filter: HostFilter = {
q: einzeln(roh.q), q: einzeln(roh.q),
kunde: einzeln(roh.kunde), kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
status: einzeln(roh.status), status: einzeln(roh.status),
produkt: einzeln(roh.produkt), produkt: einzeln(roh.produkt),
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"], ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
@@ -85,6 +86,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const basis: Record<string, string | undefined> = { const basis: Record<string, string | undefined> = {
q: filter.q, q: filter.q,
kunde: filter.kunde, kunde: filter.kunde,
tag: filter.tag,
status: filter.status, status: filter.status,
produkt: filter.produkt, produkt: filter.produkt,
ablauf: filter.ablauf, ablauf: filter.ablauf,
@@ -117,7 +119,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
</> </>
} }
> >
<HostFilterLeiste kunden={optionen.kunden} werte={basis} /> <HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped align-middle"> <table className="table table-striped align-middle">
@@ -152,7 +154,28 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname} {host.hostname}
</Link> </Link>
{host.meldetNicht ? <KeineMeldungBadge /> : null} {host.meldetNicht ? <KeineMeldungBadge /> : null}
{host.notizen ? (
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
<i
className="bi bi-sticky ms-2 text-body-secondary"
title={host.notizen}
aria-label={`Notiz: ${host.notizen}`}
/>
) : null}
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div> <div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
{host.tags.length > 0 ? (
<div className="mt-1 d-flex flex-wrap gap-1">
{host.tags.map((tag) => (
<Link
key={tag.id}
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
className="badge pl-bg-neutral text-decoration-none"
>
{tag.name}
</Link>
))}
</div>
) : null}
</td> </td>
<td> <td>
{host.kunde ? ( {host.kunde ? (
+24 -1
View File
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
*/ */
export function HostFilterLeiste({ export function HostFilterLeiste({
kunden, kunden,
tags,
werte, werte,
}: { }: {
kunden: { id: string; name: string }[]; kunden: { id: string; name: string }[];
tags: string[];
werte: Record<string, string | undefined>; werte: Record<string, string | undefined>;
}) { }) {
const router = useRouter(); const router = useRouter();
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
router.push(`${pathname}?${neu.toString()}`); router.push(`${pathname}?${neu.toString()}`);
} }
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter( const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "q"].filter(
(schluessel) => werte[schluessel], (schluessel) => werte[schluessel],
).length; ).length;
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
</select> </select>
</div> </div>
{tags.length > 0 ? (
<div className="col-6 col-md-2">
<label className="visually-hidden" htmlFor="tag">
Schlagwort
</label>
<select
className="form-select"
id="tag"
value={werte.tag ?? ""}
onChange={(e) => setzen("tag", e.target.value)}
>
<option value="">Alle Schlagworte</option>
{tags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</div>
) : null}
<div className="col-6 col-md-2"> <div className="col-6 col-md-2">
<select <select
className="form-select" className="form-select"
+17 -1
View File
@@ -6,6 +6,8 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter { export interface HostFilter {
q?: string; q?: string;
kunde?: string; kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string;
status?: string; status?: string;
produkt?: string; produkt?: string;
ablauf?: "30" | "14" | "abgelaufen"; ablauf?: "30" | "14" | "abgelaufen";
@@ -20,6 +22,10 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput { function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
const und: Prisma.HostWhereInput[] = []; const und: Prisma.HostWhereInput[] = [];
if (filter.tag) {
und.push({ tags: { some: { name: filter.tag } } });
}
if (filter.q && filter.q.trim().length > 0) { if (filter.q && filter.q.trim().length > 0) {
const suche = filter.q.trim(); const suche = filter.q.trim();
und.push({ und.push({
@@ -30,6 +36,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
{ subKey: { contains: suche, mode: "insensitive" } }, { subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } }, { ips: { has: suche } },
{ kunde: { name: { contains: suche, mode: "insensitive" } } }, { kunde: { name: { contains: suche, mode: "insensitive" } } },
{ notizen: { contains: suche, mode: "insensitive" } },
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host. // Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
{ {
laufzeiten: { laufzeiten: {
@@ -113,6 +121,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
include: { include: {
kunde: { select: { id: true, name: true } }, kunde: { select: { id: true, name: true } },
standort: { select: { name: true } }, standort: { select: { name: true } },
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
}, },
}), }),
]); ]);
@@ -181,5 +190,12 @@ export async function filterOptionen() {
orderBy: { name: "asc" }, orderBy: { name: "asc" },
select: { id: true, name: true }, select: { id: true, name: true },
}); });
return { kunden };
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
const tags = await prisma.tag.findMany({
where: { hosts: { some: kundeId ? { kundeId } : {} } },
orderBy: { name: "asc" },
select: { name: true },
});
return { kunden, tags: tags.map((tag) => tag.name) };
} }
+12 -2
View File
@@ -103,7 +103,9 @@ export async function suchIndex() {
subKey: true, subKey: true,
ips: true, ips: true,
produkt: true, produkt: true,
notizen: true,
kunde: { select: { name: true } }, kunde: { select: { name: true } },
tags: { select: { name: true } },
}, },
}), }),
prisma.kunde.findMany({ prisma.kunde.findMany({
@@ -128,9 +130,17 @@ export async function suchIndex() {
titel: host.hostname, titel: host.hostname,
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${ untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
host.subKey ? ` · ${host.subKey}` : "" host.subKey ? ` · ${host.subKey}` : ""
}`, }${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
href: `/hosts/${host.id}`, href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips] begriffe: [
host.hostname,
host.fqdn,
host.serverId,
host.subKey,
host.notizen,
...host.tags.map((tag) => tag.name),
...host.ips,
]
.filter((w): w is string => Boolean(w)) .filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()), .map((w) => w.toLowerCase()),
})), })),
+89
View File
@@ -0,0 +1,89 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Schlagworte und Notizen in Liste, Filter und Suche (#040). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
sitzungErforderlich: async () => {
throw new Error("in diesem Test nicht vorgesehen");
},
}));
const { hostListe, filterOptionen } = await import("@/server/queries/hostliste");
const { suchIndex } = await import("@/server/queries/hosts");
async function hosts() {
// Zwei Hosts mit demselben Namen bei verschiedenen Kunden – genau der Fall aus der Praxis.
const [kundeA, kundeB] = await Promise.all([
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
]);
await testPrisma.host.create({
data: {
agentId: "a-1",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeA.id,
notizen: "Steht im Serverraum links",
tags: { connectOrCreate: [{ where: { name: "rechenzentrum" }, create: { name: "rechenzentrum" } }] },
},
});
await testPrisma.host.create({
data: {
agentId: "a-2",
hostname: "hv-pve-01",
produkt: "PVE",
kundeId: kundeB.id,
tags: { connectOrCreate: [{ where: { name: "aussenstelle" }, create: { name: "aussenstelle" } }] },
},
});
}
describe.runIf(datenbankVerfuegbar)("Schlagworte und Notizen", () => {
beforeEach(async () => {
await datenbankLeeren();
await hosts();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("liefert die Schlagworte zu jedem Host mit", async () => {
const liste = await hostListe({});
const namen = liste.hosts.flatMap((host) => host.tags.map((tag) => tag.name)).sort();
expect(namen).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("filtert nach Schlagwort", async () => {
const liste = await hostListe({ tag: "aussenstelle" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde B");
});
it("findet einen Host über sein Schlagwort", async () => {
const liste = await hostListe({ q: "rechenzentrum" });
expect(liste.gesamt).toBe(1);
expect(liste.hosts[0].kunde?.name).toBe("Kunde A");
});
it("findet einen Host über seine Notiz", async () => {
const liste = await hostListe({ q: "serverraum" });
expect(liste.gesamt).toBe(1);
});
it("bietet nur vergebene Schlagworte zur Auswahl an", async () => {
const { tags } = await filterOptionen();
expect(tags).toEqual(["aussenstelle", "rechenzentrum"]);
});
it("nimmt Schlagworte und Notizen in die globale Suche auf", async () => {
const eintraege = await suchIndex();
const treffer = eintraege.filter((eintrag) => eintrag.begriffe.includes("rechenzentrum"));
expect(treffer).toHaveLength(1);
expect(treffer[0].untertitel).toContain("rechenzentrum");
});
});