// pair-client.js — loaded before app.js. Defines window.api for the web client // (talking to main/webserver.js over the local network instead of Electron IPC), // handles the one-time pairing screen, and only injects app.js once a valid // session is confirmed — app.js calls window.api immediately on load, so it must // never run before window.api (and a working session) exist. // // Phase 3: offline. Reads fall back to the local cache (offline-store.js) when the // practice PC isn't reachable; writes to Notizen/Befunde queue locally and apply // optimistically to the cache, then replay automatically once reachable again. This // only works for a tab that was already open (and the animal already viewed) while // still on the practice WiFi — a from-scratch cold load with zero network needs a // Service Worker, which needs HTTPS, which this plain-WLAN setup deliberately skips. "use strict"; (function () { const TOKEN_KEY = "fellakte_token"; let token = localStorage.getItem(TOKEN_KEY); let isOnline = true; let syncing = false; const overlay = document.getElementById("pairing-overlay"); const form = document.getElementById("pair-form"); const codeInput = document.getElementById("pair-code-input"); const status = document.getElementById("pair-status"); const toast = document.getElementById("readonly-toast"); function showPairing(prefillCode) { overlay.hidden = false; if (prefillCode) codeInput.value = prefillCode; codeInput.focus(); } function hidePairing() { overlay.hidden = true; } function authHeaders() { return token ? { Authorization: "Bearer " + token } : {}; } let toastTimer = null; function showToast(text) { if (!toast) return; toast.textContent = text; toast.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => (toast.hidden = true), 3200); } function readOnlyNotice() { showToast("Bearbeiten kommt in einer späteren Phase — aktuell nur zum Ansehen."); } function offlineUnavailableNotice() { showToast("Ohne Verbindung aktuell nicht möglich."); } function showWarningBanner(info) { const banner = document.getElementById("warning-banner"); const text = document.getElementById("warning-banner-text"); if (!banner || !text) return; const when = new Date(info.updatedAt).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); text.textContent = `Achtung: "${info.host}" wurde zuletzt um ${when} Uhr ebenfalls verwendet — kurz absprechen, bevor beide gleichzeitig speichern.`; banner.hidden = false; } async function renderSyncStatus() { const chip = document.getElementById("sync-status"); if (!chip) return; const pending = await window.offlineStore.queueCount(); if (isOnline && pending === 0) { chip.textContent = "Verbunden"; chip.style.background = "var(--ok-tint)"; chip.style.color = "var(--ok)"; } else if (isOnline && pending > 0) { chip.textContent = `Synchronisiere … (${pending})`; chip.style.background = "var(--warn-tint)"; chip.style.color = "var(--warn)"; } else if (pending > 0) { chip.textContent = `Offline — ${pending} wartend`; chip.style.background = "var(--warn-tint)"; chip.style.color = "var(--warn)"; } else { chip.textContent = "Offline"; chip.style.background = "var(--surface-alt)"; chip.style.color = "var(--ink-faint)"; } } function setOnline(v) { const changed = v !== isOnline; isOnline = v; renderSyncStatus(); if (changed && v) trySync(); } // ---- reads: network first, cache fallback ---- async function apiGet(path, cache) { try { const res = await fetch(path, { headers: authHeaders() }); if (res.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); showPairing(); throw new Error("unauthorized"); } if (!res.ok) throw new Error("request-failed:" + res.status); const data = await res.json(); setOnline(true); if (cache && cache.kind === "directory") window.offlineStore.saveDirectory(data); if (cache && cache.kind === "bundle") window.offlineStore.saveBundle(data); return data; } catch (err) { if (err.message === "unauthorized") throw err; setOnline(false); if (cache && cache.kind === "directory") { const cached = await window.offlineStore.getDirectory(); if (cached) return cached; } if (cache && cache.kind === "bundle") { const cached = await window.offlineStore.getBundle(cache.key); if (cached) return cached; } throw err; } } // ---- writes: network first; on failure, queue + apply optimistically to the // cache when `offlineSpec` says this kind of write supports that (Notizen, // Befunde). Surfaces the server's "someone else is using this data folder right // now" advisory as a dismissible banner (same idea as the desktop app's startup // dialog) — advisory only, matching how the two practice PCs already behave. async function apiSend(method, path, body, offlineSpec) { try { const res = await fetch(path, { method, headers: { ...authHeaders(), "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); showPairing(); throw new Error("unauthorized"); } if (!res.ok) throw new Error("request-failed:" + res.status); const data = await res.json().catch(() => ({})); setOnline(true); if (data && data.warning) showWarningBanner(data.warning); return data; } catch (err) { if (err.message === "unauthorized") throw err; setOnline(false); if (!offlineSpec) { offlineUnavailableNotice(); throw err; } await window.offlineStore.enqueue({ method, path, body }); await applyOptimistic(offlineSpec, body); renderSyncStatus(); return { ok: true, offline: true }; } } async function applyOptimistic(spec, body) { const nowIso = new Date().toISOString(); const localId = () => "lokal-" + Date.now() + "-" + Math.random().toString(36).slice(2, 7); if (spec.kind === "note") { const bundle = await window.offlineStore.getBundle(body.animalId); if (!bundle) return; bundle.notes.unshift({ id: localId(), animal_id: body.animalId, date: new Date().toLocaleDateString("de-DE"), text: body.text, created_at: nowIso, }); await window.offlineStore.saveBundle(bundle); } else if (spec.kind === "finding-add") { const bundle = await window.offlineStore.getBundle(body.animalId); if (!bundle) return; bundle.findings.push({ id: localId(), animal_id: body.animalId, x: body.x, y: body.y, severity: body.severity, text: body.text, created_at: nowIso, }); await window.offlineStore.saveBundle(bundle); } else if (spec.kind === "finding-update") { const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId); if (!animalId) return; const bundle = await window.offlineStore.getBundle(animalId); const f = bundle.findings.find((x) => x.id === spec.findingId); if (f) { f.severity = body.severity; f.text = body.text; await window.offlineStore.saveBundle(bundle); } } else if (spec.kind === "finding-delete") { const animalId = await window.offlineStore.findAnimalIdForFinding(spec.findingId); if (!animalId) return; const bundle = await window.offlineStore.getBundle(animalId); bundle.findings = bundle.findings.filter((x) => x.id !== spec.findingId); await window.offlineStore.saveBundle(bundle); } } // Replays the queue in order against the real server. Stops (leaves the rest // queued) at the first failure — still offline, or the practice PC went away // mid-sync. On a full, non-empty drain, reloads so the whole page re-reads fresh, // server-confirmed data instead of the optimistic local copies. async function trySync() { if (syncing) return; syncing = true; try { const queue = await window.offlineStore.listQueue(); if (!queue.length) { setOnline(await pingServer()); return; } let drained = 0; for (const op of queue) { try { const res = await fetch(op.path, { method: op.method, headers: { ...authHeaders(), "Content-Type": "application/json" }, body: op.body !== undefined ? JSON.stringify(op.body) : undefined, }); if (res.status === 401) { token = null; localStorage.removeItem(TOKEN_KEY); showPairing(); return; } if (!res.ok) throw new Error("sync-failed:" + res.status); await window.offlineStore.removeFromQueue(op.id); drained++; } catch { setOnline(false); return; // still offline (or PC unreachable) — rest stays queued for next try } } setOnline(true); if (drained > 0) location.reload(); } finally { syncing = false; } } async function pingServer() { try { await fetch("/api/directory", { headers: authHeaders() }); return true; // any HTTP response at all means the practice PC is reachable } catch { return false; } } // Resolves with `fallback` after showing the read-only notice — safe for callers // that just re-render afterward (await refreshBundle(); renderPanels();). function stub(fallback) { return async () => { readOnlyNotice(); return fallback; }; } // Rejects instead — used only where the caller immediately depends on the result // (e.g. `const id = await createAnimal(...); await selectAnimal(id)`), so a // silently-wrong id can't cascade into a broken render. function rejectingStub() { return async () => { readOnlyNotice(); throw new Error("read-only"); }; } window.api = { listDirectory: () => apiGet("/api/directory", { kind: "directory" }), getAnimalBundle: (id) => apiGet("/api/animal/" + encodeURIComponent(id), { kind: "bundle", key: id }), listAppointments: (fromDate, toDate) => apiGet(`/api/appointments?from=${encodeURIComponent(fromDate || "")}&to=${encodeURIComponent(toDate || "")}`), listRemedies: (query) => apiGet("/api/remedies?q=" + encodeURIComponent(query || "")), searchNotes: (query) => apiGet("/api/search-notes?q=" + encodeURIComponent(query || "")), openDocument: async (documentId) => { const { url } = await apiGet("/api/document-url/" + encodeURIComponent(documentId)); window.open(url, "_blank"); return true; }, documentAbsPath: stub(null), dataFolder: stub(null), createOwner: rejectingStub(), createAnimal: rejectingStub(), updateAnimalField: stub(true), updateOwnerField: stub(true), deleteAnimal: stub(true), deleteOwner: stub(true), addNote: (animalId, text) => apiSend("POST", "/api/note", { animalId, text }, { kind: "note" }), addHistoryEntry: stub(true), addLabResult: stub(null), addLabValue: stub(true), deleteLabResult: stub(true), addFinding: (animalId, finding) => apiSend("POST", "/api/finding", { animalId, ...finding }, { kind: "finding-add" }), updateFinding: (id, patch) => apiSend("PATCH", "/api/finding/" + encodeURIComponent(id), patch, { kind: "finding-update", findingId: id }), deleteFinding: (id) => apiSend("DELETE", "/api/finding/" + encodeURIComponent(id), undefined, { kind: "finding-delete", findingId: id }), addExam: stub(null), // Phase 4: needs a live connection to the practice PC (PDF/E-Mail are server-side // operations — see the file header) — not queued offline like Notizen/Befunde. setConsent: (animalId, patch) => apiSend("POST", "/api/consent", { animalId, ...patch }), saveSignature: (animalId, dataUrl) => apiSend("POST", "/api/consent/signature", { animalId, dataUrl }), setContract: (animalId, patch) => apiSend("POST", "/api/contract", { animalId, ...patch }), saveContractSignature: (animalId, dataUrl) => apiSend("POST", "/api/contract/signature", { animalId, dataUrl }), generatePdf: (animalId, kind, content) => apiSend("POST", "/api/document/pdf", { animalId, kind, ...content }), generateTemplatePdf: (animalId, filenamePrefix, content) => apiSend("POST", "/api/document/template-pdf", { animalId, filenamePrefix, ...content }), sendDocumentEmail: (animalId, kind, file) => apiSend("POST", "/api/document/email", { animalId, kind, ...file }), addRemedy: stub(null), updateRemedy: stub(true), deleteRemedy: stub(true), // Termine: not yet queued for offline (Phase 3 scope is Notizen/Befunde) — a // clear "not available offline" notice instead of queueing silently. addAppointment: (data) => apiSend("POST", "/api/appointment", data), updateAppointment: (id, data) => apiSend("PATCH", "/api/appointment/" + encodeURIComponent(id), data), deleteAppointment: (id) => apiSend("DELETE", "/api/appointment/" + encodeURIComponent(id)), getLexwarePath: stub(null), setLexwarePath: stub(true), openLexware: stub({ ok: false, error: "not-available" }), getSmtpSettings: stub(null), setSmtpSettings: stub(true), testSmtpSettings: stub({ ok: false, error: "not-available" }), getReminderLead: stub(30), setReminderLead: stub(true), backupNow: stub({ ok: false, error: "not-available" }), getBackupInfo: stub({ count: 0, last: null }), listBackups: stub([]), openBackupFolder: stub(true), restoreBackup: stub({ ok: false, error: "not-available" }), addDocument: stub([]), }; const warningClose = document.getElementById("warning-banner-close"); if (warningClose) warningClose.addEventListener("click", () => (document.getElementById("warning-banner").hidden = true)); function loadApp() { const s = document.createElement("script"); s.src = "app.js"; document.body.appendChild(s); } // Distinguishes "token is genuinely invalid" (401 → must re-pair) from "can't // reach the practice PC right now" (network error → proceed in offline mode with // whatever's cached, a valid token just can't be re-confirmed this moment). async function checkSession() { try { const res = await fetch("/api/directory", { headers: authHeaders() }); if (res.status === 401) return "invalid"; return "online"; } catch { return "offline"; } } form.addEventListener("submit", async (e) => { e.preventDefault(); const code = codeInput.value.trim(); if (!code) return; status.textContent = "Verbinde …"; try { const res = await fetch("/api/pair", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ code, deviceName: guessDeviceName() }), }); if (!res.ok) { status.textContent = res.status === 410 || res.status === 429 ? "Code abgelaufen. Am PC einen neuen Code erzeugen." : "Code falsch — bitte nochmal prüfen."; return; } const data = await res.json(); token = data.token; localStorage.setItem(TOKEN_KEY, token); location.reload(); } catch { status.textContent = "Keine Verbindung zum Praxis-PC — gleiches WLAN?"; } }); function guessDeviceName() { const ua = navigator.userAgent || ""; if (/iPad/.test(ua)) return "iPad"; if (/iPhone/.test(ua)) return "iPhone"; if (/Android/.test(ua)) return "Android-Gerät"; if (/Macintosh/.test(ua)) return "Mac"; return "Gerät"; } (async function init() { const params = new URLSearchParams(location.search); const prefill = params.get("pair"); if (!token) { showPairing(prefill); return; } const state = await checkSession(); if (state === "invalid") { showPairing(prefill); return; } hidePairing(); if (prefill) history.replaceState(null, "", location.pathname); isOnline = state === "online"; loadApp(); renderSyncStatus(); setInterval(trySync, 15000); window.addEventListener("online", trySync); if (state === "online") trySync(); // drain any queue left over from last time })(); })();