From cca29a4aef1086ef72943c18adea45078c1bc3aa Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 11:10:30 +0000 Subject: [PATCH] =?UTF-8?q?Kundenansicht=20neu:=20vor=C3=BCbergehende=20Me?= =?UTF-8?q?ssung=20der=20iPhone-H=C3=B6he?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Fix 0fa8fdb hat die zu hoch stehende Leiste am iPhone nicht behoben. Damit wir nicht weiter raten: die Seite schickt am iPhone Bildschirmwerte (Fenster, visualViewport, Bildschirm, Safe-Area, vh-Einheiten, Lage der Leiste, Eingriffe von hoehe.js) an /api/diag/hoehe, der Server schreibt sie als Zeile "[diag-hoehe]" ins Log. Keine Inhalte, nur Pilot-Kunden (nicht die Mitarbeiter-Ansicht), höchstens 80 Meldungen je Seite und 400 Zeilen je Kunde und Stunde. Wird nach der Klärung wieder entfernt. Co-Authored-By: Claude Opus 5.5 --- public/neu/diag-hoehe.js | 75 ++++++++++++++++++++++++++++++++++++++ public/neu/hoehe.js | 7 +++- public/neu/huelle.js | 6 ++- server/index.js | 3 ++ server/routes/diagHoehe.js | 31 ++++++++++++++++ test/diagHoehe.test.js | 57 +++++++++++++++++++++++++++++ test/knHoehe.test.js | 13 ++++++- 7 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 public/neu/diag-hoehe.js create mode 100644 server/routes/diagHoehe.js create mode 100644 test/diagHoehe.test.js diff --git a/public/neu/diag-hoehe.js b/public/neu/diag-hoehe.js new file mode 100644 index 0000000..84ac072 --- /dev/null +++ b/public/neu/diag-hoehe.js @@ -0,0 +1,75 @@ +/* VORÜBERGEHENDE MESSUNG (2026-10-29), wird nach der Klärung wieder entfernt: Am iPhone (App vom Home-Bildschirm) steht die Leiste zu hoch. + Schickt Bildschirmwerte (Fenster, visualViewport, Bildschirm, Safe-Area, vh-Einheiten, Lage der Leiste, Zustand von hoehe.js) an + /api/diag/hoehe; der Server schreibt sie ins Log (server/routes/diagHoehe.js). Keine Inhalte, keine Eingaben. Höchstens MAX Meldungen je Seite. */ + +const MAX = 80 +const ZIEL = '/api/diag/hoehe' +const rund = (x) => (Number.isFinite(x) ? Math.round(x * 10) / 10 : null) + +export function starteDiagHoehe({ win = window, doc = document, hoehe = null }) { + const t0 = Date.now() + let n = 0 + const probe = (stil) => { + const el = doc.createElement('div') + el.setAttribute('aria-hidden', 'true') + el.style.cssText = `position:fixed;left:0;top:0;width:0;visibility:hidden;pointer-events:none;${stil}` + doc.body.append(el) + return el + } + const sa = probe('height:0;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left)') + const vh = probe('height:100vh'); const dvh = probe('height:100dvh'); const svh = probe('height:100svh'); const lvh = probe('height:100lvh') + const unten = probe('top:auto;bottom:0;height:1px') // wo „bottom: 0“ eines festen Elements wirklich landet + + function messe(art, extra) { + const vv = win.visualViewport + const cs = win.getComputedStyle(sa) + const leiste = doc.getElementById('kn-leiste')?.getBoundingClientRect() + const huelle = doc.querySelector('.kn-huelle')?.getBoundingClientRect() + const aktiv = doc.activeElement + return { + art, ms: Date.now() - t0, hash: win.location.hash, + iw: win.innerWidth, ih: win.innerHeight, ow: win.outerWidth, oh: win.outerHeight, + cw: doc.documentElement.clientWidth, ch: doc.documentElement.clientHeight, dh: doc.documentElement.scrollHeight, + sw: win.screen?.width, sh: win.screen?.height, sah: win.screen?.availHeight, dpr: win.devicePixelRatio, + vv: vv ? [rund(vv.width), rund(vv.height), rund(vv.offsetTop), rund(vv.pageTop), rund(vv.scale)] : null, + sy: rund(win.scrollY), + sa: [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map((p) => rund(parseFloat(p))), + vh: [vh.offsetHeight, dvh.offsetHeight, svh.offsetHeight, lvh.offsetHeight], + unten: rund(unten.getBoundingClientRect().bottom), + leiste: leiste ? [rund(leiste.top), rund(leiste.bottom), rund(leiste.height)] : null, + huelle: huelle ? [rund(huelle.top), rund(huelle.height)] : null, + hoehe: hoehe?.stand?.() ?? null, + app: Boolean(win.matchMedia?.('(display-mode: standalone)').matches), ns: win.navigator?.standalone ?? null, + ori: win.screen?.orientation?.type ?? null, + fokus: aktiv && aktiv !== doc.body ? aktiv.tagName : null, + tast: doc.body.classList.contains('kn-tastatur'), + ...(n === 0 ? { ua: String(win.navigator?.userAgent ?? '').slice(0, 200) } : {}), + ...(extra ?? {}), + } + } + + function sende(art, extra) { + if (n >= MAX) return + let daten + try { daten = messe(art, extra) } catch (fehler) { daten = { art, fehler: String(fehler?.message ?? fehler).slice(0, 200) } } + n += 1 + try { + win.fetch(ZIEL, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(daten), keepalive: true }).catch(() => {}) + } catch { /* Messung darf nie stören */ } + } + + const spaeter = (fn, ms) => win.setTimeout(fn, ms) + const entprellt = (art) => { let id = null; return () => { if (id) win.clearTimeout(id); id = spaeter(() => { id = null; sende(art) }, 250) } } + sende('start') + spaeter(() => sende('start+1s'), 1000) + spaeter(() => sende('start+4s'), 4000) + win.addEventListener('resize', entprellt('resize')) + win.visualViewport?.addEventListener('resize', entprellt('vv-resize')) + win.visualViewport?.addEventListener('scroll', entprellt('vv-scroll')) + doc.addEventListener('focusin', (ev) => { if (ev.target?.matches?.('textarea, input')) sende('fokus') }) + doc.addEventListener('focusout', (ev) => { if (ev.target?.matches?.('textarea, input')) { spaeter(() => sende('unfokus+300ms'), 300); spaeter(() => sende('unfokus+1s'), 1000) } }) + doc.addEventListener('visibilitychange', () => { if (doc.visibilityState === 'visible') { sende('sichtbar'); spaeter(() => sende('sichtbar+1s'), 1000) } }) + win.addEventListener('hashchange', () => spaeter(() => sende('route'), 600)) + win.addEventListener('pageshow', (ev) => { if (ev.persisted) sende('pageshow') }) + return { sende } +} diff --git a/public/neu/hoehe.js b/public/neu/hoehe.js index 68e6327..c5da358 100644 --- a/public/neu/hoehe.js +++ b/public/neu/hoehe.js @@ -23,9 +23,10 @@ export function haengt(zustand, { hoehe, breite, textfeldAktiv }) { /** * Verdrahtung: merkt sich die volle Höhe (resize), prüft nach dem Verlassen eines Feldes (focusout), nach einem resize ohne Textfeld - * und wenn die App wieder nach vorne kommt (visibilitychange), jeweils zu den Zeiten PRUEF_MS. Rückgabe { pruefe, stand, beende }. + * und wenn die App wieder nach vorne kommt (visibilitychange), jeweils zu den Zeiten PRUEF_MS. onHeile({ vorher, nachher, voll, versuche }) nach + * jedem Eingriff. Rückgabe { pruefe, stand, beende }. */ -export function beobachteHoehe({ win = window, doc = document, element, aktiv = () => true, istTextfeld = () => false }) { +export function beobachteHoehe({ win = window, doc = document, element, aktiv = () => true, istTextfeld = () => false, onHeile = () => {} }) { const messe = () => ({ hoehe: win.innerHeight, breite: win.innerWidth }) let z = hoeheZustand(null, messe()) let versuche = 0 @@ -57,8 +58,10 @@ export function beobachteHoehe({ win = window, doc = document, element, aktiv = if (!haengt(z, { ...m, textfeldAktiv: istTextfeld(doc.activeElement) })) { merke(); return false } if (versuche >= MAX_VERSUCHE) return false versuche += 1 + const vorher = win.innerHeight heile() merke() + try { onHeile({ vorher, nachher: win.innerHeight, voll: z.voll, versuche }) } catch { /* nur Beobachter */ } return true } diff --git a/public/neu/huelle.js b/public/neu/huelle.js index cd88fbb..b1eaaf7 100644 --- a/public/neu/huelle.js +++ b/public/neu/huelle.js @@ -9,6 +9,7 @@ import * as routerStandard from './router.js' import { BEREICHE, FUNKTIONS_ETAGEN, hashFuerAnsicht, hashFuerRoute, elternVon, titelFuer, zeigtWiderrufsstreifen } from './routen.js' import { HANDY_ABFRAGE, baueNavigation, beobachteTastatur, istTextfeld, leisteSichtbar } from './leiste.js' import { beobachteHoehe } from './hoehe.js' +import { starteDiagHoehe } from './diag-hoehe.js' import { baueEtage } from './etage.js' import { erzeugeZaehler } from './zaehler.js' import { baueStreifen, ladeFristen, ladeFristenNeu } from './fristen-streifen.js' @@ -279,7 +280,10 @@ export async function starteHuelle({ win = window, doc = document, api = apiStan else handyMq.addListener?.(beiMedien) beobachteTastatur({ win, doc, onChange: (z) => doc.body.classList.toggle('kn-tastatur', !leisteSichtbar(z)) }) // iOS-App vom Home-Bildschirm: hängt nach der Tastatur die Fensterhöhe, steht die Leiste zu hoch (hoehe.js); dann einmal neu vermessen - beobachteHoehe({ win, doc, element: doc.querySelector('.kn-huelle'), aktiv: () => istIos() && standalone() && istHandy(), istTextfeld }) + let diag = null + const hoehe = beobachteHoehe({ win, doc, element: doc.querySelector('.kn-huelle'), aktiv: () => istIos() && standalone() && istHandy(), istTextfeld, onHeile: (werte) => diag?.sende('heile', werte) }) + // VORÜBERGEHENDE MESSUNG (2026-10-29, diag-hoehe.js): Bildschirmwerte am iPhone ins Server-Log, wird nach der Klärung entfernt + if (istIos() && istHandy() && !viewAs) diag = starteDiagHoehe({ win, doc, hoehe }) // 7. Fristen und Zähler laden parallel und brechen den Start nie ab ladeFristen(api).then((daten) => { fristen = daten; streifen.setze(daten); aktualisiereKonto() }) diff --git a/server/index.js b/server/index.js index 3196ab5..e8417ac 100644 --- a/server/index.js +++ b/server/index.js @@ -27,6 +27,7 @@ import zeitenRoutes from './routes/zeiten.js' import publicAuftragRoutes from './routes/auftrag.js' import kundeAuftraegeRoutes from './routes/auftraegeKunde.js' import takeoverCreditRoutes from './routes/takeoverCredits.js' +import diagHoeheRoutes from './routes/diagHoehe.js' import { purgeExpiredUploads } from './services/takeoverCredits.js' import { purgePendingChatUploads } from './services/chatAttachments.js' import { purgeViewLog } from './services/customerViewLog.js' @@ -95,6 +96,8 @@ app.use(express.json({ limit: '2mb', verify: (req, _res, buf) => { req.rawBody = app.use(corsMiddleware(config.cors.allowedOrigins)) app.use('/api/auth', authRoutes) +// VORÜBERGEHENDE MESSUNG (2026-10-29): Bildschirmwerte der iPhone-App ins Log (routes/diagHoehe.js), wird nach der Klärung entfernt +app.use('/api/diag/hoehe', diagHoeheRoutes) app.use('/api/projects', projectsRoutes) app.use('/api/features', featuresRoutes) app.use('/api/public/news', publicNewsRoutes) diff --git a/server/routes/diagHoehe.js b/server/routes/diagHoehe.js new file mode 100644 index 0000000..b25a679 --- /dev/null +++ b/server/routes/diagHoehe.js @@ -0,0 +1,31 @@ +import express from 'express' +import { parsePortalSession } from '../middleware/session.js' +import { neueAnsichtFuerSession } from '../services/kundenansicht/schalter.js' + +/* VORÜBERGEHENDE MESSUNG (2026-10-29), wird nach der Klärung wieder entfernt: Am iPhone (App vom Home-Bildschirm) steht die Leiste der neuen + Kundenansicht zu hoch. Die Seite (public/neu/diag-hoehe.js) schickt Bildschirmwerte, keine Inhalte; hier wird je Meldung eine Zeile + „[diag-hoehe] …“ ins Log geschrieben. Nur für Kunden-Sitzungen, die die neue Ansicht sehen (Pilot), nicht für die Mitarbeiter-Kundenansicht. + Ohne Appwrite-Abfrage (der signierte Cookie reicht), höchstens MAX_JE_STUNDE Zeilen je Kunde und Stunde, höchstens MAX_BYTES je Zeile. */ + +export const MAX_BYTES = 4000 +export const MAX_JE_STUNDE = 400 +const zaehler = new Map() // customerId -> { stunde, n } + +const router = express.Router() + +router.post('/', (req, res) => { + const session = parsePortalSession(req) + if (!session || session.viewAs || !neueAnsichtFuerSession(session)) return res.status(204).end() + const stunde = Math.floor(Date.now() / 3600000) + const stand = zaehler.get(session.customerId) + const z = stand && stand.stunde === stunde ? stand : { stunde, n: 0 } + z.n += 1 + zaehler.set(session.customerId, z) + if (z.n > MAX_JE_STUNDE) return res.status(204).end() + const zeile = JSON.stringify(req.body && typeof req.body === 'object' ? req.body : {}) + if (zeile.length > MAX_BYTES) return res.status(204).end() + console.log(`[diag-hoehe] ${String(session.customerId).slice(0, 6)} ${zeile}`) + return res.status(204).end() +}) + +export default router diff --git a/test/diagHoehe.test.js b/test/diagHoehe.test.js new file mode 100644 index 0000000..09f616e --- /dev/null +++ b/test/diagHoehe.test.js @@ -0,0 +1,57 @@ +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' + +// Vorübergehende Messung der iPhone-Höhe (routes/diagHoehe.js): nur Pilot-Kunden schreiben ins Log, alle bekommen 204 +const aw = await startAppwriteMock({ schema: {} }) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, send } = await import('./support/testApp.js') +const { customerCookie, kundenansichtCookie } = await import('./support/fixtures.js') +const { default: diagHoeheRoutes, MAX_BYTES, MAX_JE_STUNDE } = await import('../server/routes/diagHoehe.js') +const app = await startTestApp({ '/api/diag/hoehe': diagHoeheRoutes }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) +async function mitLog(fn) { + const zeilen = [] + const alt = console.log + console.log = (...a) => { if (String(a[0]).startsWith('[diag-hoehe]')) zeilen.push(a.join(' ')); else alt(...a) } + try { await fn() } finally { console.log = alt } + return zeilen +} +const melde = (body, cookie) => send('POST', '/api/diag/hoehe', { body, cookie }) + +test('diag-hoehe: Pilot-Kunde schreibt eine Zeile, alle anderen nichts; Antwort immer 204', async () => { + stelle('pilot', ['k@example.invalid']) + const zeilen = await mitLog(async () => { + assert.equal((await melde({ art: 'start', ih: 873 }, customerCookie('c1'))).status, 204) + assert.equal((await melde({ art: 'ohne' })).status, 204) + assert.equal((await melde({ art: 'mitarbeiter' }, kundenansichtCookie())).status, 204) + stelle('pilot', ['jemand@example.invalid']) + assert.equal((await melde({ art: 'kein-pilot' }, customerCookie('c1'))).status, 204) + stelle('off') + assert.equal((await melde({ art: 'aus' }, customerCookie('c1'))).status, 204) + }) + assert.deepEqual(zeilen, ['[diag-hoehe] c1 {"art":"start","ih":873}']) +}) + +test('diag-hoehe: zu große Meldungen und mehr als MAX_JE_STUNDE je Stunde landen nicht im Log', async () => { + stelle('on') + const zeilen = await mitLog(async () => { + assert.equal((await melde({ x: 'a'.repeat(MAX_BYTES) }, customerCookie('c1'))).status, 204) + for (let i = 0; i < MAX_JE_STUNDE + 3; i++) await melde({ i }, customerCookie('c1')) + }) + assert.ok(zeilen.length <= MAX_JE_STUNDE && zeilen.length >= MAX_JE_STUNDE - 2, `Zeilen: ${zeilen.length}`) + assert.ok(zeilen.every((z) => z.length < MAX_BYTES)) + stelle('off') +}) + +test('server/index.js: die Messung hängt hinter dem JSON-Parser unter /api/diag/hoehe', async () => { + const { readFileSync } = await import('node:fs') + const js = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') + assert.ok(js.indexOf("app.use('/api/diag/hoehe', diagHoeheRoutes)") > js.indexOf("app.use(express.json({ limit: '2mb'")) +}) diff --git a/test/knHoehe.test.js b/test/knHoehe.test.js index 77c329b..1996126 100644 --- a/test/knHoehe.test.js +++ b/test/knHoehe.test.js @@ -73,6 +73,16 @@ test('beobachteHoehe: Tastatur zu und Höhe hängt → Hülle einmal aus und ein assert.deepEqual(t.h.stand(), { voll: 932, breite: 430, versuche: 1 }) }) +test('beobachteHoehe: onHeile meldet vorher, nachher, voll und Versuch; ein Fehler im Beobachter stört nicht', () => { + const t = aufbau() + const meldungen = [] + t.h.beende() + const h = beobachteHoehe({ win: t.win, doc: t.doc, element: t.element, istTextfeld: () => false, onHeile: (w) => { meldungen.push(w); throw new Error('kaputt') } }) + t.win.innerHeight = 873 + assert.equal(h.pruefe(), true) + assert.deepEqual(meldungen, [{ vorher: 873, nachher: 873, voll: 932, versuche: 1 }]) +}) + test('beobachteHoehe: hilft es nicht, höchstens MAX_VERSUCHE je Anlass; App wieder vorne ist ein neuer Anlass', () => { const t = aufbau() t.win.innerHeight = 873 @@ -107,5 +117,6 @@ test('beobachteHoehe: nicht aktiv (kein iOS-App-Modus), keine Hülle oder Höhe test('huelle.js: Höhen-Wächter nur in der iOS-App vom Home-Bildschirm am Handy, auf der Hülle', () => { const js = lies('huelle.js') assert.match(js, /import \{ beobachteHoehe \} from '\.\/hoehe\.js'/) - assert.match(js, /beobachteHoehe\(\{ win, doc, element: doc\.querySelector\('\.kn-huelle'\), aktiv: \(\) => istIos\(\) && standalone\(\) && istHandy\(\), istTextfeld \}\)/) + assert.match(js, /beobachteHoehe\(\{ win, doc, element: doc\.querySelector\('\.kn-huelle'\), aktiv: \(\) => istIos\(\) && standalone\(\) && istHandy\(\), istTextfeld, onHeile:/) + assert.match(js, /if \(istIos\(\) && istHandy\(\) && !viewAs\) diag = starteDiagHoehe\(/) })