From e43cdb667b02b61e612861e567866ed63aabcfc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:22:53 +0000 Subject: [PATCH 01/21] Projekt-Ablauf im Ticketsystem: Anlegen, Archivieren, Import repariert - Neues Projekt ohne Subdomain: subdomain null statt '' (Unique-Index subdomain_unique, jedes weitere scheiterte mit "Document with the requested ID already exists"); ebenso Typ "Internes Projekt" - Anlegen unter derselben Repo-Sperre wie der Webhook repository/created; upsertWebsiteProjectByRepo macht aus einem 409 ein Update - Gitea-Beschreibung = Anzeigename; Art (kind) beim Anlegen aus dem Ticket - Reaktivieren setzt status zurueck (vorher blieb 'archived' stehen) - Import: archivierte Repos als archiviert, Archiv-Stand aus Gitea wird gespiegelt, ein fehlerhaftes Repo bricht den Lauf nicht mehr ab - Abgleich ueberschreibt Projektnamen nicht mehr - CORS erlaubt PUT (Preview-Variablen speichern) - Vorlage zieht beim Zuweisen nie in die Org um - Erinnerungs-Lauf ueberspringt archivierte Projekte - kaputte Umlaute (U+FFFD) in drei Fehlermeldungen Co-Authored-By: Claude Opus 5.5 --- server/middleware/appwriteStaff.js | 2 +- server/middleware/cors.js | 2 +- server/routes/admin/websiteProjects.js | 4 +- server/services/appwriteAdmin.js | 39 +++-- server/services/customerAdmin.js | 2 +- server/services/giteaAdmin.js | 205 ++++++++++++++++------ server/services/giteaClient.js | 2 +- server/services/repoAssignJob.js | 2 +- server/services/repoMove.js | 2 + test/giteaAdmin.test.js | 6 +- test/helpers/fakeAppwrite.js | 50 +++++- test/helpers/fakeGitea.js | 32 ++++ test/projektAblauf.test.js | 233 +++++++++++++++++++++++++ 13 files changed, 498 insertions(+), 83 deletions(-) create mode 100644 test/projektAblauf.test.js diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index ba7df49..d9ca95e 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -29,7 +29,7 @@ export async function requireAppwriteStaff(req, res, next) { } if (!response.ok) { - return res.status(401).json({ error: data?.message || 'Ung�ltiges JWT' }) + return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { diff --git a/server/middleware/cors.js b/server/middleware/cors.js index b5562c2..deab6d5 100644 --- a/server/middleware/cors.js +++ b/server/middleware/cors.js @@ -7,7 +7,7 @@ export function corsMiddleware(allowedOrigins) { if (origin && allowedOrigins.includes(origin)) { res.setHeader('Access-Control-Allow-Origin', origin) res.setHeader('Access-Control-Allow-Headers', ALLOW_HEADERS) - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS') if (req.method === 'OPTIONS') { return res.sendStatus(204) } diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index a22bb46..f55aa04 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -13,7 +13,7 @@ const router = Router() router.use(requireAppwriteStaff) router.post('/create-from-template', async (req, res) => { - const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner } = req.body || {} + const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} if (!repoName && !displayName) { return res.status(400).json({ error: 'repoName oder displayName ist erforderlich' }) @@ -27,6 +27,8 @@ router.post('/create-from-template', async (req, res) => { customerId, ticketId, owner, + kind, + staffName: req.appwriteUser?.name || req.appwriteUser?.email || '', }) return res.status(201).json({ ok: true, ...result }) } catch (err) { diff --git a/server/services/appwriteAdmin.js b/server/services/appwriteAdmin.js index e7c14fb..9b585f3 100644 --- a/server/services/appwriteAdmin.js +++ b/server/services/appwriteAdmin.js @@ -300,19 +300,32 @@ export async function upsertWebsiteProjectByRepo(repoFullName, data) { } const now = new Date().toISOString() - return adminFetch(collectionPath(config.collections.websiteProjects), { - method: 'POST', - body: { - ...payload, - customerId: payload.customerId ?? '', - ticketId: payload.ticketId ?? '', - status: payload.status ?? 'pending', - provisioningStatus: payload.provisioningStatus ?? 'pending', - projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, - createdAt: now, - documentId: ID.unique(), - }, - }) + try { + return await adminFetch(collectionPath(config.collections.websiteProjects), { + method: 'POST', + body: { + ...payload, + customerId: payload.customerId ?? '', + ticketId: payload.ticketId ?? '', + status: payload.status ?? 'pending', + provisioningStatus: payload.provisioningStatus ?? 'pending', + projectName: payload.projectName || repoFullName.split('/').pop() || repoFullName, + createdAt: now, + documentId: ID.unique(), + }, + }) + } catch (err) { + // Ein anderer Schreiber ohne gemeinsame Sperre hat das Dokument zwischen Suche und Anlage + // angelegt: der Unique-Index auf repoFullName meldet 409. Dann das vorhandene Dokument + // aktualisieren, statt den Fehler bis in die Oberflaeche zu reichen. + if (err.status !== 409) throw err + const raced = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!raced[0]) throw err + return updateDocument(config.collections.websiteProjects, raced[0].$id, buildProjectPayload(raced[0], data)) + } } export async function verifyDatabaseAccess() { diff --git a/server/services/customerAdmin.js b/server/services/customerAdmin.js index e91a689..dd67ec8 100644 --- a/server/services/customerAdmin.js +++ b/server/services/customerAdmin.js @@ -198,7 +198,7 @@ export async function updateCustomerWithPortalAccess(customerId, payload) { portalPassword = normalizedPassword } } else if (normalizedPassword) { - const error = new Error('Portal-Login fehlt � bitte E-Mail setzen und Passwort erneut speichern') + const error = new Error('Portal-Login fehlt – bitte E-Mail setzen und Passwort erneut speichern') error.status = 400 throw error } diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 878333b..073ce05 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -18,6 +18,7 @@ import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' import { repoLockKey } from './repoMove.js' +import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte // Gitea "page" ignorieren und immer dieselbe volle Seite liefern, blaettert die Schleife nicht @@ -49,12 +50,16 @@ export async function listAllAccessibleRepos() { /** * Findet Repos, die in Gitea liegen, aber noch kein Appwrite-Projektdokument haben, und legt fuer - * jedes nicht archivierte ein minimales Dokument an (status "synced", ohne Hosting/Subdomain) - - * ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Wird vom Projekt-Dialog bei jedem + * jedes ein minimales Dokument an (status "synced", ohne Hosting/Subdomain; in Gitea archivierte + * als archiviertes Projekt) - ohne dass dafuer manuell "Abgleich" gedrueckt werden muesste. Bei + * vorhandenen Dokumenten wird nur der Archiv-Stand aus Gitea gespiegelt (Repo dort direkt + * archiviert oder reaktiviert). Wird vom Projekt-Dialog bei jedem * Oeffnen aufgerufen, laeuft also oft: die aeussere `withLocks(['import'])`-Sperre serialisiert * parallele Laeufe komplett (ein zweiter Aufruf sieht danach garantiert den Stand des ersten), die * innere `withLocks([repoLockKey(...)])` je neuem Repo schuetzt zusaetzlich gegen andere * gleichzeitige Schreiber desselben Projektdokuments (z. B. Umzug/Zuweisung). + * Ein Fehler bei einem Repo bricht den Lauf nicht ab (sonst fehlten alle Repos danach). + * `createdRepos` enthaelt nur neue, nicht archivierte Repos (nur sie bekommen Zuordnungs-Mails). */ export async function importNewRepos() { return withLocks(['import'], async () => { @@ -64,39 +69,71 @@ export async function importNewRepos() { ]) // Ohne Ruecksicht auf Gross-/Kleinschreibung vergleichen (Controller-Ruling). - const existingNames = new Set( - docs.map((d) => String(d.repoFullName || '').toLowerCase()).filter(Boolean) - ) - - let created = 0 - const createdRepos = [] - for (const repo of repos) { - const fullName = repo.full_name - if (!fullName || repo.archived) continue - if (existingNames.has(fullName.toLowerCase())) continue - - await withLocks([repoLockKey(fullName)], async () => { - const recheck = await listDocuments(config.collections.websiteProjects, [ - Query.equal('repoFullName', fullName), - Query.limit(1), - ]) - if (recheck[0]) return - - const doc = await upsertWebsiteProjectByRepo(fullName, { - projectName: repo.description || repo.name || fullName.split('/').pop(), - giteaRepoUrl: repo.html_url, - giteaRepoName: repo.name, - repoFullName: fullName, - archived: false, - status: 'synced', - giteaSyncedAt: new Date().toISOString(), - }) - created += 1 - createdRepos.push({ projectId: doc.$id, repo }) - }) + const docsByName = new Map() + for (const d of docs) { + const key = String(d.repoFullName || '').toLowerCase() + if (key && !docsByName.has(key)) docsByName.set(key, d) } - return { created, createdRepos } + let created = 0 + let archivedImported = 0 + let archiveSynced = 0 + const createdRepos = [] + const failed = [] + for (const repo of repos) { + const fullName = repo.full_name + if (!fullName) continue + const repoArchived = Boolean(repo.archived) + + try { + const existing = docsByName.get(fullName.toLowerCase()) + if (existing) { + if (Boolean(existing.archived) === repoArchived) continue + await withLocks([repoLockKey(fullName)], async () => { + const fresh = await getDocument(config.collections.websiteProjects, existing.$id) + if (!fresh || Boolean(fresh.archived) === repoArchived) return + await updateDocument(config.collections.websiteProjects, fresh.$id, { + archived: repoArchived, + ...(repoArchived + ? { status: 'archived' } + : fresh.status === 'archived' ? { status: 'synced' } : {}), + updatedAt: new Date().toISOString(), + }) + archiveSynced += 1 + }) + continue + } + + await withLocks([repoLockKey(fullName)], async () => { + const recheck = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(1), + ]) + if (recheck[0]) return + + const doc = await upsertWebsiteProjectByRepo(fullName, { + projectName: repo.description || repo.name || fullName.split('/').pop(), + giteaRepoUrl: repo.html_url, + giteaRepoName: repo.name, + repoFullName: fullName, + archived: repoArchived, + status: repoArchived ? 'archived' : 'synced', + giteaSyncedAt: new Date().toISOString(), + }) + if (repoArchived) { + archivedImported += 1 + return + } + created += 1 + createdRepos.push({ projectId: doc.$id, repo }) + }) + } catch (err) { + failed.push(fullName) + console.error(`[importNewRepos] ${fullName} übersprungen:`, err.message) + } + } + + return { created, createdRepos, archivedImported, archiveSynced, failed } }) } @@ -104,6 +141,7 @@ export async function syncReposToAppwrite() { const repos = await listAllAccessibleRepos() let created = 0 let updated = 0 + const failed = [] for (const repo of repos) { const repoFullName = repo.full_name @@ -115,25 +153,38 @@ export async function syncReposToAppwrite() { ]) const payload = { - projectName: repo.description || repo.name || repoFullName.split('/').pop(), giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || repoFullName.split('/').pop(), repoFullName, // In Gitea archivierte Repos werden im Ticketsystem ausgeblendet. archived: Boolean(repo.archived), } + const repoTitle = repo.description || repo.name || repoFullName.split('/').pop() if (!existing[0]) { - payload.status = 'synced' + payload.projectName = repoTitle + payload.status = repo.archived ? 'archived' : 'synced' created += 1 } else { + // Der Name im Ticketsystem bleibt: frueher ueberschrieb der Abgleich ihn mit der + // Gitea-Beschreibung (aus "Döner Nation" wurde so "D-ner-Nation"). + if (!existing[0].projectName) payload.projectName = repoTitle + // Archiv-Stand aus Gitea uebernehmen, ohne dass status auf 'archived' haengen bleibt + if (repo.archived && existing[0].status !== 'archived') payload.status = 'archived' + if (!repo.archived && existing[0].status === 'archived') payload.status = 'synced' updated += 1 } - await upsertWebsiteProjectByRepo(repoFullName, payload) + try { + await upsertWebsiteProjectByRepo(repoFullName, payload) + } catch (err) { + // Ein fehlerhaftes Repo darf den Abgleich aller folgenden nicht abbrechen + failed.push(repoFullName) + console.error(`[syncReposToAppwrite] ${repoFullName} übersprungen:`, err.message) + } } - return { synced: repos.length, created, updated } + return { synced: repos.length, created, updated, failed } } export async function ensureSubdomainAvailable(subdomain) { @@ -148,7 +199,7 @@ export async function ensureSubdomainAvailable(subdomain) { } } -export async function generateRepoFromTemplate({ owner, repoName }) { +export async function generateRepoFromTemplate({ owner, repoName, description }) { const templateOwner = config.gitea.templateOwner const templateRepo = config.gitea.templateRepo const targetOwner = owner || config.gitea.defaultOwner @@ -163,7 +214,8 @@ export async function generateRepoFromTemplate({ owner, repoName }) { owner: targetOwner, name: repoName, private: false, - description: `Website-Projekt ${repoName}`, + // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) + description: description || `Website-Projekt ${repoName}`, git_content: true, }), }) @@ -278,6 +330,8 @@ export async function createProjectFromTemplate({ customerId = '', ticketId = '', owner, + kind = '', + staffName = '', }) { const targetOwner = owner || config.gitea.defaultOwner const safeRepoName = slugify(repoName) || slugify(displayName) || slugify(subdomain) @@ -289,6 +343,11 @@ export async function createProjectFromTemplate({ if (!safeRepoName) { throw new Error('repoName oder displayName ist erforderlich') } + if (kind && !isKind(kind)) { + const error = new Error('Unbekannte Projektart.') + error.status = 400 + throw error + } if (hasWebsite) { await ensureSubdomainAvailable(safeSubdomain) @@ -297,6 +356,7 @@ export async function createProjectFromTemplate({ const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, + description: displayName || safeRepoName, }) const repoFullName = repo.full_name || `${targetOwner}/${safeRepoName}` @@ -324,7 +384,11 @@ export async function createProjectFromTemplate({ } const previewUrl = hasWebsite ? buildPreviewUrl(safeSubdomain) : '' - const project = await upsertWebsiteProjectByRepo(repoFullName, { + // Gitea meldet das neue Repo sofort per Webhook (repository/created, dann der Push der Config). + // Der Webhook legt das Dokument unter dieser Repo-Sperre an; ohne dieselbe Sperre legen beide + // gleichzeitig an und der Unique-Index auf repoFullName lehnt dieses hier ab ("Document with + // the requested ID already exists") - das Repo gab es dann schon, Kunde und Ticket fehlten. + const project = await withLocks([repoLockKey(repoFullName)], () => upsertWebsiteProjectByRepo(repoFullName, { customerId, ticketId, projectName: previewConfig.displayName, @@ -332,9 +396,23 @@ export async function createProjectFromTemplate({ giteaRepoUrl: repo.html_url || `${config.gitea.baseUrl}/${repoFullName}`, giteaRepoName: repo.name || safeRepoName, repoFullName, - subdomain: safeSubdomain, + // Ohne Website null statt '': auf subdomain liegt ein Unique-Index, und ein zweites + // Projekt mit '' scheiterte mit "Document with the requested ID already exists". + subdomain: safeSubdomain || null, previewUrl, - projectType: hasWebsite ? 'website' : 'repo', + // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem + projectType: hasWebsite ? 'preview' : 'repo', + // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf + // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. + ...(kind + ? { + kind, + forCustomer: Boolean(customerId), + kindSetBy: staffName || '', + kindSetAt: new Date().toISOString(), + previewMode: kind === 'website' || kind === 'webapp' ? 'auto' : 'off', + } + : {}), ...(hasWebsite && deployKeepsStatus(deployResult) ? {} // Preview-Runner (C1): Status setzt der Verfolger : { @@ -343,14 +421,14 @@ export async function createProjectFromTemplate({ ? (deployResult.deployed ? 'ready' : 'deploy_failed') : 'ready', }), - }) + }), { waitMs: 10000 }) return { projectId: project.$id, repoFullName, previewUrl, subdomain: safeSubdomain, - projectType: hasWebsite ? 'website' : 'repo', + projectType: hasWebsite ? 'preview' : 'repo', deploy: deployResult, } } @@ -494,7 +572,7 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain, const previewUrl = hasWebsite && nextSubdomain ? buildPreviewUrl(nextSubdomain) : '' const updated = await updateDocument(config.collections.websiteProjects, projectId, { projectName: nextName, - subdomain: nextSubdomain, + subdomain: nextSubdomain || null, // null statt '' (Unique-Index, siehe createProjectFromTemplate) previewUrl, projectType: nextType, isPublic, @@ -573,7 +651,26 @@ export async function archiveRepo(projectId, archived = true) { } } - if (!archived && !repoMissing && doc.subdomain) { + if (archived) { + const updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: true, + status: 'archived', + updatedAt: new Date().toISOString(), + }) + return { project: updated, archived: true } + } + + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + const redeploy = !repoMissing && Boolean(doc.subdomain) + let updated = await updateDocument(config.collections.websiteProjects, projectId, { + archived: false, + status: redeploy ? 'pending' : 'synced', + updatedAt: new Date().toISOString(), + }) + + if (redeploy) { try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -583,19 +680,19 @@ export async function archiveRepo(projectId, archived = true) { subdomain: doc.subdomain, displayName: doc.projectName, } - await runDeploy(repoFullName, branch, cfg, { project: doc }) + const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) + if (deployResult?.deployed) { + updated = await updateDocument(config.collections.websiteProjects, projectId, { + status: 'deployed', + provisioningStatus: 'ready', + }) + } } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) } } - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: Boolean(archived), - ...(archived ? { status: 'archived' } : {}), - updatedAt: new Date().toISOString(), - }) - - return { project: updated, archived: Boolean(archived) } + return { project: updated, archived: false } } /** Repo-Basisdaten (Groesse in KB, Default-Branch). */ diff --git a/server/services/giteaClient.js b/server/services/giteaClient.js index 003ac5f..32ce603 100644 --- a/server/services/giteaClient.js +++ b/server/services/giteaClient.js @@ -52,7 +52,7 @@ export async function giteaFetch(path, options = {}) { if (!response.ok) { let message = data?.message || `Gitea ${response.status}` if (response.status === 409 && /same name already exists/i.test(message)) { - message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen w�hlen.' + message = 'Ein Gitea-Repository mit diesem Namen existiert bereits. Bitte einen anderen Subdomain-/Repo-Namen wählen.' } const error = new Error(message) error.status = response.status diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index d1fadc0..95e5c6b 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -149,7 +149,7 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { - if (!p.createdByGitea || p.kind || handled.has(p.$id)) continue + if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/server/services/repoMove.js b/server/services/repoMove.js index 17d1ce0..ad963de 100644 --- a/server/services/repoMove.js +++ b/server/services/repoMove.js @@ -59,6 +59,8 @@ function sameRepoFullName(a, b) { export async function isInternalRepo(repoFullName) { const lower = repoFullName.toLowerCase() if (config.gitea.keepInPlace.some((entry) => entry.toLowerCase() === lower)) return true + // Die Vorlage fuer "Neues Projekt" darf nie umziehen, sonst findet generate sie nicht mehr + if (`${config.gitea.templateOwner}/${config.gitea.templateRepo}`.toLowerCase() === lower) return true const hooks = await giteaFetch(`/repos/${repoFullName}/hooks`) const portalUrl = portalWebhookUrl() diff --git a/test/giteaAdmin.test.js b/test/giteaAdmin.test.js index f1c3eca..01e86d9 100644 --- a/test/giteaAdmin.test.js +++ b/test/giteaAdmin.test.js @@ -65,7 +65,11 @@ test('importNewRepos legt nur fehlende an', async () => { const neu = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/neu') assert.deepEqual([neu.projectName, neu.status, neu.subdomain || '', neu.previewUrl || ''], ['Döner Nation', 'synced', '', '']) assert.ok(aw.all('websiteProjects').some((d) => d.repoFullName === 'knso/privat')) - assert.ok(!aw.all('websiteProjects').some((d) => d.repoFullName === 'JUSN/archiv')) + // In Gitea archivierte Repos kommen als archiviertes Projekt an (sichtbar unter "Archiviert"), + // aber nicht in createdRepos (keine Zuordnungs-Mail) + const archiv = aw.all('websiteProjects').find((d) => d.repoFullName === 'JUSN/archiv') + assert.deepEqual([archiv.archived, archiv.status], [true, 'archived']) + assert.equal(r.archivedImported, 1) assert.equal(aw.calls.filter((c) => c.method === 'PATCH').length, 0) }) test('importNewRepos läuft nie doppelt', async () => { diff --git a/test/helpers/fakeAppwrite.js b/test/helpers/fakeAppwrite.js index 6051145..61a8ecd 100644 --- a/test/helpers/fakeAppwrite.js +++ b/test/helpers/fakeAppwrite.js @@ -28,6 +28,10 @@ import { randomUUID } from 'node:crypto' export async function startFakeAppwrite(options = {}) { const calls = [] const pendingFailures = [] + // Optionale Unique-Indizes wie in Appwrite/MariaDB: { websiteProjects: ['repoFullName', 'subdomain'] }. + // Strings vergleichen ohne Gross-/Kleinschreibung, null/undefined kollidieren nie, '' schon. + const uniqueAttrs = options.unique || {} + let beforeCreate = null /** @type {Map>} Sammlungsname -> (Dokument-ID -> Dokument). */ const store = new Map() @@ -114,11 +118,24 @@ export async function startFakeAppwrite(options = {}) { send(res, 200, { total, documents: result }) } - function handleCreateDocument(match, searchParams, body, headers, res) { + function uniqueConflict(col, data, ownId) { + for (const attr of uniqueAttrs[col] || []) { + const value = data?.[attr] + if (value === null || value === undefined) continue + const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v) + for (const doc of collectionOf(col).values()) { + if (doc.$id !== ownId && doc[attr] !== null && doc[attr] !== undefined && norm(doc[attr]) === norm(value)) return true + } + } + return false + } + + async function handleCreateDocument(match, searchParams, body, headers, res) { + if (beforeCreate) await beforeCreate(match[2], body?.data || {}) const map = collectionOf(match[2]) const documentId = body?.documentId || randomUUID() - if (map.has(documentId)) { - return send(res, 409, { message: 'Document with the requested ID already exists.' }) + if (map.has(documentId) || uniqueConflict(match[2], body?.data, documentId)) { + return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) } const now = new Date().toISOString() const doc = { $id: documentId, $createdAt: now, $updatedAt: now, ...(body?.data || {}) } @@ -135,6 +152,9 @@ export async function startFakeAppwrite(options = {}) { function handlePatchDocument(match, searchParams, body, headers, res) { const doc = collectionOf(match[2]).get(match[3]) if (!doc) return send(res, 404, { message: 'Document not found' }) + if (uniqueConflict(match[2], body?.data, doc.$id)) { + return send(res, 409, { message: 'Document with the requested ID already exists.', type: 'document_already_exists' }) + } Object.assign(doc, body?.data || {}) doc.$updatedAt = new Date().toISOString() send(res, 200, doc) @@ -212,11 +232,9 @@ export async function startFakeAppwrite(options = {}) { const route = ROUTES.find((r) => r.method === req.method && r.re.test(url.pathname)) if (!route) return send(res, 404, { message: 'Not Found' }) const match = route.re.exec(url.pathname) - try { - route.handler(match, url.searchParams, parsedBody, req.headers, res) - } catch (err) { - send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' }) - } + Promise.resolve() + .then(() => route.handler(match, url.searchParams, parsedBody, req.headers, res)) + .catch((err) => send(res, 500, { message: err?.message || 'fakeAppwrite: interner Fehler' })) }) }) @@ -245,10 +263,24 @@ export async function startFakeAppwrite(options = {}) { pendingFailures.push({ method, pathIncludes, status }) } + /** fn(collection, data) laeuft vor jeder Anlage (z. B. um einen gleichzeitigen Schreiber nachzustellen). */ + function setBeforeCreate(fn) { + beforeCreate = fn || null + } + + /** Dokument direkt in den Speicher legen (ohne HTTP), z. B. aus einem setBeforeCreate-Haken. */ + function insert(col, doc) { + const now = new Date().toISOString() + const id = doc.$id || randomUUID() + const full = { $createdAt: now, $updatedAt: now, ...doc, $id: id } + collectionOf(col).set(id, full) + return full + } + function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) } - return { endpoint, all, get, calls, failNext, close } + return { endpoint, all, get, calls, failNext, setBeforeCreate, insert, close } } diff --git a/test/helpers/fakeGitea.js b/test/helpers/fakeGitea.js index 25b9ea7..40b8604 100644 --- a/test/helpers/fakeGitea.js +++ b/test/helpers/fakeGitea.js @@ -63,6 +63,7 @@ export async function startFakeGitea(options = {}) { let transferDelayMs = 0 let responseDelayMs = 0 let transferNoopNext = false + let onGenerate = null const pendingFailures = [] let nextRepoId = 1 @@ -229,6 +230,30 @@ export async function startFakeGitea(options = {}) { send(res, 200, serializeRepo(found.record)) } + /** + * Repo aus Vorlage erzeugen wie Gitea (`POST /repos/{tplOwner}/{tpl}/generate`): 409, wenn es das + * Ziel schon gibt. `setOnGenerate(fn)` ruft fn(repo) nach dem Anlegen auf, BEVOR geantwortet + * wird - so laesst sich der sofort feuernde Webhook `repository/created` nachstellen. + */ + async function handleGenerate(match, searchParams, body, headers, res) { + const owner = body?.owner || '' + const name = body?.name || '' + if (!owner || !name) return send(res, 422, { message: 'owner und name fehlen' }) + if (repos.has(keyFrom(owner, name))) { + return send(res, 409, { message: 'The repository with the same name already exists.' }) + } + const record = addRepoInternal({ full_name: `${owner}/${name}`, description: body.description || '', private: body.private }) + if (onGenerate) await onGenerate(serializeRepo(record)) + send(res, 201, serializeRepo(record)) + } + + function handleGetBranches(match, searchParams, body, headers, res) { + const found = findRepoOrRedirect(match[1], match[2]) + if (!found) return send(res, 404, { message: 'Not Found' }) + if (found.redirectTo) return sendRepoRedirect(res, found.redirectTo, '/branches') + send(res, 200, [{ name: found.record.default_branch || 'main' }]) + } + function handlePatchRepo(match, searchParams, body, headers, res) { const found = findRepoOrRedirect(match[1], match[2]) if (!found) return send(res, 404, { message: 'Not Found' }) @@ -419,6 +444,8 @@ export async function startFakeGitea(options = {}) { { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks$/, handler: handleGetRepoHooks }, { method: 'DELETE', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/hooks\/([^/]+)$/, handler: handleDeleteRepoHook }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/activities\/feeds$/, handler: handleFeeds }, + { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/generate$/, handler: handleGenerate }, + { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/branches$/, handler: handleGetBranches }, { method: 'GET', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleGetContents }, { method: 'POST', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, { method: 'PUT', re: /^\/api\/v1\/repos\/([^/]+)\/([^/]+)\/contents\/(.+)$/, handler: handleWriteContents }, @@ -542,6 +569,10 @@ export async function startFakeGitea(options = {}) { pendingFailures.push({ method, pathIncludes, status }) } + function setOnGenerate(fn) { + onGenerate = fn || null + } + function close() { for (const socket of sockets) socket.destroy() return new Promise((resolve) => server.close(() => resolve())) @@ -549,6 +580,7 @@ export async function startFakeGitea(options = {}) { return { baseUrl, + setOnGenerate, calls, repo, addRepo, diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js new file mode 100644 index 0000000..45b8c6d --- /dev/null +++ b/test/projektAblauf.test.js @@ -0,0 +1,233 @@ +// Ablauf Projekt anlegen / archivieren / Import im Ticketsystem (Fehler aus dem Live-Durchlauf 2026-10-04): +// - "Neues Projekt" ohne Subdomain schrieb subdomain '' -> Unique-Index -> "Document with the requested ID already exists" +// - Wettlauf mit dem Webhook repository/created beim Anlegen +// - Reaktivieren liess status 'archived' stehen +// - archivierte Repos kamen nie ins Ticketsystem, Archiv-Stand aus Gitea nie an +// - "Gitea-Repos synchronisieren" ueberschrieb Projektnamen +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +let gitea +let aw +let config +let svc +let handleRepositoryCreated +let upsertWebsiteProjectByRepo + +const docs = () => aw.all('websiteProjects') +const docOf = (name) => docs().find((d) => String(d.repoFullName).toLowerCase() === name.toLowerCase()) + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: 'knso/webklar-preview-template' }, + { full_name: 'knso/gradient-component' }, + { full_name: 'WEBklar/ohne-site' }, + { full_name: 'WEBklar/mit-site' }, + { full_name: 'WEBklar/D-ner-Nation', description: 'D-ner-Nation' }, + ], + }) + aw = await startFakeAppwrite({ + unique: { websiteProjects: ['repoFullName', 'subdomain'] }, + seed: { + websiteProjects: [ + // wie live: ein altes Projekt traegt subdomain '' (danach scheiterte jedes weitere ohne Website) + { repoFullName: 'knso/gradient-component', projectName: 'gradient component', subdomain: '' }, + { $id: 'p_ohne', repoFullName: 'WEBklar/ohne-site', projectName: 'Ohne Site', status: 'created' }, + { $id: 'p_mit', repoFullName: 'WEBklar/mit-site', projectName: 'Mit Site', subdomain: 'mit-site', status: 'deployed' }, + { $id: 'p_doener', repoFullName: 'WEBklar/D-ner-Nation', projectName: 'Döner Nation', subdomain: 'doener-nation' }, + ], + customers: [{ $id: 'c1', name: 'Kunde' }], + }, + }) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_DEFAULT_OWNER: 'WEBklar', + GITEA_TEMPLATE_OWNER: 'knso', + GITEA_TEMPLATE_REPO: 'webklar-preview-template', + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + }) + ;({ config } = await import('../server/config.js')) + svc = await import('../server/services/giteaAdmin.js') + ;({ handleRepositoryCreated } = await import('../server/services/repoAssignJob.js')) + ;({ upsertWebsiteProjectByRepo } = await import('../server/services/appwriteAdmin.js')) +}) + +after(async () => { + await gitea.close() + await aw.close() +}) + +test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt dran', async () => { + const r = await svc.createProjectFromTemplate({ displayName: 'Zweites ohne Website', customerId: 'c1' }) + assert.equal(r.repoFullName, 'WEBklar/zweites-ohne-website') + assert.equal(r.projectType, 'repo') + const d = docOf('WEBklar/zweites-ohne-website') + assert.equal(d.subdomain, null) + assert.equal(d.customerId, 'c1') + assert.equal(d.projectName, 'Zweites ohne Website') + // Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort) + const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate')) + assert.equal(gen.body.description, 'Zweites ohne Website') +}) + +test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => { + gitea.setOnGenerate(async (repo) => { + await handleRepositoryCreated({ action: 'created', repository: repo, sender: { login: 'webklar-system' } }, { + sendMail: async () => ({ sent: false }), + }) + }) + try { + const r = await svc.createProjectFromTemplate({ displayName: 'Webhook zuerst', customerId: 'c1', ticketId: 't1' }) + const all = docs().filter((d) => d.repoFullName === r.repoFullName) + assert.equal(all.length, 1) + assert.equal(all[0].customerId, 'c1') + assert.equal(all[0].ticketId, 't1') + assert.equal(all[0].projectType, 'repo') + assert.equal(all[0].createdByGitea, 'webklar-system') + } finally { + gitea.setOnGenerate(null) + } +}) + +test('Wettlauf zwischen Suche und Anlage: 409 wird zum Update statt zur Fehlermeldung', async () => { + let raced = false + aw.setBeforeCreate((col, data) => { + if (col !== 'websiteProjects' || raced || data.repoFullName !== 'WEBklar/wettlauf') return + raced = true + aw.insert('websiteProjects', { repoFullName: 'WEBklar/wettlauf', projectName: 'wettlauf', status: 'synced' }) + }) + try { + const d = await upsertWebsiteProjectByRepo('WEBklar/wettlauf', { repoFullName: 'WEBklar/wettlauf', customerId: 'c1', projectName: 'Wettlauf' }) + assert.ok(raced) + assert.equal(d.customerId, 'c1') + assert.equal(docs().filter((x) => x.repoFullName === 'WEBklar/wettlauf').length, 1) + } finally { + aw.setBeforeCreate(null) + } +}) + +test('Neues Projekt mit Art (Ticket): kind gesetzt; unbekannte Art -> 400 ohne Repo', async () => { + await svc.createProjectFromTemplate({ displayName: 'Mit Art', customerId: 'c1', ticketId: 't2', kind: 'website', staffName: 'Kenso' }) + const d = docOf('WEBklar/mit-art') + assert.deepEqual([d.kind, d.forCustomer, d.kindSetBy, d.previewMode], ['website', true, 'Kenso', 'auto']) + assert.ok(d.kindSetAt) + + const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length + await assert.rejects(svc.createProjectFromTemplate({ displayName: 'Falsch', kind: 'quatsch' }), (err) => err.status === 400) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) +}) + +test('Typ "Internes Projekt" schreibt null statt leerer Subdomain', async () => { + const r = await svc.updateWebsiteProject('p_mit', { projectType: 'project' }) + assert.equal(r.subdomain, '') + assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, null) + // fuer die folgenden Tests wieder als Website + await svc.updateWebsiteProject('p_mit', { projectType: 'preview', subdomain: 'mit-site' }) + assert.equal(aw.get('websiteProjects', 'p_mit').subdomain, 'mit-site') +}) + +test('Archivieren und Reaktivieren ohne Website: status geht zurueck auf synced', async () => { + await svc.archiveRepo('p_ohne', true) + assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [true, 'archived']) + assert.equal(gitea.repo('WEBklar/ohne-site').archived, true) + + await svc.archiveRepo('p_ohne', false) + assert.deepEqual([aw.get('websiteProjects', 'p_ohne').archived, aw.get('websiteProjects', 'p_ohne').status], [false, 'synced']) + assert.equal(gitea.repo('WEBklar/ohne-site').archived, false) +}) + +test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Deploy meldet)', async () => { + await svc.archiveRepo('p_mit', true) + assert.equal(aw.get('websiteProjects', 'p_mit').status, 'archived') + await svc.archiveRepo('p_mit', false) + const d = aw.get('websiteProjects', 'p_mit') + assert.equal(d.archived, false) + assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed' + // Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed') + const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) + assert.equal(patches.at(-1).body.data.status, 'pending') +}) + +test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => { + await svc.syncReposToAppwrite() + assert.equal(aw.get('websiteProjects', 'p_doener').projectName, 'Döner Nation') +}) + +test('Import: archivierte Repos kommen als archiviert an (ohne Mail-Liste), Gitea-Archivstand wird gespiegelt, ein Fehler bricht nicht ab', async () => { + const g = await startFakeGitea({ + repos: [ + { full_name: 'knso/neu-archiviert', archived: true, description: 'Alt' }, + { full_name: 'knso/kaputt' }, + { full_name: 'knso/neu-aktiv' }, + { full_name: 'knso/in-gitea-archiviert', archived: true }, + { full_name: 'knso/in-gitea-reaktiviert' }, + ], + }) + const a = await startFakeAppwrite({ + seed: { + websiteProjects: [ + { $id: 'x1', repoFullName: 'knso/in-gitea-archiviert', projectName: 'A', status: 'synced', archived: false }, + { $id: 'x2', repoFullName: 'knso/in-gitea-reaktiviert', projectName: 'B', status: 'archived', archived: true }, + ], + }, + }) + const original = { gitea: config.gitea.baseUrl, appwrite: config.appwrite.endpoint } + config.gitea.baseUrl = g.baseUrl + config.appwrite.endpoint = a.endpoint + const originalError = console.error + console.error = () => {} + try { + // erste Anlage (knso/kaputt kommt nach dem archivierten) schlaegt fehl + a.failNext({ method: 'POST', pathIncludes: '/collections/websiteProjects/documents', status: 400 }) + const r = await svc.importNewRepos() + const byName = (n) => a.all('websiteProjects').find((d) => d.repoFullName === n) + // der erzwungene Fehler trifft die erste Anlage: das archivierte neue Repo + assert.deepEqual(r.failed, ['knso/neu-archiviert']) + assert.ok(byName('knso/kaputt')) + assert.ok(byName('knso/neu-aktiv')) + assert.deepEqual(r.createdRepos.map((x) => x.repo.full_name).sort(), ['knso/kaputt', 'knso/neu-aktiv']) + assert.equal(r.archiveSynced, 2) + assert.deepEqual([a.get('websiteProjects', 'x1').archived, a.get('websiteProjects', 'x1').status], [true, 'archived']) + assert.deepEqual([a.get('websiteProjects', 'x2').archived, a.get('websiteProjects', 'x2').status], [false, 'synced']) + + // naechster Lauf holt das archivierte Repo nach - als archiviert, ohne Mail-Liste + const r2 = await svc.importNewRepos() + assert.equal(r2.archivedImported, 1) + assert.deepEqual(r2.createdRepos, []) + const arch = byName('knso/neu-archiviert') + assert.deepEqual([arch.archived, arch.status, arch.projectName], [true, 'archived', 'Alt']) + // dritter Lauf: nichts mehr zu tun + const r3 = await svc.importNewRepos() + assert.deepEqual([r3.created, r3.archivedImported, r3.archiveSynced, r3.failed.length], [0, 0, 0, 0]) + } finally { + config.gitea.baseUrl = original.gitea + config.appwrite.endpoint = original.appwrite + console.error = originalError + await g.close() + await a.close() + } +}) + +test('Vorlage fuer "Neues Projekt" zieht beim Zuweisen nie um', async () => { + const { isInternalRepo } = await import('../server/services/repoMove.js') + assert.equal(await isInternalRepo('knso/webklar-preview-template'), true) +}) + +test('CORS erlaubt PUT (Preview-Variablen speichern)', async () => { + const { corsMiddleware } = await import('../server/middleware/cors.js') + const headers = {} + let status = 0 + const req = { method: 'OPTIONS', get: (h) => (h === 'Origin' ? 'https://ticket.webklar.com' : undefined) } + const res = { setHeader: (k, v) => { headers[k] = v }, sendStatus: (s) => { status = s } } + corsMiddleware(['https://ticket.webklar.com'])(req, res, () => {}) + assert.equal(status, 204) + assert.match(headers['Access-Control-Allow-Methods'], /\bPUT\b/) +}) From d878df96e00d58ae5073be23d403bfba936f9c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:24:06 +0000 Subject: [PATCH 02/21] Zuordnungs-Lauf: vom System-Konto angelegte Projekte nicht alle 10 Minuten abfragen Co-Authored-By: Claude Opus 5.5 --- server/services/repoAssignJob.js | 3 +++ test/repoAssignDetect.test.js | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/server/services/repoAssignJob.js b/server/services/repoAssignJob.js index 95e5c6b..e513542 100644 --- a/server/services/repoAssignJob.js +++ b/server/services/repoAssignJob.js @@ -150,6 +150,9 @@ export async function runRepoAssignJob({ const docs = await listDocuments(collection(), [Query.limit(5000)]) for (const p of docs) { if (!p.createdByGitea || p.kind || p.archived || handled.has(p.$id)) continue + // Vom System-Konto angelegt (WOMS "Neues Projekt"): kein Mitarbeiter, den man erinnern + // koennte - sonst alle 10 Minuten Gitea-Abfrage und Log "keinem Mitarbeiter zugeordnet". + if (String(p.createdByGitea).toLowerCase() === String(config.gitea.systemUser || '').toLowerCase()) continue // Wiederholung/Nachholen: erste Mail noch nicht raus (0-2 Versuche), Repo noch juenger als 24 h. const attempts = Number(p.assignMailAttempts || 0) if (!p.assignMailSentAt && attempts < 3) { diff --git a/test/repoAssignDetect.test.js b/test/repoAssignDetect.test.js index e930ba5..2f4d692 100644 --- a/test/repoAssignDetect.test.js +++ b/test/repoAssignDetect.test.js @@ -126,6 +126,17 @@ test('Job: Mail ohne vorherigen Versuch wird nachgeholt (createdByGitea, 0 Versu assert.equal(sink.sent.length, 1) }) +test('Job: vom System-Konto angelegtes Projekt ohne Art wird nicht abgefragt und nicht gemailt', async () => { + freshSink() + gitea.addRepo({ full_name: 'WEBklar/aus-womS', created_at: ago(1) }) + aw.insert('websiteProjects', { repoFullName: 'WEBklar/aus-womS', projectName: 'aus WOMS', createdByGitea: 'webklar-system', assignMailAttempts: 0 }) + const before = gitea.calls.filter((c) => c.path === '/api/v1/repos/WEBklar/aus-womS').length + const c = await runRepoAssignJob({ now: NOW }) + assert.equal(c.retries, 0) + assert.equal(sink.sent.length, 0) + assert.equal(gitea.calls.filter((x) => x.path === '/api/v1/repos/WEBklar/aus-womS').length, before) +}) + test('Job: neues Nutzer-Repo → Mail, Org-Repo → keine Mail', async () => { freshSink() gitea.addRepo({ full_name: 'knso/jobneu', created_at: ago(1) }) From 017a9c22f2a7f8ba1ead9a050473bf02a81230c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:32:51 +0000 Subject: [PATCH 03/21] Webhook: System-Commits legen kein Auto-Ticket an Beim Anlegen mit Kunde (ohne Ticket) loeste der Commit der .webklar-preview.json durch webklar-system ein zusaetzliches Ticket "Website " aus. Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 7 +++++-- test/giteaRoutes.test.js | 8 ++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index dee6506..5e23f4a 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -10,7 +10,7 @@ import { buildPreviewUrl, } from '../../services/previewDeploy.js' import { ensureRepoPreviewConfig } from '../../services/giteaAdmin.js' -import { createGitPushWorksheet } from '../../services/gitWorksheet.js' +import { createGitPushWorksheet, isSystemPusher } from '../../services/gitWorksheet.js' import { sendPushTimeMail } from '../../services/pushTimeMail.js' import { ensureProjectTicket } from '../../services/ticketAdmin.js' import { invalidateProjectCache } from '../../services/projectInsights.js' @@ -177,7 +177,10 @@ router.post('/gitea', async (req, res) => { // (derselben wie assignProject/reconcileProject), damit ein gleichzeitiges Zuweisen nicht // dieselbe ticketId-Aufloesung doppelt anstoesst. let gitWorksheet = null - if (commitSha && commitSha !== '0000000000000000000000000000000000000000') { + // System-Commits (z. B. .webklar-preview.json beim Anlegen aus dem Ticketsystem) bekommen weder + // Arbeitsblatt noch Auto-Ticket - sonst entstand beim Anlegen mit Kunde ein zusaetzliches + // Ticket "Website " neben dem, das im Zuordnen-Assistenten gewaehlt wird. + if (commitSha && commitSha !== '0000000000000000000000000000000000000000' && !isSystemPusher(pusher)) { gitWorksheet = await withLocks( ['project:' + project.$id], async () => { diff --git a/test/giteaRoutes.test.js b/test/giteaRoutes.test.js index fd491ba..3bd6600 100644 --- a/test/giteaRoutes.test.js +++ b/test/giteaRoutes.test.js @@ -223,6 +223,14 @@ test('Webhook des System-Kontos', async () => { await webhook(pushPayload({ full_name: 'WEBklar/D-ner-Nation', sha: 'cafe0001', pusher: 'webklar-system' })) assert.equal(aw.all('worksheets').length, before) }) +test('System-Commit an Projekt mit Kunde ohne Ticket legt kein Auto-Ticket an', async () => { + gitea.addRepo({ full_name: 'WEBklar/neu-mit-kunde' }) + aw.insert('websiteProjects', { $id: 'p_sys', repoFullName: 'WEBklar/neu-mit-kunde', projectName: 'Neu', customerId: 'c1', ticketId: '' }) + const before = aw.all('workorders').length + await webhook(pushPayload({ full_name: 'WEBklar/neu-mit-kunde', sha: 'cafe0002', pusher: 'webklar-system' })) + assert.equal(aw.all('workorders').length, before) + assert.equal(aw.get('websiteProjects', 'p_sys').ticketId, '') +}) test('Webhook wartet auf laufenden Umzug', async () => { gitea.setTransferDelay(300) const move = post('/api/admin/website-projects/p5/assign', { customerId: 'c1', ticketId: 't1' }, 'jwt-admin') // p5: JUSN/langsam From 800630ea4bbcf530542ad0dd66c9d2b0a78b2963 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 13:36:26 +0000 Subject: [PATCH 04/21] Projekt anlegen/archivieren: Review-Punkte - Anlegen uebernimmt keinen alten Eintrag gleichen Namens mehr (Repo in Gitea geloescht): Platzhalter werden entfernt, ein verknuepftes altes Projekt blockiert den Namen mit klarer Meldung, bevor ein Repo entsteht; neue Projekte starten mit archived=false - archiveRepo aendert Gitea und Dokument unter der Repo-Sperre; der Import liest den Archiv-Stand vor dem Spiegeln frisch aus Gitea - Reaktivieren: Preview aus -> synced, Deploy-Fehler -> deploy_failed statt dauerhaft 'pending' Co-Authored-By: Claude Opus 5.5 --- server/services/giteaAdmin.js | 130 ++++++++++++++++++++++------------ test/projektAblauf.test.js | 24 ++++++- 2 files changed, 105 insertions(+), 49 deletions(-) diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 073ce05..0506862 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -5,6 +5,7 @@ import { Query, getDocument, updateDocument, + deleteDocument, } from './appwriteAdmin.js' import { runDeploy, @@ -17,7 +18,7 @@ import { import { giteaFetch } from './giteaClient.js' import { slugify } from './slug.js' import { withLocks } from './keyedLock.js' -import { repoLockKey } from './repoMove.js' +import { repoLockKey, isUnlinkedProject } from './repoMove.js' import { isKind } from './projectKinds.js' // Sicherheitsnetz wie bei listRecentPushes (siehe pushHistory.js, Controller-Ruling R8): sollte @@ -90,17 +91,21 @@ export async function importNewRepos() { if (existing) { if (Boolean(existing.archived) === repoArchived) continue await withLocks([repoLockKey(fullName)], async () => { + // Unter der Sperre frisch lesen (Dokument UND Gitea): die Repo-Liste oben kann aelter + // sein als ein gerade laufendes Archivieren/Reaktivieren im Ticketsystem. const fresh = await getDocument(config.collections.websiteProjects, existing.$id) - if (!fresh || Boolean(fresh.archived) === repoArchived) return + const live = await giteaFetch(`/repos/${fullName}`) + const liveArchived = Boolean(live?.archived) + if (!fresh || Boolean(fresh.archived) === liveArchived) return await updateDocument(config.collections.websiteProjects, fresh.$id, { - archived: repoArchived, - ...(repoArchived + archived: liveArchived, + ...(liveArchived ? { status: 'archived' } : fresh.status === 'archived' ? { status: 'synced' } : {}), updatedAt: new Date().toISOString(), }) archiveSynced += 1 - }) + }, { waitMs: 10000 }) continue } @@ -323,6 +328,37 @@ export async function ensureRepoPreviewConfig(repoFullName, branch = 'main') { return previewConfig } +/** + * Vor dem Anlegen: gibt es das Repo in Gitea nicht (mehr), aber noch ein Projekt-Dokument mit dem + * Namen, wuerde das neue Projekt in diesen alten Eintrag uebernommen - samt archived, Art, Preview- + * Stand (und waere z. B. als archiviert unsichtbar). Platzhalter ohne Bezug werden entfernt, ein + * verknuepftes altes Projekt blockiert den Namen mit einer klaren Meldung - bevor ein Repo entsteht. + */ +async function releaseLeftoverProjectName(fullName) { + try { + await giteaFetch(`/repos/${fullName}`) + return // Repo existiert: generate meldet gleich "existiert bereits" + } catch (err) { + if (err.status !== 404) return + } + const leftovers = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', fullName), + Query.limit(10), + ]) + for (const old of leftovers) { + if (isUnlinkedProject(old)) { + await deleteDocument(config.collections.websiteProjects, old.$id) + continue + } + const error = new Error( + `Der Name "${fullName}" gehört noch zu einem alten Projekt ("${old.projectName || fullName}"` + + `${old.archived ? ', archiviert' : ''}). Bitte einen anderen Namen wählen.` + ) + error.status = 409 + throw error + } +} + export async function createProjectFromTemplate({ repoName, displayName, @@ -353,6 +389,8 @@ export async function createProjectFromTemplate({ await ensureSubdomainAvailable(safeSubdomain) } + await releaseLeftoverProjectName(`${targetOwner}/${safeRepoName}`) + const repo = await generateRepoFromTemplate({ owner: targetOwner, repoName: safeRepoName, @@ -402,6 +440,7 @@ export async function createProjectFromTemplate({ previewUrl, // Login-geschuetzte Preview (isPublic bleibt aus) - wie deriveProjectType und das Ticketsystem projectType: hasWebsite ? 'preview' : 'repo', + archived: false, // Art direkt mitgeben (Ticket: "Neues Projekt" = Website) - sonst stand das Projekt auf // "Art fehlt" und der Zuordnungs-Lauf mahnte es an. Felder wie completeAssignment. ...(kind @@ -628,49 +667,48 @@ export async function archiveRepo(projectId, archived = true) { const repoFullName = doc.repoFullName || '' let repoMissing = false - if (repoFullName.includes('/')) { - try { - await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) - } catch (err) { - // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem - // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. - if (err.status === 404) { - repoMissing = true - console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) - } else { - const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) - error.status = err.status || 500 - throw error + // Gitea und Dokument unter der Repo-Sperre aendern - dieselbe nimmt der Import, der den + // Archiv-Stand aus Gitea spiegelt; sonst koennte er einen alten Stand zurueckschreiben. + const locks = repoFullName ? [repoLockKey(repoFullName)] : [] + let updated = await withLocks(locks, async () => { + if (repoFullName.includes('/')) { + try { + await patchGiteaRepo(repoFullName, { archived: Boolean(archived) }) + } catch (err) { + // Verwaistes Projekt: Repo existiert nicht mehr in Gitea (404). Dann trotzdem + // nur das Appwrite-Dokument (ent)archivieren, damit es aus der Liste verschwindet. + if (err.status === 404) { + repoMissing = true + console.warn(`[archiveRepo] Gitea-Repo ${repoFullName} nicht gefunden (404) - nur Appwrite wird aktualisiert`) + } else { + const error = new Error('Gitea-Archivierung fehlgeschlagen: ' + err.message) + error.status = err.status || 500 + throw error + } } } - } - if (archived && doc.subdomain) { - try { await removeDeploy(doc.subdomain) } catch (err) { - console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) - } - } - - if (archived) { - const updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: true, - status: 'archived', + // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner + // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. + // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. + return updateDocument(config.collections.websiteProjects, projectId, { + archived: Boolean(archived), + status: archived ? 'archived' : (!repoMissing && doc.subdomain ? 'pending' : 'synced'), updatedAt: new Date().toISOString(), }) + }, { waitMs: 10000 }) + + if (archived) { + if (doc.subdomain) { + try { await removeDeploy(doc.subdomain) } catch (err) { + console.error('[archiveRepo] Deploy entfernen fehlgeschlagen:', err.message) + } + } return { project: updated, archived: true } } - // Reaktivieren: status 'archived' zuruecksetzen, und zwar VOR dem Redeploy - der Preview-Runner - // setzt nach dem Bau selbst 'deployed', das darf hier danach nicht ueberschrieben werden. - // Blieb 'archived' stehen, bot das Ticket das Projekt unter "Projekt wählen" nicht mehr an. - const redeploy = !repoMissing && Boolean(doc.subdomain) - let updated = await updateDocument(config.collections.websiteProjects, projectId, { - archived: false, - status: redeploy ? 'pending' : 'synced', - updatedAt: new Date().toISOString(), - }) - - if (redeploy) { + if (!repoMissing && doc.subdomain) { + let after = null try { const branch = (await getRepoInfo(repoFullName))?.defaultBranch || 'main' const cfg = (await fetchPreviewConfig(repoFullName, branch)) || { @@ -681,15 +719,15 @@ export async function archiveRepo(projectId, archived = true) { displayName: doc.projectName, } const deployResult = await runDeploy(repoFullName, branch, cfg, { project: doc }) - if (deployResult?.deployed) { - updated = await updateDocument(config.collections.websiteProjects, projectId, { - status: 'deployed', - provisioningStatus: 'ready', - }) - } + if (deployResult?.deployed) after = { status: 'deployed', provisioningStatus: 'ready' } + else if (deployResult?.skipped) after = { status: 'synced' } // Preview ausgeschaltet + else if (!deployResult?.queued) after = { provisioningStatus: 'deploy_failed' } + // queued: der Runner-Verfolger setzt den Status nach dem Bau } catch (err) { console.error('[archiveRepo] Redeploy bei Reaktivierung fehlgeschlagen:', err.message) + after = { provisioningStatus: 'deploy_failed' } } + if (after) updated = await updateDocument(config.collections.websiteProjects, projectId, after) } return { project: updated, archived: false } diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index 45b8c6d..a12032e 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -150,10 +150,28 @@ test('Reaktivieren mit Website: status nicht mehr archived (pending bis der Depl await svc.archiveRepo('p_mit', false) const d = aw.get('websiteProjects', 'p_mit') assert.equal(d.archived, false) - assert.equal(d.status, 'pending') // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy, also nicht 'deployed' - // Status vor dem Redeploy geschrieben, nicht danach (der Runner setzt spaeter selbst 'deployed') + // hier ohne PREVIEW_DEPLOY_ROOT: kein Deploy -> 'pending' und als fehlgeschlagen markiert + assert.deepEqual([d.status, d.provisioningStatus], ['pending', 'deploy_failed']) + // der Status wird vor dem Redeploy geschrieben, danach nicht mehr (der Runner setzt 'deployed') const patches = aw.calls.filter((c) => c.method === 'PATCH' && c.path.endsWith('/p_mit')) - assert.equal(patches.at(-1).body.data.status, 'pending') + assert.equal(patches.at(-1).body.data.status, undefined) +}) + +test('Neues Projekt: alter verknuepfter Eintrag mit demselben Namen blockiert mit klarer Meldung, bevor ein Repo entsteht', async () => { + aw.insert('websiteProjects', { repoFullName: 'WEBklar/alt-belegt', projectName: 'Alt', archived: true, status: 'archived', kind: 'website', customerId: 'cAlt' }) + const before = gitea.calls.filter((c) => c.path.endsWith('/generate')).length + await assert.rejects(svc.createProjectFromTemplate({ displayName: 'alt belegt', repoName: 'alt-belegt' }), (err) => + err.status === 409 && /alten Projekt/.test(err.message) && /archiviert/.test(err.message)) + assert.equal(gitea.calls.filter((c) => c.path.endsWith('/generate')).length, before) +}) + +test('Neues Projekt: alter Platzhalter ohne Bezug wird entfernt, das neue Projekt startet sauber', async () => { + aw.insert('websiteProjects', { $id: 'platzhalter', repoFullName: 'WEBklar/frei-machen', projectName: 'alt', archived: true, status: 'archived' }) + const r = await svc.createProjectFromTemplate({ displayName: 'Frei machen', customerId: 'c1' }) + assert.equal(aw.get('websiteProjects', 'platzhalter'), undefined) + const d = docOf(r.repoFullName) + assert.equal(d.archived, false) + assert.equal(d.customerId, 'c1') }) test('Abgleich ueberschreibt den Projektnamen nicht mehr', async () => { From 3156bb790a798d134878466c0ddb958b0d773395 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:02:02 +0000 Subject: [PATCH 05/21] Projekte: alle Mitarbeiter, private Repos, Archiv fuer Kunden ausgeblendet Entscheidungen vom 04.10.2026: - Projekt-Routen (website-projects, preview, gitea, services, analyse) fuer jeden Mitarbeiter (Label mitarbeiter oder admin); Kunden-, Mitarbeiter- und Finanz-Verwaltung bleiben nur fuer Admins - Neue Repos aus der Vorlage werden privat angelegt - Archivierte Projekte sieht der Kunde nicht mehr: Projektliste, Projektdetails, Preview-Zugang, Funktionen (Mitarbeiter-Ansicht schon) - Projektliste des Kunden mit Limit (Appwrite liefert sonst nur 25) Co-Authored-By: Claude Opus 5.5 --- server/middleware/appwriteStaff.js | 20 +++++++-- server/routes/admin/analyse.js | 5 ++- server/routes/admin/funktionenAdmin.js | 2 +- server/routes/admin/gitea.js | 5 ++- server/routes/admin/preview.js | 5 ++- server/routes/admin/services.js | 5 ++- server/routes/admin/websiteProjects.js | 5 ++- server/routes/auth.js | 3 +- server/routes/projects.js | 7 ++- server/services/giteaAdmin.js | 4 +- server/services/siteFeatures.js | 11 +++-- test/previewReadiness.test.js | 11 +++++ test/projektAblauf.test.js | 2 + test/rechtePrivatArchiv.test.js | 60 ++++++++++++++++++++++++++ 14 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 test/rechtePrivatArchiv.test.js diff --git a/server/middleware/appwriteStaff.js b/server/middleware/appwriteStaff.js index d9ca95e..b9382a4 100644 --- a/server/middleware/appwriteStaff.js +++ b/server/middleware/appwriteStaff.js @@ -1,6 +1,20 @@ import { config } from '../config.js' +import { ADMIN_LABEL, STAFF_LABELS } from '../services/staffLabels.js' -export async function requireAppwriteStaff(req, res, next) { +/** + * Prueft das Appwrite-JWT aus dem Ticketsystem und verlangt eines der Labels `allowed`. + * requireAppwriteStaff: nur "admin" (Kunden, Mitarbeiter, Finanzen ...). + * requireAppwriteTeam: jeder Mitarbeiter ("mitarbeiter" oder "admin") - Projekt-Aktionen + * (Anlegen, Zuordnen, Archivieren, Preview, Analyse, Gitea-Import), Entscheidung 2026-10-04. + */ +function requireAppwriteLabels(allowed, deniedMessage) { + return (req, res, next) => checkAppwriteJwt(req, res, next, allowed, deniedMessage) +} + +export const requireAppwriteStaff = requireAppwriteLabels([ADMIN_LABEL], 'Admin-Berechtigung erforderlich') +export const requireAppwriteTeam = requireAppwriteLabels(STAFF_LABELS, 'Nur für Mitarbeiter') + +async function checkAppwriteJwt(req, res, next, allowed, deniedMessage) { const authHeader = req.get('Authorization') || '' const match = authHeader.match(/^Bearer\s+(.+)$/i) if (!match) { @@ -32,8 +46,8 @@ export async function requireAppwriteStaff(req, res, next) { return res.status(401).json({ error: data?.message || 'Ungültiges JWT' }) } - if (!Array.isArray(data?.labels) || !data.labels.includes('admin')) { - return res.status(403).json({ error: 'Admin-Berechtigung erforderlich' }) + if (!Array.isArray(data?.labels) || !allowed.some((label) => data.labels.includes(label))) { + return res.status(403).json({ error: deniedMessage }) } req.appwriteUser = data diff --git a/server/routes/admin/analyse.js b/server/routes/admin/analyse.js index f9f2e47..b1cec8e 100644 --- a/server/routes/admin/analyse.js +++ b/server/routes/admin/analyse.js @@ -2,12 +2,13 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js' import { getFeatureDoc } from '../../services/siteFeatures.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) function sendError(res, err) { const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500 diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..6125e5d 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -49,7 +49,7 @@ async function ticketExists(ticketId) { // Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) - const projects = await listCustomerFeatures(customerId, { docs }) + const projects = await listCustomerFeatures(customerId, { docs, includeArchived: true }) for (const p of projects) { for (const key of FEATURE_KEYS) { const view = p.features[key] diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index efecce8..ad9b83c 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,10 +1,11 @@ import { Router } from 'express' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/sync-repos', async (_req, res) => { try { diff --git a/server/routes/admin/preview.js b/server/routes/admin/preview.js index 3f135f5..ded1461 100644 --- a/server/routes/admin/preview.js +++ b/server/routes/admin/preview.js @@ -2,7 +2,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument, updateDocument } from '../../services/appwriteAdmin.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js' import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js' import * as runnerClient from '../../services/previewRunnerClient.js' @@ -11,7 +11,8 @@ import { withLocks } from '../../services/keyedLock.js' import { repoHasDevScript } from '../../services/previewDevScript.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const BUSY = 'Server ausgelastet, versuche es gleich noch mal' const MODES = ['auto', 'build', 'dev', 'off'] diff --git a/server/routes/admin/services.js b/server/routes/admin/services.js index b9dc194..21bdcc7 100644 --- a/server/routes/admin/services.js +++ b/server/routes/admin/services.js @@ -2,11 +2,12 @@ import { Router } from 'express' import { config } from '../../config.js' import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { servicesCatalog } from '../../services/projectKinds.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) const CHUNK = 100 diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index f55aa04..c119c98 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -6,11 +6,12 @@ import { } from '../../services/giteaAdmin.js' import { assignProject, reconcileProject } from '../../services/projectAssignment.js' import { completeAssignment } from '../../services/projectKindAssign.js' -import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' const router = Router() -router.use(requireAppwriteStaff) +// Projekt-Aktionen: jeder Mitarbeiter (nicht nur Admins) +router.use(requireAppwriteTeam) router.post('/create-from-template', async (req, res) => { const { repoName, displayName, subdomain, customerId = '', ticketId = '', owner, kind = '' } = req.body || {} diff --git a/server/routes/auth.js b/server/routes/auth.js index 578dcf4..6ab0708 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -292,7 +292,8 @@ router.get('/preview-access', async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, queries) const project = projects[0] - if (!project) { + // Archiviert: fuer Kunden wie kein Projekt (das Portal blendet es aus) + if (!project || (project.archived && !admin)) { return res.json({ allowed: false, reason: 'no_project' }) } if (!isPreviewProjectReady(project)) { diff --git a/server/routes/projects.js b/server/routes/projects.js index 093d24a..70697e6 100644 --- a/server/routes/projects.js +++ b/server/routes/projects.js @@ -14,7 +14,8 @@ const router = Router() async function loadOwnProject(req, res, next) { const customerId = getSessionCustomerId(req) const project = await getDocument(config.collections.websiteProjects, req.params.id).catch(() => null) - if (!project || !customerId || project.customerId !== customerId) { + // Archivierte Projekte sieht der Kunde nicht mehr (wie in der Liste) + if (!project || !customerId || project.customerId !== customerId || project.archived) { return res.status(404).json({ error: 'Projekt nicht gefunden' }) } req.project = project @@ -31,9 +32,11 @@ router.get('/', requireSession, async (req, res) => { const projects = await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), + Query.limit(500), // ohne Limit liefert Appwrite nur 25 ]) - const sanitized = projects.map((p) => ({ + // Archivierte Projekte (Vorschau entfernt) blendet das Portal aus (Entscheidung 2026-10-04) + const sanitized = projects.filter((p) => !p.archived).map((p) => ({ id: p.$id, projectName: p.projectName || '', subdomain: p.subdomain || '', diff --git a/server/services/giteaAdmin.js b/server/services/giteaAdmin.js index 0506862..c0c75a4 100644 --- a/server/services/giteaAdmin.js +++ b/server/services/giteaAdmin.js @@ -218,7 +218,9 @@ export async function generateRepoFromTemplate({ owner, repoName, description }) body: JSON.stringify({ owner: targetOwner, name: repoName, - private: false, + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04). Portal und Preview-Runner + // lesen Gitea mit dem System-Schluessel, Mitarbeiter sehen Org-Repos ueber das Team Owners. + private: true, // Die Beschreibung ist der Projektname in Gitea (Abgleich und Webhook lesen ihn von dort) description: description || `Website-Projekt ${repoName}`, git_content: true, diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 85350dd..27f4ec9 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -129,13 +129,16 @@ export function listCustomerFeatureDocs(customerId) { return listDocuments(config.collections.portalFeatures, [Query.equal('customerId', customerId), Query.limit(1000)]) } -/** docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. */ -export async function listCustomerFeatures(customerId, { docs: geladen } = {}) { - const projects = await listDocuments(config.collections.websiteProjects, [ +/** + * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. + * includeArchived: nur fuer die Mitarbeiter-Ansicht - der Kunde sieht archivierte Projekte nicht. + */ +export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ]) + ])).filter((p) => includeArchived || !p.archived) const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) const result = [] for (const p of projects) { diff --git a/test/previewReadiness.test.js b/test/previewReadiness.test.js index 949ad8c..d4414e7 100644 --- a/test/previewReadiness.test.js +++ b/test/previewReadiness.test.js @@ -67,3 +67,14 @@ test('Route: anderer Kunde bleibt ausgesperrt', async () => { assert.equal(r.allowed, false) assert.equal(r.reason, 'no_project') }) +test('Route: archiviertes Projekt ist fuer den Kunden gesperrt (Entscheidung 2026-10-04)', async () => { + const doc = aw.all('websiteProjects').find((d) => d.$id === 'p1') + doc.archived = true + try { + const r = await access(token.c1) + assert.equal(r.allowed, false) + assert.equal(r.reason, 'no_project') + } finally { + doc.archived = false + } +}) diff --git a/test/projektAblauf.test.js b/test/projektAblauf.test.js index a12032e..ad9c7ba 100644 --- a/test/projektAblauf.test.js +++ b/test/projektAblauf.test.js @@ -76,6 +76,8 @@ test('Neues Projekt ohne Subdomain: kein subdomain-Konflikt mehr, Kunde bleibt d // Gitea-Beschreibung = Anzeigename (Abgleich/Webhook lesen den Namen von dort) const gen = gitea.calls.find((c) => c.method === 'POST' && c.path.endsWith('/generate')) assert.equal(gen.body.description, 'Zweites ohne Website') + // Kundenquelltext nicht oeffentlich (Entscheidung 2026-10-04) + assert.equal(gen.body.private, true) }) test('Neues Projekt: Webhook repository/created legt das Dokument zuerst an -> eines, mit Kunde und Ticket', async () => { diff --git a/test/rechtePrivatArchiv.test.js b/test/rechtePrivatArchiv.test.js new file mode 100644 index 0000000..df01400 --- /dev/null +++ b/test/rechtePrivatArchiv.test.js @@ -0,0 +1,60 @@ +// Entscheidungen vom 04.10.2026: Projekt-Aktionen fuer alle Mitarbeiter, archivierte Projekte im +// Kundenportal ausgeblendet. (Private Repos: siehe projektAblauf.test.js, generate-Aufruf.) +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' + +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { startTestApp, rawGet, get, sessionCookie } = await import('./support/testApp.js') +const { default: projectsRouter } = await import('../server/routes/projects.js') +const { default: servicesRouter } = await import('../server/routes/admin/services.js') +const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const app = await startTestApp({ + '/api/projects': projectsRouter, + '/api/admin/services': servicesRouter, + '/api/admin/customers': customersRouter, +}) +after(() => Promise.all([aw.close(), st.close(), app.close()])) + +aw.seed('websiteProjects', { $id: 'p_aktiv', customerId: 'c1', projectName: 'Aktiv', subdomain: 'aktiv', status: 'deployed' }) +aw.seed('websiteProjects', { $id: 'p_archiv', customerId: 'c1', projectName: 'Alt', subdomain: 'alt', status: 'archived', archived: true }) +const kunde = { cookie: sessionCookie({ customerId: 'c1', appwriteUserId: 'u1' }) } +const bearer = (jwt) => ({ headers: { Authorization: `Bearer ${jwt}` } }) + +test('Kundenportal: archivierte Projekte stehen nicht in der Liste', async () => { + const { projects } = await get('/api/projects', kunde) + assert.deepEqual(projects.map((p) => p.id), ['p_aktiv']) +}) + +test('Kundenportal: archiviertes Projekt auch per Direktaufruf nicht erreichbar', async () => { + assert.equal((await rawGet('/api/projects/p_archiv/work', kunde)).status, 404) + assert.equal((await rawGet('/api/projects/p_aktiv/work', kunde)).status, 200) +}) + +test('Projekt-Routen: Mitarbeiter ohne Admin-Label kommen rein, Kunden-Konto nicht', async () => { + assert.equal((await rawGet('/api/admin/services', bearer('jwt-staff'))).status, 200) + assert.equal((await rawGet('/api/admin/services', bearer('jwt-admin'))).status, 200) + const kundeKonto = await rawGet('/api/admin/services', bearer('jwt-kunde')) + assert.equal(kundeKonto.status, 403) + assert.equal(kundeKonto.body.error, 'Nur für Mitarbeiter') + assert.equal((await rawGet('/api/admin/services')).status, 401) +}) + +test('Kunden-Verwaltung bleibt nur fuer Admins', async () => { + const r = await rawGet('/api/admin/customers', bearer('jwt-staff')) + assert.equal(r.status, 403) + assert.equal(r.body.error, 'Admin-Berechtigung erforderlich') +}) + +test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansicht schon', async () => { + const { listCustomerFeatures } = await import('../server/services/siteFeatures.js') + assert.deepEqual((await listCustomerFeatures('c1', { docs: [] })).map((p) => p.projectId), ['p_aktiv']) + assert.deepEqual( + (await listCustomerFeatures('c1', { docs: [], includeArchived: true })).map((p) => p.projectId).sort(), + ['p_aktiv', 'p_archiv'] + ) +}) From 4fc4c70b7df6a18a3aa7cbd5028d24bffb4d69b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 15:11:54 +0000 Subject: [PATCH 06/21] Archiv/Rechte: Review-Punkte - archiviertes Projekt bleibt unter Funktionen sichtbar, solange dort eine Funktion bestellt/eingerichtet/aktiv ist (z. B. Terminbuchungen) - auf archivierten Projekten nichts neu bestellen oder einschalten - Projekttyp Vorlage nur fuer Admins; repo-readme nur fuer bekannte Projekte Co-Authored-By: Claude Opus 5.5 --- server/routes/admin/gitea.js | 8 +++++++ server/routes/admin/websiteProjects.js | 4 ++++ server/routes/funktionen.js | 3 ++- server/services/siteFeatures.js | 14 +++++++++---- test/rechtePrivatArchiv.test.js | 29 +++++++++++++++++++++++++- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/server/routes/admin/gitea.js b/server/routes/admin/gitea.js index ad9b83c..b84bb57 100644 --- a/server/routes/admin/gitea.js +++ b/server/routes/admin/gitea.js @@ -1,4 +1,6 @@ import { Router } from 'express' +import { config } from '../../config.js' +import { listDocuments, Query } from '../../services/appwriteAdmin.js' import { syncReposToAppwrite, fetchRepoReadme, importNewRepos } from '../../services/giteaAdmin.js' import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' @@ -36,6 +38,12 @@ router.get('/repo-readme', async (req, res) => { } try { + // Nur Repos, die ein Projekt sind - der System-Schluessel sieht sonst jedes (auch private) Repo + const known = await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', repoFullName), + Query.limit(1), + ]) + if (!known[0]) return res.status(404).json({ error: 'Projekt nicht gefunden' }) const readme = await fetchRepoReadme(repoFullName) if (!readme) { return res.json({ ok: true, found: false, repoFullName }) diff --git a/server/routes/admin/websiteProjects.js b/server/routes/admin/websiteProjects.js index c119c98..1930d35 100644 --- a/server/routes/admin/websiteProjects.js +++ b/server/routes/admin/websiteProjects.js @@ -41,6 +41,10 @@ router.post('/create-from-template', async (req, res) => { // Projekt bearbeiten: Name / Subdomain / Typ -> synchronisiert Git + Preview + Appwrite router.patch('/:id', async (req, res) => { const { projectName, subdomain, projectType } = req.body || {} + // Vorlagen bleiben Admin-Sache (im Ticketsystem nur fuer Admins sichtbar) + if (projectType === 'template' && !req.appwriteUser?.labels?.includes('admin')) { + return res.status(403).json({ error: 'Nur Admins können Vorlagen anlegen.' }) + } try { const result = await updateWebsiteProject(req.params.id, { projectName, subdomain, projectType }) return res.json({ ok: true, ...result }) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 604cd06..7c0694a 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -147,7 +147,7 @@ router.post('/news/checkout', requireSession, async (req, res) => { return res.status(400).json({ error: 'Bitte wähle ein Projekt.' }) } const customerId = getSessionCustomerId(req) - await assertOwnProject(customerId, projectId) + await assertOwnProject(customerId, projectId, { forNewOrder: true }) // Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine // zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => { @@ -188,6 +188,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => { return res.status(403).json({ error: 'In der Kundenansicht kann nichts geändert werden.' }) } const { projectId, accentColor, consent } = req.body || {} + await assertOwnProject(getSessionCustomerId(req), analyseProjectId(projectId), { forNewOrder: true }) const { doc } = await activateAnalyse({ customerId: getSessionCustomerId(req), projectId: analyseProjectId(projectId), diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 27f4ec9..b8108c8 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -64,7 +64,8 @@ export async function getFeatureDoc(customerId, projectId, key) { } } -export async function assertOwnProject(customerId, projectId) { +/** forNewOrder: Neu bestellen/einschalten geht auf archivierten Projekten nicht mehr (Kunde sieht sie nicht). */ +export async function assertOwnProject(customerId, projectId, { forNewOrder = false } = {}) { let project = null try { project = await getDocument(config.collections.websiteProjects, projectId) @@ -72,6 +73,9 @@ export async function assertOwnProject(customerId, projectId) { if (err.status !== 404) throw err } if (!project || !customerId || project.customerId !== customerId) throw httpError(403, 'Kein Zugriff auf dieses Projekt') + if (forNewOrder && project.archived) { + throw httpError(409, 'Dieses Projekt ist archiviert - dafür kann nichts mehr bestellt oder eingeschaltet werden.') + } return project } @@ -131,15 +135,17 @@ export function listCustomerFeatureDocs(customerId) { /** * docs: bereits geladene Funktionsdokumente des Kunden (listCustomerFeatureDocs), damit sie nicht doppelt gelesen werden. - * includeArchived: nur fuer die Mitarbeiter-Ansicht - der Kunde sieht archivierte Projekte nicht. + * includeArchived: nur fuer die Mitarbeiter-Ansicht. Der Kunde sieht archivierte Projekte nur noch, solange dort + * eine Funktion bestellt/eingerichtet/aktiv ist - sonst kaeme er z. B. nicht mehr an seine Terminbuchungen. */ export async function listCustomerFeatures(customerId, { docs: geladen, includeArchived = false } = {}) { + const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + const laufend = new Set(docs.filter((d) => ['ordered', 'setup', 'active'].includes(d.state)).map((d) => d.projectId)) const projects = (await listDocuments(config.collections.websiteProjects, [ Query.equal('customerId', customerId), Query.orderDesc('$createdAt'), Query.limit(1000), - ])).filter((p) => includeArchived || !p.archived) - const docs = geladen ?? (await listCustomerFeatureDocs(customerId)) + ])).filter((p) => includeArchived || !p.archived || laufend.has(p.$id)) const result = [] for (const p of projects) { const features = {} diff --git a/test/rechtePrivatArchiv.test.js b/test/rechtePrivatArchiv.test.js index df01400..2d7b030 100644 --- a/test/rechtePrivatArchiv.test.js +++ b/test/rechtePrivatArchiv.test.js @@ -9,14 +9,20 @@ import { useTestEnv } from './support/testEnv.js' const aw = await startAppwriteMock() const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) -const { startTestApp, rawGet, get, sessionCookie } = await import('./support/testApp.js') +const { startTestApp, rawGet, get, send, sessionCookie } = await import('./support/testApp.js') const { default: projectsRouter } = await import('../server/routes/projects.js') const { default: servicesRouter } = await import('../server/routes/admin/services.js') const { default: customersRouter } = await import('../server/routes/admin/customers.js') +const { default: websiteProjectsRouter } = await import('../server/routes/admin/websiteProjects.js') +const { default: giteaRouter } = await import('../server/routes/admin/gitea.js') +const { default: funktionenRouter } = await import('../server/routes/funktionen.js') const app = await startTestApp({ '/api/projects': projectsRouter, '/api/admin/services': servicesRouter, '/api/admin/customers': customersRouter, + '/api/admin/website-projects': websiteProjectsRouter, + '/api/admin/gitea': giteaRouter, + '/api/funktionen': funktionenRouter, }) after(() => Promise.all([aw.close(), st.close(), app.close()])) @@ -58,3 +64,24 @@ test('Funktionen: Kunde sieht archivierte Projekte nicht, die Mitarbeiter-Ansich ['p_aktiv', 'p_archiv'] ) }) + +test('Funktionen: archiviertes Projekt mit laufender Funktion bleibt fuer den Kunden sichtbar (Buchungen verwalten)', async () => { + const { listCustomerFeatures } = await import('../server/services/siteFeatures.js') + const docs = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'active' }] + assert.deepEqual((await listCustomerFeatures('c1', { docs })).map((p) => p.projectId).sort(), ['p_aktiv', 'p_archiv']) + const aus = [{ customerId: 'c1', projectId: 'p_archiv', featureKey: 'termine', state: 'off' }] + assert.deepEqual((await listCustomerFeatures('c1', { docs: aus })).map((p) => p.projectId), ['p_aktiv']) +}) + +test('Neu bestellen auf archiviertem Projekt geht nicht', async () => { + const r = await send('POST', '/api/funktionen/news/checkout', { ...kunde, body: { projectId: 'p_archiv', consent: true } }) + assert.equal(r.status, 409) + assert.match(r.body.error, /archiviert/) +}) + +test('Vorlage bleibt Admin-Sache; README nur fuer bekannte Projekte', async () => { + const t = await send('PATCH', '/api/admin/website-projects/p_aktiv', { ...bearer('jwt-staff'), body: { projectType: 'template' } }) + assert.equal(t.status, 403) + const fremd = await rawGet('/api/admin/gitea/repo-readme?repoFullName=JUSN/tickte-system', bearer('jwt-staff')) + assert.equal(fremd.status, 404) +}) From 75794f14d5ed52906eff3f3ba0a242ceff2e1a31 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:46:27 +0000 Subject: [PATCH 07/21] Preview: Deploy auf fremde Subdomain sperren Ein kopiertes Repo (git clone + push, Vorlage) bringt die .webklar-preview.json des Originals mit. Sein erster Push ueberschrieb die Preview des Originals, danach scheiterte der Webhook mit 500 (Unique-Index auf subdomain). Fast passiert bei Olzinger -> HSK-Vorlage, 04.10.2026. - foreignSubdomainOwner: Subdomain gehoert einem anderen Repo oder einem Projekt ohne Repo; ein in Gitea umgezogenes Repo (Weiterleitung) zaehlt nicht als fremd - Webhook: fremde Subdomain -> kein Deploy, Projekt ohne Preview (neu: synced, Repo-Name statt fremdem Anzeigenamen); eine schon vergebene eigene Preview bleibt, WOMS zeigt den Grund als Preview-Fehler - runDeploy prueft fuer alle anderen Wege (WOMS, Reaktivieren), vor dem Runner, damit ein gesperrter Aufruf keinen laufenden Bau verwaist; Appwrite-Fehler bei der Pruefung blockieren dort nicht (Webhook streng) Co-Authored-By: Claude Opus 5.5 --- server/routes/webhook/gitea.js | 32 +++- server/services/previewDeploy.js | 68 +++++++++ test/previewRunner.test.js | 26 ++++ test/subdomainSperre.test.js | 249 +++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 test/subdomainSperre.test.js diff --git a/server/routes/webhook/gitea.js b/server/routes/webhook/gitea.js index 5e23f4a..fa9a251 100644 --- a/server/routes/webhook/gitea.js +++ b/server/routes/webhook/gitea.js @@ -1,11 +1,12 @@ import crypto from 'node:crypto' import { Router } from 'express' import { config } from '../../config.js' -import { upsertWebsiteProjectByRepo, getDocument } from '../../services/appwriteAdmin.js' +import { upsertWebsiteProjectByRepo, getDocument, listDocuments, Query } from '../../services/appwriteAdmin.js' import { fetchPreviewConfig, defaultPreviewConfig, runDeploy, + foreignSubdomainBlock, deployKeepsStatus, buildPreviewUrl, } from '../../services/previewDeploy.js' @@ -107,18 +108,24 @@ router.post('/gitea', async (req, res) => { } } const previewConfig = previewConfigFile || defaultPreviewConfig(name) - const hasPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + const wantsPreview = Boolean(previewConfigFile?.enabled !== false && previewConfigFile) + // Kopiertes Repo mit der .webklar-preview.json eines anderen Projekts: dessen Subdomain weder + // beliefern noch uebernehmen - das Projekt bleibt ohne Preview, bis WOMS ihm eine eigene gibt. + const blocked = wantsPreview + ? await foreignSubdomainBlock(name, previewConfig.subdomain || name.split('/').pop()) + : null + const hasPreview = wantsPreview && !blocked // Nur Projekte mit aktivierter Preview bekommen eine Subdomain zugewiesen - // Repo-only-Projekte (enabled:false, keine Subdomain) bleiben ohne Website. const subdomain = hasPreview ? previewConfig.subdomain || name.split('/').pop() : '' const previewUrl = subdomain ? buildPreviewUrl(subdomain) : '' - let deployResult = { deployed: false, skipped: true } + let deployResult = blocked || { deployed: false, skipped: true } const configBranch = previewConfig.branch || 'main' if (hasPreview && previewConfig.enabled !== false && branch === configBranch) { try { - deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha }) + deployResult = await runDeploy(name, branch, previewConfig, { sha: commitSha, subdomainChecked: true }) } catch (deployErr) { console.error('[webhook] deploy failed:', deployErr.message) deployResult = { deployed: false, error: deployErr.message } @@ -141,7 +148,8 @@ router.post('/gitea', async (req, res) => { const payloadNameStillCurrent = payloadName.toLowerCase() === current.toLowerCase() const projectPayload = { - projectName: previewConfig.displayName || (payloadNameStillCurrent ? repo.name : '') || subdomain, + // der Anzeigename einer fremden Config gehoert ebenfalls dem Original + projectName: (blocked ? '' : previewConfig.displayName) || (payloadNameStillCurrent ? repo.name : '') || subdomain, templateName: previewConfig.templateName || config.gitea.templateRepo, giteaRepoUrl: payloadNameStillCurrent ? repo.html_url || `${config.gitea.baseUrl}/${current}` @@ -161,6 +169,20 @@ router.post('/gitea', async (req, res) => { } else if (hasPreview) { projectPayload.status = 'pending' projectPayload.provisioningStatus = deployResult.error ? 'deploy_failed' : 'pending' + } else if (blocked) { + // eine schon vergebene eigene Preview bleibt, wie sie ist - sonst Projekt ohne Preview; + // ein vorhandener Projektname auch (der Repo-Name waere nur der Ersatz fuer neue Projekte) + const existing = (await listDocuments(config.collections.websiteProjects, [ + Query.equal('repoFullName', current), + Query.limit(1), + ]))[0] + if (!existing?.subdomain) projectPayload.status = 'synced' + else { + // sonst stuende im WOMS weiter der alte Bau als aktuell da (Fehlertext nur bei 'failed') + projectPayload.previewState = 'failed' + projectPayload.previewError = `Neuer Stand nicht veröffentlicht: In .webklar-preview.json steht die Subdomain „${blocked.subdomain}“, die gehört ${blocked.owner}. Projekt im WOMS bearbeiten und speichern, dann stimmt die Datei wieder.`.slice(0, 500) + } + if (existing?.projectName) delete projectPayload.projectName } else { projectPayload.status = 'synced' } diff --git a/server/services/previewDeploy.js b/server/services/previewDeploy.js index 2d83d5f..fff127a 100644 --- a/server/services/previewDeploy.js +++ b/server/services/previewDeploy.js @@ -802,7 +802,75 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project } } +/** + * Gehoert `subdomain` schon einem ANDEREN Projekt? Ein kopiertes Repo (git clone + push, Vorlage) + * bringt die .webklar-preview.json des Originals mit - ohne diese Pruefung ueberschrieb sein erster + * Push die Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). Ein in Gitea umbenanntes + * oder umgezogenes Repo ist nicht fremd: Gitea leitet den alten Namen auf `repoFullName` weiter. + * Ein Projekt ohne Repo (z. B. die Relaunch-Vorschau) haelt seine Subdomain ebenfalls. + * @returns {Promise} Repo-Name bzw. Projekt des fremden Besitzers, sonst '' + */ +export async function foreignSubdomainOwner(repoFullName, subdomain) { + const own = String(repoFullName || '').toLowerCase() + // ohne Repo gibt es nichts zu klonen - und ein Projekt ohne Repo sperrt sich nicht selbst + if (!subdomain || !own) return '' + const docs = await listDocuments(config.collections.websiteProjects, [ + Query.equal('subdomain', subdomain), + Query.limit(5), + ]) + for (const doc of docs) { + const other = String(doc.repoFullName || '') + if (!other) return `Projekt „${doc.projectName || doc.$id}“ (ohne Repo)` + if (other.toLowerCase() === own) continue + let resolved = '' + try { + resolved = String((await giteaFetch(`/repos/${other}`))?.full_name || '') + } catch { + // geloescht oder Gitea nicht erreichbar: im Zweifel fremd + } + if (resolved.toLowerCase() !== own) return other + } + return '' +} + +/** + * Ergebnis fuer einen gesperrten Deploy oder null. `failOpen`: ist die Pruefung selbst nicht + * moeglich (Appwrite weg), trotzdem deployen - so nutzt es runDeploy; der Webhook prueft vorher + * streng, und die WOMS-Wege lesen das Projekt ohnehin zuerst aus Appwrite. + */ +export async function foreignSubdomainBlock(repoFullName, subdomain, { failOpen = false } = {}) { + let owner + try { + owner = await foreignSubdomainOwner(repoFullName, subdomain) + } catch (err) { + if (!failOpen) throw err + console.error(`[previewDeploy] ${repoFullName}: Besitzer der Subdomain "${subdomain}" nicht pruefbar, deploye trotzdem:`, err.message) + return null + } + if (!owner) return null + console.warn(`[previewDeploy] ${repoFullName}: Subdomain "${subdomain}" gehoert ${owner} - kein Deploy`) + return { deployed: false, blocked: true, reason: 'subdomain_foreign', subdomain, owner } +} + +/** + * Muss runDeploy den Besitzer der Subdomain pruefen? Nicht, wenn der Aufrufer es schon getan hat + * (`subdomainChecked`, der Webhook) oder wenn es die schon vergebene Subdomain des uebergebenen + * Projekts selbst ist. + */ +function needsOwnerCheck(repoFullName, subdomain, { project, subdomainChecked } = {}) { + if (subdomainChecked) return false + const same = (a, b) => String(a || '').toLowerCase() === String(b || '').toLowerCase() + return !(project?.subdomain && same(project.subdomain, subdomain) && same(project.repoFullName, repoFullName)) +} + export async function runDeploy(repoFullName, branch, previewConfig, options = {}) { + // Vor dem Runner pruefen: ein gesperrter Aufruf belegt dort keinen Platz und laesst so den + // Verfolger eines laufenden Baus desselben Repos in Ruhe. + const target = previewConfig.subdomain || repoFullName.split('/').pop() + if (needsOwnerCheck(repoFullName, target, options)) { + const blocked = await foreignSubdomainBlock(repoFullName, target, { failOpen: true }) + if (blocked) return blocked + } if (config.previewRunner.enabled) { return runDeployViaRunner(repoFullName, branch, previewConfig, options) } diff --git a/test/previewRunner.test.js b/test/previewRunner.test.js index 29db6bd..ba02f47 100644 --- a/test/previewRunner.test.js +++ b/test/previewRunner.test.js @@ -103,6 +103,32 @@ test('Schalter aus: kein Runner-Aufruf, alter Pfad', async () => { assert.equal(rs.calls.length, 0) }) +test('fremde Subdomain (kopierte Config): kein Runner-Auftrag, Projekt unverändert', async () => { + const original = await project({ subdomain: 'original-seite' }) + const kopie = await project() + const r = await pd.runDeploy(kopie.repo, 'main', { ...cfg(kopie.repo), subdomain: 'original-seite' }, { project: kopie.doc, sha: SHA }) + assert.equal(r.blocked, true) + assert.equal(r.owner, original.repo) + assert.equal(submits().length, 0) + assert.equal(aw.get('websiteProjects', kopie.id).previewState, undefined) + // danach laeuft ein normaler Auftrag fuer dasselbe Repo wieder an + const ok = await pd.runDeploy(kopie.repo, 'main', cfg(kopie.repo), { project: kopie.doc, sha: SHA }) + assert.equal(ok.queued, true) + await pd.waitForRunFollowers() +}) + +test('gesperrter Aufruf waehrend eines laufenden Baus: der Verfolger bringt den Bau trotzdem online', async () => { + await project({ subdomain: 'andere-seite' }) + const { id, repo, doc } = await project() + const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) + assert.equal(r.queued, true) + const blocked = await pd.runDeploy(repo, 'main', { ...cfg(repo), subdomain: 'andere-seite' }, { project: doc, sha: SHA }) + assert.equal(blocked.blocked, true) + await pd.waitForRunFollowers() + assert.equal(aw.get('websiteProjects', id).previewState, 'online') + assert.equal(submits().length, 1) +}) + test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis online', async () => { const { id, repo, doc } = await project({ projectName: 'Mein Projekt', previewEnv: envLib.encryptEnv({ API_URL: 'https://x.test' }, key) }) const r = await pd.runDeploy(repo, 'main', cfg(repo), { project: doc, sha: SHA }) diff --git a/test/subdomainSperre.test.js b/test/subdomainSperre.test.js new file mode 100644 index 0000000..1e2135a --- /dev/null +++ b/test/subdomainSperre.test.js @@ -0,0 +1,249 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import express from 'express' +import { startFakeGitea } from './helpers/fakeGitea.js' +import { startFakeAppwrite } from './helpers/fakeAppwrite.js' + +// Sperre gegen fremde Subdomains: Ein kopiertes Repo (git clone + push, Vorlage) bringt die +// .webklar-preview.json des Originals mit. Ohne Sperre ueberschrieb sein erster Push die +// Preview des Originals (Olzinger -> HSK-Vorlage, 04.10.2026). + +const WEBHOOK_TOKEN = 's3cret' +const ORIGINAL = 'WEBklar/Olzinger' +const KOPIE = 'WEBklar/HSK-Kopie' + +const previewJson = (subdomain) => + JSON.stringify({ enabled: true, type: 'static', branch: 'main', displayName: 'Olzinger', subdomain }) + +let gitea +let aw +let tmp +let originalCwd +let server +let baseUrl +let foreignSubdomainOwner +let runDeploy +let foreignSubdomainBlock + +function pushPayload(repo, { sha, branch = 'main', pusher = 'knso' }) { + return { + ref: `refs/heads/${branch}`, + repository: { full_name: repo, name: repo.split('/')[1], html_url: `${gitea.baseUrl}/${repo}` }, + commits: [{ id: sha, message: `Commit ${sha}`, author: { name: pusher } }], + pusher: { username: pusher }, + after: sha, + } +} + +async function webhook(payload) { + const bodyText = JSON.stringify(payload) + const signature = createHmac('sha256', WEBHOOK_TOKEN).update(bodyText).digest('hex') + const res = await fetch(`${baseUrl}/webhook/gitea`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-Gitea-Signature': signature }, + body: bodyText, + }) + return { status: res.status, body: await res.json().catch(() => null) } +} + +const docOf = (repo) => aw.all('websiteProjects').find((d) => d.repoFullName === repo) + +before(async () => { + gitea = await startFakeGitea({ + repos: [ + { full_name: ORIGINAL }, + { full_name: KOPIE }, + { full_name: 'WEBklar/neu-name' }, + { full_name: 'WEBklar/relaunch-kopie' }, + { full_name: 'WEBklar/eigene-seite' }, + ], + redirects: { 'knso/alt-name': 'WEBklar/neu-name' }, + contents: { + [`${ORIGINAL}:.webklar-preview.json`]: previewJson('olzinger'), + [`${KOPIE}:.webklar-preview.json`]: previewJson('olzinger'), + 'WEBklar/relaunch-kopie:.webklar-preview.json': previewJson('relaunch'), + // hat schon eine eigene Preview, Config von Hand auf eine fremde Subdomain gestellt + 'WEBklar/eigene-seite:.webklar-preview.json': previewJson('olzinger'), + }, + }) + aw = await startFakeAppwrite({ + unique: { websiteProjects: ['repoFullName', 'subdomain'] }, + seed: { + websiteProjects: [ + { $id: 'olz', repoFullName: ORIGINAL, projectName: 'Olzinger', subdomain: 'olzinger', customerId: 'c1', ticketId: 't1', status: 'deployed' }, + // per Gitea umgezogen (alter Name leitet weiter), Dokument noch unter dem alten Namen + { $id: 'umzug', repoFullName: 'knso/alt-name', projectName: 'Umzug', subdomain: 'umzug-seite' }, + // Repo in Gitea geloescht, Dokument haelt die Subdomain noch + { $id: 'weg', repoFullName: 'knso/geloescht', projectName: 'Weg', subdomain: 'weg-seite' }, + // gehostet ohne Repo (wie die Relaunch-Vorschau) + { $id: 'rel', repoFullName: '', projectName: 'WEBklar Relaunch', subdomain: 'relaunch' }, + { $id: 'eigen', repoFullName: 'WEBklar/eigene-seite', projectName: 'Eigene Seite', subdomain: 'eigene-seite', status: 'deployed', provisioningStatus: 'ready' }, + ], + workorders: [{ $id: 't1', woid: '100130', customerName: 'Olzinger', topic: 'Website', status: 'Open' }], + customers: [{ $id: 'c1', name: 'Olzinger GmbH' }], + employees: [{ $id: 'k', displayName: 'Kenso Grimm', shortcode: 'KNSO', email: 'kenso@webklar.com' }], + }, + }) + + originalCwd = process.cwd() + tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'subdomain-sperre-')) + process.chdir(tmp) + + Object.assign(process.env, { + GITEA_BASE_URL: gitea.baseUrl, + GITEA_API_TOKEN: 'test-token', + GITEA_WEBHOOK_TOKEN: WEBHOOK_TOKEN, + APPWRITE_ENDPOINT: aw.endpoint, + APPWRITE_API_KEY: 'test-key', + PREVIEW_DEPLOY_ROOT: '', + TRAEFIK_DYNAMIC_DIR: '', + PUSH_TIME_SECRET: 'w'.repeat(40), + }) + + const [{ default: giteaWebhookRoutes }, previewDeploy, mailer] = await Promise.all([ + import('../server/routes/webhook/gitea.js'), + import('../server/services/previewDeploy.js'), + import('../server/services/mailer.js'), + ]) + foreignSubdomainOwner = previewDeploy.foreignSubdomainOwner + runDeploy = previewDeploy.runDeploy + foreignSubdomainBlock = previewDeploy.foreignSubdomainBlock + mailer.setMailTransportForTests({ sendMail: async () => ({}) }) + + const app = express() + app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = buf } })) + app.use('/webhook', giteaWebhookRoutes) + server = await new Promise((resolve) => { + const s = app.listen(0, '127.0.0.1', () => resolve(s)) + }) + baseUrl = `http://127.0.0.1:${server.address().port}` +}) + +after(async () => { + if (server) await new Promise((resolve) => server.close(resolve)) + if (originalCwd) process.chdir(originalCwd) + await gitea?.close() + await aw?.close() + if (tmp) await fs.rm(tmp, { recursive: true, force: true }) +}) + +test('foreignSubdomainOwner: freie Subdomain und eigenes Repo sind nicht fremd', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'noch-frei'), '') + assert.equal(await foreignSubdomainOwner(ORIGINAL, 'olzinger'), '') + assert.equal(await foreignSubdomainOwner('webklar/OLZINGER', 'olzinger'), '', 'Schreibweise egal') + assert.equal(await foreignSubdomainOwner(KOPIE, ''), '') +}) + +test('foreignSubdomainOwner: Subdomain eines anderen Repos ist fremd', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'olzinger'), ORIGINAL) +}) + +test('foreignSubdomainOwner: in Gitea umgezogenes Repo ist nicht fremd, geloeschtes schon', async () => { + assert.equal(await foreignSubdomainOwner('WEBklar/neu-name', 'umzug-seite'), '') + assert.equal(await foreignSubdomainOwner(KOPIE, 'weg-seite'), 'knso/geloescht') +}) + +test('foreignSubdomainOwner: Projekt ohne Repo haelt seine Subdomain, sperrt sich aber nicht selbst', async () => { + assert.equal(await foreignSubdomainOwner(KOPIE, 'relaunch'), 'Projekt „WEBklar Relaunch“ (ohne Repo)') + assert.equal(await foreignSubdomainOwner('', 'relaunch'), '') +}) + +test('runDeploy verweigert eine fremde Subdomain, bevor irgendetwas geklont wird', async () => { + const before = gitea.calls.length + const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.deployed, false) + assert.equal(r.blocked, true) + assert.equal(r.reason, 'subdomain_foreign') + assert.equal(r.subdomain, 'olzinger') + assert.equal(r.owner, ORIGINAL) + // nur die Besitzer-Pruefung fragt Gitea (nach dem Original), fuer die Kopie wird nichts geholt + assert.deepEqual(gitea.calls.slice(before).map((c) => `${c.method} ${c.path}`), [`GET /api/v1/repos/${ORIGINAL}`]) +}) + +test('runDeploy: Pruefung nicht moeglich (Appwrite-Fehler) → Deploy laeuft trotzdem', async () => { + aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 }) + const r = await runDeploy(KOPIE, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.blocked, undefined) + assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') +}) + +test('foreignSubdomainBlock ohne failOpen (Webhook): Appwrite-Fehler wird weitergereicht', async () => { + aw.failNext({ method: 'GET', pathIncludes: 'websiteProjects', status: 500 }) + await assert.rejects(foreignSubdomainBlock(KOPIE, 'olzinger')) + assert.equal((await foreignSubdomainBlock(KOPIE, 'olzinger')).owner, ORIGINAL) +}) + +test('runDeploy: eigene Subdomain des uebergebenen Projekts und subdomainChecked brauchen keine Abfrage', async () => { + const calls = aw.calls.length + const own = await runDeploy(ORIGINAL, 'main', { subdomain: 'olzinger' }, { project: aw.get('websiteProjects', 'olz') }) + assert.equal(own.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') + const checked = await runDeploy(KOPIE, 'main', { subdomain: 'olzinger' }, { subdomainChecked: true }) + assert.equal(checked.blocked, undefined, 'der Aufrufer hat schon geprueft') + assert.equal(aw.calls.length, calls) +}) + +test('runDeploy: eigene Subdomain laeuft weiter wie bisher', async () => { + const r = await runDeploy(ORIGINAL, 'main', { enabled: true, type: 'static', subdomain: 'olzinger' }) + assert.equal(r.blocked, undefined) + assert.equal(r.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') +}) + +test('Webhook: Push einer Kopie mit fremder Subdomain → kein Deploy, Projekt ohne Preview', async () => { + const r = await webhook(pushPayload(KOPIE, { sha: 'aaaa1111' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, '') + assert.equal(r.body.previewUrl, '') + assert.equal(r.body.deploy.blocked, true) + assert.equal(r.body.deploy.owner, ORIGINAL) + + const kopie = docOf(KOPIE) + assert.ok(kopie, 'Dokument der Kopie angelegt') + assert.ok(!kopie.subdomain, 'Kopie bekommt die fremde Subdomain nicht') + assert.equal(kopie.status, 'synced') + assert.equal(kopie.projectName, 'HSK-Kopie', 'nicht der Anzeigename aus der fremden Config') + + const original = aw.get('websiteProjects', 'olz') + assert.equal(original.subdomain, 'olzinger') + assert.equal(original.status, 'deployed') +}) + +test('Webhook: Kopie auf anderem Branch bekommt die fremde Subdomain auch nicht', async () => { + const r = await webhook(pushPayload(KOPIE, { sha: 'bbbb2222', branch: 'entwurf' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, '') + assert.ok(!docOf(KOPIE).subdomain) +}) + +test('Webhook: Kopie mit der Subdomain eines Projekts ohne Repo wird gesperrt', async () => { + const r = await webhook(pushPayload('WEBklar/relaunch-kopie', { sha: 'cccc3333' })) + assert.equal(r.status, 200) + assert.equal(r.body.deploy.blocked, true) + assert.equal(aw.get('websiteProjects', 'rel').subdomain, 'relaunch') +}) + +test('Webhook: Push des Originals deployt wie bisher', async () => { + const r = await webhook(pushPayload(ORIGINAL, { sha: 'dddd4444' })) + assert.equal(r.status, 200) + assert.equal(r.body.subdomain, 'olzinger') + assert.equal(r.body.deploy.blocked, undefined) + assert.equal(r.body.deploy.reason, 'PREVIEW_DEPLOY_ROOT nicht gesetzt') + assert.equal(docOf(ORIGINAL).subdomain, 'olzinger') +}) + +test('Webhook: Projekt mit eigener Preview und fremder Subdomain in der Config bleibt, wie es ist', async () => { + const r = await webhook(pushPayload('WEBklar/eigene-seite', { sha: 'ffff6666' })) + assert.equal(r.status, 200) + assert.equal(r.body.deploy.blocked, true) + const doc = aw.get('websiteProjects', 'eigen') + assert.equal(doc.subdomain, 'eigene-seite') + assert.equal(doc.status, 'deployed') + assert.equal(doc.provisioningStatus, 'ready') + assert.equal(doc.projectName, 'Eigene Seite') + assert.equal(doc.previewState, 'failed', 'WOMS zeigt den Fehlertext nur bei failed') + assert.match(doc.previewError, /„olzinger“, die gehört WEBklar\/Olzinger/) + assert.equal(aw.get('websiteProjects', 'olz').subdomain, 'olzinger') +}) From 2f9dfd8ad5da536902fa4188abef332c6c32f51c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 19:37:19 +0000 Subject: [PATCH 08/21] =?UTF-8?q?Spec:=20Analyse-=C3=9Cbersicht=20neu=20(C?= =?UTF-8?q?ockpit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../2026-10-04-analyse-uebersicht-design.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md diff --git a/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md b/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md new file mode 100644 index 0000000..e6866e0 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md @@ -0,0 +1,239 @@ +# Analyse-Übersicht neu („Cockpit“) – Design + +Stand: 2026-10-04 · Status: Aufbau, Design-Richtung und Abschnitte 1–3 vom Nutzer im Chat freigegeben, Spec zur Durchsicht +Baut auf der Website-Analyse auf (Spec `2026-10-01-website-analyse-design.md`, live seit 01.10.2026). +Recherche: `/root/reports/analyse-ueberarbeitung-2026-10-04/recherche.md` (30 Tools, 25 Features, UI-Muster). +Entwürfe: `/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png` (gewählt), `entwurf-b-bericht.png`. +Regel des Nutzers: bauen → live (Nutzer pusht `main`) → Nutzer testet live. + +## Ziel + +Die Analyse-Seite im Kundenportal (Funktionen → Analyse) soll für kleine Betriebe ohne Analytics-Kenntnisse +**auf einen Blick** sagen, wie die Website läuft: ein Satz in Klartext, Vergleich zum Vorzeitraum, +Kontakt-Aktionen als Hauptzahl. Das Interface darf sich vom restlichen Portal abheben, muss aber +aufgeräumt und einheitlich sein. + +## Entscheidungen (vom Nutzer, 04.10.2026) + +| # | Entscheidung | +|---|---| +| U1 | Umfang: nur das Paket **„Übersicht neu“** (Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Klartext-Zusammenfassung, Kontakt-Aktionen als Hauptzahl, Datenbasis-Hinweis). **Nicht** in diesem Projekt: Besucher-Details (Wochentag × Uhrzeit, Kanäle, Zeit je Seite), Website-Check (Erreichbarkeit, SSL, Links, SEO), Bericht per Mail. | +| U2 | Design-Richtung **A „Cockpit“**: dunkel wie das Portal, eigene Akzentfarbe Türkis nur innerhalb der Analyse, leuchtender Block „Das Wichtigste“. | +| U3 | Aufbau mit drei Reitern **Übersicht · Heatmap · Einstellungen**; Heatmap und „Ausschalten“ hängen nicht mehr unten an der Übersicht. | +| U4 | Nur **vorhandene Daten**. Keine Änderung an Messskript (`w.js`, `h.js`), Banner, AVV, Leistungsbeschreibung, Datenschutz-Zusatzseite oder Abo-Bedingungen. | +| U5 | Klartext entsteht durch **feste Regeln im Browser**, keine KI, kein externer Dienst. | + +Rechtliche Einordnung (keine Rechtsberatung): Der Vergleich nutzt die Tageswerte des Vorzeitraums (bis zu 180 +Tage zurück; Tageswerte werden 25 Monate aufbewahrt). Durchschnitte und Tageswerte für Besucher, Kontakte und +Verweildauer stammen aus bereits erhobenen und verdichteten Daten (`daily`: `visitors`, `ct_kind`, +`en_count`, `en_sec_sum`, `scroll_sum`). Die Leistungsbeschreibung (§ 5: „Kennzahlen und Diagramme für die +letzten 7, 30 oder 90 Tage“) deckt das ab; kein neuer Text, keine neue Einwilligung. + +## 1. Daten: Statistik-Antwort des Collectors + +`GET /a/admin/stats/:siteKey?range=7|30|90` (interner Listener, `analytics/src/stats.js`) liefert **zusätzlich** +zu den bisherigen Feldern (alle bisherigen Felder bleiben unverändert, damit der Wechsel abwärtskompatibel ist): + +```js +{ + range: 30, + from: '2026-09-05', to: '2026-10-04', // Berliner Kalendertage des Zeitraums (to = heute) + firstDay: '2026-08-20', // frühester Berliner Tag mit Daten dieser Website ('' = keine) + totals: { + pv, visitors, contacts, consentRate, // wie bisher + engaged, // Zahl der en-Ereignisse (Seitenbesuche mit Zustimmung und Abschluss) + avgSec, // Ø Sekunden pro Seitenbesuch = en_sec_sum / en_count, gerundet; null wenn engaged = 0 + avgScroll, // Ø Scrolltiefe in % = scroll_sum / en_count, gerundet; null wenn engaged = 0 + }, + series: [{ day, pv, visitors, contacts, avgSec }], // bisher nur {day, pv}; avgSec je Tag null ohne en + pages, refs, countries, devices, contacts, banner, // unverändert + previous: { + from: '2026-08-06', to: '2026-09-04', // die `range` Kalendertage direkt vor `from` + complete: true, // firstDay !== '' && firstDay <= previous.from + totals: { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }, + series: [{ day, pv, visitors, contacts, avgSec }], + contacts: [{ key, count }], // je Art, für die Veränderung pro Zeile + }, +} +``` + +Regeln: +- Tageslisten werden **kalendarisch** gebildet (Berliner Tage, wie `dayList`), nicht durch Abziehen von + `range × 24 h` – sonst verrutscht der Vorzeitraum bei der Zeitumstellung um einen Tag. +- Gemeinsame Hilfsfunktion sammelt die Dimensionen für eine beliebige Tagesliste: verdichtete Tage aus `daily`, + nicht verdichtete Tage live aus `events_raw` (wie heute). Kein Tag wird doppelt gezählt. +- `firstDay` = kleinster Tag aus `daily` und `events_raw` dieser Website (`MIN(day)` bzw. `dayOf(MIN(ts))`). +- `visitors` bleibt die Summe der Tages-Besucher (Salz wechselt täglich, kein tagesübergreifender Abgleich). +- Der Portal-Server reicht die Antwort unverändert durch (`GET /api/funktionen/analyse/stats`). + +Portal-Server, einzige weitere Änderung: `analyseView` (Route `GET /api/funktionen`) gibt zusätzlich +`activatedAt` aus dem Analyse-Dokument zurück (`null`, wenn nicht aktiv). + +## 2. Oberfläche + +### 2.1 Aufbau der Seite (Zustand „aktiv“) + +``` +[ Kopfleiste: ● Website-Name · „Analyse aktiv · Stand: heute, 19:42 Uhr“ (Website-Auswahl) [7 | 30 | 90 Tage] ] +[ Reiter: Übersicht | Heatmap | Einstellungen ] +Übersicht: + [ Das Wichtigste · 5. Sep. – 4. Okt. — Klartext-Satz, darunter bis zu 4 Hinweise mit Symbol ] + [ Seitenaufrufe ] [ Besucher* ] [ Kontakt-Aktionen* ] [ Ø Zeit pro Seite* ] je: Zahl, ▲/▼-Chip, „vs. “, Mini-Verlauf + [ Seitenaufrufe pro Tag (Linie + gestrichelter Vorzeitraum) ] [ Kontakt-Aktionen: Anrufe, E-Mails, WhatsApp, Links nach außen + Quote ] + [ Was angesehen wird: Seiten | Herkunft ] [ Wer zu Besuch ist: Geräte | Länder ] + [ Datenbasis: Ring mit Zustimmungsquote + Erklärung ] [ Heatmap: Vorschaubild + „Öffnen“ → Reiter Heatmap ] + Fußzeile: „Länderdaten: IP-Geolocation by DB-IP“ +``` + +- **Kopfleiste**: Statuspunkt (türkis = aktiv). Bei `devMode` statt „Analyse aktiv“ gelb: „Im Dev-Modus wird + nicht gemessen.“ „Stand“ = Ladezeitpunkt der Daten. Bei mehreren Websites ein Auswahlfeld in der Leiste + (ersetzt das bisherige `fn-project`-Panel). Der Zeitraum-Schalter erscheint nur im Reiter Übersicht + (die Heatmap zeigt fest 30 Tage). +- **Reiter**: `role="tablist"`, Zustand bleibt beim Wechsel von Zeitraum oder Website erhalten + (`analyseSel.tab`), der URL-Hash bleibt `#analyse`. Auf dem Handy horizontal wischbar. +- **Das Wichtigste**: siehe 2.3. Unter 30 Seitenaufrufen im Zeitraum: „Sobald ein paar mehr Besuche da sind, + steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“ +- **Kennzahlen-Kacheln** (4): Seitenaufrufe, Besucher*, Kontakt-Aktionen*, Ø Zeit pro Seite* (Format `m:ss`, + darunter klein „Ø 64 % gescrollt“). Chip mit Veränderung (2.4). Mini-Verlauf aus `series` (Fläche + Linie, + ohne Achsen). Ohne Vergleich steht statt Chip „Vergleich ab TT.MM.“ (2.4). +- **Diagramm**: Seitenaufrufe pro Tag, aktuelle Linie türkis mit Fläche, Vorzeitraum gestrichelt grau (nach + Tagesindex ausgerichtet; nur wenn `previous.complete`). Legende „30 Tage“ / „Zeitraum davor“. Antippen oder + Überfahren eines Tages zeigt eine Blase „Sa., 19.09.: 371 Aufrufe (davor 210)“. SVG behält ``. +- **Kontakt-Aktionen**: Summe im Kopf, Zeilen Anrufe (`tel`), E-Mails (`mail`), WhatsApp (`wa`), Links nach außen + (`out`) mit Symbol, Zahl und Veränderungs-Chip; Arten mit 0 in beiden Zeiträumen entfallen. Darunter die + Quote: „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“ (erst ab 30 Besuchern; Kontakt-Aktionen sind + Klicks, keine Personen – daher „pro 100 Besucher“, nicht „x % der Besucher“). Ohne Kontakte: „Noch keine + Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder + WhatsApp tippt.“ +- **Ranglisten** in zwei Karten mit kleinem Umschalter: „Was angesehen wird“ (Seiten | Herkunft), „Wer zu Besuch + ist“ (Geräte | Länder). Zeile = Balken als Hintergrund des Namens, Zahl, Anteil in %. Erst 6 Zeilen, „Alle + anzeigen“ klappt bis 20 auf. `/` heißt „Startseite“. Herkunft mit lesbaren Namen für bekannte Hosts + (google.* → Google, bing.com → Bing, instagram.com → Instagram, facebook.com/m.facebook.com/l.facebook.com → + Facebook, duckduckgo.com → DuckDuckGo, ecosia.org → Ecosia), sonst der Host; `''` → „Direkt oder unbekannt“. +- **Datenbasis** (ersetzt Banner-Liste und Sternchen-Fußnote): Ring mit `consentRate`, Text „61 % haben dem + Banner zugestimmt (2.410 von 3.950 Entscheidungen). Seitenaufrufe zählen wir vollständig; Besucher, + Kontakt-Aktionen und Zeit pro Seite (*) nur bei Zustimmung.“ Ohne Entscheidungen: „Noch hat niemand über das + Banner entschieden.“ +- **Heatmap-Karte**: kleines statisches Vorschaubild (CSS-Verlauf, keine echte Website), Satz, Knopf „Öffnen“ + wechselt in den Reiter Heatmap. + +### 2.2 Reiter Heatmap und Einstellungen, andere Zustände + +- **Heatmap**: die bestehende Heatmap in Geräterahmen (`renderAnalyseHeat`, `geraete.js/.css`, iframes, + postMessage-Zähler, 20-s-Rückfall „In neuem Tab öffnen“) unverändert im Verhalten, optisch im Cockpit-Stil + (Seitenauswahl und Computer/Tablet-Schalter in einer Leiste). Die iframes laden erst, wenn der Reiter + geöffnet wird. +- **Einstellungen**: Karte „Status“ (aktiv seit `activatedAt`, Banner-Farbe als Farbfeld, Fassung + `avvVersion`), Karte „Unterlagen“ (Links AVV und Leistungsbeschreibung aus `legal`), Karte „Analyse + ausschalten“ (`renderAnalyseOff`, Texte und Ablauf unverändert). +- **Einschalten** (`renderAnalyseActivation`), **gesperrt**, **nicht verfügbar**, **unbekannt**, **noch nicht + freigeschaltet**: gleicher Cockpit-Rahmen (Kopfleiste ohne Zeitraum und Reiter). Alle Texte, Links, Häkchen, + Farbwahl und Banner-Vorschau bleiben **wörtlich** wie heute (Rechtsrunde 01./02.10.). +- **Laden**: Platzhalter-Kacheln in der Form der echten Kacheln statt „Lade…“. **Fehler 503**: Karte + „Auswertung gerade nicht erreichbar“ mit Knopf „Neu laden“. + +### 2.3 Klartext-Regeln („Das Wichtigste“) + +Reine Funktion `summarize(stats)` → `{ lead, items: [{ icon, text }] }` in `public/analyse-view.js` +(ohne DOM, Node-testbar). Zahlen deutsch formatiert. Nur bei `totals.pv >= 30`, sonst Leerzustand (2.1). + +**Satz** (`lead`): +- Vergleich möglich (2.4): „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen – **Y % mehr** / + **Y % weniger** als in den N Tagen davor.“ Bei |Veränderung| < 5 %: „… – etwa so oft wie in den N Tagen + davor.“ Bei kleinen Zahlen (2.4) absolut: „… – **12 Aufrufe mehr** als in den N Tagen davor.“ +- Kein Vergleich: „Deine Website wurde in den letzten N Tagen **X-mal** aufgerufen.“ Liegt `firstDay` im + Zeitraum: „Seit dem Start der Analyse am TT.MM. wurde deine Website **X-mal** aufgerufen.“ + +**Hinweise** (`items`, in dieser Reihenfolge, nur wenn die Bedingung erfüllt ist, höchstens 4): +1. Kontakte (`contacts >= 1`): „N-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).“ + bei + Vergleich und Vorwert ≥ 1: „Das sind 45 mehr als zuvor.“ / „… weniger …“ / „Genauso oft wie zuvor.“ + Bei 0 Kontakten und `visitors >= 30`: „Von den Besuchern mit Zustimmung hat noch niemand über die Website + angerufen oder geschrieben.“ (Kontakt-Aktionen zählen nur bei Zustimmung.) +2. Wochentag (mindestens 7 Tage mit Daten im Zeitraum und `pv >= 70`): Ø Aufrufe je Wochentag über die Tage + ab `firstDay`; stärkster Wochentag, wenn sein Schnitt ≥ 1,2 × Gesamtschnitt: „Samstag ist im Schnitt dein + stärkster Tag.“ Zusätzlich Spitzentag, wenn ≥ 1,8 × Tagesschnitt: „Spitzentag war Sa., 19.09. mit 371 + Aufrufen.“ (beides in einem Hinweis, wenn beides zutrifft). +3. Herkunft (erster Eintrag in `refs` mit Host, Anteil ≥ 20 % der Herkunfts-Summe): „Die meisten, die über + einen Link kamen, kommen von Google (880).“ +4. Gerät (`m` oder `d` ≥ 55 % der Geräte-Summe): „66 % der Aufrufe kommen vom Handy.“ / „… vom Computer.“ +5. Seite (`pages[0]`): „Am meisten angesehen: die Startseite, danach /preise (1.310).“ bzw. „Am meisten + angesehen: /preise (1.310 Aufrufe).“ + +### 2.4 Vergleich und Veränderungs-Chips + +- Vergleich möglich, wenn `previous.complete` und `previous.totals.pv >= 20`. Sonst kein Chip; Kacheln zeigen + „Vergleich ab TT.MM.“ mit TT.MM. = `firstDay` + (2 × range − 1) Tage; ohne `firstDay` gar nichts. Fehlt + `previous` ganz (alter Collector), wie „kein Vergleich“ ohne Datum. +- Funktion `delta(cur, prev, { kind })` → `{ dir: 'up'|'down'|'flat', text, aria }`: + - Zählwerte: Prozent (ganzzahlig, „▲ 14 %“), wenn `prev >= 100` und `cur >= 100`; sonst absolut („▲ 12“, + „▼ 3“). Gleich → „± 0“ (`flat`). |Prozent| < 1 → `flat` „± 0 %“. + - Sekunden: absolut, „▲ 6 s“ / „▼ 1:05“ (ab 60 s als m:ss). + - `aria`: „plus 14 Prozent gegenüber dem Zeitraum davor“ usw. Farbe nie alleiniger Träger (Pfeile ▲▼). +- Farben: mehr = türkis, weniger = rosé, gleich = grau. Bewusst **keine** Wertung „gut/schlecht“ (weniger + Zeit pro Seite kann gut sein). + +### 2.5 Optik („Cockpit“) + +- Alles unter einem Wurzelelement `.an` mit eigenen CSS-Variablen; außerhalb ändert sich nichts. + Akzent `--an-acc: #2dd4bf`, stark `#5eead4`, weich `rgba(45,212,191,.1)`; weniger `#fda4af`; + Karten `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstände 14 px. +- Kartentitel einheitlich: 13 px, fett, Großbuchstaben mit Sperrung, gedämpft; Zahlen `tabular-nums`. +- „Das Wichtigste“: Verlauf türkis → violett, türkiser Rand, Satz 21 px. +- Desktop: Raster wie Entwurf A (4 Kacheln; Diagramm 1,65 : 1 Kontakte; 2 × 2 Karten). Diagramm und + Kontaktkarte gleich hoch, das Diagramm füllt die Höhe. +- Handy (≤ 880 px, App-Ansicht): Kopfleiste zweizeilig, Zeitraum-Schalter volle Breite, Kacheln 2 × 2, Hinweise + einspaltig, alle Karten untereinander. Keine waagrechte Seiten-Scrollleiste. +- `prefers-reduced-motion`: keine Übergänge. + +## 3. Code-Aufteilung + +`public/funktionen.js` (980 Zeilen) wird entlastet: +- **`public/analyse.js`** (neu): gesamte Analyse-Oberfläche – `initAnalyse`, `renderAnalyseActivation`, + `renderAnalyseOff`, Übersicht, Heatmap, Einstellungen. `app.js` lädt `/analyse.js` für die Ansicht `analyse`. +- **`public/fn-util.js`** (neu): `esc`, `safeUrl`, `request` – bisher lokal in `funktionen.js`, jetzt von + beiden Modulen importiert. +- **`public/analyse-view.js`**: bekommt die reinen Funktionen `summarize`, `delta`, `fmtDuration`, + `contactRate`, `compareFrom`, `refName`, `weekdayStats` sowie `analyseOf`, `analyseState`, + `pickAnalyseProject` (bisher in `funktionen.js`, von der Kachel und der Analyse gebraucht). +- **`public/analyse.css`** (neu, in `dashboard.html` eingebunden): alle `.an`-Regeln; die alten + `.an-*`-Regeln in `funktionen.css` entfallen, soweit sie nur die Analyse betreffen. +- Kachel „Analyse“ auf der Funktionen-Seite bleibt in `funktionen.js` (unverändert im Verhalten). +- Bestehende Element-IDs, die Tests und Abläufe nutzen, bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, + `#an-avv`, `#an-on`, `#an-error`, `#an-off-*`, `#an-heat-page`. + +## 4. Tests + +- **Collector** (`analytics/test/stats.test.js`): `previous` mit richtigen Tagen und Summen; `complete` + false/true je nach `firstDay`; `firstDay` aus `daily` und Rohdaten; `series` mit visitors/contacts/avgSec; + `avgSec`/`avgScroll` null ohne en; Vorzeitraum über eine Zeitumstellung (Ende Oktober) lückenlos und ohne + Überlappung; nichts doppelt (verdichtet + live). Vertragstest `test/analyticsContract.test.js` um die neuen + Felder ergänzen. +- **Portal, rein** (`test/analyseInsights.test.js`, neu): `summarize` für Wachstum, Rückgang, gleich, kleine + Zahlen, ohne Vergleich, neu gestartet, zu wenig Daten, ohne Kontakte, Wochentag/Spitzentag, Herkunft, Gerät, + höchstens 4 Hinweise; `delta` (Prozent/absolut/flat/Sekunden/aria); `compareFrom`; `contactRate`; + `refName`; HTML-Maskierung aller Werte, die aus Daten kommen (Seitenpfade, Hosts). +- **Portal, bestehend**: `funktionenTiles.test.js` und `analyseView.test.js` auf `analyse.js`/`fn-util.js` + umstellen; alle bisherigen Text-Prüfungen (Einschalten, Ausschalten) laufen unverändert weiter. + `analyseRoutes.test.js`: `activatedAt` in der Ansicht. +- **Browser** (`test/ui/analyse.ui.mjs`, erweitert; Playwright mit gemockter API): Desktop 1440 und Handy 402 × + Zustände aktiv (volle Daten), aktiv neu (ohne Vergleich), wenig Daten, Dev-Modus, Einschalten, fehlende + Angaben, 503; Reiterwechsel; Heatmap-iframes erst nach Reiterwechsel; keine `pageerror`; keine waagrechte + Scrollleiste. Screenshots nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/`. +- Gesamte Suiten: `npm test` (Portal) und `analytics: npm test` grün; Node 20 (Portal) und Node 24 (Collector). + +## 5. Livegang + +1. Nutzer pusht `feature/analyse-uebersicht` nach `main` (mein Push nach `main` ist im Auto-Modus gesperrt). +2. `deploy.sh` startet Portal und – weil `analytics/` geändert – den Collector neu. Keine Schema-Änderung, + keine `.env`-Änderung. +3. Prüfung durch mich: Collector `/a/health`, Stats-Abruf einer echten aktiven Website im Container + (neue Felder vorhanden, alte unverändert), Portal-Bundle enthält `analyse.js`, Ansicht lädt ohne Fehler. +4. Nutzer testet live mit einer Website mit aktiver Analyse. + +Rückweg: Portal und Collector sind gegenseitig abwärtskompatibel (alte Oberfläche ignoriert neue Felder, neue +Oberfläche kommt ohne `previous` aus); Zurückrollen = Revert-Commit auf `main`. + +## Nicht in diesem Projekt + +Besucher-Details, Website-Check, Bericht per Mail (U1); Änderungen an Messskript, Banner und Rechtstexten (U4); +WOMS; Anpassen der Banner-Farbe nach dem Einschalten; Export. From c1dada31895624d0971a61008198aea77ebd6baa Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:44:10 +0000 Subject: [PATCH 09/21] =?UTF-8?q?Plan:=20Analyse-=C3=9Cbersicht=20neu=20(C?= =?UTF-8?q?ockpit)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../plans/2026-10-04-analyse-uebersicht.md | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-analyse-uebersicht.md diff --git a/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md new file mode 100644 index 0000000..707f69b --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-analyse-uebersicht.md @@ -0,0 +1,456 @@ +# Analyse-Übersicht neu („Cockpit“) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Die Analyse-Seite im Kundenportal zeigt eine Übersicht im „Cockpit“-Stil: Klartext-Zusammenfassung, Kennzahlen mit Vorzeitraum-Vergleich und Mini-Verlauf, Kontakt-Aktionen, Datenbasis; Heatmap und Einstellungen in eigenen Reitern. + +**Architecture:** Der Collector (`analytics/src/stats.js`) liefert zusätzlich Vorzeitraum, `firstDay`, Tageswerte und Durchschnitte; der Portal-Server reicht das durch. Im Browser: reine Funktionen (`analyse-view.js`), reine HTML-Bausteine (`analyse-cards.js`), DOM-Verdrahtung (`analyse.js`), Stil (`analyse.css`). Die Analyse-Oberfläche zieht aus `funktionen.js` aus. + +**Tech Stack:** Node 24 + `node:sqlite` (Collector), Node 20 + Express (Portal), Vanilla-JS-ES-Module im Browser, `node --test`, Playwright (`playwright-core` aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core`). + +**Spec:** `docs/superpowers/specs/2026-10-04-analyse-uebersicht-design.md` + +## Global Constraints + +- Arbeitsort: Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu`, Branch `feature/analyse-uebersicht`. `node_modules` ist ein Symlink auf `../build/node_modules` und bleibt untracked: **nie `git add -A`**, immer Dateien einzeln adden. +- Keine neuen Abhängigkeiten. Keine Änderung an `analytics/client/*`, `public/rechtliches/*`, Banner, AVV, Leistungsbeschreibung, Datenschutztexten, WOMS, Schema, `.env`. +- Nur vorhandene Daten (Spec U4). Klartext nur durch feste Regeln im Browser (U5). +- Texte der Zustände Einschalten und Ausschalten bleiben **wörtlich** wie heute (bestehende Tests in `test/funktionenTiles.test.js` prüfen sie). +- Neue Texte: Deutsch, Du-Form klein („du“, „deine“), Zahlen `de-DE` („6.930“, „7,8“). +- Element-IDs bleiben: `#analyse-body`, `#an-stage`, `#an-preview`, `#an-avv`, `#an-on`, `#an-error`, `#an-color`, `#an-hint`, `#an-off`, `#an-off-*`, `#an-heat-page`. +- Alle Werte aus Daten (Seitenpfade, Hosts, Projektnamen) werden in HTML maskiert (`esc`). +- Akzent nur unter `.an`: `--an-acc: #2dd4bf`, `--an-acc-strong: #5eead4`, `--an-acc-soft: rgba(45,212,191,.1)`, weniger `#fda4af`, Karte `rgba(14,16,20,.92)`, Rand `rgba(255,255,255,.09)`, Radius 16 px, Abstand 14 px. +- Reiter-Schlüssel: `'uebersicht' | 'heatmap' | 'einstellungen'`. Zeiträume: 7, 30, 90. +- Portal-Tests laufen auf Node 20 (Produktion) und 24; Collector-Tests auf Node 24. +- Commit-Nachrichten enden mit `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. + +## Review Focus + +1. Antwort eines **alten Collectors** ohne `previous`, `firstDay`, `from/to` und ohne `series[].visitors` (Deploy-Reihenfolge): Seite rendert ohne Chips und ohne Fehler, Mini-Verläufe nur, wo Werte da sind → Test in Task 4. +2. **Website ohne einen einzigen Aufruf** (alles 0, `consentRate` 0, `avgSec` null): keine `NaN`/`Infinity`/`undefined` im HTML, Leerzustand im Block „Das Wichtigste“, Ring 0 % → Test in Task 4. +3. **Seitenpfad oder Host mit HTML** (`/<img src=x onerror=alert(1)>`, Host `a"b.de`): überall maskiert (Hinweise, Ranglisten, Heatmap-Auswahl) → Tests in Task 3 und Task 4. +4. **Schnelles Umschalten** von Zeitraum/Website: eine langsame ältere Antwort überschreibt die neuere nicht → Playwright-Check mit verzögerter Antwort in Task 5. +5. **Sehr langer Pfad** auf dem Handy (402 px): keine waagrechte Scrollleiste, Text gekürzt → Playwright-Check in Task 5. + +--- + +### Task 1: Collector liefert Vorzeitraum, firstDay, Tageswerte, Durchschnitte; Portal liefert activatedAt + +**Files:** +- Modify: `analytics/src/stats.js` (gesamte Datei, 69 Zeilen) +- Modify: `analytics/test/stats.test.js` (Test „Stats-Endpunkt: Form …“ ab Zeile 118 anpassen, neue Tests anhängen) +- Modify: `test/analyticsContract.test.js:42-50` +- Modify: `server/routes/funktionen.js:51-75` (`analyseView`) +- Modify: `test/analyseRoutes.test.js:115` (+ neuer Test) + +**Interfaces:** +- Produces: Antwort von `computeStats(db, site, range, now)` (und damit `GET /a/admin/stats/:key` und `GET /api/funktionen/analyse/stats`) mit den Feldern aus Spec Abschnitt 1: + `range`, `from`, `to`, `firstDay`, `totals { pv, visitors, contacts, consentRate, engaged, avgSec, avgScroll }`, `series [{ day, pv, visitors, contacts, avgSec }]`, `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `previous { from, to, complete, totals {…gleich…}, series […gleich…], contacts [{key,count}] }`. +- Produces: `p.analyse.activatedAt` (ISO-String oder `null`) in `GET /api/funktionen`. + +- [ ] **Step 1: Failing Tests im Collector schreiben** (`analytics/test/stats.test.js`, Helfer `mk`, `admin`, `KEY`, `NOW` = 10.10.2026 12:00 Berlin, `D`, `H` existieren) + +Bestehenden Test „Stats-Endpunkt: Form …“ anpassen: +```js +assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); +assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 }); +assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 }); +assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null }); +assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7); +``` +Neue Tests: +```js +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + // pv am 30.09. (im Vorzeitraum 27.09.–03.10.), en am 01.10., ct am 02.10., pv am 26.09. (vor dem Vorzeitraum) + // erst ohne 26.09.: firstDay '2026-09-30', complete false; dann mit 26.09.: firstDay '2026-09-26', complete true + assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 1); // 26.09. zählt nicht mit + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer +}); +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { /* '' ohne Ereignisse; nach runNightly weiterhin gleich */ }); +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + // zwei en am selben Tag: sec 30/scroll 50 und sec 91/scroll 100 → avgSec 61 (60,5 gerundet), avgScroll 75, engaged 2 +}); +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30 + // series[0].day '2026-10-12', series[29].day '2026-11-10', previous.series[29].day '2026-10-11', previous.series[0].day '2026-09-12' + // alle 60 Tage verschieden +}); +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { /* Ereignis im Vorzeitraum vor und nach runNightly: previous.totals.pv bleibt 1 */ }); +``` +Die Ereignisse der Tests mit `ev({ ts, type, … })` einfügen; `runNightly(db, { now: NOW })` verdichtet Tage < heute. + +- [ ] **Step 2: Tests laufen lassen, sie schlagen fehl** + +Run: `cd analytics && node --test test/stats.test.js` +Expected: FAIL (fehlende Felder `previous`, `firstDay` …) + +- [ ] **Step 3: `computeStats(db, site, range, now = Date.now())` in `analytics/src/stats.js` umbauen** + +Aufteilen in eine Sammel-Funktion für eine beliebige Tagesliste und eine Formungs-Funktion: +- `dayList(endDay, range)` bildet Kalendertage per `Date.UTC(y, m-1, d-i)` (wie heute). Aktueller Zeitraum: `dayList(dayOf(now), range)`. Vorzeitraum: `dayList(<Tag vor days[0]>, range)` – kalendarisch, nie `now - range*DAY_MS`. +- `collect(db, site, days, now)`: `daily` für `day IN days` und noch nicht in `agg_state` stehende Tage live aus `events_raw` (Abfrage begrenzt auf Mitternacht UTC des ersten Tags − 2 Tage bis letzter Tag + 2 Tage und `ts <= now`, dann nach `dayOf(ts)` gefiltert; `aggregateRows` aus `jobs.js` wie heute). Ergebnis je Tag: `pv_total`, `visitors`, `ct_kind`-Summe, `en_count`, `en_sec_sum`, `scroll_sum` sowie die Top-Dimensionen über alle Tage. +- Durchschnitte: `avgSec = Math.round(en_sec_sum / en_count)`, `avgScroll = Math.round(scroll_sum / en_count)`, beide `null` bei `en_count = 0`; `engaged = en_count`. +- `firstDay`: Minimum aus `SELECT MIN(day) FROM daily WHERE site = ?` und `dayOf(SELECT MIN(ts) FROM events_raw WHERE site = ?)`, `''` wenn beides fehlt. `previous.complete = firstDay !== '' && firstDay <= previous.from`. +- `pages`, `refs`, `countries`, `devices`, `contacts`, `banner`, `consentRate` bleiben exakt wie bisher (nur für den aktuellen Zeitraum); `previous.contacts` = `top(ct_kind)` des Vorzeitraums. + +- [ ] **Step 4: Collector-Tests grün** + +Run: `cd analytics && npm test` +Expected: PASS (bisher 137 Tests + neue), 0 fail + +- [ ] **Step 5: Vertragstest ergänzen** (`test/analyticsContract.test.js` nach Zeile 50) + +```js +assert.equal(stats.range, 7) +assert.equal(stats.previous.series.length, 7) +assert.ok('firstDay' in stats && 'avgSec' in stats.totals) +assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) +``` +Run: `node --test test/analyticsContract.test.js` → PASS + +- [ ] **Step 6: Failing Test für activatedAt** (`test/analyseRoutes.test.js`) + +Zeile 115: erwartetes Objekt um `activatedAt: null` ergänzen. Im Test nach erfolgreichem `activate` (Zeile ~128–135): `assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/)`. +Run: `node --test test/analyseRoutes.test.js` → FAIL + +- [ ] **Step 7: `analyseView` in `server/routes/funktionen.js` gibt `activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null` zurück** + +Run: `node --test test/analyseRoutes.test.js` → PASS + +- [ ] **Step 8: Gesamte Portal-Suite** + +Run: `npm test` +Expected: PASS, 0 fail (Ausgangslage 2259 pass) + +- [ ] **Step 9: Commit** + +```bash +git add analytics/src/stats.js analytics/test/stats.test.js test/analyticsContract.test.js server/routes/funktionen.js test/analyseRoutes.test.js +git commit -m "Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht" +``` + +--- + +### Task 2: Analyse-Oberfläche zieht aus funktionen.js aus (ohne sichtbare Änderung) + +**Files:** +- Create: `public/fn-util.js` +- Create: `public/analyse.js` +- Modify: `public/funktionen.js` (Zeilen 6–7 Importe, 28–61 Helfer, 101–110 Analyse-Zustand, 611–980 Analyse-Abschnitt) +- Modify: `public/analyse-view.js` (Zustand und Auswahl-Helfer dazu) +- Modify: `public/app.js:1920` +- Modify: `test/funktionenTiles.test.js:8-16`, `test/analyseView.test.js:50-59` + +**Interfaces:** +- Produces: `public/fn-util.js` exportiert `esc(value): string`, `safeUrl(url): string`, `request(path, { method, body, raw }): Promise<object>` (unverändert aus `funktionen.js` verschoben). +- Produces: `public/analyse-view.js` exportiert zusätzlich `analyseOf(project)`, `analyseState(a)`, `pickAnalyseProject(projects)` (verschoben) und `export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' }` (gemeinsamer Zustand für Kachel und Analyse; ersetzt das bisherige `analyseSel` in `funktionen.js`). +- Produces: `public/analyse.js` exportiert `initAnalyse(root, { projectId })`, `renderAnalyseActivation(stage, project, reload)`, `renderAnalyseOff(box, projectId, reload)` (Verhalten unverändert). +- Importstil: `funktionen.js` importiert absolut (`'/fn-util.js'`, `'/analyse-view.js'`), `analyse.js` und `analyse-view.js` relativ (`'./fn-util.js'`, `'./analyse-view.js'`, `'./geraete.js'`) – so lassen sie sich in Node direkt importieren und sind im Browser dieselben Module. + +- [ ] **Step 1: Tests auf die neue Aufteilung umstellen (schlagen fehl)** + +`test/funktionenTiles.test.js`: zusätzlich `.replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`)`; `initAnalyse`, `renderAnalyseOff` aus `await import(fileUrl('analyse.js'))` statt aus `funktionen.mjs`. +`test/analyseView.test.js` („verdrahtet“): +```js +assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) +const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') +assert.doesNotMatch(an, /\bconfirm\(/) +const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') +assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) +``` +Run: `node --test test/funktionenTiles.test.js test/analyseView.test.js` → FAIL + +- [ ] **Step 2: Verschieben** + +Abschnitt `/* --- Analyse --- */` (Zeile 611 bis Dateiende) und die Analyse-Importe aus `funktionen.js` nach `public/analyse.js`; `esc`/`safeUrl`/`request` nach `fn-util.js`; `analyseOf`/`analyseState`/`pickAnalyseProject` und `analyseSel` nach `analyse-view.js`. `funktionen.js` behält Kacheln und News und setzt beim Klick auf die Analyse-Kachel `analyseSel.projectId` (importiert). Vorher prüfen, wer die Funktionen sonst nutzt: `grep -a -rn "initAnalyse\|renderAnalyse\|analyseSel" public server test`. + +- [ ] **Step 3: `app.js:1920` lädt `/analyse.js`** + +`analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),` + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 5: Sichtprüfung, dass nichts anders aussieht** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task2` +Expected: alle Zeilen `OK`, keine `FAIL`. + +- [ ] **Step 6: Commit** + +```bash +git add public/fn-util.js public/analyse.js public/funktionen.js public/analyse-view.js public/app.js test/funktionenTiles.test.js test/analyseView.test.js +git commit -m "Analyse: Oberfläche in eigenes Modul analyse.js, gemeinsame Helfer in fn-util.js" +``` + +--- + +### Task 3: Reine Funktionen für Vergleich und Klartext + +**Files:** +- Modify: `public/analyse-view.js` +- Create: `test/analyseInsights.test.js` + +**Interfaces:** +- Consumes: Statistik-Form aus Task 1; `esc` aus `./fn-util.js`. +- Produces (alle in `public/analyse-view.js`, ohne DOM): + - `fmtNum(n): string` – `de-DE`, ganzzahlig. + - `fmtDuration(sec): string` – `'1:48'`, `'0:07'`; `null`/ungültig → `'–'`. + - `dayLabel(day): string` – `'2026-09-19'` → `'Sa., 19.09.'`. + - `periodLabel(from, to): string` – `'5. Sep. – 4. Okt.'` (Monatskürzel Jan., Feb., März, Apr., Mai, Juni, Juli, Aug., Sep., Okt., Nov., Dez.); leere Eingabe → `''`. + - `compareFrom(stats): { ok: boolean, from: string }` – `ok` wenn `stats.previous?.complete && stats.previous.totals.pv >= 20`; `from` = `'TT.MM.'` von `firstDay + (2 × range − 1)` Tagen nur wenn `previous` vorhanden, `!complete` und `firstDay`; sonst `''`. + - `delta(cur, prev, { kind = 'count' } = {}): { dir: 'up'|'down'|'flat', text: string, aria: string } | null` – Regeln Spec 2.4; `null`, wenn `cur` oder `prev` keine Zahl ist. + - `contactRate(contacts, visitors): number | null` – Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; `null` wenn `visitors < 30`. + - `refName(host): string` – Spec 2.1 (Google, Bing, Instagram, Facebook, DuckDuckGo, Ecosia, sonst Host; `''` → `'Direkt oder unbekannt'`). + - `weekdayStats(series, firstDay): { best: { weekday, name, avg } | null, avg: number, peak: { day, pv } | null } | null` – über Tage ≥ `firstDay` (alle, wenn `''`); `null` bei < 7 solchen Tagen; `best` nur wenn Schnitt ≥ 1,2 × Gesamtschnitt; `peak` nur wenn ≥ 1,8 × Gesamtschnitt. + - `summarize(stats): { lead: string, items: [{ icon: 'contact'|'day'|'source'|'device'|'page', html: string }] } | null` – Spec 2.3; `null` bei `totals.pv < 30`; `lead` und `html` sind fertiges, maskiertes HTML mit `<b>`. + - `chartModel(series, opts)` bekommt die Option `compare` (Array `{ pv }`): gemeinsame Skala über beide Reihen, zusätzliches Feld `prevLine` (Pfad, `''` ohne `compare`), `points[i].prev` (Zahl oder `null`). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseInsights.test.js`, Testdaten-Helfer `stats(over)` baut eine vollständige Antwort mit 30 Tagen) + +```js +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) +}) +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(fmtNum(6930), '6.930') +}) +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) +}) +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') +}) +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { /* Samstage 2× Schnitt → best.name 'Samstag'; ein Tag 3× Schnitt → peak */ }) +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen <b>6.930-mal</b> aufgerufen – <b>14 % mehr</b> als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { /* '– <b>9 % weniger</b> als', '– etwa so oft wie in den 30 Tagen davor.', '– <b>12 Aufrufe mehr</b> als' */ }) +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen <b>X-mal</b> aufgerufen.' + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website <b>X-mal</b> aufgerufen.' +}) +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' +}) +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + // alle 5 Bedingungen wahr → Seite fällt weg +}) +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/<img src=x>', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes('<img')) + assert.ok(JSON.stringify(s).includes('<img')) +}) +test('chartModel mit compare: gemeinsame Skala, prevLine', () => { /* top über beide Reihen; ohne compare prevLine '' und bestehender Test unverändert */ }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseInsights.test.js` → FAIL (Exporte fehlen) + +- [ ] **Step 3: Funktionen in `public/analyse-view.js` implementieren** (Signaturen oben; `esc` aus `'./fn-util.js'`; Wochentagsnamen Sonntag…Samstag; Spitzentag-Satz „Spitzentag war Sa., 19.09. mit 371 Aufrufen.“ hängt am Wochentag-Hinweis, wenn beides zutrifft) + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseInsights.test.js test/analyseView.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-view.js test/analyseInsights.test.js +git commit -m "Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen" +``` + +--- + +### Task 4: HTML-Bausteine der Cockpit-Ansicht + +**Files:** +- Create: `public/analyse-cards.js` +- Create: `test/analyseCards.test.js` + +**Interfaces:** +- Consumes: Task 3 (`summarize`, `delta`, `compareFrom`, `contactRate`, `refName`, `fmtDuration`, `fmtNum`, `periodLabel`, `dayLabel`, `chartModel`, `barRows`, `countryName`, `flagEmoji`, `DEVICE_LABELS`), `esc`. +- Produces (alle geben HTML-Strings zurück, ohne DOM; relative Importe): + - `headerHtml({ project, usable, range, showRange, devMode, stamp })` – Kopfleiste `.an-top`; Auswahl `<select id="an-project">` nur bei `usable.length > 1`; Zeitraum-Knöpfe `.an-seg button[data-range]` nur bei `showRange`; Status „Analyse aktiv · Stand: heute, 19:42 Uhr“ (`stamp` = `'19:42'`) bzw. gelb „Im Dev-Modus wird nicht gemessen.“ + - `tabsHtml(tab)` – `role="tablist"`, Knöpfe `[data-tab="uebersicht|heatmap|einstellungen"]`, `aria-selected`. + - `overviewHtml(stats)` – setzt zusammen: `summaryHtml`, `kpisHtml`, Zeile `.an-row2` (`chartHtml` + `contactsHtml`), `rankCardHtml('seen', …)` + `rankCardHtml('who', …)`, `basisHtml` + `heatTeaserHtml`, Fußzeile „Länderdaten: IP-Geolocation by DB-IP“ (Link wie heute). + - `summaryHtml(stats)`, `kpisHtml(stats)`, `sparkHtml(values)`, `chartHtml(stats)`, `contactsHtml(stats)`, `rankCardHtml(card, stats, { tab, expanded } = {})` mit `card` `'seen'` (Reiter `pages` | `refs`) oder `'who'` (`devices` | `countries`), `basisHtml(stats)`, `heatTeaserHtml()` (Knopf `[data-tab="heatmap"]`), `skeletonHtml()`, `errorHtml(message)` (Knopf `#an-retry`), `settingsHtml(project)` (Status, Unterlagen, leere `<section class="an-card" id="an-off">`). + - Texte exakt aus Spec 2.1–2.4 (Leerzustand „Sobald ein paar mehr Besuche da sind, steht hier das Wichtigste über deine Website in zwei, drei Sätzen.“, Quote „Auf 100 Besucher kommen 7,8 Kontakt-Aktionen (davor 6,5).“, „Noch keine Kontakt-Aktionen. Sie zählen, wenn jemand auf deiner Website auf Telefonnummer, E-Mail-Adresse oder WhatsApp tippt.“, Datenbasis-Sätze, „Noch hat niemand über das Banner entschieden.“, „Vergleich ab TT.MM.“). + +- [ ] **Step 1: Failing Tests schreiben** (`test/analyseCards.test.js`; Datenhelfer wie in Task 3) + +```js +test('overviewHtml: volle Daten – Kacheln mit Chips, Vorzeitraum-Linie, Kontakte, Quote', () => { + const h = overviewHtml(full) + assert.match(h, /▲ 14 %/); assert.match(h, /vs\. 6\.080/); assert.match(h, /class="an-prev"/) + assert.match(h, /Ø Zeit pro Seite/); assert.match(h, /1:48/); assert.match(h, /Ø 64 % gescrollt/) + assert.match(h, /Auf 100 Besucher kommen 7,8 Kontakt-Aktionen \(davor 6,5\)\./) + assert.match(h, /61 % haben dem Banner zugestimmt \(2\.410 von 3\.950 Entscheidungen\)/) +}) +test('overviewHtml: alter Collector ohne previous/firstDay – keine Chips, kein Fehler', () => { + const h = overviewHtml({ totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: [{ day: '2026-09-17', pv: 20 }], pages: [], refs: [], countries: [], devices: [], contacts: [], banner: { shown: 1, accept: 1, reject: 0 } }) + assert.doesNotMatch(h, /▲|▼|undefined|NaN/); assert.doesNotMatch(h, /class="an-prev"/) +}) +test('overviewHtml: Website ohne Aufrufe – Leerzustand, keine NaN/Infinity', () => { + const h = overviewHtml(zero) + assert.match(h, /Sobald ein paar mehr Besuche da sind/); assert.match(h, /Noch hat niemand über das Banner entschieden\./) + assert.doesNotMatch(h, /NaN|Infinity|undefined|null/) +}) +test('kpisHtml: neue Website zeigt "Vergleich ab 18.11."', () => {}) +test('contactsHtml: Arten mit 0 in beiden Zeiträumen entfallen, Leertext ohne Kontakte', () => {}) +test('rankCardHtml: Startseite statt /, Anteil in %, 6 Zeilen, expanded bis 20, Herkunft mit refName, Pfad maskiert', () => { + const h = rankCardHtml('seen', stats({ pages: [{ key: '/<img src=x>', count: 9 }] })) + assert.match(h, /<img src=x>/); assert.doesNotMatch(h, /<img src=x>/) +}) +test('headerHtml: Auswahl nur bei mehreren Websites, Zeitraum nur bei showRange, Dev-Hinweis, Name maskiert', () => {}) +test('settingsHtml: aktiv seit TT.MM.JJJJ, Farbfeld, Fassung, Links AVV und Leistungsbeschreibung, #an-off', () => {}) +test('errorHtml und skeletonHtml', () => { assert.match(errorHtml('Auswertung gerade nicht erreichbar'), /id="an-retry"/) }) +``` + +- [ ] **Step 2: Tests laufen, schlagen fehl** + +Run: `node --test test/analyseCards.test.js` → FAIL + +- [ ] **Step 3: `public/analyse-cards.js` implementieren** + +Klassen-Präfix `an-` (Karten `.an-card`, Kacheln `.an-kpi`, Chips `.an-delta.up|down|flat` mit `aria-label`, Mini-Verlauf `.an-spark`, Diagramm `.an-chart` mit `.an-area`, `.an-line`, gestrichelt `.an-prev` (nur wenn `compareFrom(stats).ok`), Legende „<range> Tage“ / „Zeitraum davor“ (zweiter Eintrag nur mit Vergleich), Punkte mit `data-i`, Daten für die Blase als `data-day`/`data-pv`/`data-prev` am Punkt). Mini-Verläufe: Seitenaufrufe `series[].pv`, Besucher `series[].visitors`, Kontakte `series[].contacts`, Zeit `series[].avgSec` (fehlende Werte als 0, ganz fehlende Reihe → kein Verlauf). Symbole als Inline-SVG (gleicher Stil wie `ICONS` in `funktionen.js`, `stroke`-basiert), keine Emoji. + +- [ ] **Step 4: Tests grün, Suite grün** + +Run: `node --test test/analyseCards.test.js && npm test` → PASS + +- [ ] **Step 5: Commit** + +```bash +git add public/analyse-cards.js test/analyseCards.test.js +git commit -m "Analyse: HTML-Bausteine der Cockpit-Übersicht" +``` + +--- + +### Task 5: Cockpit verdrahten – Rahmen, Reiter, Übersicht, Heatmap, Einstellungen, Stil + +**Files:** +- Modify: `public/analyse.js` +- Create: `public/analyse.css` +- Modify: `public/dashboard.html` (Zeile 24: `<link rel="stylesheet" href="/analyse.css">` nach `funktionen.css`) +- Modify: `public/funktionen.css:140-218` (Analyse-Abschnitt entfernen; was die Einschalten-Ansicht und Heatmap brauchen, zieht nach `analyse.css`) +- Modify: `test/ui/analyse.ui.mjs` + +**Interfaces:** +- Consumes: Task 2 (`analyseSel`, `request`, `esc`, `safeUrl`, bestehende Heatmap-Funktionen in `analyse.js`), Task 3, Task 4 (alle Bausteine). +- Produces: `initAnalyse(root, { projectId })` rendert `<div class="an">` mit `headerHtml` + (nur Zustand `active`) `tabsHtml` + `<div id="an-stage">`. Bestehende Exporte bleiben. + +Verhalten: +- Zustand `active`: Reiter aus `analyseSel.tab`. Übersicht: erst `skeletonHtml()`, dann `overviewHtml(stats)`; Fehler → `errorHtml(…)`, `#an-retry` lädt neu; Zähler `statsRequestId` verwirft veraltete Antworten (wie heute). Zeitraum-Knöpfe setzen `analyseSel.range` und laden nur die Übersicht neu. `#an-project` wechselt die Website (Reiter bleibt). Ranglisten-Umschalter und „Alle anzeigen“ rendern nur ihre Karte neu (`rankCardHtml`). `[data-tab="heatmap"]` im Teaser wechselt den Reiter. +- Diagramm-Blase: Zeigen/Tippen auf `.an-chart` wählt den nächsten Punkt (`data-i`) und zeigt `.an-tip` mit „Sa., 19.09.: 371 Aufrufe (davor 210)“ (ohne Vorwert ohne Klammer); verlassen → ausblenden. +- Heatmap-Reiter: `renderAnalyseHeat` wie heute; die Seitenliste kommt aus der zuletzt geladenen Statistik (sonst einmal `range=30` laden). iframes entstehen erst beim Öffnen des Reiters. +- Einstellungen-Reiter: `settingsHtml(project)`, danach `renderAnalyseOff(stage.querySelector('#an-off'), projectId, reload)`. +- Andere Zustände (Einschalten, gesperrt, nicht verfügbar, unbekannt, keine nutzbare Website): Kopfleiste ohne Zeitraum und ohne Reiter, Inhalt im Cockpit-Rahmen, Texte unverändert. `root.querySelector` kann in Node-Tests für andere Selektoren als `#an-stage` `null` liefern – überall `?.` verwenden. +- `analyse.css`: Werte aus Global Constraints und Spec 2.5; Raster Desktop wie Entwurf A (`/root/reports/analyse-ueberarbeitung-2026-10-04/entwurf-a-cockpit.png`); `.an-row2` mit `align-items: stretch`, Diagramm füllt die Höhe; ≤ 880 px: Kacheln 2 × 2, alles einspaltig, Reiter `overflow-x: auto`, Zeitraum volle Breite, lange Namen mit `text-overflow: ellipsis`; `@media (prefers-reduced-motion: reduce)` ohne Übergänge. + +- [ ] **Step 1: UI-Test erweitern (schlägt fehl)** – `test/ui/analyse.ui.mjs` + +Neue Szenarien in `SCENARIOS`/Statistik: `full` (30 Tage, Werte wie Fixture „Boulderhalle Überhang“: pv 6.930/Vorzeitraum 6.080, visitors 2.410/2.210, contacts 188/143, avgSec 108/114, avgScroll 64, consentRate 0,61, banner 3.950/2.410/1.540, `firstDay` vor dem Vorzeitraum), `neu` (`firstDay` im Zeitraum, `previous.complete` false), `wenig` (pv 12), `dev` (`devMode: true`), `alt` (ohne `previous`), `langerPfad` (Seite `/` + 120 Zeichen), `fehler` (Statistik 503). Prüfungen je Viewport 1440 × 1000 und 402 × 874: +``` +OK aktiv: Block "Das Wichtigste" mit "14 % mehr" +OK aktiv: 4 Kacheln, 3 Chips ▲, 1 Chip ▼ +OK Reiter Heatmap: vorher 0 iframes, nach Klick 2 iframes +OK Reiter Einstellungen: "Analyse ausschalten" sichtbar, "aktiv seit" +OK Zeitraum 7 → Anfrage range=7, Reiter bleibt Übersicht +OK verzögerte erste Antwort (range=30, 1,5 s) überschreibt die spätere (range=7) nicht +OK neu: "Vergleich ab" statt Chips, Satz "Seit dem Start der Analyse" +OK wenig: Leerzustand +OK dev: "Im Dev-Modus wird nicht gemessen." +OK alt: keine Chips, keine pageerror +OK fehler: "Auswertung gerade nicht erreichbar" + Neu laden +OK langer Pfad, 402 px: document.scrollingElement.scrollWidth <= innerWidth +OK Einschalten: Banner-Vorschau, Häkchen schaltet Knopf frei (wie bisher) +OK keine pageerror in allen Zuständen +``` +Screenshots je Zustand und Viewport nach dem Ordner aus `argv[2]`. +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` → FAIL + +- [ ] **Step 2: `analyse.js` verdrahten, `analyse.css` schreiben, `dashboard.html` und `funktionen.css` anpassen** + +- [ ] **Step 3: UI-Test grün** + +Run: `node test/ui/analyse.ui.mjs /tmp/claude-0/-root/072dd97e-d807-40b8-b757-afe9463e87f8/scratchpad/ui-task5` +Expected: nur `OK`-Zeilen. + +- [ ] **Step 4: Screenshots ansehen und mit Entwurf A vergleichen** + +Desktop- und Handy-Bilder von `full`, `neu`, Einschalten und Einstellungen öffnen (Read-Tool). Erwartet: Aufbau wie Entwurf A, Diagramm und Kontaktkarte gleich hoch, keine abgeschnittenen Texte, keine Überlappungen, Kacheln auf dem Handy 2 × 2. Abweichungen beheben und Step 3 wiederholen. + +- [ ] **Step 5: Suite grün** + +Run: `npm test` → PASS, 0 fail + +- [ ] **Step 6: Commit** + +```bash +git add public/analyse.js public/analyse.css public/dashboard.html public/funktionen.css test/ui/analyse.ui.mjs +git commit -m "Analyse: Cockpit-Übersicht mit Reitern Übersicht, Heatmap, Einstellungen" +``` + +--- + +### Task 6: Abschlussprüfung auf Node 20 und Bilder für den Nutzer + +**Files:** +- keine Code-Dateien (nur Prüfung); Bilder nach `/root/reports/analyse-ueberarbeitung-2026-10-04/screens/` + +- [ ] **Step 1: Portal-Suite auf Node 20** + +Run: `docker run --rm -v /home/webklar/apps/kundenbereich.webklar.com:/home/webklar/apps/kundenbereich.webklar.com:ro -w /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu node:20-alpine node --test test/*.test.js 2>&1 | tail -8` +Expected: `fail 0` (bekannte Ausnahme laut Notizen: nur `sharp`-abhängige Tests, falls `sharp` für musl fehlt – dann diese benennen, nicht ignorieren). + +- [ ] **Step 2: Collector-Suite auf Node 24** + +Run: `cd analytics && npm test` → `fail 0` + +- [ ] **Step 3: Bilder für den Nutzer** + +Run: `node test/ui/analyse.ui.mjs /root/reports/analyse-ueberarbeitung-2026-10-04/screens` +Expected: nur `OK`; Bilder `full`, `neu`, Heatmap, Einstellungen, Einschalten je Desktop/Handy liegen im Ordner. + +- [ ] **Step 4: Branch-Zustand festhalten** + +Run: `git status --short` (nur `?? node_modules`) und `git log --oneline origin/main..HEAD` (Spec, Plan, Tasks 1–5). + +--- + +## Livegang (nur mit OK des Nutzers, nicht Teil der Tasks) + +- **L1** Nutzer pusht: `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-analyse-neu push origin feature/analyse-uebersicht:main` (vorher ggf. auf `origin/main` rebasen und `npm test` erneut). +- **L2** Prüfen: `build/` auf dem neuen Stand; `docker ps` zeigt `webklar-kundenbereich-server` und `webklar-analytics` frisch gestartet; `curl -s https://project.webklar.com/a/health`; im Container Stats einer echten aktiven Website abrufen (über `analyticsClient.getStats(siteKey, 30)` in `docker exec -w /app webklar-kundenbereich-server node -e …`) → Felder `previous`, `firstDay`, `totals.avgSec` vorhanden; `https://project.webklar.com/analyse.js` und `/analyse.css` 200. +- **L3** Nutzer öffnet Funktionen → Analyse einer Website mit aktiver Analyse und testet Reiter, Zeitraum, Handy. +- **L4** Notizen aktualisieren (Memory `analyse-uebersicht-neu.md`). From 37bec17d73cf4f868c80c9c2bfaf397b13b2114d Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:49:55 +0000 Subject: [PATCH 10/21] Analyse: Vorzeitraum, firstDay, Tageswerte und Durchschnitte in der Statistik; activatedAt in der Ansicht Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- analytics/src/stats.js | 114 +++++++++++++++++++++++++-------- analytics/test/stats.test.js | 109 ++++++++++++++++++++++++++++++- server/routes/funktionen.js | 1 + test/analyseRoutes.test.js | 3 +- test/analyticsContract.test.js | 4 ++ 5 files changed, 202 insertions(+), 29 deletions(-) diff --git a/analytics/src/stats.js b/analytics/src/stats.js index e2033a9..1e72c90 100644 --- a/analytics/src/stats.js +++ b/analytics/src/stats.js @@ -1,69 +1,131 @@ // Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten. import { dayOf, aggregateRows } from './jobs.js'; -const DAY_MS = 86400 * 1000; const TOP = 20; -// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`. -function dayList(now, range) { - const [y, m, d] = dayOf(now).split('-').map(Number); +// Kalendertag (YYYY-MM-DD) um `n` Tage verschoben, rein kalendarisch (UTC-Datumsrechnung, keine Zeitumstellung). +function shiftDay(day, n) { + const [y, m, d] = day.split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); +} + +// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Tag `endDay`. +function dayList(endDay, range) { const days = []; - for (let i = range - 1; i >= 0; i--) days.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10)); + for (let i = range - 1; i >= 0; i--) days.push(shiftDay(endDay, -i)); return days; } const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0) .sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP); -export function computeStats(db, site, range, now = Date.now()) { - const days = dayList(now, range); +// Sammelt die Zähler für eine zusammenhängende Tagesliste: verdichtete Tage aus `daily`, +// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten. Kein Tag zählt doppelt. +function collect(db, site, days, now) { const first = days[0]; + const last = days[days.length - 1]; const inRange = new Set(days); - const pvByDay = new Map(days.map((d) => [d, 0])); + const perDay = new Map(days.map((d) => [d, { pv: 0, visitors: 0, contacts: 0, en: 0, sec: 0, scroll: 0 }])); const dims = {}; - const bump = (dim, key, n) => { + const apply = (day, dim, key, n) => { + const t = perDay.get(day); + if (dim === 'pv_total') t.pv += n; + else if (dim === 'visitors') t.visitors += n; + else if (dim === 'ct_kind') t.contacts += n; + else if (dim === 'en_count') t.en += n; + else if (dim === 'en_sec_sum') t.sec += n; + else if (dim === 'scroll_sum') t.scroll += n; if (!dims[dim]) dims[dim] = new Map(); dims[dim].set(key, (dims[dim].get(key) || 0) + n); }; - const apply = (day, dim, key, n) => { - if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n); - bump(dim, key, n); - }; - const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day)); - for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) { + const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last).map((r) => r.day)); + for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ? AND day <= ?').all(site, first, last)) { if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count); } - // Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten + // Ein Berliner Tag beginnt 22:00/23:00 UTC des Vortags: zwei Tage Rand reichen auf beiden Seiten, danach filtert dayOf + const [fy, fm, fd] = first.split('-').map(Number); + const [ly, lm, ld] = last.split('-').map(Number); + const lo = Date.UTC(fy, fm - 1, fd - 2); + const hi = Math.min(now, Date.UTC(ly, lm - 1, ld + 2)); const live = new Map(); - const from = now - (range + 1) * DAY_MS; for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts') - .all(site, from, now)) { + .all(site, lo, hi)) { const d = dayOf(r.ts); if (!inRange.has(d) || done.has(d)) continue; if (!live.has(d)) live.set(d, []); live.get(d).push(r); } for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n); + return { days, perDay, dims }; +} +const avg = (sum, n) => (n ? Math.round(sum / n) : null); + +// Formt gesammelte Zähler zu banner + totals + series (gleiche Form für aktuellen Zeitraum und Vorzeitraum). +function shape({ days, perDay, dims }) { const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0); const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') }; const decided = banner.accept + banner.reject; - const contacts = top(dims.ct_kind || new Map()); + const en = sum('en_count'); return { + banner, totals: { pv: sum('pv_total'), // Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich) visitors: sum('visitors'), contacts: sum('ct_kind'), consentRate: decided ? banner.accept / decided : 0, + engaged: en, + avgSec: avg(sum('en_sec_sum'), en), + avgScroll: avg(sum('scroll_sum'), en), + }, + series: days.map((day) => { + const t = perDay.get(day); + return { day, pv: t.pv, visitors: t.visitors, contacts: t.contacts, avgSec: avg(t.sec, t.en) }; + }), + }; +} + +// Frühester Berliner Tag mit Daten dieser Website ('' = keine): kleinster Tag aus daily und events_raw. +function firstDayOf(db, site) { + const fromDaily = db.prepare('SELECT MIN(day) AS d FROM daily WHERE site = ?').get(site)?.d || ''; + const ts = db.prepare('SELECT MIN(ts) AS t FROM events_raw WHERE site = ?').get(site)?.t; + const fromRaw = ts == null ? '' : dayOf(ts); + if (!fromDaily) return fromRaw; + if (!fromRaw) return fromDaily; + return fromDaily < fromRaw ? fromDaily : fromRaw; +} + +export function computeStats(db, site, range, now = Date.now()) { + const days = dayList(dayOf(now), range); + const cur = collect(db, site, days, now); + const { banner, totals, series } = shape(cur); + // Vorzeitraum: die `range` Kalendertage direkt vor dem aktuellen Zeitraum (kalendarisch, nie now - range * 24 h) + const prevDays = dayList(shiftDay(days[0], -1), range); + const prevCollected = collect(db, site, prevDays, now); + const prev = shape(prevCollected); + const firstDay = firstDayOf(db, site); + return { + range, + from: days[0], + to: days[days.length - 1], + firstDay, + totals, + series, + pages: top(cur.dims.pv_path || new Map()), + refs: top(cur.dims.pv_ref || new Map()), + countries: top(cur.dims.pv_country || new Map()), + devices: top(cur.dims.pv_device || new Map()), + contacts: top(cur.dims.ct_kind || new Map()), + banner, + previous: { + from: prevDays[0], + to: prevDays[prevDays.length - 1], + complete: firstDay !== '' && firstDay <= prevDays[0], + totals: prev.totals, + series: prev.series, + contacts: top(prevCollected.dims.ct_kind || new Map()), }, - series: days.map((day) => ({ day, pv: pvByDay.get(day) })), - pages: top(dims.pv_path || new Map()), - refs: top(dims.pv_ref || new Map()), - countries: top(dims.pv_country || new Map()), - devices: top(dims.pv_device || new Map()), - contacts, - banner, }; } diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index a9e07c2..d24d832 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -132,12 +132,15 @@ test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus dai const r = await a.get(`/a/admin/stats/${KEY}?range=7`); assert.equal(r.status, 200); const j = await r.json(); - assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'pages', 'refs', 'series', 'totals']); + assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'firstDay', 'from', 'pages', 'previous', 'range', 'refs', 'series', 'to', 'totals']); assert.equal(j.series.length, 7); assert.equal(j.series[0].day, '2026-10-04'); assert.equal(j.series[6].day, '2026-10-10'); assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]); - assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 }); + assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4, engaged: 1, avgSec: 5, avgScroll: 5 }); + assert.deepEqual(j.series[5], { day: '2026-10-09', pv: 3, visitors: 1, contacts: 0, avgSec: 5 }); + assert.deepEqual(j.series[6], { day: '2026-10-10', pv: 1, visitors: 1, contacts: 1, avgSec: null }); + assert.equal(j.from, '2026-10-04'); assert.equal(j.to, '2026-10-10'); assert.equal(j.range, 7); assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]); assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 }); assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]); @@ -161,6 +164,108 @@ test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', asy a.close(); }); +test('Stats: Vorzeitraum = die range Kalendertage davor, complete nach firstDay', async () => { + const { db, ev } = mk(); + // Vorzeitraum zu range 7 am 10.10.: 27.09.–03.10. + ev({ ts: Date.UTC(2026, 8, 30, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 20, scroll: 40 }); + ev({ ts: Date.UTC(2026, 9, 2, 10), type: 'ct', path: '/', visitor: 'v2', kind: 'tel' }); + const a = await admin(db); + let j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.firstDay, '2026-09-30'); + assert.equal(j.previous.complete, false); + // 26.09. liegt vor dem Vorzeitraum und darf nicht mitzählen + ev({ ts: Date.UTC(2026, 8, 26, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.firstDay, '2026-09-26'); + assert.equal(j.previous.complete, true); + assert.equal(j.previous.from, '2026-09-27'); assert.equal(j.previous.to, '2026-10-03'); + assert.equal(j.previous.series.length, 7); assert.equal(j.previous.series[3].day, '2026-09-30'); + assert.equal(j.previous.totals.pv, 1); + assert.equal(j.previous.totals.engaged, 1); + assert.equal(j.previous.totals.avgSec, 20); + assert.equal(j.previous.totals.contacts, 1); + assert.deepEqual(j.previous.series[4], { day: '2026-10-01', pv: 0, visitors: 1, contacts: 0, avgSec: 20 }); + assert.deepEqual(j.previous.contacts, [{ key: 'tel', count: 1 }]); + assert.equal(j.totals.pv, 0); // aktueller Zeitraum leer + assert.equal(j.totals.avgSec, null); + a.close(); +}); + +test('Stats: firstDay aus daily und Rohdaten, leer ohne Daten', async () => { + const { db, ev } = mk(); + const a = await admin(db); + const first = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()); + let j = await first(); + assert.equal(j.firstDay, ''); + assert.equal(j.previous.complete, false); + // nur Rohdaten (heute) + ev({ ts: NOW - H, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-10-10'); + // 40 Tage alt: nach dem Nachtlauf nur noch in daily (Rohdaten > 30 Tage gelöscht) + ev({ ts: NOW - 40 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + assert.equal((await first()).firstDay, '2026-08-31'); + runNightly(db, { now: NOW }); + assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw WHERE ts < ?').get(NOW - 31 * D).c, 0); + j = await first(); + assert.equal(j.firstDay, '2026-08-31'); + assert.equal(j.previous.complete, true); + a.close(); +}); + +test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async () => { + const { db, ev } = mk(); + const t = NOW - D; // 9.10. + ev({ ts: t, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 30, scroll: 50 }); + ev({ ts: t + 1, type: 'en', path: '/a', vp: 'd', visitor: 'v2', sec: 91, scroll: 100 }); + const a = await admin(db); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json(); + assert.equal(j.totals.engaged, 2); + assert.equal(j.totals.avgSec, 61); // 60,5 gerundet + assert.equal(j.totals.avgScroll, 75); + assert.equal(j.series[5].avgSec, 61); + assert.equal(j.series[4].avgSec, null); + // ohne en: beide null, engaged 0 + const leer = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(leer.previous.totals.engaged, 0); + assert.equal(leer.previous.totals.avgSec, null); + assert.equal(leer.previous.totals.avgScroll, null); + a.close(); +}); + +test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { + const NOV = Date.UTC(2026, 10, 10, 10); // range 30, Zeitumstellung am 25.10. liegt im Zeitraum + const { db } = mk(); + const a = await admin(db, () => NOV); + const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); + assert.equal(j.series[0].day, '2026-10-12'); + assert.equal(j.series[29].day, '2026-11-10'); + assert.equal(j.previous.series[29].day, '2026-10-11'); + assert.equal(j.previous.series[0].day, '2026-09-12'); + assert.equal(j.previous.from, '2026-09-12'); assert.equal(j.previous.to, '2026-10-11'); + const all = [...j.series, ...j.previous.series].map((s) => s.day); + assert.equal(all.length, 60); + assert.equal(new Set(all).size, 60); + a.close(); +}); + +test('Stats: Vorzeitraum aus daily und live, nie doppelt', async () => { + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 1, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); + ev({ ts: Date.UTC(2026, 9, 1, 11), type: 'ct', path: '/', visitor: 'v1', kind: 'mail' }); + const a = await admin(db); + const get = async () => (await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).previous; + const live = await get(); // noch nicht verdichtet: live aus den Rohdaten + assert.equal(live.totals.pv, 1); + runNightly(db, { now: NOW }); // jetzt aus daily + const verdichtet = await get(); + assert.equal(verdichtet.totals.pv, 1); + assert.deepEqual(verdichtet, live); + runNightly(db, { now: NOW + H }); + assert.deepEqual(await get(), live); + a.close(); +}); + test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => { const { db, ev } = mk(); for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' }); diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 7c0694a..35299fd 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -70,6 +70,7 @@ async function analyseView(customerId, projectId) { avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller, + activatedAt: doc?.state === 'active' ? doc.activatedAt || null : null, } } diff --git a/test/analyseRoutes.test.js b/test/analyseRoutes.test.js index 2fabb7b..454f78d 100644 --- a/test/analyseRoutes.test.js +++ b/test/analyseRoutes.test.js @@ -112,7 +112,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a const body = await get('/api/funktionen', { cookie }) const p = (id) => body.projects.find((x) => x.projectId === id) assert.equal(p('p1').features.news.state, 'off') - assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } }) + assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] }, activatedAt: null }) assert.equal(p('p2').analyse.eligible, false) assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich') assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich) @@ -133,6 +133,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => { const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse assert.equal(view.state, 'active') assert.equal(view.accentColor, '#112233') + assert.match(view.activatedAt, /^\d{4}-\d{2}-\d{2}T/) const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }) assert.deepEqual(off.body, { ok: true, state: 'off' }) assert.equal(stops.length, 1) diff --git a/test/analyticsContract.test.js b/test/analyticsContract.test.js index 9d11b23..4df3a50 100644 --- a/test/analyticsContract.test.js +++ b/test/analyticsContract.test.js @@ -44,6 +44,10 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as assert.equal(stats.totals.contacts, 1) assert.equal(stats.series.length, 7) assert.equal(stats.series.reduce((n, d) => n + d.pv, 0), 3) + assert.equal(stats.range, 7) + assert.equal(stats.previous.series.length, 7) + assert.ok('firstDay' in stats && 'avgSec' in stats.totals) + assert.ok(stats.series.every((d) => 'visitors' in d && 'contacts' in d && 'avgSec' in d)) assert.deepEqual(stats.pages, [{ key: '/', count: 2 }, { key: '/kontakt', count: 1 }]) assert.deepEqual(stats.refs, [{ key: '', count: 2 }, { key: 'google.com', count: 1 }]) assert.equal(stats.banner.accept, 1) From 97bff91a9994324dbf86f480231ab5815e231f18 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:53:33 +0000 Subject: [PATCH 11/21] Analyse: Zeitumstellungs-Test mit Abenduhr und Ereignissen, erkennt naives Vorzeitraum-Fenster Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- analytics/test/stats.test.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/analytics/test/stats.test.js b/analytics/test/stats.test.js index d24d832..3083502 100644 --- a/analytics/test/stats.test.js +++ b/analytics/test/stats.test.js @@ -234,8 +234,12 @@ test('Stats: avgSec/avgScroll gerundet, null ohne en; Tageswert avgSec', async ( }); test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappung', async () => { - const NOV = Date.UTC(2026, 10, 10, 10); // range 30, Zeitumstellung am 25.10. liegt im Zeitraum - const { db } = mk(); + // range 30 um 23:30 Berlin: Zeitumstellung am 25.10. liegt im Zeitraum. Spätabends wäre now - 30 * 24 h + // schon der 12.10. (00:30 Berlin) und ein naives Fenster würde sich überlappen; mittags fiele das nicht auf. + const NOV = Date.UTC(2026, 10, 10, 22, 30); + const { db, ev } = mk(); + ev({ ts: Date.UTC(2026, 9, 11, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 11.10. mittags: Vorzeitraum + ev({ ts: Date.UTC(2026, 9, 12, 10), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 12.10. mittags: aktueller Zeitraum const a = await admin(db, () => NOV); const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json(); assert.equal(j.series[0].day, '2026-10-12'); @@ -246,6 +250,8 @@ test('Stats: Vorzeitraum über die Zeitumstellung lückenlos und ohne Überlappu const all = [...j.series, ...j.previous.series].map((s) => s.day); assert.equal(all.length, 60); assert.equal(new Set(all).size, 60); + assert.equal(j.previous.totals.pv, 1); assert.equal(j.previous.series[29].pv, 1); + assert.equal(j.totals.pv, 1); assert.equal(j.series[0].pv, 1); a.close(); }); From 9d1691cf6b04999e3356953a3f9b4fa7e4a525d9 Mon Sep 17 00:00:00 2001 From: Claude <claude@webklar.local> Date: Sun, 4 Oct 2026 19:57:27 +0000 Subject: [PATCH 12/21] =?UTF-8?q?Analyse:=20Oberfl=C3=A4che=20in=20eigenes?= =?UTF-8?q?=20Modul=20analyse.js,=20gemeinsame=20Helfer=20in=20fn-util.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- public/analyse-view.js | 13 +- public/analyse.js | 377 ++++++++++++++++++++++++++++++++ public/app.js | 2 +- public/fn-util.js | 22 ++ public/funktionen.js | 402 +---------------------------------- test/analyseView.test.js | 5 +- test/avvPage.test.js | 2 +- test/funktionenTiles.test.js | 8 +- test/termineView.test.js | 4 +- 9 files changed, 426 insertions(+), 409 deletions(-) create mode 100644 public/analyse.js create mode 100644 public/fn-util.js diff --git a/public/analyse-view.js b/public/analyse-view.js index bc6f612..7ea000c 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,4 +1,4 @@ -/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ +/* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { @@ -90,3 +90,14 @@ export function controllerLines(controller) { const c = controller || {} return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean) } + +/* Analyse-Zustand je Website (comingSoon = Schalter aus) und Auswahl */ +export const analyseOf = (project) => project.analyse || { comingSoon: true } +export const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') + +export function pickAnalyseProject(projects) { + return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] +} + +// Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js) +export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } diff --git a/public/analyse.js b/public/analyse.js new file mode 100644 index 0000000..b18d7ca --- /dev/null +++ b/public/analyse.js @@ -0,0 +1,377 @@ +/* Analyse-Ansicht im Kundenbereich (ES-Modul, wird von app.js erst beim Oeffnen geladen). + initAnalyse(rootEl, { projectId }): je nach Zustand der Analyse Einschalten (off), Hinweis (disabled/blocked) oder Auswertung (active). + Reine Hilfsfunktionen und der gemeinsame Zustand: analyse-view.js. Styles: funktionen.css. + Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ + +import { esc, safeUrl, request } from './fn-util.js' +import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from './analyse-view.js' +import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js' + +const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] +const HEX_RE = /^#[0-9a-f]{6}$/i +const nf = new Intl.NumberFormat('de-DE') +const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' + +export async function initAnalyse(root, { projectId } = {}) { + if (!root) return + if (projectId) analyseSel.projectId = projectId + const { projects = [] } = await request('/api/funktionen') + const usable = projects.filter((p) => !analyseOf(p).comingSoon) + if (!usable.length) { + root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>' + return + } + const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) + analyseSel.projectId = project.projectId + root.innerHTML = ` + ${usable.length > 1 ? ` + <div class="fn-project kb-panel"> + <label for="an-project">Website</label> + <select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select> + </div>` : ''} + <div id="an-stage"></div>` + root.querySelector('#an-project')?.addEventListener('change', (ev) => { + analyseSel.projectId = ev.target.value + initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }) + }) + const stage = root.querySelector('#an-stage') + const a = analyseOf(project) + const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` }) + if (a.state === 'active') renderAnalyseStats(stage, project, reload) + else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>' + else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>' + else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>` + else renderAnalyseActivation(stage, project, reload) +} + +function bannerPreview(color) { + const c = bannerColor(color) + const btn = `style="background:${c};border-color:${c};color:#fff"` + return ` + <div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website"> + <p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p> + <div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div> + </div>` +} + +const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' + +// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt +function controllerBlock(controller) { + if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>` + return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>` +} + +export function renderAnalyseActivation(stage, project, reload) { + const a = analyseOf(project) + const legal = a.legal || {} + const controller = a.controller || { ok: false } + let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' + stage.innerHTML = ` + <div class="kb-panel an-panel"> + <h3>Analyse für ${esc(project.projectName)} einschalten</h3> + <p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p> + <p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p> + <h4 class="an-h4">Farbe des Banners</h4> + <div class="an-colors"> + <input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen"> + <div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div> + </div> + <p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p> + <div id="an-preview"></div> + ${controllerBlock(controller)} + <label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label> + <p class="error hidden" id="an-error" role="alert"></p> + <div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div> + </div>` + const input = stage.querySelector('#an-color') + const hint = stage.querySelector('#an-hint') + const paint = () => { + stage.querySelector('#an-preview').innerHTML = bannerPreview(color) + hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) + } + paint() + input.addEventListener('input', () => { color = input.value; paint() }) + stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) + const box = stage.querySelector('#an-avv') + const button = stage.querySelector('#an-on') + const error = stage.querySelector('#an-error') + box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) + button.addEventListener('click', async () => { + if (!box.checked || controller.ok !== true) return + button.disabled = true + error.classList.add('hidden') + try { + await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) + reload() + } catch (err) { + error.textContent = err.message + error.classList.remove('hidden') + button.disabled = !box.checked || controller.ok !== true + } + }) +} + +function lineChart(series) { + const m = chartModel(series) + if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>' + const total = m.points.reduce((sum, p) => sum + p.pv, 0) + const first = m.points[0].day + const last = m.points[m.points.length - 1].day + return ` + <svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title"> + <title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} + ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} + ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} + + + ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} + ` +} + +function barList(title, items, labelOf, { html = false } = {}) { + const rows = barRows(items, labelOf) + return ` +
+

${esc(title)}

+ ${rows.length ? `
    ${rows.map((r) => ` +
  • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
  • `).join('')}
` : '

Noch keine Daten.

'} +
` +} + +const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` + +let statsRequestId = 0 +async function renderAnalyseStats(stage, project, reload) { + const myRequest = ++statsRequestId + const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 + const pid = project.projectId + stage.innerHTML = ` +
${[7, 30, 90].map((r) => ``).join('')}
+

Lade…

+
+
+

Länderdaten: IP-Geolocation by DB-IP

` + stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) + renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) + + const data = stage.querySelector('#an-data') + let stats + try { + stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) + } catch (err) { + if (!stage.isConnected || myRequest !== statsRequestId) return + data.innerHTML = `` + return + } + if (!stage.isConnected || myRequest !== statsRequestId) return + const t = stats.totals || {} + const rate = Math.round((Number(t.consentRate) || 0) * 100) + const kpi = (label, value) => `
${label}${esc(value)}
` + const b = stats.banner || {} + data.innerHTML = ` +
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
+

Seitenaufrufe pro Tag

${lineChart(stats.series)}
+
+ ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} + ${barList('Herkunft', stats.refs, refLabel)} + ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} + ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} + ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} + ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} +
+

${esc(KPI_NOTE)}

` + renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) +} + +/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. + Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl + per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ +const heatSel = { big: 'd' } +const HEAT_TIMEOUT_MS = 20000 + +let geraeteCss = null +function loadGeraeteCss() { + if (!geraeteCss) { + geraeteCss = new Promise((resolve) => { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = '/geraete.css' + link.onload = link.onerror = () => resolve() + document.head.appendChild(link) + }) + } + return geraeteCss +} + +// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler +let heatListening = false +function onHeatMessage(ev) { + const d = ev.data + if (!d || d.wk !== 'heat') return + for (const fig of document.querySelectorAll('.hs-dev')) { + const frame = fig.querySelector('iframe') + if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue + clearTimeout(fig.heatTimer) + fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' + setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) + } +} + +function setHeatCaption(fig, text) { + const cap = fig.querySelector('.hs-cap') + if (cap) cap.textContent = text +} + +// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website +function fitHeatDevice(fig) { + fig.heatResize?.disconnect() + const canvas = fig.querySelector('.dm-canvas') + const host = canvas?.parentElement + if (!host) return + const fit = (width) => { + if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) + } + fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) + if (typeof ResizeObserver === 'function') { + fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) + fig.heatResize.observe(host) + } +} + +async function loadHeatDevice(fig, projectId, vp, path) { + const run = (fig.heatRun = (fig.heatRun || 0) + 1) + clearTimeout(fig.heatTimer) + fig.heatResize?.disconnect() + fig.dataset.state = 'loading' + fig.innerHTML = `
${esc(heatCaption('loading'))}
` + let url + try { + ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) + } catch (err) { + if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) + return + } + await loadGeraeteCss() + if (run !== fig.heatRun || !fig.isConnected) return + if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) + fig.dataset.origin = new URL(url).origin + fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) + fitHeatDevice(fig) + // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden + let loads = 0 + fig.querySelector('iframe').addEventListener('load', () => { + if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) + }) + fig.heatTimer = setTimeout(() => { + if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) + }, HEAT_TIMEOUT_MS) +} + +// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher +async function showHeatFallback(fig, projectId, vp, path) { + fig.dataset.state = 'timeout' + const cap = fig.querySelector('.hs-cap') + if (!cap) return + cap.textContent = heatCaption('timeout') + try { + const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) + // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt + if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) + } catch { + /* Text reicht */ + } +} + +function renderAnalyseHeat(box, projectId, pages) { + if (!box) return + if (!pages.length) { + box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' + return + } + if (!heatListening) { + window.addEventListener('message', onHeatMessage) + heatListening = true + } + const big = heatSel.big === 't' ? 't' : 'd' + box.innerHTML = ` +
+

Heatmap

+
${['d', 't'].map((vp) => ``).join('')}
+
+

So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

+
+ +
+
+
+
+
` + const stage = box.querySelector('.hs-stage') + const bigFig = box.querySelector('.hs-dev--big') + const phoneFig = box.querySelector('.hs-dev--phone') + const page = () => box.querySelector('#an-heat-page').value || '/' + const show = (fig, vp) => { + fig.heatReloads = 0 + loadHeatDevice(fig, projectId, vp, page()) + } + box.querySelector('#an-heat-page').addEventListener('change', () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + }) + box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { + if (b.dataset.vp === heatSel.big) return + heatSel.big = b.dataset.vp + box.querySelectorAll('.hs-switch button').forEach((x) => { + const on = x.dataset.vp === heatSel.big + x.classList.toggle('active', on) + x.setAttribute('aria-pressed', String(on)) + }) + stage.dataset.big = heatSel.big + show(bigFig, heatSel.big) + })) + heatSel.big = big + // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) + const start = () => { + show(bigFig, heatSel.big) + show(phoneFig, 'm') + } + if (typeof IntersectionObserver !== 'function') return start() + const io = new IntersectionObserver((entries) => { + if (!entries.some((e) => e.isIntersecting)) return + io.disconnect() + start() + }, { rootMargin: '300px 0px' }) + io.observe(stage) +} + +// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog +export function renderAnalyseOff(box, projectId, reload) { + box.innerHTML = ` +

Analyse ausschalten

+

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

+ +
` + const actions = box.querySelector('#an-off-actions') + const error = box.querySelector('#an-off-error') + box.querySelector('#an-off-ask').addEventListener('click', () => { + actions.innerHTML = 'Wirklich ausschalten? ' + actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) + actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { + ev.currentTarget.disabled = true + try { + await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) + box.innerHTML = ` +

Analyse ausgeschaltet

+

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

+
` + box.querySelector('#an-off-ok').addEventListener('click', () => reload()) + } catch (err) { + renderAnalyseOff(box, projectId, reload) + const e2 = box.querySelector('#an-off-error') + e2.textContent = err.message + e2.classList.remove('hidden') + } + }) + }) +} diff --git a/public/app.js b/public/app.js index ce23af1..02c9075 100644 --- a/public/app.js +++ b/public/app.js @@ -1917,7 +1917,7 @@ const viewLoaders = { chat: initChat } const viewRefreshers = { funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))), news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))), - analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), + analyse: () => import('/analyse.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))), termine: () => import('/termine.js').then((m) => m.initTermine(document.getElementById('termine-body'))), } const loadedViews = new Set() diff --git a/public/fn-util.js b/public/fn-util.js new file mode 100644 index 0000000..5b1ef11 --- /dev/null +++ b/public/fn-util.js @@ -0,0 +1,22 @@ +/* Gemeinsame Hilfen der Funktions-Module (funktionen.js, analyse.js): Escaping fuer Kundentexte, URL-Pruefung, API-Aufruf. */ + +export const esc = (value) => String(value ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') +export const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') + +export async function request(path, { method = 'GET', body, raw } = {}) { + const response = await fetch(path, { + method, + credentials: 'same-origin', + headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, + body: raw || (body ? JSON.stringify(body) : undefined), + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) { + const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) + err.status = response.status + if (data.field) err.field = data.field + throw err + } + return data +} diff --git a/public/funktionen.js b/public/funktionen.js index 8ef3dbb..7a9e5c7 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -3,8 +3,8 @@ Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css. Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */ -import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' -import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { esc, safeUrl, request } from '/fn-util.js' +import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js' const PRICE = '30 €' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' @@ -25,10 +25,6 @@ const TYPES = [ ['combo', 'Foto + Text', 'beides', ''], ] -const esc = (value) => String(value ?? '') - .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, ''') -const safeUrl = (url) => (/^https?:\/\//i.test(url || '') ? url : '') - function fmtDay(value) { const d = new Date(value) if (!value || Number.isNaN(d.getTime())) return '' @@ -41,23 +37,6 @@ function berlinIsoDay(offsetDays = 0) { return d.toLocaleDateString('sv-SE', { timeZone: 'Europe/Berlin' }) } -async function request(path, { method = 'GET', body, raw } = {}) { - const response = await fetch(path, { - method, - credentials: 'same-origin', - headers: raw ? { 'Content-Type': 'application/octet-stream' } : body ? { 'Content-Type': 'application/json' } : {}, - body: raw || (body ? JSON.stringify(body) : undefined), - }) - const data = await response.json().catch(() => ({})) - if (!response.ok) { - const err = new Error(data.error || (response.status === 502 ? 'Server nicht erreichbar. Bitte kurz warten und noch einmal versuchen.' : `Fehler ${response.status}`)) - err.status = response.status - if (data.field) err.field = data.field - throw err - } - return data -} - /* ------------------------------------------------------------ Zustand --- */ // Das im News-Bereich gewaehlte Projekt bleibt beim Wechseln zwischen Kacheln und News erhalten const selection = { projectId: '' } @@ -99,15 +78,9 @@ function newsTile(projects) { } /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ -const analyseOf = (project) => project.analyse || { comingSoon: true } -const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.' const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.' -function pickAnalyseProject(projects) { - return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0] -} - function analyseTile(projects) { const target = pickAnalyseProject(projects) const a = target && analyseOf(target) @@ -607,374 +580,3 @@ function renderEditor(stage, project) { sync() return loadList() } - -/* -------------------------------------------------------------- Analyse --- */ -const analyseSel = { projectId: '', range: 30 } -const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c'] -const HEX_RE = /^#[0-9a-f]{6}$/i -const nf = new Intl.NumberFormat('de-DE') -const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.' - -export async function initAnalyse(root, { projectId } = {}) { - if (!root) return - if (projectId) analyseSel.projectId = projectId - const { projects = [] } = await request('/api/funktionen') - const usable = projects.filter((p) => !analyseOf(p).comingSoon) - if (!usable.length) { - root.innerHTML = '
Die Analyse ist für deine Websites noch nicht freigeschaltet.
' - return - } - const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable) - analyseSel.projectId = project.projectId - root.innerHTML = ` - ${usable.length > 1 ? ` -
- - -
` : ''} -
` - root.querySelector('#an-project')?.addEventListener('change', (ev) => { - analyseSel.projectId = ev.target.value - initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) - }) - const stage = root.querySelector('#an-stage') - const a = analyseOf(project) - const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `

${esc(err.message)}

` }) - if (a.state === 'active') renderAnalyseStats(stage, project, reload) - else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' - else if (a.state === 'unknown') stage.innerHTML = '

Die Daten konnten gerade nicht geladen werden.

' - else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` - else renderAnalyseActivation(stage, project, reload) -} - -function bannerPreview(color) { - const c = bannerColor(color) - const btn = `style="background:${c};border-color:${c};color:#fff"` - return ` - ` -} - -const MISSING_PRIVACY_HINT = 'Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.' - -// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt -function controllerBlock(controller) { - if (controller.ok !== true) return `` - return `

Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:

${controllerLines(controller).map(esc).join(' · ')}

Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.

` -} - -export function renderAnalyseActivation(stage, project, reload) { - const a = analyseOf(project) - const legal = a.legal || {} - const controller = a.controller || { ok: false } - let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111' - stage.innerHTML = ` -
-

Analyse für ${esc(project.projectName)} einschalten

-

Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.

-

Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.

-

Farbe des Banners

-
- -
${PRESET_COLORS.map((c) => ``).join('')}
-
- -
- ${controllerBlock(controller)} - - -
-
` - const input = stage.querySelector('#an-color') - const hint = stage.querySelector('#an-hint') - const paint = () => { - stage.querySelector('#an-preview').innerHTML = bannerPreview(color) - hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase()) - } - paint() - input.addEventListener('input', () => { color = input.value; paint() }) - stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() })) - const box = stage.querySelector('#an-avv') - const button = stage.querySelector('#an-on') - const error = stage.querySelector('#an-error') - box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true }) - button.addEventListener('click', async () => { - if (!box.checked || controller.ok !== true) return - button.disabled = true - error.classList.add('hidden') - try { - await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } }) - reload() - } catch (err) { - error.textContent = err.message - error.classList.remove('hidden') - button.disabled = !box.checked || controller.ok !== true - } - }) -} - -function lineChart(series) { - const m = chartModel(series) - if (!m.points.length) return '

Noch keine Daten.

' - const total = m.points.reduce((sum, p) => sum + p.pv, 0) - const first = m.points[0].day - const last = m.points[m.points.length - 1].day - return ` - - Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))} - ${m.yTicks.map((t) => `${esc(t.label)}`).join('')} - ${m.xLabels.map((l) => `${esc(l.label)}`).join('')} - - - ${m.points.length <= 31 ? m.points.map((p) => `${esc(p.day)}: ${esc(nf.format(p.pv))}`).join('') : ''} - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -
-

${esc(title)}

- ${rows.length ? `
    ${rows.map((r) => ` -
  • ${html ? r.label : esc(r.label)}${esc(nf.format(r.count))}
  • `).join('')}
` : '

Noch keine Daten.

'} -
` -} - -const COUNTRY_ROW = (key) => `${flagEmoji(key) ? ` ` : ''}${esc(countryName(key))}` - -let statsRequestId = 0 -async function renderAnalyseStats(stage, project, reload) { - const myRequest = ++statsRequestId - const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30 - const pid = project.projectId - stage.innerHTML = ` -
${[7, 30, 90].map((r) => ``).join('')}
-

Lade…

-
-
-

Länderdaten: IP-Geolocation by DB-IP

` - stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) })) - renderAnalyseOff(stage.querySelector('#an-off'), pid, reload) - - const data = stage.querySelector('#an-data') - let stats - try { - stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`) - } catch (err) { - if (!stage.isConnected || myRequest !== statsRequestId) return - data.innerHTML = `` - return - } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `
${label}${esc(value)}
` - const b = stats.banner || {} - data.innerHTML = ` -
${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}
-

Seitenaufrufe pro Tag

${lineChart(stats.series)}
-
- ${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')} - ${barList('Herkunft', stats.refs, refLabel)} - ${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })} - ${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')} - ${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)} - ${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))} -
-

${esc(KPI_NOTE)}

` - renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || []) -} - -/* Heatmap: die echte Website in Geraeterahmen (gross: Computer oder Tablet, daneben das Handy), darueber die Klicks. - Die Website laeuft im iframe in echter Breite; h.js zeichnet dort das Overlay, sperrt Links und meldet die Klickzahl - per postMessage. Geraeterahmen: geraete.js + geraete.css (wird erst hier geladen). */ -const heatSel = { big: 'd' } -const HEAT_TIMEOUT_MS = 20000 - -let geraeteCss = null -function loadGeraeteCss() { - if (!geraeteCss) { - geraeteCss = new Promise((resolve) => { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = '/geraete.css' - link.onload = link.onerror = () => resolve() - document.head.appendChild(link) - }) - } - return geraeteCss -} - -// Nachricht von h.js aus einem der Rahmen: Klickzahl oder Fehler -let heatListening = false -function onHeatMessage(ev) { - const d = ev.data - if (!d || d.wk !== 'heat') return - for (const fig of document.querySelectorAll('.hs-dev')) { - const frame = fig.querySelector('iframe') - if (!frame || frame.contentWindow !== ev.source || ev.origin !== fig.dataset.origin) continue - clearTimeout(fig.heatTimer) - fig.dataset.state = d.state === 'ready' ? 'ready' : 'error' - setHeatCaption(fig, heatCaption(fig.dataset.state, Number(d.count) || 0, (n) => nf.format(n))) - } -} - -function setHeatCaption(fig, text) { - const cap = fig.querySelector('.hs-cap') - if (cap) cap.textContent = text -} - -// Bildschirmflaeche (dm-canvas) in Designgroesse per scale() einpassen, wie device-frames.js der Website -function fitHeatDevice(fig) { - fig.heatResize?.disconnect() - const canvas = fig.querySelector('.dm-canvas') - const host = canvas?.parentElement - if (!host) return - const fit = (width) => { - if (width > 0) canvas.style.setProperty('--dm-k', String(Math.round((width / Number(canvas.dataset.designWidth)) * 1e6) / 1e6)) - } - fit(parseFloat(getComputedStyle(host).width) || host.clientWidth) - if (typeof ResizeObserver === 'function') { - fig.heatResize = new ResizeObserver((entries) => fit(entries[entries.length - 1].contentRect.width)) - fig.heatResize.observe(host) - } -} - -async function loadHeatDevice(fig, projectId, vp, path) { - const run = (fig.heatRun = (fig.heatRun || 0) + 1) - clearTimeout(fig.heatTimer) - fig.heatResize?.disconnect() - fig.dataset.state = 'loading' - fig.innerHTML = `
${esc(heatCaption('loading'))}
` - let url - try { - ;({ url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp, embed: true } })) - } catch (err) { - if (run === fig.heatRun) setHeatCaption(fig, err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message) - return - } - await loadGeraeteCss() - if (run !== fig.heatRun || !fig.isConnected) return - if (!safeUrl(url)) return setHeatCaption(fig, heatCaption('error')) - fig.dataset.origin = new URL(url).origin - fig.querySelector('.hs-frame').innerHTML = deviceHtml(vp, { src: url, title: `Heatmap ${HEAT_DEVICES[vp].label}, Seite ${path}` }) - fitHeatDevice(fig) - // Seitenwechsel im Rahmen (Links sperrt h.js, Skripte der Seite koennten trotzdem wechseln): Heatmap neu laden - let loads = 0 - fig.querySelector('iframe').addEventListener('load', () => { - if (++loads > 1 && run === fig.heatRun && (fig.heatReloads = (fig.heatReloads || 0) + 1) <= 3) loadHeatDevice(fig, projectId, vp, path) - }) - fig.heatTimer = setTimeout(() => { - if (run === fig.heatRun && fig.dataset.state === 'loading') showHeatFallback(fig, projectId, vp, path) - }, HEAT_TIMEOUT_MS) -} - -// Website laesst sich nicht einbetten (oder das Analyse-Skript fehlt): Heatmap im eigenen Tab wie bisher -async function showHeatFallback(fig, projectId, vp, path) { - fig.dataset.state = 'timeout' - const cap = fig.querySelector('.hs-cap') - if (!cap) return - cap.textContent = heatCaption('timeout') - try { - const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path, vp } }) - // Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt - if (safeUrl(url) && cap.isConnected) cap.insertAdjacentHTML('beforeend', ` In neuem Tab öffnen`) - } catch { - /* Text reicht */ - } -} - -function renderAnalyseHeat(box, projectId, pages) { - if (!box) return - if (!pages.length) { - box.innerHTML = '

Heatmap

Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.

' - return - } - if (!heatListening) { - window.addEventListener('message', onHeatMessage) - heatListening = true - } - const big = heatSel.big === 't' ? 't' : 'd' - box.innerHTML = ` -
-

