# Virtualisierung & Offline-First Architektur **Stand:** 24. Februar 2026 **Bereich:** Frontend (Quasar/Vue.js 3) + Backend (Laravel 12) --- ## 1. Zusammenfassung Die Timeline-App wurde um eine skalierbare Architektur erweitert, die hunderte bis tausende Events performant darstellt und offline-fähig macht. Die Umsetzung erfolgte in 4 Phasen: 1. **DOM-Virtualisierung** — Nur sichtbare Events werden gerendert 2. **IndexedDB-Persistenz** — Events überleben Page Reload (Dexie.js) 3. **Image Caching** — Thumbnails offline verfügbar 4. **Backend API + Sync** — Laravel REST API mit Passport OAuth2, bidirektionaler Sync ### Ergebnis - Timeline scrollt flüssig mit 200+ Events (vorher: alle DOM-Nodes gleichzeitig) - Events, Einstellungen und Thumbnails persistent in IndexedDB - Sync Queue puffert Änderungen offline, synct automatisch bei Reconnect - REST API mit Batch-Sync (bis 100 Mutationen/Request) - 12 Backend-Tests (Pest v3) bestanden --- ## 2. Phase 1: DOM-Virtualisierung ### Problem Alle Events wurden als DOM-Nodes (`v-for displayEvents → GlowDot`) gerendert. Bei 200+ Events: zu viele DOM-Nodes, O(n) Label-Berechnung, unnötiger Render-Overhead. ### Lösung **Visible Range Computation** in `TimelineView.vue`: ``` scrollLeft + viewportWidth → visibleRange { start, end } → nur visibleEvents rendern (+ 2 Buffer Events pro Seite) ``` #### Geänderte Dateien | Datei | Änderung | | --------------------------------- | -------------------------------------------------------------------------------- | | `src/components/TimelineView.vue` | `visibleRange`, `visibleEvents`, `visibleLabels`, `visibleYearMarkers` Computeds | | `src/layouts/LifeWaveLayout.vue` | Smart 8-Punkt Shader-Selektion | #### TimelineView.vue — Kern-Logik ```javascript const VIS_BUFFER = 2 const visibleRange = computed(() => { const start = Math.max(0, Math.floor((scrollLeft - PADDING) / EVENT_SPACING) - VIS_BUFFER) const end = Math.min( total - 1, Math.ceil((scrollLeft + viewportWidth - PADDING) / EVENT_SPACING) + VIS_BUFFER, ) return { start, end } }) const visibleEvents = computed(() => { return displayEvents.slice(start, end + 1).map((event, i) => ({ event, globalIndex: start + i, })) }) ``` - `v-for` iteriert nur `visibleEvents` statt `displayEvents` - `trackWidth` bleibt unverändert (Scrollbar korrekt) - Labels und Year Markers ebenfalls gefiltert - `activeLabel` optimiert von O(n) Scan auf O(1): `Math.round((centerX - PADDING) / EVENT_SPACING)` #### LifeWaveLayout.vue — Shader-Punkt-Selektion Der Shader akzeptiert max. 8 Punkte. Statt immer die ersten 8 Events zu nehmen, werden jetzt die sichtbaren Events + 1 Boundary auf jeder Seite gewählt: ```javascript const shaderSelection = computed(() => { const rangeStart = Math.max(0, visibleStart - 1) const rangeEnd = Math.min(events.length - 1, visibleEnd + 1) let candidates = events.slice(rangeStart, rangeEnd + 1) if (candidates.length > 8) { // Gleichmäßig subsamplen, first + last behalten const sampled = [candidates[0]] const step = (candidates.length - 1) / 7 for (let i = 1; i < 7; i++) sampled.push(candidates[Math.round(i * step)]) sampled.push(candidates[candidates.length - 1]) candidates = sampled } return candidates }) ``` --- ## 3. Phase 2: IndexedDB-Persistenz (Dexie.js) ### Problem Events existierten nur im Memory (Pinia ref). Page Reload = alles weg. ### Lösung **Dexie.js v4.3** als IndexedDB-Wrapper. Events werden lokal persistent gespeichert. #### Neue Dateien | Datei | Zweck | | ----------------- | ----------------------- | | `src/db/index.js` | Dexie-Schema Definition | #### Geänderte Dateien | Datei | Änderung | | ---------------------- | ---------------------------------------- | | `src/stores/events.js` | Komplett refactored für Dexie-Persistenz | | `package.json` | `dexie: ^4.3.0` hinzugefügt | #### DB-Schema (`src/db/index.js`) ```javascript import Dexie from 'dexie' export const db = new Dexie('thatsMeDB') db.version(1).stores({ events: 'id, date, updatedAt, syncStatus', syncQueue: '++queueId, eventId, action, createdAt', imageCache: 'url, eventId, type, cachedAt', meta: 'key', }) ``` | Tabelle | Zweck | | ------------ | ------------------------------------------ | | `events` | Alle Events (PK: client-side UUID) | | `syncQueue` | Outbound-Mutationen (FIFO) für API-Sync | | `imageCache` | Offline-Thumbnails als Blobs | | `meta` | Key-Value Store (Token, Sync-Cursor, etc.) | #### Events Store — Fire-and-Forget Pattern ``` User-Aktion → Vue ref sofort updaten (UI flüssig) → Dexie async schreiben (Background) ``` - `init()`: Lädt Events aus IndexedDB, seeded Demo-Daten wenn leer - `dbPut(event)`: Fire-and-forget `db.events.put()` - `dbDelete(id)`: Fire-and-forget `db.events.delete()` - `dbQueueSync(eventId, action, payload)`: Mutation in Sync Queue - Jedes Event hat `syncStatus`: `'local'` | `'synced'` | `'modified'` --- ## 4. Phase 3: Image Caching ### Problem Bilder in GlowDots und EventPanel laden nur mit Netzwerk. Offline = keine Bilder. ### Lösung Thumbnails (200x200 JPEG) werden beim ersten Laden in IndexedDB gecacht. #### Neue Dateien | Datei | Zweck | | ---------------------------------- | --------------------------- | | `src/composables/useImageCache.js` | Composable für Bild-Caching | #### Geänderte Dateien | Datei | Änderung | | ------------------------------- | --------------------------------------- | | `src/components/GlowDot.vue` | Nutzt `useImageCache` für Thumbnail-Src | | `src/components/EventPanel.vue` | Nutzt `resolveFullRes` für Key Image | #### Ablauf ``` 1. Memory-Cache prüfen (Map, instant) ↓ miss 2. IndexedDB prüfen (db.imageCache.get(url)) ↓ miss 3. Fetch → Canvas 200x200 Thumbnail → toBlob('image/jpeg', 0.8) → IndexedDB speichern → Blob URL zurückgeben ``` #### API ```javascript // In GlowDot.vue — reaktives Thumbnail const { resolvedSrc: imageSrc } = useImageCache(event.image, event.id) // In EventPanel.vue — Full-Res (online) oder Thumbnail-Fallback (offline) const src = await resolveFullRes(imageUrl) // Cleanup bei Event-Löschung await clearEventImages(eventId) ``` **Strategie:** - **Thumbnails** (200x200, ~20KB): Immer lokal gecacht in IndexedDB - **Full-Res**: On-Demand wenn EventPanel öffnet, Browser-Cache via HTTP Headers - Durch Virtualisierung werden nur sichtbare GlowDots gerendert → Image Loading ist inherent lazy --- ## 5. Phase 4: Backend API + Sync Service ### 5.1 Backend (Laravel 12) #### Neue/Geänderte Dateien | Datei | Zweck | | ----------------------------------------------- | ----------------------------------------- | | `app/Models/Event.php` | Eloquent Model | | `app/Models/User.php` | `HasApiTokens` Trait, `events()` Relation | | `database/migrations/*_create_events_table.php` | DB-Schema | | `database/factories/EventFactory.php` | Test-Factory | | `app/Http/Controllers/Api/EventController.php` | REST Controller | | `app/Http/Resources/EventResource.php` | JSON-Transformation | | `app/Http/Requests/StoreEventRequest.php` | Validierung (Create) | | `app/Http/Requests/UpdateEventRequest.php` | Validierung (Update) | | `routes/api.php` | API-Routen | | `config/auth.php` | Passport `api` Guard | | `tests/Feature/Api/EventTest.php` | 12 Pest-Tests | #### Events-Tabelle ```sql events: id BIGINT (PK, auto-increment) client_id UUID (unique) — vom Frontend generiert user_id BIGINT (FK → users) title VARCHAR(255) date DATE emotion DECIMAL(4,3) — -1.000 bis +1.000 custom_color VARCHAR(20) nullable gradient_preset TINYINT nullable — 0-9 image VARCHAR(500) nullable note TEXT nullable created_at TIMESTAMP updated_at TIMESTAMP INDEX: (user_id, date) INDEX: (user_id, updated_at) ``` #### API-Endpunkte | Method | Route | Beschreibung | | -------- | ------------------------ | -------------------------------------------------------------------- | | `GET` | `/api/events` | Alle Events (Cursor-Pagination, `?since=` für Delta-Sync, `?limit=`) | | `POST` | `/api/events` | Neues Event erstellen | | `GET` | `/api/events/{clientId}` | Einzelnes Event | | `PUT` | `/api/events/{clientId}` | Event aktualisieren | | `DELETE` | `/api/events/{clientId}` | Event löschen | | `POST` | `/api/events/sync` | **Batch-Sync** — bis 100 Mutationen auf einmal | #### Batch-Sync Endpoint Der Kern des Sync-Systems. Verarbeitet create/update/delete in einem Request: ```json POST /api/events/sync { "mutations": [ { "action": "create", "eventId": "uuid", "payload": { "title": "...", ... } }, { "action": "update", "eventId": "uuid", "payload": { "title": "Neu" } }, { "action": "delete", "eventId": "uuid", "payload": null } ] } Response: { "results": [ { "eventId": "uuid", "status": "ok" }, { "eventId": "uuid", "status": "ok" }, { "eventId": "uuid", "status": "ok" } ] } ``` - **Idempotent**: Doppelte Creates werden ignoriert (kein Fehler) - **Max 100 Mutationen** pro Request - **Alle Operationen** sind user-scoped (kein Zugriff auf fremde Events) #### JSON-Mapping (EventResource) Backend (snake_case) → Frontend (camelCase): ``` client_id → id custom_color → customColor gradient_preset → gradientPreset syncStatus → immer 'synced' (vom Server) created_at → createdAt (Millisekunden) updated_at → updatedAt (Millisekunden) ``` #### Authentifizierung - **Laravel Passport v13.5** (OAuth2) - Guard: `auth:api` auf allen API-Routen - Token wird im Frontend in IndexedDB `meta` Tabelle gespeichert #### Tests 12 Pest-Tests in `tests/Feature/Api/EventTest.php`: | Test | Was | | -------------------------------------- | ----------------------------------------- | | can list events | GET /api/events gibt eigene Events zurück | | list only returns own events | Keine fremden Events sichtbar | | can filter events by since | Delta-Sync Filter funktioniert | | can create an event | POST mit UUID → 201 Created | | create validates required fields | Fehler bei fehlenden Pflichtfeldern | | can show a single event | GET /api/events/{id} | | cannot show another users event | 404 bei fremdem Event | | can update an event | PUT mit Partial-Update | | can delete an event | DELETE → 204 No Content | | cannot delete another users event | 404 bei fremdem Event | | batch sync creates updates and deletes | Alle 3 Aktionen in einem Request | | sync is idempotent for creates | Doppelter Create = kein Fehler | ### 5.2 Frontend Sync Service #### Neue Dateien | Datei | Zweck | | ----------------------------- | ----------- | | `src/services/syncService.js` | Sync-Engine | #### Geänderte Dateien | Datei | Änderung | | ---------------------- | ----------------------------------------------- | | `src/stores/events.js` | `startAutoSync()` bei Init wenn Token vorhanden | #### Sync-Ablauf ``` App Start ↓ Events aus IndexedDB laden ↓ Token vorhanden? → startAutoSync() ↓ ┌─────────────────────────────────────┐ │ fullSync() — alle 30s + reconnect │ │ │ │ 1. processSyncQueue() │ │ → Sync Queue lesen (FIFO) │ │ → POST /api/events/sync │ │ → Erfolgreiche Items löschen │ │ → syncStatus → 'synced' │ │ │ │ 2. pullRemoteChanges() │ │ → GET /api/events?since=... │ │ → Last-Write-Wins Merge │ │ → Neue Events → IndexedDB │ │ → Sync Cursor updaten │ └─────────────────────────────────────┘ ``` #### Conflict Resolution **Last-Write-Wins** basierend auf `updatedAt`: - Remote neuer UND lokal `synced` → Remote übernehmen - Lokal `modified` → Lokale Änderung behalten, wird via Sync Queue gepusht - Neues Remote Event (nicht lokal vorhanden) → Einfügen #### Netzwerk-Erkennung ```javascript window.addEventListener('online', () => { isOnline.value = true processSyncQueue() // Sofort pushen bei Reconnect }) window.addEventListener('offline', () => { isOnline.value = false }) ``` #### Exports ```javascript import { isOnline, // ref — Netzwerkstatus isSyncing, // ref — Sync läuft gerade lastSyncAt, // ref — Timestamp letzter Sync getToken, // () → Promise setToken, // (token) → Promise apiFetch, // (path, options) → Promise processSyncQueue, // () → Promise pullRemoteChanges, // () → Promise fullSync, // () → Promise startAutoSync, // () → void — Startet 30s Intervall stopAutoSync, // () → void — Stoppt Intervall } from 'src/services/syncService' ``` --- ## 6. Datenfluss-Übersicht ``` ┌─────────────────────────────────────────────────────────────┐ │ FRONTEND │ │ │ │ User → Vue Component → Pinia Store (ref sofort updaten) │ │ ↓ │ │ IndexedDB (Dexie.js) │ │ ┌──────────────────┐ │ │ │ events │ ← Alle Events │ │ │ syncQueue │ ← Outbound Queue │ │ │ imageCache │ ← Thumbnails │ │ │ meta │ ← Token, Cursor │ │ └──────────────────┘ │ │ ↓ │ │ Sync Service (30s) │ │ Push Queue → Pull Changes │ └──────────────────────────────┬──────────────────────────────┘ │ POST /api/events/sync GET /api/events?since= │ ┌──────────────────────────────┴──────────────────────────────┐ │ BACKEND │ │ │ │ Laravel 12 + Passport OAuth2 │ │ EventController → Event Model → MySQL │ │ │ │ events: id, client_id, user_id, title, date, emotion, ... │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 7. Noch offen (Phase 5) **Chunked Loading** — Erst bei 500+ Events relevant: - `src/services/chunkLoader.js` — Scroll-triggered Loading - Nur 100 Events um aktuelle Scroll-Position laden - Bei Scroll an Boundary: nächsten Chunk nachladen (200ms Debounce) - API Cursor-Pagination für initiales Laden großer Datasets Wird erst implementiert wenn die Datenmenge es erfordert.