thats-me/frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md
2026-03-06 14:01:49 +01:00

17 KiB

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

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:

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)

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

// 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

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:

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

window.addEventListener('online', () => {
  isOnline.value = true
  processSyncQueue() // Sofort pushen bei Reconnect
})
window.addEventListener('offline', () => {
  isOnline.value = false
})

Exports

import {
  isOnline, // ref<boolean> — Netzwerkstatus
  isSyncing, // ref<boolean> — Sync läuft gerade
  lastSyncAt, // ref<number|null> — Timestamp letzter Sync
  getToken, // () → Promise<string|null>
  setToken, // (token) → Promise<void>
  apiFetch, // (path, options) → Promise<Response>
  processSyncQueue, // () → Promise<void>
  pullRemoteChanges, // () → Promise<void>
  fullSync, // () → Promise<void>
  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.