Heatmap

-
${['d', 't'].map((vp) => ``).join('')}
-
-

So sieht deine Website auf den Geräten aus, mit den Klicks der letzten 30 Tage. Scrolle in den Geräten durch die Seite; Links sind hier gesperrt.

-
- -
-
-
-
-
` - const stage = box.querySelector('.hs-stage') - const bigFig = box.querySelector('.hs-dev--big') - const phoneFig = box.querySelector('.hs-dev--phone') - const page = () => box.querySelector('#an-heat-page').value || '/' - const show = (fig, vp) => { - fig.heatReloads = 0 - loadHeatDevice(fig, projectId, vp, page()) - } - box.querySelector('#an-heat-page').addEventListener('change', () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - }) - box.querySelectorAll('.hs-switch button').forEach((b) => b.addEventListener('click', () => { - if (b.dataset.vp === heatSel.big) return - heatSel.big = b.dataset.vp - box.querySelectorAll('.hs-switch button').forEach((x) => { - const on = x.dataset.vp === heatSel.big - x.classList.toggle('active', on) - x.setAttribute('aria-pressed', String(on)) - }) - stage.dataset.big = heatSel.big - show(bigFig, heatSel.big) - })) - heatSel.big = big - // Websites erst laden, wenn die Heatmap in Sicht kommt (sie steht unten auf der Seite) - const start = () => { - show(bigFig, heatSel.big) - show(phoneFig, 'm') - } - if (typeof IntersectionObserver !== 'function') return start() - const io = new IntersectionObserver((entries) => { - if (!entries.some((e) => e.isIntersecting)) return - io.disconnect() - start() - }, { rootMargin: '300px 0px' }) - io.observe(stage) -} - -// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog -export function renderAnalyseOff(box, projectId, reload) { - box.innerHTML = ` -

