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:
- DOM-Virtualisierung — Nur sichtbare Events werden gerendert
- IndexedDB-Persistenz — Events überleben Page Reload (Dexie.js)
- Image Caching — Thumbnails offline verfügbar
- 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-foriteriert nurvisibleEventsstattdisplayEventstrackWidthbleibt unverändert (Scrollbar korrekt)- Labels und Year Markers ebenfalls gefiltert
activeLabeloptimiert 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 leerdbPut(event): Fire-and-forgetdb.events.put()dbDelete(id): Fire-and-forgetdb.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:apiauf allen API-Routen - Token wird im Frontend in IndexedDB
metaTabelle 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.