25-02-2025
This commit is contained in:
parent
98084de7d0
commit
70a7776da5
53 changed files with 6719 additions and 833 deletions
456
frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md
Normal file
456
frontend/dev/UMSETZUNG-VIRTUALISIERUNG-OFFLINE.md
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue