456 lines
17 KiB
Markdown
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.
|