From f4c8b235b99685c6a25e28339020bb9282c08be4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:23:01 +0000 Subject: [PATCH 1/3] Projekte im Ticketsystem: Anlegen, Archiv und Anzeige repariert - Neues Projekt (Projekte-Seite): Repo-Name blieb nach dem ersten Buchstaben der Subdomain stehen ("m" statt "musterfirma") - Ticket "Projekt waehlen": archivierte Repos nach Flag ausblenden, nicht nach status (reaktivierte fehlten, archivierte wurden angeboten) - Projektliste: "Aktive Projekte" + neue Ansicht "Alle inkl. Archiv"; Liste wird auch nach einem Anlege-Fehler neu geladen - Kundenauswahl: "Kein Kunde" entfernt Kunde und Ticket wirklich (mit Rueckfrage); beim Kundenwechsel bleibt kein fremdes Ticket haengen - Zuordnen-Assistent ueberschreibt eine vorhandene Website nicht mehr - Neues Projekt am Ticket bekommt die Art Website - Umlaute in Subdomains wie im Portal (mueller statt m-ller) - Preview-Block nur mit gespeicherter Subdomain; archivierte Projekte erst reaktivieren, bevor sie bearbeitet werden - Repo-Import beim Aufklappen hoechstens einmal pro Minute Co-Authored-By: Claude Opus 5.5 --- src/components/TicketProjects.jsx | 28 +++-------- src/hooks/useWebsiteProjects.js | 15 +++++- src/lib/projectAdminApi.js | 6 +++ src/lib/projectAssign.js | 28 ++++++++++- src/lib/projectList.js | 28 +++++++++++ src/lib/subdomain.js | 27 +++++----- src/lib/throttleRuns.js | 17 +++++++ src/pages/AssignProjectPage.jsx | 23 ++++++--- src/pages/ProjectsPage.jsx | 80 +++++++++++++----------------- tests/unit/importThrottle.test.mjs | 31 ++++++++++++ tests/unit/projectAssign.test.mjs | 33 ++++++++++-- tests/unit/projectList.test.mjs | 36 ++++++++++++++ tests/unit/subdomain.test.mjs | 9 +++- 13 files changed, 269 insertions(+), 92 deletions(-) create mode 100644 src/lib/projectList.js create mode 100644 src/lib/throttleRuns.js create mode 100644 tests/unit/importThrottle.test.mjs create mode 100644 tests/unit/projectList.test.mjs diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 7d94978..9351f5f 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -1,28 +1,13 @@ import { useState, useEffect, useCallback } from 'react' import { FaPlus, FaLink } from 'react-icons/fa6' import { useWebsiteProjects } from '../hooks/useWebsiteProjects' -import { createProjectFromTemplate, importNewRepos } from '../lib/projectAdminApi' -import { isOutsideOrg, assignSummary } from '../lib/projectAssign' +import { createProjectFromTemplate, importNewReposThrottled } from '../lib/projectAdminApi' +import { isOutsideOrg, assignSummary, assignableProjects } from '../lib/projectAssign' +import { slugify } from '../lib/subdomain' import AssignedProjectCard from './AssignedProjectCard' import { fetchServices, completeAssignment } from '../lib/projectKindsApi' import { kindLabelFor } from '../lib/assignFlow' -function slugify(v) { - return String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100) -} - -// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht -// archiviert, keine Vorlage und noch nicht an diesem Ticket sind -function assignableProjects(all, ticket, assignedIds) { - return (all || []).filter((p) => - p.repoFullName && - !assignedIds.has(p.$id) && - p.status !== 'archived' && - !p.isTemplate && - (!p.customerId || p.customerId === ticket.customerId) - ) -} - // Projekte am Ticket: Liste, Zuweisen, Loesen und neues Projekt an einer Stelle. // Loesen nimmt das Projekt nur vom Ticket; die Kundenzuordnung (Sichtbarkeit im Portal) bleibt. export default function TicketProjects({ ticket }) { @@ -57,7 +42,7 @@ export default function TicketProjects({ ticket }) { setImporting(true) setImportHint('') try { - await importNewRepos() + await importNewReposThrottled() } catch (err) { if (!cancelled) setImportHint(`Neue Repos konnten nicht geladen werden (${err.message}).`) } finally { @@ -73,10 +58,11 @@ export default function TicketProjects({ ticket }) { setError('') try { await action() - await load() } catch (err) { setError(err.message || 'Aktion fehlgeschlagen') } finally { + // Auch nach einem Fehler neu laden: das Repo kann trotzdem entstanden sein + try { await load() } catch { /* Anzeige bleibt wie sie ist */ } setBusy(false) } } @@ -157,6 +143,8 @@ export default function TicketProjects({ ticket }) { subdomain, customerId: ticket.customerId || '', ticketId: ticket.$id, + // Aus der Website-Vorlage mit Subdomain: die Art steht fest (sonst "Art fehlt") + kind: 'website', }) setCreateForm({ displayName: '', subdomain: '' }) setShowCreate(false) diff --git a/src/hooks/useWebsiteProjects.js b/src/hooks/useWebsiteProjects.js index 1241fc1..0992339 100644 --- a/src/hooks/useWebsiteProjects.js +++ b/src/hooks/useWebsiteProjects.js @@ -59,5 +59,18 @@ export function useWebsiteProjects() { } } - return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject } + // Projektliste "Kein Kunde": Kunde UND Ticket entfernen (bewusste Auswahl, mit Rueckfrage) + const clearCustomer = async (projectId) => { + try { + await databases.updateDocument( + DATABASE_ID, COLLECTIONS.WEBSITE_PROJECTS, projectId, + { customerId: '', ticketId: '', updatedAt: new Date().toISOString() } + ) + return { success: true } + } catch (err) { + return { success: false, error: err.message } + } + } + + return { projects, loading, error, fetchAllProjects, fetchByTicketId, assignProjects, unassignProject, clearCustomer } } diff --git a/src/lib/projectAdminApi.js b/src/lib/projectAdminApi.js index 37ef772..fc8f06f 100644 --- a/src/lib/projectAdminApi.js +++ b/src/lib/projectAdminApi.js @@ -1,4 +1,5 @@ import { account } from './appwrite' +import { throttleRuns } from './throttleRuns' const PROJECT_ADMIN_URL = import.meta.env.VITE_PROJECT_ADMIN_URL || 'https://project.webklar.com' @@ -33,6 +34,11 @@ export async function importNewRepos() { return adminFetch('/api/admin/gitea/import-repos', { method: 'POST' }) } +// Jedes Aufklappen eines Tickets fragte frueher alle Gitea-Repos ab (serverseitig unter einer +// globalen Sperre). Neue Repos kommen ohnehin per Webhook und 10-Minuten-Lauf - hier hoechstens +// ein Abgleich pro Minute; ein laufender Abgleich wird geteilt. null = uebersprungen. +export const importNewReposThrottled = throttleRuns(() => importNewRepos(), 60000) + export async function assignWebsiteProject(id, { customerId, ticketId }) { return adminFetch(`/api/admin/website-projects/${id}/assign`, { method: 'POST', diff --git a/src/lib/projectAssign.js b/src/lib/projectAssign.js index 9f0b7f9..dfc28de 100644 --- a/src/lib/projectAssign.js +++ b/src/lib/projectAssign.js @@ -6,8 +6,34 @@ export function isOutsideOrg(repoFullName) { return org.toLowerCase() !== GITEA_ORG.toLowerCase() } +// "Preview einschalten" nur, solange das Projekt noch keine Website hat. Mit Subdomain laeuft die +// Preview schon oder wird gerade gebaut (der Runner setzt 'deployed' erst danach) - Neubauen +// geht dann ueber den Preview-Block. Archivierte Projekte erst reaktivieren. export function needsPreviewButton(project) { - return !project.previewUrl || project.status !== 'deployed' + if (project.archived) return false + return !project.subdomain || !project.previewUrl +} + +// Zuordnen lassen sich echte Repos des gleichen Kunden oder ohne Kunden, die nicht archiviert, +// keine Vorlage und noch nicht an diesem Ticket sind. Massgeblich ist das Flag `archived` +// (nicht status): nach dem Reaktivieren blieb status frueher auf 'archived' stehen. +export function assignableProjects(all, ticket, assignedIds) { + return (all || []).filter((p) => + p.repoFullName && + !assignedIds.has(p.$id) && + !p.archived && + !p.isTemplate && + (!p.customerId || p.customerId === ticket.customerId) + ) +} + +// Kundenwechsel in der Projektliste: das bisherige Ticket nur behalten, wenn es dem neuen Kunden +// gehoert - sonst haengt das Projekt von Kunde B am Ticket von Kunde A. +export function ticketIdForCustomer(project, customerId, workorderById = {}) { + const ticketId = project?.ticketId || '' + if (!ticketId) return '' + if (customerId === project.customerId) return ticketId + return workorderById[ticketId]?.customerId === customerId ? ticketId : '' } export function assignSummary(projectName, result) { diff --git a/src/lib/projectList.js b/src/lib/projectList.js new file mode 100644 index 0000000..218a4db --- /dev/null +++ b/src/lib/projectList.js @@ -0,0 +1,28 @@ +// Ansichten der Projektliste (Projekte-Seite). Jedes Gitea-Repo ist ein Projekt; "Aktive Projekte" +// blendet Archiv und Vorlagen aus, "Alle inkl. Archiv" zeigt wirklich alles (Vorlagen nur Admins). + +const active = (p) => !p.archived && !p.isTemplate + +export function projectCounts(projects, { isAdmin = false } = {}) { + const list = projects || [] + const act = list.filter(active) + return { + all: act.length, + unassigned: act.filter((p) => !p.customerId).length, + assigned: act.filter((p) => Boolean(p.customerId)).length, + archived: list.filter((p) => p.archived && (isAdmin || !p.isTemplate)).length, + templates: list.filter((p) => p.isTemplate && !p.archived).length, + everything: list.filter((p) => isAdmin || !p.isTemplate).length, + } +} + +export function projectsInView(projects, filter, { isAdmin = false } = {}) { + const list = projects || [] + if (filter === 'templates') return list.filter((p) => p.isTemplate && !p.archived) + if (filter === 'archived') return list.filter((p) => p.archived && (isAdmin || !p.isTemplate)) + if (filter === 'everything') return list.filter((p) => isAdmin || !p.isTemplate) + const act = list.filter(active) + if (filter === 'unassigned') return act.filter((p) => !p.customerId) + if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId)) + return act +} diff --git a/src/lib/subdomain.js b/src/lib/subdomain.js index 0a5b2bd..8237d28 100644 --- a/src/lib/subdomain.js +++ b/src/lib/subdomain.js @@ -5,18 +5,21 @@ const UMLAUT_MAP = { ß: 'ss', } +// Subdomain/Repo-Kuerzel wie im Portal (slug.js): Umlaute ausgeschrieben statt "m-ller", +// hoechstens 63 Zeichen (Laenge eines DNS-Labels), ohne Bindestrich am Rand. +export function slugify(value) { + let v = String(value ?? '').trim().toLowerCase() + if (!v) return '' + v = v.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) + v = v.normalize('NFD').replace(/[\u0300-\u036f]/g, '') + v = v.replace(/['’‘`]/g, '') + v = v.replace(/[^a-z0-9]+/g, '-') + v = v.replace(/^-+|-+$/g, '') + v = v.slice(0, 63) + return v.replace(/-+$/g, '') +} + export function suggestSubdomain(customerName, projectName) { const base = (customerName || '').trim() || (projectName || '').trim() - if (!base) return '' - - let value = base.toLowerCase() - value = value.replace(/[äöüß]/g, (ch) => UMLAUT_MAP[ch]) - value = value.normalize('NFD').replace(/[̀-ͯ]/g, '') - value = value.replace(/['’‘`]/g, '') - value = value.replace(/[^a-z0-9]+/g, '-') - value = value.replace(/^-+|-+$/g, '') - value = value.slice(0, 63) - value = value.replace(/-+$/g, '') - - return value + return slugify(base) } diff --git a/src/lib/throttleRuns.js b/src/lib/throttleRuns.js new file mode 100644 index 0000000..70b03bd --- /dev/null +++ b/src/lib/throttleRuns.js @@ -0,0 +1,17 @@ +// Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die +// Funktion bis zum Ablauf null (uebersprungen). `now` nur fuer Tests. +export function throttleRuns(run, maxAgeMs) { + let pending = null + let doneAt = 0 + return ({ now = Date.now() } = {}) => { + if (pending) return pending + if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null) + pending = Promise.resolve() + .then(run) + .finally(() => { + doneAt = Date.now() + pending = null + }) + return pending + } +} diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx index 2128f03..9cf9f83 100644 --- a/src/pages/AssignProjectPage.jsx +++ b/src/pages/AssignProjectPage.jsx @@ -6,15 +6,13 @@ import { useAuth } from '../context/AuthContext' import { databases, DATABASE_ID, COLLECTIONS, Query } from '../lib/appwrite' import { fetchServices, completeAssignment, fetchProject } from '../lib/projectKindsApi' import { nextStep, prevStep, newTicketDefaults, canFinish, openTicketsFor } from '../lib/assignFlow' -import { suggestSubdomain } from '../lib/subdomain' +import { suggestSubdomain, slugify } from '../lib/subdomain' import { isArchived } from '../lib/leads' import { PageHeader, Card, Button, Field } from '../components/ui' import { CreateCustomerForm } from './CustomersPage' const todayIso = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) const fmtDate = (iso) => (iso ? new Date(iso).toLocaleDateString('de-DE') : '') -const slugify = (v) => - String(v || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 63) const STEP_TITLES = { kind: 'Was ist das?', @@ -95,11 +93,15 @@ export default function AssignProjectPage() { const openTickets = useMemo(() => openTicketsFor(tickets, state.customerId), [tickets, state.customerId]) const isWebsiteWithCustomer = state.kind === 'website' && state.forCustomer + // Hat das Projekt schon eine Website (z. B. ueber "Neues Projekt" mit Subdomain angelegt), bleibt + // sie wie sie ist: kein Vorschlag, kein erneutes Einschalten (das benannte sie sonst um und + // stellte eine oeffentliche Website auf Login-Preview). + const hasPreviewAlready = Boolean(project?.subdomain) useEffect(() => { - if (state.step === 'summary' && isWebsiteWithCustomer && !subdomain) { + if (state.step === 'summary' && isWebsiteWithCustomer && !hasPreviewAlready && !subdomain) { setSubdomain(suggestSubdomain('', repoName)) } - }, [state.step, isWebsiteWithCustomer, subdomain, repoName]) + }, [state.step, isWebsiteWithCustomer, hasPreviewAlready, subdomain, repoName]) const patch = (p) => setState((s) => ({ ...s, ...p })) const go = (step) => { setError(null); patch({ step }) } @@ -141,7 +143,7 @@ export default function AssignProjectPage() { const { topic, requestedBy, startDate, details } = state.newTicket body.newTicket = { topic, requestedBy, startDate, details } } - if (isWebsiteWithCustomer && previewOn && subdomain.trim()) { + if (isWebsiteWithCustomer && !hasPreviewAlready && previewOn && subdomain.trim()) { body.preview = { subdomain: slugify(subdomain) } } } @@ -353,7 +355,12 @@ export default function AssignProjectPage() { )} - {isWebsiteWithCustomer && ( + {isWebsiteWithCustomer && hasPreviewAlready && ( +

+ Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert. +

+ )} + {isWebsiteWithCustomer && !hasPreviewAlready && (
)} - diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index fe22b8c..011a757 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -25,18 +25,12 @@ import { archiveWebsiteProject, unarchiveWebsiteProject, } from '../lib/projectAdminApi' -import { assignSummary } from '../lib/projectAssign' +import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' +import { projectCounts, projectsInView } from '../lib/projectList' +import { slugify } from '../lib/subdomain' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' -function slugify(value) { - return String(value || '') - .toLowerCase() - .replace(/[^a-z0-9-]+/g, '-') - .replace(/^-+|-+$/g, '') - .slice(0, 100) -} - const WORKORDER_FILTERS = { limit: 500 } // Projekt-Typen (= "Template"/Hosting-Modus) @@ -91,7 +85,7 @@ function isProjectReady(project) { export default function ProjectsPage() { const navigate = useNavigate() - const { projects, loading, error, fetchAllProjects, assignProjects, unassignProject } = useWebsiteProjects() + const { projects, loading, error, fetchAllProjects, assignProjects, clearCustomer } = useWebsiteProjects() const { customers } = useCustomers() const { workorders } = useWorkorders(WORKORDER_FILTERS) const { employees } = useEmployees() @@ -158,29 +152,12 @@ export default function ProjectsPage() { [employees] ) - const counts = useMemo(() => { - // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) - const active = projects.filter((p) => !p.archived && !p.isTemplate) - return { - all: active.length, - unassigned: active.filter((p) => !p.customerId).length, - assigned: active.filter((p) => Boolean(p.customerId)).length, - archived: projects.filter((p) => p.archived).length, - templates: projects.filter((p) => p.isTemplate && !p.archived).length, - } - }, [projects]) + // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) + const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin]) const filteredProjects = useMemo(() => { // 1) Kategorie (Dropdown) - let base - if (filter === 'templates') base = projects.filter((p) => p.isTemplate && !p.archived) - else if (filter === 'archived') base = projects.filter((p) => p.archived) - else { - const active = projects.filter((p) => !p.archived && !p.isTemplate) - if (filter === 'unassigned') base = active.filter((p) => !p.customerId) - else if (filter === 'assigned') base = active.filter((p) => Boolean(p.customerId)) - else base = active - } + let base = projectsInView(projects, filter, { isAdmin }) // 2) Textsuche (Titel/Subdomain/Repo) const q = search.trim().toLowerCase() @@ -204,7 +181,7 @@ export default function ProjectsPage() { } return base - }, [projects, filter, search, customerFilter, employeeFilter, workorderById]) + }, [projects, filter, isAdmin, search, customerFilter, employeeFilter, workorderById]) const handleCreate = async (e) => { e.preventDefault() @@ -212,7 +189,7 @@ export default function ProjectsPage() { setCreateError('') try { const result = await createProjectFromTemplate({ - repoName: createForm.repoName || slugify(createForm.subdomain) || slugify(createForm.displayName), + repoName: slugify(createForm.repoName) || slugify(createForm.subdomain) || slugify(createForm.displayName), displayName: createForm.displayName, subdomain: slugify(createForm.subdomain), customerId: createForm.customerId || '', @@ -230,6 +207,8 @@ export default function ProjectsPage() { if (result.projectId) navigate(`/projects/zuordnen/${result.projectId}`) } catch (err) { setCreateError(err.message || 'Projekt konnte nicht angelegt werden') + // Das Repo kann trotz Fehler entstanden sein - Liste trotzdem neu laden + fetchAllProjects() } finally { setCreateLoading(false) } @@ -253,13 +232,14 @@ export default function ProjectsPage() { setAssignBusyId(project.$id) setNotice('') if (customerId) { - const r = await assignProjects([project.$id], { customerId, ticketId: project.ticketId || '' }) + const ticketId = ticketIdForCustomer(project, customerId, workorderById) + const r = await assignProjects([project.$id], { customerId, ticketId }) const one = r.results[0] const summary = assignSummary(project.projectName, one.ok ? one.result : { error: one.error }) setNotice(summary.warnings.length ? `${summary.text} – ${summary.warnings.join(' – ')}` : summary.text) - } else { - const r = await unassignProject(project.$id) - if (!r.success) setNotice('Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) + } else if (window.confirm(`Kunde von "${project.projectName}" entfernen? Der Kunde sieht das Projekt dann nicht mehr im Portal, das Ticket wird gelöst.`)) { + const r = await clearCustomer(project.$id) + setNotice(r.success ? `"${project.projectName}" hat keinen Kunden mehr.` : 'Zuordnung fehlgeschlagen: ' + (r.error || 'unbekannt')) } await fetchAllProjects() setAssignBusyId('') @@ -366,12 +346,13 @@ export default function ProjectsPage() { } const FILTER_OPTIONS = [ - { value: 'all', label: `Alle Projekte (${counts.all})` }, + { value: 'all', label: `Aktive Projekte (${counts.all})` }, { value: 'unassigned', label: `Unzugeordnet (${counts.unassigned})` }, { value: 'assigned', label: `Zugeordnet (${counts.assigned})` }, // Vorlagen-Ansicht nur fuer Admins ...(isAdmin ? [{ value: 'templates', label: `Vorlagen (${counts.templates})` }] : []), { value: 'archived', label: `Archiviert (${counts.archived})` }, + { value: 'everything', label: `Alle inkl. Archiv (${counts.everything})` }, ] // Typ-Optionen im Bearbeiten-Modal: adminOnly-Typen (Vorlage) nur fuer Admins @@ -627,13 +608,9 @@ export default function ProjectsPage() { type="text" className="form-control" value={createForm.subdomain} - onChange={(e) => - setCreateForm((p) => ({ - ...p, - subdomain: e.target.value, - repoName: p.repoName || slugify(e.target.value), - })) - } + // Repo-Name NICHT hier mitfuellen: nach dem ersten Buchstaben blieb er sonst + // stehen (Repo "m" statt "musterfirma"). Leer = beim Anlegen aus Subdomain/Name. + onChange={(e) => setCreateForm((p) => ({ ...p, subdomain: e.target.value }))} placeholder="leer lassen = Projekt ohne Website" /> @@ -747,12 +724,23 @@ export default function ProjectsPage() { ? <> Reaktivieren : <> Repository archivieren} - + {editProject.archived && ( + + Archiviert: zum Bearbeiten oder Hosten erst reaktivieren. + + )} - {editForm.projectType !== 'project' && ( + {/* Nur mit gespeicherter Subdomain - sonst meldet der Block "keine Preview-Subdomain" */} + {editForm.projectType !== 'project' && editProject.subdomain && !editProject.archived && (
diff --git a/tests/unit/importThrottle.test.mjs b/tests/unit/importThrottle.test.mjs new file mode 100644 index 0000000..70f25bf --- /dev/null +++ b/tests/unit/importThrottle.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { throttleRuns } = await loadModule('src/lib/throttleRuns.js') + +test('Repo-Import hoechstens einmal pro Minute, laufender Abgleich wird geteilt', async () => { + let runs = 0 + let release + const throttled = throttleRuns(() => { runs += 1; return new Promise((r) => { release = r }) }, 60000) + const a = throttled() + const b = throttled() + assert.equal(a, b) + await new Promise((r) => setTimeout(r, 0)) + release({ created: 0 }) + await a + assert.equal(runs, 1) + assert.equal(await throttled(), null) + assert.equal(runs, 1) + const later = throttled({ now: Date.now() + 61000 }) + await new Promise((r) => setTimeout(r, 0)) + release({ created: 1 }) + assert.deepEqual(await later, { created: 1 }) + assert.equal(runs, 2) +}) + +test('Fehler wird weitergereicht, der naechste Versuch wartet trotzdem die Minute ab', async () => { + const throttled = throttleRuns(async () => { throw new Error('weg') }, 60000) + await assert.rejects(throttled(), /weg/) + assert.equal(await throttled(), null) +}) diff --git a/tests/unit/projectAssign.test.mjs b/tests/unit/projectAssign.test.mjs index 7a3dfe7..489d23f 100644 --- a/tests/unit/projectAssign.test.mjs +++ b/tests/unit/projectAssign.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially } = await loadModule( +const { isOutsideOrg, needsPreviewButton, assignSummary, assignSequentially, assignableProjects, ticketIdForCustomer } = await loadModule( 'src/lib/projectAssign.js' ) @@ -17,9 +17,36 @@ test('isOutsideOrg', () => { }) test('needsPreviewButton', () => { - assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'deployed' }), false) + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'deployed' }), false) assert.equal(needsPreviewButton({ previewUrl: '', status: 'synced' }), true) - assert.equal(needsPreviewButton({ previewUrl: 'https://x', status: 'pending' }), true) + // gerade angelegt, der Runner baut noch (status noch synced/pending): kein "Preview einschalten" + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'synced' }), false) + assert.equal(needsPreviewButton({ subdomain: 'x', previewUrl: 'https://x', status: 'pending' }), false) + // archiviert: erst reaktivieren + assert.equal(needsPreviewButton({ archived: true, status: 'archived' }), false) +}) + +test('assignableProjects: Flag archived zaehlt, nicht status', () => { + const all = [ + { $id: 'a', repoFullName: 'WEBklar/a', status: 'archived', archived: false }, // reaktiviert, alter status + { $id: 'b', repoFullName: 'JUSN/b', status: 'synced', archived: true }, // in Gitea archiviert + { $id: 'c', repoFullName: 'WEBklar/c', customerId: 'k2' }, // anderer Kunde + { $id: 'd', repoFullName: 'WEBklar/d', customerId: 'k1' }, + { $id: 'e', repoFullName: 'WEBklar/e', isTemplate: true }, + { $id: 'f', projectName: 'ohne Repo' }, + { $id: 'g', repoFullName: 'WEBklar/g' }, + ] + const ids = assignableProjects(all, { customerId: 'k1' }, new Set(['g'])).map((p) => p.$id) + assert.deepEqual(ids, ['a', 'd']) +}) + +test('ticketIdForCustomer: altes Ticket nur beim gleichen Kunden', () => { + const p = { customerId: 'A', ticketId: 'tA' } + const wo = { tA: { customerId: 'A' }, tB: { customerId: 'B' } } + assert.equal(ticketIdForCustomer(p, 'A', wo), 'tA') + assert.equal(ticketIdForCustomer(p, 'B', wo), '') + assert.equal(ticketIdForCustomer({ customerId: 'A', ticketId: 'tB' }, 'B', wo), 'tB') + assert.equal(ticketIdForCustomer({ customerId: '', ticketId: '' }, 'B', wo), '') }) test('assignSummary', () => { diff --git a/tests/unit/projectList.test.mjs b/tests/unit/projectList.test.mjs new file mode 100644 index 0000000..275d693 --- /dev/null +++ b/tests/unit/projectList.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { loadModule } from './load.mjs' + +const { projectCounts, projectsInView } = await loadModule('src/lib/projectList.js') + +const P = [ + { $id: 'a' }, + { $id: 'b', customerId: 'k' }, + { $id: 'c', archived: true }, + { $id: 'd', isTemplate: true }, + { $id: 'e', isTemplate: true, archived: true }, +] +const ids = (list) => list.map((p) => p.$id) + +test('Aktive Projekte ohne Archiv und Vorlagen', () => { + assert.deepEqual(ids(projectsInView(P, 'all')), ['a', 'b']) + assert.deepEqual(ids(projectsInView(P, 'unassigned')), ['a']) + assert.deepEqual(ids(projectsInView(P, 'assigned')), ['b']) +}) + +test('Alle inkl. Archiv: wirklich alles (Vorlagen nur fuer Admins)', () => { + assert.deepEqual(ids(projectsInView(P, 'everything')), ['a', 'b', 'c']) + assert.deepEqual(ids(projectsInView(P, 'everything', { isAdmin: true })), ['a', 'b', 'c', 'd', 'e']) +}) + +test('Archiviert und Vorlagen', () => { + assert.deepEqual(ids(projectsInView(P, 'archived')), ['c']) + assert.deepEqual(ids(projectsInView(P, 'archived', { isAdmin: true })), ['c', 'e']) + assert.deepEqual(ids(projectsInView(P, 'templates')), ['d']) +}) + +test('Zaehler passen zu den Ansichten', () => { + assert.deepEqual(projectCounts(P), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 }) + assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5) +}) diff --git a/tests/unit/subdomain.test.mjs b/tests/unit/subdomain.test.mjs index bff1f6d..4daaab0 100644 --- a/tests/unit/subdomain.test.mjs +++ b/tests/unit/subdomain.test.mjs @@ -2,7 +2,14 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { suggestSubdomain } = await loadModule('src/lib/subdomain.js') +const { suggestSubdomain, slugify } = await loadModule('src/lib/subdomain.js') + +test('slugify schreibt Umlaute aus wie das Portal', () => { + assert.equal(slugify('Müller & Söhne'), 'mueller-soehne') + assert.equal(slugify(' ZZ Test Ablauf '), 'zz-test-ablauf') + assert.equal(slugify('x'.repeat(70)), 'x'.repeat(63)) + assert.equal(slugify(null), '') +}) test('Kundenname vor Projektname', () => { assert.equal(suggestSubdomain('Döner Nation', 'D-ner-Nation'), 'doener-nation') From c26226801574964df14c72a4e8ffb4f26d701076 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:33:40 +0000 Subject: [PATCH 2/3] Projekte: neue Gitea-Repos beim Oeffnen der Projekte-Seite abholen; Review-Kleinigkeiten - Projekte-Seite holt neue/archivierte Repos selbst ab (vorher bis zu 10 Minuten unsichtbar) - Import-Drossel: nach einem Fehler sofort neuer Versuch - Zuordnen-Assistent nennt den Status einer vorhandenen Preview - Ticket "Neues Projekt": ungueltige Subdomain wird abgewiesen Co-Authored-By: Claude Opus 5.5 --- src/components/TicketProjects.jsx | 4 ++++ src/lib/throttleRuns.js | 12 +++++++----- src/pages/AssignProjectPage.jsx | 4 +++- src/pages/ProjectsPage.jsx | 8 ++++++++ tests/unit/importThrottle.test.mjs | 7 +++++-- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/src/components/TicketProjects.jsx b/src/components/TicketProjects.jsx index 9351f5f..e1c395e 100644 --- a/src/components/TicketProjects.jsx +++ b/src/components/TicketProjects.jsx @@ -135,6 +135,10 @@ export default function TicketProjects({ ticket }) { const create = (e) => { e.preventDefault() + if (!slugify(createForm.subdomain)) { + setError('Bitte eine gültige Subdomain eingeben (Buchstaben, Ziffern, Bindestrich).') + return + } run(async () => { const subdomain = slugify(createForm.subdomain) await createProjectFromTemplate({ diff --git a/src/lib/throttleRuns.js b/src/lib/throttleRuns.js index 70b03bd..537a716 100644 --- a/src/lib/throttleRuns.js +++ b/src/lib/throttleRuns.js @@ -1,5 +1,7 @@ // Ein Aufruf hoechstens alle `maxAgeMs`: ein laufender Aufruf wird geteilt, danach liefert die -// Funktion bis zum Ablauf null (uebersprungen). `now` nur fuer Tests. +// Funktion bis zum Ablauf null (uebersprungen). Nach einem Fehler darf sofort neu versucht +// werden (sonst verschwand der Hinweis und der Abgleich fiel eine Minute lang still aus). +// `now` nur fuer Tests. export function throttleRuns(run, maxAgeMs) { let pending = null let doneAt = 0 @@ -8,10 +10,10 @@ export function throttleRuns(run, maxAgeMs) { if (doneAt && now - doneAt < maxAgeMs) return Promise.resolve(null) pending = Promise.resolve() .then(run) - .finally(() => { - doneAt = Date.now() - pending = null - }) + .then( + (result) => { doneAt = Date.now(); pending = null; return result }, + (err) => { doneAt = 0; pending = null; throw err } + ) return pending } } diff --git a/src/pages/AssignProjectPage.jsx b/src/pages/AssignProjectPage.jsx index 9cf9f83..3731c9e 100644 --- a/src/pages/AssignProjectPage.jsx +++ b/src/pages/AssignProjectPage.jsx @@ -357,7 +357,9 @@ export default function AssignProjectPage() { {isWebsiteWithCustomer && hasPreviewAlready && (

- Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert. + {['deployed', 'ready'].includes(String(project.status || '').toLowerCase()) + ? <>Preview läuft bereits unter https://{project.subdomain}.project.webklar.com und bleibt unverändert. + : <>Preview ist unter https://{project.subdomain}.project.webklar.com eingerichtet (Status: {project.status || 'unbekannt'}). Neu bauen geht im Projekt über „Preview".}

)} {isWebsiteWithCustomer && !hasPreviewAlready && ( diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 011a757..19acdd6 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -24,6 +24,7 @@ import { updateWebsiteProject, archiveWebsiteProject, unarchiveWebsiteProject, + importNewReposThrottled, } from '../lib/projectAdminApi' import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' import { projectCounts, projectsInView } from '../lib/projectList' @@ -116,8 +117,15 @@ export default function ProjectsPage() { const [editError, setEditError] = useState('') const [archiveBusy, setArchiveBusy] = useState(false) + // Erst die bekannte Liste zeigen, dann neue/archivierte Gitea-Repos abholen und neu laden - + // sonst fehlte ein frisch angelegtes Repo hier bis zum naechsten 10-Minuten-Lauf. useEffect(() => { + let cancelled = false fetchAllProjects() + importNewReposThrottled() + .then((r) => { if (r && !cancelled) fetchAllProjects() }) + .catch(() => { /* Import-Fehler zeigt die Ticketkarte; die Liste bleibt wie sie ist */ }) + return () => { cancelled = true } }, [fetchAllProjects]) const customerMap = useMemo(() => { diff --git a/tests/unit/importThrottle.test.mjs b/tests/unit/importThrottle.test.mjs index 70f25bf..6b1cff4 100644 --- a/tests/unit/importThrottle.test.mjs +++ b/tests/unit/importThrottle.test.mjs @@ -24,8 +24,11 @@ test('Repo-Import hoechstens einmal pro Minute, laufender Abgleich wird geteilt' assert.equal(runs, 2) }) -test('Fehler wird weitergereicht, der naechste Versuch wartet trotzdem die Minute ab', async () => { - const throttled = throttleRuns(async () => { throw new Error('weg') }, 60000) +test('Fehler wird weitergereicht, danach darf sofort neu versucht werden', async () => { + let runs = 0 + const throttled = throttleRuns(async () => { runs += 1; if (runs === 1) throw new Error('weg'); return { ok: true } }, 60000) await assert.rejects(throttled(), /weg/) + assert.deepEqual(await throttled(), { ok: true }) + assert.equal(runs, 2) assert.equal(await throttled(), null) }) From 603d08c9c2bedd86a4c58a4d3aadf142016a10a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:01:38 +0000 Subject: [PATCH 3/3] Projekte: zur Vorlage gemachtes Projekt verschwindet nicht mehr Vorlagen stehen absichtlich nur in der Ansicht "Vorlagen" (Admins). Nach dem Speichern als Vorlage fiel das Projekt deshalb kommentarlos aus "Aktive Projekte" (SHK-vorlage, 04.10.2026). - nach dem Bearbeiten in die Ansicht wechseln, in der das Projekt jetzt steht, mit Hinweis ("steht jetzt unter Vorlagen") - Suche zeigt Treffer in ausgeblendeten Ansichten (Vorlagen, Archiv) mit Klick zum Wechseln - Hilfen viewShowing / matchesSearch / hiddenMatches in lib/projectList.js Co-Authored-By: Claude Opus 5.5 --- src/lib/projectList.js | 44 +++++++++++++++++ src/pages/ProjectsPage.jsx | 86 ++++++++++++++++++++------------- tests/unit/projectList.test.mjs | 44 ++++++++++++++++- 3 files changed, 140 insertions(+), 34 deletions(-) diff --git a/src/lib/projectList.js b/src/lib/projectList.js index 218a4db..53fac4f 100644 --- a/src/lib/projectList.js +++ b/src/lib/projectList.js @@ -26,3 +26,47 @@ export function projectsInView(projects, filter, { isAdmin = false } = {}) { if (filter === 'assigned') return act.filter((p) => Boolean(p.customerId)) return act } + +export const VIEW_LABELS = { all: 'Aktive Projekte', templates: 'Vorlagen', archived: 'Archiviert', everything: 'Alle inkl. Archiv' } + +// Ansichten, die ein Projekt zeigen koennen - die Vorlagen-Ansicht gibt es nur fuer Admins +const viewsFor = (isAdmin) => ['all', ...(isAdmin ? ['templates'] : []), 'archived', 'everything'] + +/** + * Ansicht, in der ein Projekt (z. B. nach dem Bearbeiten) zu sehen ist: die aktuelle, wenn es dort + * steht, sonst die erste passende. Ohne passende Ansicht bleibt es bei der aktuellen. + * Hintergrund: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte". + */ +export function viewShowing(project, filter, { isAdmin = false } = {}) { + if (!project) return filter + for (const view of [filter, ...viewsFor(isAdmin)]) { + if (view === 'templates' && !isAdmin) continue + if (projectsInView([project], view, { isAdmin }).length) return view + } + return filter +} + +/** Textsuche ueber Titel, Subdomain und Repo (leer passt immer). */ +export function matchesSearch(project, search) { + const q = String(search || '').trim().toLowerCase() + if (!q) return true + return [project.projectName, project.subdomain, project.repoFullName, project.giteaRepoName].some((v) => + String(v || '').toLowerCase().includes(q) + ) +} + +/** + * Treffer, die die aktuelle Ansicht ausblendet (Vorlagen nur fuer Admins, Archiv), je Ansicht gezaehlt. + * `matches` ist der Filter der Seite (Suche, Kunde, Mitarbeiter). + */ +export function hiddenMatches(projects, filter, matches, { isAdmin = false } = {}) { + if (filter === 'everything') return [] + const shown = new Set(projectsInView(projects, filter, { isAdmin }).map((p) => p.$id)) + const out = [] + for (const view of [...(isAdmin ? ['templates'] : []), 'archived']) { + if (view === filter) continue + const count = projectsInView(projects, view, { isAdmin }).filter((p) => !shown.has(p.$id) && matches(p)).length + if (count) out.push({ view, count }) + } + return out +} diff --git a/src/pages/ProjectsPage.jsx b/src/pages/ProjectsPage.jsx index 19acdd6..053f787 100644 --- a/src/pages/ProjectsPage.jsx +++ b/src/pages/ProjectsPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useCallback } from 'react' import { useNavigate } from 'react-router-dom' import PreviewBlock from '../components/PreviewBlock' import { @@ -27,7 +27,7 @@ import { importNewReposThrottled, } from '../lib/projectAdminApi' import { assignSummary, ticketIdForCustomer } from '../lib/projectAssign' -import { projectCounts, projectsInView } from '../lib/projectList' +import { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches, VIEW_LABELS } from '../lib/projectList' import { slugify } from '../lib/subdomain' import PreviewLinkButton from '../components/PreviewLinkButton' import GiteaLinkButton from '../components/GiteaLinkButton' @@ -163,33 +163,28 @@ export default function ProjectsPage() { // Vorlagen sind aus den normalen Ansichten ausgeblendet (eigene Ansicht, nur Admin) const counts = useMemo(() => projectCounts(projects, { isAdmin }), [projects, isAdmin]) - const filteredProjects = useMemo(() => { - // 1) Kategorie (Dropdown) - let base = projectsInView(projects, filter, { isAdmin }) - - // 2) Textsuche (Titel/Subdomain/Repo) - const q = search.trim().toLowerCase() - if (q) { - base = base.filter((p) => - [p.projectName, p.subdomain, p.repoFullName, p.giteaRepoName].some((v) => - String(v || '').toLowerCase().includes(q) - ) - ) - } - - // 3) Kunde - if (customerFilter) base = base.filter((p) => p.customerId === customerFilter) - - // 4) Mitarbeiter (ueber das zugeordnete Ticket -> assignedTo) + // Filter neben der Kategorie: Textsuche (Titel/Subdomain/Repo), Kunde, Mitarbeiter (ueber das + // zugeordnete Ticket -> assignedTo) + const pageMatches = useCallback((p) => { + if (!matchesSearch(p, search)) return false + if (customerFilter && p.customerId !== customerFilter) return false if (employeeFilter) { - base = base.filter((p) => { - const wo = p.ticketId ? workorderById[p.ticketId] : null - return wo && wo.assignedTo === employeeFilter - }) + const wo = p.ticketId ? workorderById[p.ticketId] : null + if (!wo || wo.assignedTo !== employeeFilter) return false } + return true + }, [search, customerFilter, employeeFilter, workorderById]) - return base - }, [projects, filter, isAdmin, search, customerFilter, employeeFilter, workorderById]) + const filteredProjects = useMemo( + () => projectsInView(projects, filter, { isAdmin }).filter(pageMatches), + [projects, filter, isAdmin, pageMatches] + ) + + // Suchtreffer in ausgeblendeten Ansichten (Vorlagen, Archiv) - sonst wirkt ein Projekt verschwunden + const hiddenHits = useMemo( + () => (search.trim() ? hiddenMatches(projects, filter, pageMatches, { isAdmin }) : []), + [projects, filter, isAdmin, search, pageMatches] + ) const handleCreate = async (e) => { e.preventDefault() @@ -314,14 +309,21 @@ export default function ProjectsPage() { if (editForm.projectType !== 'project') { payload.subdomain = slugify(editForm.subdomain) } - const result = await updateWebsiteProject(editProject.$id, payload) + const projectId = editProject.$id + const result = await updateWebsiteProject(projectId, payload) setEditProject(null) - setNotice( - result.previewUrl - ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` - : 'Projekt aktualisiert.' - ) - await fetchAllProjects() + let text = result.previewUrl + ? `Projekt aktualisiert - ${result.isPublic ? 'oeffentlich' : 'Preview'}: ${result.previewUrl}` + : 'Projekt aktualisiert.' + // Faellt das Projekt aus der aktuellen Ansicht (z. B. zur Vorlage gemacht), dorthin wechseln, + // wo es jetzt steht - sonst wirkt es verschwunden + const fresh = (await fetchAllProjects()).find((p) => p.$id === projectId) + const view = viewShowing(fresh, filter, { isAdmin }) + if (fresh && view !== filter) { + setFilter(view) + text += ` – „${fresh.projectName}“ steht jetzt unter „${VIEW_LABELS[view]}“.` + } + setNotice(text) } catch (err) { setEditError(err.message || 'Projekt konnte nicht aktualisiert werden') } finally { @@ -448,6 +450,24 @@ export default function ProjectsPage() {
)} + {hiddenHits.length > 0 && ( +
+ Weitere Treffer: + {hiddenHits.map((h, i) => ( + + {i > 0 && · } + + + ))} +
+ )} + {error && (
{error} diff --git a/tests/unit/projectList.test.mjs b/tests/unit/projectList.test.mjs index 275d693..ef6a5ed 100644 --- a/tests/unit/projectList.test.mjs +++ b/tests/unit/projectList.test.mjs @@ -2,7 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { loadModule } from './load.mjs' -const { projectCounts, projectsInView } = await loadModule('src/lib/projectList.js') +const { projectCounts, projectsInView, viewShowing, matchesSearch, hiddenMatches } = await loadModule('src/lib/projectList.js') const P = [ { $id: 'a' }, @@ -34,3 +34,45 @@ test('Zaehler passen zu den Ansichten', () => { assert.deepEqual(projectCounts(P), { all: 2, unassigned: 1, assigned: 1, archived: 1, templates: 1, everything: 3 }) assert.deepEqual(projectCounts(P, { isAdmin: true }).everything, 5) }) + +// 04.10.2026: SHK-vorlage zur Vorlage gemacht -> verschwand kommentarlos aus "Aktive Projekte" +test('viewShowing: bleibt in der aktuellen Ansicht, wenn das Projekt dort steht', () => { + assert.equal(viewShowing(P[0], 'all', { isAdmin: true }), 'all') + assert.equal(viewShowing(P[1], 'assigned'), 'assigned') + assert.equal(viewShowing(P[3], 'everything', { isAdmin: true }), 'everything') +}) + +test('viewShowing: zur Vorlage gemacht -> Vorlagen, zurueck -> Aktive Projekte', () => { + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all', { isAdmin: true }), 'templates') + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'unassigned', { isAdmin: true }), 'templates') + assert.equal(viewShowing({ $id: 'x' }, 'templates', { isAdmin: true }), 'all') + assert.equal(viewShowing({ $id: 'x', archived: true }, 'all'), 'archived') +}) + +test('viewShowing: ohne Admin keine Vorlagen-Ansicht, dann bleibt die Ansicht', () => { + assert.equal(viewShowing({ $id: 'x', isTemplate: true }, 'all'), 'all') + assert.equal(viewShowing(null, 'assigned'), 'assigned') +}) + +test('matchesSearch: Titel, Subdomain, Repo; leer passt immer', () => { + const p = { projectName: 'SHK-vorlage', subdomain: 'shk-vorlage', repoFullName: 'knso/SHK-vorlage', giteaRepoName: 'SHK-vorlage' } + assert.equal(matchesSearch(p, ''), true) + assert.equal(matchesSearch(p, ' shk '), true) + assert.equal(matchesSearch(p, 'knso/'), true) + assert.equal(matchesSearch(p, 'olzinger'), false) +}) + +test('hiddenMatches: Treffer, die die aktuelle Ansicht ausblendet', () => { + const list = [ + { $id: 'a', projectName: 'SHK Kunde' }, + { $id: 't', projectName: 'SHK-vorlage', isTemplate: true }, + { $id: 'r', projectName: 'SHK alt', archived: true }, + { $id: 'o', projectName: 'Olzinger' }, + ] + const shk = (p) => matchesSearch(p, 'shk') + assert.deepEqual(hiddenMatches(list, 'all', shk, { isAdmin: true }), [{ view: 'templates', count: 1 }, { view: 'archived', count: 1 }]) + assert.deepEqual(hiddenMatches(list, 'all', shk), [{ view: 'archived', count: 1 }], 'Vorlagen nur fuer Admins') + assert.deepEqual(hiddenMatches(list, 'templates', shk, { isAdmin: true }), [{ view: 'archived', count: 1 }]) + assert.deepEqual(hiddenMatches(list, 'everything', shk, { isAdmin: true }), [], 'zeigt schon alles') + assert.deepEqual(hiddenMatches(list, 'all', (p) => matchesSearch(p, 'olz'), { isAdmin: true }), []) +})