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 = '
${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 = 'Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
Die Daten konnten gerade nicht geladen werden.
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}
${esc(BANNER_PREVIEW_TEXT)}Datenschutz
+${esc(MISSING_PRIVACY_HINT)}
` + 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.
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.
+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 ` + ` +} + +function barList(title, items, labelOf, { html = false } = {}) { + const rows = barRows(items, labelOf) + return ` +Noch keine Daten.
'} +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 = `${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}
` + return + } + if (!stage.isConnected || myRequest !== statsRequestId) return + const t = stats.totals || {} + const rate = Math.round((Number(t.consentRate) || 0) * 100) + const kpi = (label, value) => `${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 = `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 = ` +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.
+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 = ` +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', '${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 = 'Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.
Die Daten konnten gerade nicht geladen werden.
${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}
${esc(BANNER_PREVIEW_TEXT)}Datenschutz
-${esc(MISSING_PRIVACY_HINT)}
` - 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.
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.
-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 ` - ` -} - -function barList(title, items, labelOf, { html = false } = {}) { - const rows = barRows(items, labelOf) - return ` -Noch keine Daten.
'} -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 = `${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}
` - return - } - if (!stage.isConnected || myRequest !== statsRequestId) return - const t = stats.totals || {} - const rate = Math.round((Number(t.consentRate) || 0) * 100) - const kpi = (label, value) => `${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 = `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 = ` -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.
-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 = ` -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, /