Analyse ausschalten

-

Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.

- -
` - const actions = box.querySelector('#an-off-actions') - const error = box.querySelector('#an-off-error') - box.querySelector('#an-off-ask').addEventListener('click', () => { - actions.innerHTML = 'Wirklich ausschalten? ' - actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload)) - actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => { - ev.currentTarget.disabled = true - try { - await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } }) - box.innerHTML = ` -

Analyse ausgeschaltet

-

Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.

-
` - box.querySelector('#an-off-ok').addEventListener('click', () => reload()) - } catch (err) { - renderAnalyseOff(box, projectId, reload) - const e2 = box.querySelector('#an-off-error') - e2.textContent = err.message - e2.classList.remove('hidden') - } - }) - }) -} diff --git a/test/analyseView.test.js b/test/analyseView.test.js index 662e7bf..8628c0e 100644 --- a/test/analyseView.test.js +++ b/test/analyseView.test.js @@ -54,8 +54,11 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => { assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/) assert.match(html, /id="view-analyse"/) assert.match(html, /id="analyse-body"/) + assert.match(app, /analyse: \(\) => import\('\/analyse\.js'\)/) + const an = readFileSync(new URL('../public/analyse.js', import.meta.url), 'utf8') + assert.doesNotMatch(an, /\bconfirm\(/) const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') - assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/) + assert.doesNotMatch(fn, /function renderAnalyseStats|function renderAnalyseHeat/) }) test('Portal-Vorschau: Banner-Text ist der des Clients', () => { diff --git a/test/avvPage.test.js b/test/avvPage.test.js index dcf9972..31c8763 100644 --- a/test/avvPage.test.js +++ b/test/avvPage.test.js @@ -25,7 +25,7 @@ test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => { }) test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => { - const fn = readFileSync(root('public/funktionen.js'), 'utf8') + const fn = readFileSync(root('public/analyse.js'), 'utf8') for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) { assert.ok(!fn.includes(old), old) } diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index df39ae5..f3a8e34 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -5,15 +5,17 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) +// public/funktionen.js wirklich laden (die absoluten Importe /fn-util.js und /analyse-view.js werden auf die Dateien umgeleitet); +// analyse.js importiert relativ und wird direkt geladen const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') + .replace("'/fn-util.js'", `'${fileUrl('fn-util.js')}'`) .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) - .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) -const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) +const { initFunktionen } = await import(pathToFileURL(file).href) +const { initAnalyse, renderAnalyseOff } = await import(fileUrl('analyse.js')) const news = { state: 'off' } const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse }) diff --git a/test/termineView.test.js b/test/termineView.test.js index 8ab2d0f..a25b50b 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -264,9 +264,9 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp assert.match(html, /
[\s\S]*

Terminplaner<\/h1>[\s\S]*
/) assert.match(html, //) // R4: funktionen.js lädt keine Terminplaner-Module (die Kachel steht inline); seine absoluten Importe biegt - // test/funktionenTiles.test.js um – genau diese beiden (/geraete.js kam mit der Heatmap im Gerätrahmen auf main) + // test/funktionenTiles.test.js um – genau diese beiden (die Analyse-Oberfläche liegt in analyse.js, mit relativen Importen) const fn = lies('public/funktionen.js') - assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"]) + assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/fn-util.js'", "from '/analyse-view.js'"]) assert.doesNotMatch(fn, /from '\/termine/) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) From 69c94d83d7b94a2417c0f8840ddc668c1c6abdbc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 20:04:59 +0000 Subject: [PATCH 13/21] Analyse: Vergleich zum Vorzeitraum und Klartext-Regeln als reine Funktionen Co-Authored-By: Claude Opus 5.5 --- public/analyse-view.js | 256 +++++++++++++++++++++++++++++++++- test/analyseInsights.test.js | 258 +++++++++++++++++++++++++++++++++++ 2 files changed, 509 insertions(+), 5 deletions(-) create mode 100644 test/analyseInsights.test.js diff --git a/public/analyse-view.js b/public/analyse-view.js index 7ea000c..ef3e5b2 100644 --- a/public/analyse-view.js +++ b/public/analyse-view.js @@ -1,5 +1,7 @@ /* Reine Hilfsfunktionen und gemeinsamer Zustand der Analyse-Ansicht (analyse.js, Kachel in funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */ +import { esc } from './fn-util.js' + // Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1 export function contrastOk(hex) { if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false @@ -53,22 +55,33 @@ export function shortDay(day) { return m ? `${m[2]}.${m[1]}.` : String(day || '') } -/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */ -export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) { +/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. Mit compare ([{pv}], Zeitraum davor) gemeinsame Skala und gestrichelte Vergleichslinie (nach Tagesindex ausgerichtet). */ +export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6, compare } = {}) { const data = Array.isArray(series) ? series : [] + const before = Array.isArray(compare) ? compare : [] + const prevOf = (i) => { + const v = before[i]?.pv + return v == null || !Number.isFinite(Number(v)) ? null : Number(v) + } const innerW = width - pad.l - pad.r const innerH = height - pad.t - pad.b - const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0))) + const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0), ...data.map((_, i) => prevOf(i) ?? 0))) const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2) const y = (v) => pad.t + innerH - (innerH * v) / top - const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 })) + const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0, prev: prevOf(i) })) const step = Math.max(1, Math.ceil(data.length / maxLabels)) const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) })) const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) })) const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ') const baseY = round(pad.t + innerH) const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : '' - return { width, height, pad, top, points, xLabels, yTicks, line, area } + // Vergleichslinie: Luecken (fehlende Tage) beginnen einen neuen Abschnitt + const prevLine = points.reduce((path, p, i) => { + if (p.prev === null) return path + const move = i > 0 && points[i - 1].prev !== null ? 'L' : 'M' + return `${path}${path ? ' ' : ''}${move}${p.x} ${round(y(p.prev))}` + }, '') + return { width, height, pad, top, points, xLabels, yTicks, line, area, prevLine } } function round(n) { @@ -101,3 +114,236 @@ export function pickAnalyseProject(projects) { // Gemeinsam fuer die Kachel (funktionen.js) und die Analyse-Ansicht (analyse.js) export const analyseSel = { projectId: '', range: 30, tab: 'uebersicht' } + +/* --- Vergleich zum Vorzeitraum und Klartext ("Das Wichtigste"), Spec 2.3 und 2.4 --- */ + +const MONTHS = ['Jan.', 'Feb.', 'März', 'Apr.', 'Mai', 'Juni', 'Juli', 'Aug.', 'Sep.', 'Okt.', 'Nov.', 'Dez.'] +const WEEKDAYS = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'] // wie Date#getUTCDay() +const WEEKDAYS_SHORT = ['So.', 'Mo.', 'Di.', 'Mi.', 'Do.', 'Fr.', 'Sa.'] +const AGAINST = 'gegenüber dem Zeitraum davor' + +const NUMBER_DE = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 0 }) +const isNum = (v) => typeof v === 'number' && Number.isFinite(v) + +/** Ganzzahl auf Deutsch: 6930 => "6.930". */ +export const fmtNum = (n) => NUMBER_DE.format(Number(n) || 0) + +/** Sekunden als m:ss: 108 => "1:48", 7 => "0:07"; null oder ungueltig => "–". */ +export function fmtDuration(sec) { + if (!isNum(sec) || sec < 0) return '–' + const s = Math.round(sec) + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` +} + +// "YYYY-MM-DD" => { y, m, d } oder null; Wochentage und Tagesrechnung laufen nur ueber Date.UTC (keine Ortszeit) +function parseDay(day) { + const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day || '') + if (!m) return null + const [y, mo, d] = [+m[1], +m[2], +m[3]] + const t = new Date(Date.UTC(y, mo - 1, d)) + return t.getUTCMonth() === mo - 1 && t.getUTCDate() === d ? { y, m: mo, d } : null +} +const weekdayOf = (day) => { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d)).getUTCDay() : -1 +} +function addDays(day, n) { + const p = parseDay(day) + return p ? new Date(Date.UTC(p.y, p.m - 1, p.d + n)).toISOString().slice(0, 10) : '' +} + +/** "2026-09-19" => "Sa., 19.09." */ +export function dayLabel(day) { + const w = weekdayOf(day) + return w < 0 ? String(day || '') : `${WEEKDAYS_SHORT[w]}, ${shortDay(day)}` +} + +/** ("2026-09-05", "2026-10-04") => "5. Sep. – 4. Okt."; leere oder ungueltige Eingabe => "". */ +export function periodLabel(from, to) { + const a = parseDay(from) + const b = parseDay(to) + return a && b ? `${a.d}. ${MONTHS[a.m - 1]} – ${b.d}. ${MONTHS[b.m - 1]}` : '' +} + +/** Vergleich moeglich, wenn der Vorzeitraum vollstaendig ist und mindestens 20 Aufrufe hat. Sonst from = "TT.MM." ab dem der Vergleich geht (nur bei Vorzeitraum, der noch nicht voll ist). */ +export function compareFrom(stats) { + const prev = stats?.previous + if (prev?.complete && Number(prev.totals?.pv) >= 20) return { ok: true, from: '' } + const range = Number(stats?.range) + if (prev && !prev.complete && stats.firstDay && range > 0) { + const from = addDays(stats.firstDay, 2 * range - 1) + return { ok: false, from: from ? shortDay(from) : '' } + } + return { ok: false, from: '' } +} + +// Sekunden in Worten fuer Screenreader: 65 => "1 Minute 5 Sekunden" +function secWords(sec) { + const m = Math.floor(sec / 60) + const s = sec % 60 + return [m ? `${m} ${m === 1 ? 'Minute' : 'Minuten'}` : '', s || !m ? `${s} ${s === 1 ? 'Sekunde' : 'Sekunden'}` : ''].filter(Boolean).join(' ') +} + +/** Veraenderung gegenueber dem Vorwert fuer den Chip: { dir, text, aria } oder null, wenn ein Wert keine Zahl ist. kind 'count': Prozent ab 100/100, sonst absolut; kind 'sec': absolut in Sekunden. */ +export function delta(cur, prev, { kind = 'count' } = {}) { + if (!isNum(cur) || !isNum(prev)) return null + const flat = (text) => ({ dir: 'flat', text, aria: `unverändert ${AGAINST}` }) + const diff = cur - prev + if (diff === 0) return flat('± 0') + const dir = diff > 0 ? 'up' : 'down' + const arrow = diff > 0 ? '▲' : '▼' + const sign = diff > 0 ? 'plus' : 'minus' + if (kind === 'sec') { + const n = Math.round(Math.abs(diff)) + if (n === 0) return flat('± 0') + return { dir, text: `${arrow} ${n < 60 ? `${n} s` : fmtDuration(n)}`, aria: `${sign} ${secWords(n)} ${AGAINST}` } + } + if (cur >= 100 && prev >= 100) { + const pct = (Math.abs(diff) / prev) * 100 + if (pct < 1) return flat('± 0 %') + const n = Math.round(pct) + return { dir, text: `${arrow} ${n} %`, aria: `${sign} ${n} Prozent ${AGAINST}` } + } + const n = fmtNum(Math.abs(diff)) + return { dir, text: `${arrow} ${n}`, aria: `${sign} ${n} ${AGAINST}` } +} + +/** Kontakt-Aktionen pro 100 Besucher, eine Nachkommastelle; null unter 30 Besuchern (zu unsicher). */ +export function contactRate(contacts, visitors) { + if (!isNum(contacts) || !isNum(visitors) || visitors < 30) return null + return Math.round((contacts / visitors) * 1000) / 10 +} + +// Bekannte Herkunfts-Hosts (Teilstring am Label-Rand: "l.facebook.com" ja, "notgoogle.com" nein) +const REF_NAMES = [ + [/(^|\.)google\.(com?\.)?[a-z]{2,3}$/, 'Google'], + [/(^|\.)bing\.com$/, 'Bing'], + [/(^|\.)instagram\.com$/, 'Instagram'], + [/(^|\.)facebook\.com$/, 'Facebook'], + [/(^|\.)duckduckgo\.com$/, 'DuckDuckGo'], + [/(^|\.)ecosia\.org$/, 'Ecosia'], +] + +/** Lesbarer Name einer Herkunft; unbekannter Host bleibt unveraendert (beim Einsetzen in HTML maskieren). */ +export function refName(host) { + const h = String(host ?? '').trim() + if (!h) return 'Direkt oder unbekannt' + const hit = REF_NAMES.find(([re]) => re.test(h.toLowerCase())) + return hit ? hit[1] : h +} + +/** Staerkster Wochentag und Spitzentag ueber die Tage ab firstDay ('' = alle). null bei weniger als 7 Tagen. */ +export function weekdayStats(series, firstDay) { + const rows = (Array.isArray(series) ? series : []) + .filter((d) => parseDay(d?.day) && (!firstDay || d.day >= firstDay)) + .map((d) => ({ day: d.day, pv: Number(d.pv) || 0 })) + if (rows.length < 7) return null + const avg = rows.reduce((a, r) => a + r.pv, 0) / rows.length + const perWeekday = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 })) + for (const r of rows) { + const w = perWeekday[weekdayOf(r.day)] + w.sum += r.pv + w.n += 1 + } + let best = null + perWeekday.forEach((w, weekday) => { + if (w.n && (!best || w.sum / w.n > best.avg)) best = { weekday, name: WEEKDAYS[weekday], avg: w.sum / w.n } + }) + if (!best || !(avg > 0) || best.avg < 1.2 * avg) best = null + const max = rows.reduce((m, r) => (r.pv > m.pv ? r : m), rows[0]) + const peak = avg > 0 && max.pv >= 1.8 * avg ? { day: max.day, pv: max.pv } : null + return { best, avg, peak } +} + +const CONTACT_WAYS = { tel: 'per Telefon', mail: 'per E-Mail', wa: 'per WhatsApp', out: 'über einen Link nach außen' } +const DEVICE_WORDS = { m: 'vom Handy', d: 'vom Computer' } + +// Satz ueber dem Hinweis-Block; nur hier steht fett (), alle Zahlen sind fmtNum +function leadOf(stats, cmp) { + const days = Number(stats.range) || (Array.isArray(stats.series) ? stats.series.length : 0) + const pv = Number(stats.totals.pv) + const count = `${fmtNum(pv)}-mal` + if (!cmp.ok) { + return stats.firstDay && stats.from && stats.firstDay >= stats.from + ? `Seit dem Start der Analyse am ${esc(shortDay(stats.firstDay))} wurde deine Website ${count} aufgerufen.` + : `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen.` + } + const before = Number(stats.previous.totals.pv) + const diff = pv - before + const start = `Deine Website wurde in den letzten ${days} Tagen ${count} aufgerufen – ` + if (Math.abs(diff) / before < 0.05) return `${start}etwa so oft wie in den ${days} Tagen davor.` + const more = diff > 0 + const size = pv >= 100 && before >= 100 + ? `${fmtNum(Math.round((Math.abs(diff) / before) * 100))} %` + : `${fmtNum(Math.abs(diff))} ${Math.abs(diff) === 1 ? 'Aufruf' : 'Aufrufe'}` + return `${start}${size} ${more ? 'mehr' : 'weniger'} als in den ${days} Tagen davor.` +} + +function contactItem(stats, cmp) { + const total = Number(stats.totals.contacts) || 0 + if (total < 1) { + return Number(stats.totals.visitors) >= 30 + ? 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' + : null + } + const main = (Array.isArray(stats.contacts) ? stats.contacts : []) + .filter((c) => CONTACT_WAYS[c.key] && c.count > 0) + .reduce((m, c) => (!m || c.count > m.count ? c : m), null) + let text = `${fmtNum(total)}-mal hat dich jemand direkt kontaktiert${main ? `, meist ${CONTACT_WAYS[main.key]} (${fmtNum(main.count)})` : ''}.` + const before = cmp.ok ? Number(stats.previous.totals.contacts) : 0 + if (before >= 1) { + const diff = total - before + text += diff === 0 ? ' Genauso oft wie zuvor.' : ` Das sind ${fmtNum(Math.abs(diff))} ${diff > 0 ? 'mehr' : 'weniger'} als zuvor.` + } + return text +} + +function dayItem(stats) { + if (!(Number(stats.totals.pv) >= 70)) return null + const w = weekdayStats(stats.series, stats.firstDay || '') + if (!w) return null + const parts = [] + if (w.best) parts.push(`${w.best.name} ist im Schnitt dein stärkster Tag.`) + if (w.peak) parts.push(`Spitzentag war ${dayLabel(w.peak.day)} mit ${fmtNum(w.peak.pv)} Aufrufen.`) + return parts.length ? parts.join(' ') : null +} + +function sourceItem(stats) { + const refs = Array.isArray(stats.refs) ? stats.refs : [] + const sum = refs.reduce((a, r) => a + (Number(r.count) || 0), 0) + const first = refs.find((r) => r.key) + if (!first || !(sum > 0) || first.count / sum < 0.2) return null + return `Die meisten, die über einen Link kamen, kommen von ${esc(refName(first.key))} (${fmtNum(first.count)}).` +} + +function deviceItem(stats) { + const devices = Array.isArray(stats.devices) ? stats.devices : [] + const sum = devices.reduce((a, d) => a + (Number(d.count) || 0), 0) + const main = devices.find((d) => DEVICE_WORDS[d.key] && sum > 0 && d.count / sum >= 0.55) + return main ? `${fmtNum(Math.round((main.count / sum) * 100))} % der Aufrufe kommen ${DEVICE_WORDS[main.key]}.` : null +} + +function pageItem(stats) { + const [first, second] = Array.isArray(stats.pages) ? stats.pages : [] + if (!first?.key) return null + if (first.key === '/') { + return second?.key + ? `Am meisten angesehen: die Startseite, danach ${esc(second.key)} (${fmtNum(second.count)}).` + : `Am meisten angesehen: die Startseite (${fmtNum(first.count)} Aufrufe).` + } + return `Am meisten angesehen: ${esc(first.key)} (${fmtNum(first.count)} Aufrufe).` +} + +/** Klartext fuer "Das Wichtigste": { lead, items: [{ icon, html }] } als maskiertes HTML; null unter 30 Seitenaufrufen. Feste Regeln, keine KI. */ +export function summarize(stats) { + if (!stats?.totals || !(Number(stats.totals.pv) >= 30)) return null + const cmp = compareFrom(stats) + const items = [ + ['contact', contactItem(stats, cmp)], + ['day', dayItem(stats)], + ['source', sourceItem(stats)], + ['device', deviceItem(stats)], + ['page', pageItem(stats)], + ].filter(([, html]) => html).slice(0, 4).map(([icon, html]) => ({ icon, html })) + return { lead: leadOf(stats, cmp), items } +} diff --git a/test/analyseInsights.test.js b/test/analyseInsights.test.js new file mode 100644 index 0000000..30c3337 --- /dev/null +++ b/test/analyseInsights.test.js @@ -0,0 +1,258 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { delta, fmtDuration, fmtNum, dayLabel, periodLabel, compareFrom, contactRate, refName, weekdayStats, summarize, chartModel } from '../public/analyse-view.js' + +// Testdaten: eine vollstaendige Statistik-Antwort mit 30 Tagen (Form wie analytics/src/stats.js) +const isoDay = (from, n) => new Date(Date.UTC(+from.slice(0, 4), +from.slice(5, 7) - 1, +from.slice(8, 10) + n)).toISOString().slice(0, 10) +const days = (from, n, pv) => Array.from({ length: n }, (_, i) => ({ day: isoDay(from, i), pv: typeof pv === 'function' ? pv(i, isoDay(from, i)) : pv })) +const t = { pv: 6930, visitors: 2410, contacts: 188, consentRate: 0.61, engaged: 1800, avgSec: 108, avgScroll: 64 } +const prev = { + from: '2026-08-06', to: '2026-09-04', complete: true, + totals: { pv: 6080, visitors: 2200, contacts: 143, consentRate: 0.6, engaged: 1500, avgSec: 114, avgScroll: 60 }, + series: days('2026-08-06', 30, 203), + contacts: [{ key: 'tel', count: 80 }, { key: 'mail', count: 40 }, { key: 'wa', count: 23 }], +} +const stats = (over = {}) => ({ + range: 30, from: '2026-09-05', to: '2026-10-04', firstDay: '2026-07-01', + totals: t, + series: days('2026-09-05', 30, 231), + pages: [{ key: '/', count: 3200 }, { key: '/preise', count: 1310 }, { key: '/kontakt', count: 900 }], + refs: [{ key: 'google.com', count: 880 }, { key: '', count: 700 }, { key: 'instagram.com', count: 400 }, { key: 'l.facebook.com', count: 290 }], + countries: [{ key: 'DE', count: 6000 }], + devices: [{ key: 'm', count: 4590 }, { key: 'd', count: 2200 }, { key: 't', count: 140 }], + contacts: [{ key: 'tel', count: 96 }, { key: 'mail', count: 52 }, { key: 'wa', count: 30 }, { key: 'out', count: 10 }], + banner: { shown: 3950, accept: 2410, reject: 1540 }, + previous: prev, + ...over, +}) + +test('delta: Prozent ab 100, sonst absolut, flat, Sekunden, null', () => { + assert.deepEqual(delta(6930, 6080), { dir: 'up', text: '▲ 14 %', aria: 'plus 14 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(188, 143).text, '▲ 31 %') // beide ≥ 100 → Prozent + assert.equal(delta(45, 33).text, '▲ 12') + assert.equal(delta(30, 33).text, '▼ 3') + assert.deepEqual(delta(7, 7), { dir: 'flat', text: '± 0', aria: 'unverändert gegenüber dem Zeitraum davor' }) + assert.equal(delta(1004, 1000).text, '± 0 %') // |0,4 %| < 1 + assert.equal(delta(1004, 1000).dir, 'flat') + assert.equal(delta(108, 114, { kind: 'sec' }).text, '▼ 6 s') + assert.equal(delta(200, 135, { kind: 'sec' }).text, '▲ 1:05') + assert.equal(delta(null, 3), null) + assert.equal(delta(3, undefined), null) + assert.equal(delta('3', 4), null) +}) + +test('delta: Richtung und aria in Wörtern', () => { + assert.deepEqual(delta(5000, 6000), { dir: 'down', text: '▼ 17 %', aria: 'minus 17 Prozent gegenüber dem Zeitraum davor' }) + assert.equal(delta(45, 33).aria, 'plus 12 gegenüber dem Zeitraum davor') + assert.equal(delta(30, 33).aria, 'minus 3 gegenüber dem Zeitraum davor') + assert.equal(delta(108, 114, { kind: 'sec' }).aria, 'minus 6 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(200, 135, { kind: 'sec' }).aria, 'plus 1 Minute 5 Sekunden gegenüber dem Zeitraum davor') + assert.equal(delta(240, 120, { kind: 'sec' }).aria, 'plus 2 Minuten gegenüber dem Zeitraum davor') +}) + +test('fmtDuration, dayLabel, periodLabel, fmtNum', () => { + assert.equal(fmtDuration(108), '1:48'); assert.equal(fmtDuration(7), '0:07'); assert.equal(fmtDuration(null), '–') + assert.equal(fmtDuration(NaN), '–'); assert.equal(fmtDuration(-3), '–'); assert.equal(fmtDuration(undefined), '–') + assert.equal(dayLabel('2026-09-19'), 'Sa., 19.09.') + assert.equal(dayLabel('2026-09-20'), 'So., 20.09.') + assert.equal(periodLabel('2026-09-05', '2026-10-04'), '5. Sep. – 4. Okt.') + assert.equal(periodLabel('2026-03-01', '2026-06-30'), '1. März – 30. Juni') + assert.equal(periodLabel('', ''), '') + assert.equal(periodLabel('2026-09-05', ''), '') + assert.equal(fmtNum(6930), '6.930') + assert.equal(fmtNum(0), '0') + assert.equal(fmtNum(1234567), '1.234.567') +}) + +test('compareFrom: ok, Datum bei neuer Website, nichts ohne previous', () => { + assert.deepEqual(compareFrom(stats()), { ok: true, from: '' }) + assert.deepEqual(compareFrom(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })), { ok: false, from: '18.11.' }) // range 30: +59 Tage + assert.deepEqual(compareFrom(stats({ range: 7, firstDay: '2026-09-28', previous: { ...prev, complete: false } })), { ok: false, from: '11.10.' }) // range 7: +13 Tage + assert.deepEqual(compareFrom(stats({ firstDay: '', previous: { ...prev, complete: false } })), { ok: false, from: '' }) + assert.deepEqual(compareFrom({ totals: { pv: 50 }, series: [] }), { ok: false, from: '' }) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 19 } } })).ok, false) + assert.equal(compareFrom(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 20 } } })).ok, true) +}) + +test('contactRate und refName', () => { + assert.equal(contactRate(188, 2410), 7.8); assert.equal(contactRate(5, 29), null) + assert.equal(contactRate(0, 30), 0); assert.equal(contactRate(null, 100), null) + assert.equal(refName('www.google.de'), 'Google'); assert.equal(refName('l.facebook.com'), 'Facebook'); assert.equal(refName(''), 'Direkt oder unbekannt'); assert.equal(refName('blog.example'), 'blog.example') + assert.equal(refName('google.com'), 'Google'); assert.equal(refName('google.co.uk'), 'Google') + assert.equal(refName('www.bing.com'), 'Bing'); assert.equal(refName('l.instagram.com'), 'Instagram') + assert.equal(refName('m.facebook.com'), 'Facebook'); assert.equal(refName('facebook.com'), 'Facebook') + assert.equal(refName('duckduckgo.com'), 'DuckDuckGo'); assert.equal(refName('www.ecosia.org'), 'Ecosia') + assert.equal(refName('notgoogle.com'), 'notgoogle.com'); assert.equal(refName('google.evil.de'), 'google.evil.de') + assert.equal(refName('Google.DE'), 'Google') +}) + +test('weekdayStats: stärkster Wochentag, Spitzentag, < 7 Tage null', () => { + // 05.09.2026 ist ein Samstag: 5 Samstage mit 20, alle anderen 10 → Schnitt 11,67; Samstag 20 ≥ 1,2 × 11,67, aber < 1,8 × 11,67 (kein Spitzentag) + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const w = weekdayStats(days('2026-09-05', 30, sat), '') + assert.equal(w.best.weekday, 6); assert.equal(w.best.name, 'Samstag'); assert.equal(w.best.avg, 20) + assert.ok(Math.abs(w.avg - 350 / 30) < 1e-9) + assert.equal(w.peak, null) + // ein Tag mit dem 3-Fachen des Schnitts → Spitzentag + const spike = (i, day) => (day === '2026-09-19' ? 60 : 10) + const p = weekdayStats(days('2026-09-05', 30, spike), '') + assert.deepEqual(p.peak, { day: '2026-09-19', pv: 60 }) + assert.equal(p.best.name, 'Samstag') + // gleichmäßig: weder stärkster Tag noch Spitzentag + assert.deepEqual(weekdayStats(days('2026-09-05', 30, 10), ''), { best: null, avg: 10, peak: null }) + // weniger als 7 Tage → null; firstDay schneidet Tage davor ab + assert.equal(weekdayStats(days('2026-09-05', 6, 10), ''), null) + assert.notEqual(weekdayStats(days('2026-09-05', 30, 10), '2026-09-28'), null) // 28.09.–04.10. = genau 7 Tage +}) + +test('weekdayStats: firstDay zählt nur Tage ab firstDay', () => { + assert.equal(weekdayStats(days('2026-09-05', 30, 10), '2026-09-29'), null) // nur 6 Tage (29.09.–04.10.) + const w = weekdayStats(days('2026-09-05', 30, (i, day) => (day < '2026-09-20' ? 1000 : 10)), '2026-09-20') + assert.equal(w.avg, 10) // die Tage davor (1000) bleiben außen vor + assert.equal(weekdayStats(null, ''), null) +}) + +test('summarize: Wachstum mit Prozent', () => { + const s = summarize(stats()) + assert.equal(s.lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 14 % mehr als in den 30 Tagen davor.') + assert.equal(s.items[0].icon, 'contact') + assert.equal(s.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 45 mehr als zuvor.') + assert.ok(s.items.length <= 4) +}) + +test('summarize: Rückgang, etwa gleich, kleine Zahlen absolut', () => { + const lead = (totalsPv, prevPv) => summarize(stats({ totals: { ...t, pv: totalsPv }, previous: { ...prev, totals: { ...prev.totals, pv: prevPv } } })).lead + assert.equal(lead(6930, 7615), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – 9 % weniger als in den 30 Tagen davor.') + assert.equal(lead(6930, 6800), 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(80, 68), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – 12 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(60, 75), 'Deine Website wurde in den letzten 30 Tagen 60-mal aufgerufen – 15 Aufrufe weniger als in den 30 Tagen davor.') + assert.equal(lead(84, 83), 'Deine Website wurde in den letzten 30 Tagen 84-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') // unter 5 % gilt auch bei kleinen Zahlen + assert.match(lead(32, 31), /aufgerufen – etwa so oft wie in den 30 Tagen davor\.$/) + assert.equal(lead(40, 30), 'Deine Website wurde in den letzten 30 Tagen 40-mal aufgerufen – 10 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(31, 20), 'Deine Website wurde in den letzten 30 Tagen 31-mal aufgerufen – 11 Aufrufe mehr als in den 30 Tagen davor.') + assert.equal(lead(80, 80), 'Deine Website wurde in den letzten 30 Tagen 80-mal aufgerufen – etwa so oft wie in den 30 Tagen davor.') + assert.equal(lead(7200, 6857), 'Deine Website wurde in den letzten 30 Tagen 7.200-mal aufgerufen – 5 % mehr als in den 30 Tagen davor.') // genau 5 % gilt noch als Veränderung +}) + +test('summarize: ohne Vergleich und neu gestartet', () => { + // previous fehlt: '…in den letzten 30 Tagen X-mal aufgerufen.' + const { previous, ...ohne } = stats() + assert.equal(summarize(ohne).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum unvollständig, firstDay vor dem Zeitraum: kein Vergleich, aber kein „Seit dem Start“ + assert.equal(summarize(stats({ firstDay: '2026-08-20', previous: { ...prev, complete: false } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // Vorzeitraum zu klein: kein Vergleich + assert.equal(summarize(stats({ previous: { ...prev, totals: { ...prev.totals, pv: 12 } } })).lead, 'Deine Website wurde in den letzten 30 Tagen 6.930-mal aufgerufen.') + // firstDay im Zeitraum (complete false): 'Seit dem Start der Analyse am 20.09. wurde deine Website X-mal aufgerufen.' + const neu = summarize(stats({ firstDay: '2026-09-20', previous: { ...prev, complete: false } })) + assert.equal(neu.lead, 'Seit dem Start der Analyse am 20.09. wurde deine Website 6.930-mal aufgerufen.') + // auch das Startdatum wird maskiert, falls der Collector je Unsinn liefert + assert.ok(!summarize(stats({ firstDay: 'x', previous: { ...prev, complete: false } })).lead.includes('')) + // ohne Vergleich kein „Das sind … mehr als zuvor“ + assert.equal(neu.items[0].html, '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96).') + // 7 Tage: „7 Tagen“ + assert.match(summarize(stats({ range: 7 })).lead, /in den letzten 7 Tagen .* als in den 7 Tagen davor\.$/) +}) + +test('summarize: zu wenig Daten → null; keine Kontakte → Hinweis nur ab 30 Besuchern', () => { + assert.equal(summarize(stats({ totals: { ...t, pv: 29 } })), null) + assert.equal(summarize(null), null) + assert.notEqual(summarize(stats({ totals: { ...t, pv: 30 } })), null) + // contacts 0, visitors 40: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' + const keine = summarize(stats({ totals: { ...t, contacts: 0, visitors: 40 }, contacts: [] })) + assert.deepEqual(keine.items[0], { icon: 'contact', html: 'Von den Besuchern mit Zustimmung hat noch niemand über die Website angerufen oder geschrieben.' }) + const wenig = summarize(stats({ totals: { ...t, contacts: 0, visitors: 29 }, contacts: [] })) + assert.notEqual(wenig.items[0].icon, 'contact') +}) + +test('summarize: Kontakte – Vergleichssatz und häufigste Art', () => { + const html = (contactsNow, contactsBefore, kinds) => summarize(stats({ + totals: { ...t, contacts: contactsNow }, + contacts: kinds, + previous: { ...prev, totals: { ...prev.totals, contacts: contactsBefore } }, + })).items[0].html + assert.equal(html(188, 200, [{ key: 'tel', count: 96 }]), '188-mal hat dich jemand direkt kontaktiert, meist per Telefon (96). Das sind 12 weniger als zuvor.') + assert.equal(html(188, 188, [{ key: 'mail', count: 100 }]), '188-mal hat dich jemand direkt kontaktiert, meist per E-Mail (100). Genauso oft wie zuvor.') + assert.equal(html(5, 0, [{ key: 'wa', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist per WhatsApp (3).') // Vorwert 0: kein Vergleichssatz + assert.equal(html(5, 2, [{ key: 'out', count: 3 }]), '5-mal hat dich jemand direkt kontaktiert, meist über einen Link nach außen (3). Das sind 3 mehr als zuvor.') +}) + +test('summarize: Herkunft, Gerät, Seite und Reihenfolge, höchstens 4', () => { + // refs google.com 880 von 2270 → 'Die meisten, die über einen Link kamen, kommen von Google (880).' + const s = summarize(stats()) + const byIcon = Object.fromEntries(s.items.map((i) => [i.icon, i.html])) + assert.equal(byIcon.source, 'Die meisten, die über einen Link kamen, kommen von Google (880).') + // devices m 4590/6930 → '66 % der Aufrufe kommen vom Handy.' + assert.equal(byIcon.device, '66 % der Aufrufe kommen vom Handy.') + // pages / dann /preise → 'Am meisten angesehen: die Startseite, danach /preise (1.310).' + assert.equal(byIcon.page, 'Am meisten angesehen: die Startseite, danach /preise (1.310).') + assert.deepEqual(s.items.map((i) => i.icon), ['contact', 'source', 'device', 'page']) + // Computer, Seite ohne Startseite + const pc = summarize(stats({ devices: [{ key: 'd', count: 600 }, { key: 'm', count: 300 }, { key: 't', count: 100 }], pages: [{ key: '/preise', count: 1310 }, { key: '/', count: 900 }] })) + assert.equal(pc.items.find((i) => i.icon === 'device').html, '60 % der Aufrufe kommen vom Computer.') + assert.equal(pc.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /preise (1.310 Aufrufe).') + // nur Startseite + assert.equal(summarize(stats({ pages: [{ key: '/', count: 50 }] })).items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: die Startseite (50 Aufrufe).') + // Gerät unter 55 %, Herkunft unter 20 %: beide Hinweise entfallen + const flach = summarize(stats({ devices: [{ key: 'm', count: 50 }, { key: 'd', count: 50 }], refs: [{ key: 'google.com', count: 10 }, { key: '', count: 90 }] })) + assert.ok(!flach.items.some((i) => i.icon === 'device' || i.icon === 'source')) + // Direkt (leerer Host) zählt nie als Quelle, auch wenn er vorne steht + const direkt = summarize(stats({ refs: [{ key: '', count: 900 }, { key: 'google.com', count: 300 }] })) + assert.equal(direkt.items.find((i) => i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von Google (300).') + // Samstage 2× → Wochentag-Hinweis; alle 5 Bedingungen wahr → Seite fällt weg + const sat = (i, day) => (new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 20 : 10) + const voll = summarize(stats({ series: days('2026-09-05', 30, sat) })) + assert.deepEqual(voll.items.map((i) => i.icon), ['contact', 'day', 'source', 'device']) + assert.equal(voll.items[1].html, 'Samstag ist im Schnitt dein stärkster Tag.') +}) + +test('summarize: Wochentag und Spitzentag in einem Hinweis', () => { + // Samstage 20, Spitzentag Sa., 19.09. mit 371, sonst 10: beides trifft zu + const both = (i, day) => (day === '2026-09-19' ? 371 : new Date(day + 'T00:00:00Z').getUTCDay() === 6 ? 40 : 10) + const s = summarize(stats({ series: days('2026-09-05', 30, both) })) + assert.equal(s.items.find((i) => i.icon === 'day').html, 'Samstag ist im Schnitt dein stärkster Tag. Spitzentag war Sa., 19.09. mit 371 Aufrufen.') + // nur Spitzentag am Mittwoch 23.09.: die anderen Mittwoche (0) gleichen den Wochentagsschnitt aus (alle Wochentage Ø 25) + const spike = (i, day) => (day === '2026-09-23' ? 100 : new Date(day + 'T00:00:00Z').getUTCDay() === 3 ? 0 : 25) + const p = summarize(stats({ series: days('2026-09-05', 30, spike) })) + assert.equal(p.items.find((i) => i.icon === 'day').html, 'Spitzentag war Mi., 23.09. mit 100 Aufrufen.') + // weniger als 70 Aufrufe oder weniger als 7 Tage: kein Wochentag-Hinweis + assert.ok(!summarize(stats({ totals: { ...t, pv: 69 }, series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) + assert.ok(!summarize(stats({ firstDay: '2026-09-30', series: days('2026-09-05', 30, spike) })).items.some((i) => i.icon === 'day')) +}) + +test('summarize: Pfade und Hosts werden maskiert', () => { + const s = summarize(stats({ pages: [{ key: '/', count: 900 }], refs: [{ key: 'a"b.de', count: 900 }] })) + assert.ok(!JSON.stringify(s).includes(' i.icon === 'source').html, 'Die meisten, die über einen Link kamen, kommen von a"b.de (900).') + assert.equal(s.items.find((i) => i.icon === 'page').html, 'Am meisten angesehen: /<img src=x> (900 Aufrufe).') + // auch an zweiter Stelle hinter der Startseite + const z = summarize(stats({ pages: [{ key: '/', count: 900 }, { key: '/' + const s = stats({ + pages: [{ key: evil, count: 5 }], + refs: [{ key: evil, count: 5 }], + countries: [{ key: evil, count: 5 }], + devices: [{ key: evil, count: 5 }], + series: [{ day: evil, pv: 5 }, { day: '2026-09-18', pv: 7 }], + previous: { ...prev, series: [{ pv: 3 }, { pv: 4 }] }, + }) + const all = overviewHtml(s) + rankCardHtml('seen', s, { tab: 'refs' }) + rankCardHtml('who', s, { tab: 'countries' }) + rankCardHtml('who', s) + chartHtml(s) + assert.doesNotMatch(all, /