Kundenansicht neu: vorübergehende Messung der iPhone-Höhe

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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-29 11:10:30 +00:00
1 parent 0fa8fdbbac
commit cca29a4aef
7 files changed
+188 -4

No files matched your search

+75
View File
@@ -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 }
}
+5 -2
View File
@@ -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
}
+5 -1
View File
@@ -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() })
+3
View File
@@ -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)
+31
View File
@@ -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
+57
View File
@@ -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'"))
})
+12 -1
View File
@@ -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\(/)
})