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

456 lines
17 KiB
Markdown

# 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<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.