- TimelineView: Shader-Event-Mapping in computed ausgelagert, deep-Watch entfernt, natives Wheel-Scrolling statt @wheel.prevent - Events-Store: shallowRef statt tiefer Reaktivität, String-Sortierung statt Date-Objekten, getGlowColor memoized, DB-Writes debounced - Edits werden jetzt dedupliziert als 'update' in die SyncQueue geschrieben (vorher gingen Änderungen beim Sync verloren) - useImageCache: In-Flight-Deduping, Cache-Limit mit revokeObjectURL - FloatingLines: Antialiasing aus (Fullscreen-Quad), deep-Watches entfernt - Ungenutzte Seiten/Komponenten entfernt (~6.700 Zeilen, nicht geroutet) - implementation-plan.md: Hinweis zu update-Action im Sync ergänzt
418 lines
17 KiB
JavaScript
418 lines
17 KiB
JavaScript
import { defineStore } from 'pinia'
|
||
import { ref, computed, watch, shallowRef, triggerRef } from 'vue'
|
||
import { db } from 'src/db'
|
||
import { startAutoSync, getToken } from 'src/services/syncService'
|
||
|
||
// Color interpolation
|
||
function lerpColor(a, b, t) {
|
||
const ar = parseInt(a.slice(1, 3), 16)
|
||
const ag = parseInt(a.slice(3, 5), 16)
|
||
const ab = parseInt(a.slice(5, 7), 16)
|
||
const br = parseInt(b.slice(1, 3), 16)
|
||
const bg = parseInt(b.slice(3, 5), 16)
|
||
const bb = parseInt(b.slice(5, 7), 16)
|
||
const r = Math.round(ar + (br - ar) * t)
|
||
const g = Math.round(ag + (bg - ag) * t)
|
||
const blue = Math.round(ab + (bb - ab) * t)
|
||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${blue.toString(16).padStart(2, '0')}`
|
||
}
|
||
|
||
// Gradient presets: [negative, neutral, positive]
|
||
const GRADIENT_PRESETS = [
|
||
{ name: 'Standard', colors: ['#E91E63', '#FFD700', '#4CAF50'] },
|
||
{ name: 'Sunset', colors: ['#FD1D1D', '#FCB045', '#833AB4'] },
|
||
{ name: 'Earth', colors: ['#ED8153', '#ED8153', '#217B9E'] },
|
||
{ name: 'Ocean', colors: ['#00D4FF', '#164173', '#440559'] },
|
||
{ name: 'Spring', colors: ['#FDBB2D', '#96BE74', '#22C1C3'] },
|
||
{ name: 'Neon', colors: ['#FC466B', '#9A52B6', '#3F5EFB'] },
|
||
{ name: 'Pastel', colors: ['#EEAECA', '#C2B4D9', '#94BBE9'] },
|
||
{ name: 'Aurora', colors: ['#FF6B6B', '#C084FC', '#67E8F9'] },
|
||
{ name: 'Forest', colors: ['#DC2626', '#A3A830', '#059669'] },
|
||
{ name: 'Berry', colors: ['#F472B6', '#FB923C', '#A78BFA'] }
|
||
]
|
||
|
||
// Glow color logic: emotion value → color, with optional gradient preset
|
||
function emotionToColor(emotion, gradientIdx = null) {
|
||
const preset = gradientIdx !== null ? GRADIENT_PRESETS[gradientIdx] : null
|
||
if (preset) {
|
||
const [neg, mid, pos] = preset.colors
|
||
if (emotion >= 0) {
|
||
return lerpColor(mid, pos, emotion)
|
||
} else {
|
||
return lerpColor(mid, neg, Math.abs(emotion))
|
||
}
|
||
}
|
||
if (emotion >= 0) {
|
||
if (emotion < 0.5) {
|
||
return lerpColor('#FF6B35', '#FFD700', emotion / 0.5)
|
||
}
|
||
return lerpColor('#FFD700', '#4CAF50', (emotion - 0.5) / 0.5)
|
||
} else {
|
||
const abs = Math.abs(emotion)
|
||
if (abs < 0.5) {
|
||
return lerpColor('#2196F3', '#9C27B0', abs / 0.5)
|
||
}
|
||
return lerpColor('#9C27B0', '#E91E63', (abs - 0.5) / 0.5)
|
||
}
|
||
}
|
||
|
||
// Demo seed data
|
||
const demoEvents = [
|
||
{ id: crypto.randomUUID(), title: 'Erster Schultag', date: '1995-09-01', emotion: 0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Abiball', date: '2004-06-25', emotion: 0.85, customColor: null, gradientPreset: 1, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Was für eine Party!', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Trennung', date: '2010-03-15', emotion: -0.7, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Bergwanderung', date: '2014-08-12', emotion: 0.75, customColor: null, gradientPreset: 4, image: 'demo/photo-1534067783941-51c9c23ecefd.jpeg', note: 'Unvergesslicher Ausblick', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Jobverlust', date: '2016-11-03', emotion: -0.6, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Hochzeit', date: '2018-07-20', emotion: 0.95, customColor: null, gradientPreset: 5, image: 'demo/photo-1506905925346-21bda4d32df4.jpeg', note: 'Der schönste Tag', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Umzug', date: '2021-04-01', emotion: -0.3, customColor: null, gradientPreset: null, image: null, note: '', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() },
|
||
{ id: crypto.randomUUID(), title: 'Neuer Job', date: '2023-01-10', emotion: 0.5, customColor: null, gradientPreset: null, image: 'demo/photo-1530103862676-de8c9debad1d.jpeg', note: 'Neues Kapitel', syncStatus: 'local', createdAt: Date.now(), updatedAt: Date.now() }
|
||
]
|
||
|
||
// Generate realistic demo events for testing at scale
|
||
function generateManyEvents(count = 500) {
|
||
// Realistic life event categories with emotion ranges
|
||
const categories = [
|
||
// Positive events
|
||
{ titles: ['Geburtstag', 'Geburtstagsfeier', 'Überraschungsparty'], emotionRange: [0.3, 0.8], noteChance: 0.4, notes: ['Tolles Fest!', 'Viele Geschenke', 'Schöner Tag mit Freunden', 'Alles Gute!'] },
|
||
{ titles: ['Urlaub', 'Strandurlaub', 'Städtereise', 'Roadtrip', 'Backpacking'], emotionRange: [0.4, 0.95], noteChance: 0.6, notes: ['Unvergesslich', 'Wunderschöne Landschaft', 'Endlich Erholung', 'Muss ich wiederholen'] },
|
||
{ titles: ['Hochzeit', 'Verlobung', 'Jahrestag'], emotionRange: [0.7, 1.0], noteChance: 0.8, notes: ['Der schönste Tag', 'Für immer', 'Tränen der Freude', 'Unbeschreiblich'] },
|
||
{ titles: ['Beförderung', 'Neuer Job', 'Gehaltserhöhung', 'Jobangebot'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Endlich!', 'Harte Arbeit zahlt sich aus', 'Neues Kapitel', 'Verdient'] },
|
||
{ titles: ['Konzert', 'Festival', 'Theaterbesuch', 'Oper'], emotionRange: [0.3, 0.85], noteChance: 0.5, notes: ['Gänsehaut', 'Beste Band ever', 'Geniale Atmosphäre', 'Nächstes Jahr wieder'] },
|
||
{ titles: ['Geburt', 'Baby da!', 'Nachwuchs'], emotionRange: [0.85, 1.0], noteChance: 0.9, notes: ['Das größte Wunder', 'Willkommen auf der Welt', 'Unbeschreibliches Glück'] },
|
||
{ titles: ['Abschluss', 'Prüfung bestanden', 'Diplom', 'Master geschafft'], emotionRange: [0.6, 0.95], noteChance: 0.6, notes: ['Geschafft!', 'Jahre harter Arbeit', 'Stolz', 'Endlich vorbei'] },
|
||
{ titles: ['Bergwanderung', 'Gipfel erreicht', 'Marathon geschafft', 'Triathlon'], emotionRange: [0.5, 0.9], noteChance: 0.5, notes: ['Was für ein Ausblick!', 'Körperliche Grenzen überwunden', 'Nie aufgeben'] },
|
||
{ titles: ['Hauskauf', 'Wohnungseinweihung', 'Renovierung fertig'], emotionRange: [0.4, 0.8], noteChance: 0.5, notes: ['Endlich eigene vier Wände', 'Traum wird wahr', 'Viel Arbeit, aber es lohnt sich'] },
|
||
{ titles: ['Erstes Date', 'Zusammengekommen', 'Liebeserklärung'], emotionRange: [0.5, 0.95], noteChance: 0.6, notes: ['Schmetterlinge', 'Liebe auf den ersten Blick', 'Endlich getraut'] },
|
||
// Neutral events
|
||
{ titles: ['Umzug', 'Neue Stadt', 'Wohnungswechsel'], emotionRange: [-0.2, 0.3], noteChance: 0.4, notes: ['Neuanfang', 'Alles anders', 'Spannend und stressig zugleich'] },
|
||
{ titles: ['Arztbesuch', 'Vorsorge', 'Check-up'], emotionRange: [-0.1, 0.1], noteChance: 0.2, notes: ['Alles okay', 'Routine'] },
|
||
{ titles: ['Meeting', 'Präsentation', 'Workshop'], emotionRange: [-0.1, 0.4], noteChance: 0.3, notes: ['Gut gelaufen', 'Viel gelernt', 'Anstrengend'] },
|
||
{ titles: ['Friseur', 'Shopping', 'Einkauf'], emotionRange: [0.0, 0.3], noteChance: 0.1, notes: ['Neuer Look', 'Guter Fund'] },
|
||
// Negative events
|
||
{ titles: ['Trennung', 'Beziehungsende', 'Scheidung'], emotionRange: [-1.0, -0.5], noteChance: 0.5, notes: ['Schmerzhaft', 'Warum?', 'Es ist besser so', 'Brauche Zeit'] },
|
||
{ titles: ['Jobverlust', 'Kündigung', 'Firma pleite'], emotionRange: [-0.9, -0.4], noteChance: 0.5, notes: ['Schock', 'Wie geht es weiter?', 'Unverdient'] },
|
||
{ titles: ['Krankheit', 'OP', 'Krankenhaus'], emotionRange: [-0.8, -0.3], noteChance: 0.6, notes: ['Wird schon', 'Hauptsache gesund werden', 'Lange Genesung'] },
|
||
{ titles: ['Abschied', 'Verlust', 'Trauer'], emotionRange: [-1.0, -0.6], noteChance: 0.7, notes: ['Ruhe in Frieden', 'Fehlt mir', 'Unvergessen', 'Schwerer Tag'] },
|
||
{ titles: ['Streit', 'Konflikt', 'Auseinandersetzung'], emotionRange: [-0.7, -0.2], noteChance: 0.3, notes: ['Muss nicht sein', 'Hoffe auf Klärung'] },
|
||
{ titles: ['Unfall', 'Panne', 'Autopanne'], emotionRange: [-0.6, -0.2], noteChance: 0.4, notes: ['Zum Glück nichts Schlimmes', 'Ärgerlich', 'Hätte schlimmer sein können'] },
|
||
{ titles: ['Prüfung nicht bestanden', 'Absage', 'Ablehnung'], emotionRange: [-0.7, -0.3], noteChance: 0.4, notes: ['Nächstes Mal', 'Nicht aufgeben', 'Enttäuschend'] },
|
||
]
|
||
|
||
const demoImages = [
|
||
'demo/photo-1530103862676-de8c9debad1d.jpeg',
|
||
'demo/photo-1534067783941-51c9c23ecefd.jpeg',
|
||
'demo/photo-1506905925346-21bda4d32df4.jpeg'
|
||
]
|
||
|
||
// Seeded random for reproducibility
|
||
let seed = 42
|
||
function rand() {
|
||
seed = (seed * 16807 + 0) % 2147483647
|
||
return (seed - 1) / 2147483646
|
||
}
|
||
|
||
function randInt(min, max) {
|
||
return Math.floor(rand() * (max - min + 1)) + min
|
||
}
|
||
|
||
function pick(arr) {
|
||
return arr[Math.floor(rand() * arr.length)]
|
||
}
|
||
|
||
function randFloat(min, max) {
|
||
return Math.round((min + rand() * (max - min)) * 100) / 100
|
||
}
|
||
|
||
const evts = []
|
||
const startYear = 1985
|
||
const endYear = 2026
|
||
|
||
// Generate events with realistic distribution (more events in recent years)
|
||
for (let i = 0; i < count; i++) {
|
||
// Weight towards recent years: cube root distribution
|
||
const t = rand()
|
||
const yearFloat = startYear + (endYear - startYear) * (t * t * 0.4 + t * 0.6)
|
||
const year = Math.floor(yearFloat)
|
||
const month = randInt(1, 12)
|
||
const day = randInt(1, 28) // safe for all months
|
||
const date = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||
|
||
const cat = pick(categories)
|
||
const title = pick(cat.titles)
|
||
const emotion = randFloat(cat.emotionRange[0], cat.emotionRange[1])
|
||
const hasNote = rand() < cat.noteChance
|
||
const note = hasNote ? pick(cat.notes) : ''
|
||
const hasImage = rand() < 0.15 // 15% chance
|
||
const image = hasImage ? pick(demoImages) : null
|
||
const hasPreset = rand() < 0.25 // 25% chance
|
||
const gradientPreset = hasPreset ? randInt(0, 9) : null
|
||
|
||
evts.push({
|
||
id: crypto.randomUUID(),
|
||
title,
|
||
date,
|
||
emotion,
|
||
customColor: null,
|
||
gradientPreset,
|
||
image,
|
||
note,
|
||
syncStatus: 'local',
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
})
|
||
}
|
||
|
||
// Sort by date
|
||
evts.sort((a, b) => a.date.localeCompare(b.date))
|
||
return evts
|
||
}
|
||
|
||
export { emotionToColor, GRADIENT_PRESETS, demoEvents, generateManyEvents }
|
||
|
||
export const useEventsStore = defineStore('events', () => {
|
||
// shallowRef: event objects are never mutated field-by-field, only replaced
|
||
// wholesale — deep reactivity on 500+ objects would be pure proxy overhead.
|
||
// In-place array mutations must call triggerRef(events) afterwards.
|
||
const events = shallowRef([])
|
||
const isLoaded = ref(false)
|
||
const selectedEventId = ref(null)
|
||
const panelOpen = ref(false)
|
||
const editingEventId = ref(null)
|
||
|
||
// Load events from IndexedDB; seed demo data on first launch
|
||
async function init() {
|
||
try {
|
||
let stored = await db.events.orderBy('date').toArray()
|
||
if (stored.length === 0) {
|
||
const seed = generateManyEvents(500)
|
||
await db.events.bulkPut(seed)
|
||
stored = seed
|
||
}
|
||
events.value = stored
|
||
} catch (e) {
|
||
console.warn('Dexie load failed, using demo data:', e)
|
||
events.value = [...demoEvents]
|
||
}
|
||
isLoaded.value = true
|
||
|
||
// Start auto-sync if authenticated
|
||
getToken().then((token) => {
|
||
if (token) startAutoSync()
|
||
})
|
||
}
|
||
|
||
// Fire-and-forget DB write (UI already updated via ref)
|
||
function dbPut(event) {
|
||
db.events.put(event).catch(e => console.warn('Dexie put failed:', e))
|
||
}
|
||
|
||
function dbDelete(id) {
|
||
db.events.delete(id).catch(e => console.warn('Dexie delete failed:', e))
|
||
}
|
||
|
||
function dbQueueSync(eventId, action, payload) {
|
||
db.syncQueue.add({ eventId, action, payload, createdAt: Date.now() })
|
||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
||
}
|
||
|
||
// Queue an 'update' mutation. Any older pending update for the same event
|
||
// is replaced, so the queue holds at most one update per event.
|
||
function dbQueueUpdate(event) {
|
||
db.syncQueue
|
||
.where('eventId').equals(event.id)
|
||
.and((item) => item.action === 'update')
|
||
.delete()
|
||
.then(() => db.syncQueue.add({
|
||
eventId: event.id,
|
||
action: 'update',
|
||
payload: { ...event },
|
||
createdAt: Date.now()
|
||
}))
|
||
.catch(e => console.warn('Dexie sync queue failed:', e))
|
||
}
|
||
|
||
// Ghost event for live preview while creating/editing
|
||
const ghostEmotion = ref(0)
|
||
const ghostCustomColor = ref(null)
|
||
const ghostGradientPreset = ref(null)
|
||
const ghostTitle = ref('')
|
||
const ghostDate = ref(new Date().toISOString().slice(0, 10))
|
||
const ghostNote = ref('')
|
||
const ghostImage = ref(null)
|
||
|
||
const ghostEvent = computed(() => ({
|
||
id: '__ghost__',
|
||
title: ghostTitle.value || 'New Event',
|
||
date: ghostDate.value,
|
||
emotion: ghostEmotion.value,
|
||
customColor: ghostCustomColor.value,
|
||
gradientPreset: ghostGradientPreset.value,
|
||
image: ghostImage.value,
|
||
note: ghostNote.value
|
||
}))
|
||
|
||
const sortedEvents = computed(() => {
|
||
// Dates are ISO strings (YYYY-MM-DD) — lexicographic order is chronological
|
||
return [...events.value].sort((a, b) => a.date.localeCompare(b.date))
|
||
})
|
||
|
||
function selectEvent(id) {
|
||
selectedEventId.value = id
|
||
}
|
||
|
||
function openPanel(eventId = null) {
|
||
if (eventId) {
|
||
editingEventId.value = eventId
|
||
const event = events.value.find((e) => e.id === eventId)
|
||
if (event) {
|
||
ghostTitle.value = event.title
|
||
ghostDate.value = event.date
|
||
ghostEmotion.value = event.emotion
|
||
ghostCustomColor.value = event.customColor
|
||
ghostGradientPreset.value = event.gradientPreset ?? null
|
||
ghostImage.value = event.image || null
|
||
ghostNote.value = event.note
|
||
}
|
||
} else {
|
||
editingEventId.value = null
|
||
ghostTitle.value = ''
|
||
ghostDate.value = new Date().toISOString().slice(0, 10)
|
||
ghostEmotion.value = 0
|
||
ghostCustomColor.value = null
|
||
ghostGradientPreset.value = null
|
||
ghostImage.value = null
|
||
ghostNote.value = ''
|
||
}
|
||
panelOpen.value = true
|
||
}
|
||
|
||
// Debounced DB write: the UI updates on every keystroke via the ref,
|
||
// but IndexedDB only gets the final state after typing pauses
|
||
const PERSIST_DEBOUNCE_MS = 400
|
||
let persistTimer = null
|
||
let pendingWrite = null
|
||
|
||
function schedulePersist(event) {
|
||
pendingWrite = event
|
||
clearTimeout(persistTimer)
|
||
persistTimer = setTimeout(flushPersist, PERSIST_DEBOUNCE_MS)
|
||
}
|
||
|
||
function flushPersist() {
|
||
clearTimeout(persistTimer)
|
||
persistTimer = null
|
||
if (pendingWrite) {
|
||
dbPut(pendingWrite)
|
||
dbQueueUpdate(pendingWrite)
|
||
pendingWrite = null
|
||
}
|
||
}
|
||
|
||
function cancelPersist() {
|
||
clearTimeout(persistTimer)
|
||
persistTimer = null
|
||
pendingWrite = null
|
||
}
|
||
|
||
// Auto-save: persist ghost → event in edit mode
|
||
function persistToEvent() {
|
||
if (!editingEventId.value) return
|
||
const idx = events.value.findIndex((e) => e.id === editingEventId.value)
|
||
if (idx === -1) return
|
||
const updated = {
|
||
...events.value[idx],
|
||
title: ghostTitle.value,
|
||
date: ghostDate.value,
|
||
emotion: ghostEmotion.value,
|
||
customColor: ghostCustomColor.value,
|
||
gradientPreset: ghostGradientPreset.value,
|
||
image: ghostImage.value,
|
||
note: ghostNote.value,
|
||
syncStatus: 'modified',
|
||
updatedAt: Date.now()
|
||
}
|
||
events.value[idx] = updated
|
||
triggerRef(events)
|
||
schedulePersist(updated)
|
||
}
|
||
|
||
watch(
|
||
[ghostTitle, ghostDate, ghostEmotion, ghostCustomColor, ghostGradientPreset, ghostImage, ghostNote],
|
||
() => { persistToEvent() }
|
||
)
|
||
|
||
function closePanel() {
|
||
flushPersist()
|
||
if (!editingEventId.value && ghostTitle.value.trim()) {
|
||
const newEvent = {
|
||
id: crypto.randomUUID(),
|
||
title: ghostTitle.value,
|
||
date: ghostDate.value,
|
||
emotion: ghostEmotion.value,
|
||
customColor: ghostCustomColor.value,
|
||
gradientPreset: ghostGradientPreset.value,
|
||
image: ghostImage.value,
|
||
note: ghostNote.value,
|
||
syncStatus: 'local',
|
||
createdAt: Date.now(),
|
||
updatedAt: Date.now()
|
||
}
|
||
events.value.push(newEvent)
|
||
triggerRef(events)
|
||
dbPut(newEvent)
|
||
dbQueueSync(newEvent.id, 'create', { ...newEvent })
|
||
}
|
||
panelOpen.value = false
|
||
editingEventId.value = null
|
||
selectedEventId.value = null
|
||
}
|
||
|
||
function deleteEvent(id) {
|
||
if (pendingWrite?.id === id) cancelPersist()
|
||
events.value = events.value.filter((e) => e.id !== id)
|
||
dbDelete(id)
|
||
dbQueueSync(id, 'delete', null)
|
||
closePanel()
|
||
}
|
||
|
||
// Memoized: emotionToColor is pure and its input space is small
|
||
// (emotion in 0.05 steps × 10 presets), so the cache stays bounded
|
||
const glowColorCache = new Map()
|
||
|
||
function getGlowColor(event) {
|
||
if (event.customColor) return event.customColor
|
||
const preset = event.gradientPreset ?? null
|
||
const key = `${event.emotion}|${preset}`
|
||
let color = glowColorCache.get(key)
|
||
if (!color) {
|
||
color = emotionToColor(event.emotion, preset)
|
||
glowColorCache.set(key, color)
|
||
}
|
||
return color
|
||
}
|
||
|
||
// Auto-init on store creation
|
||
init()
|
||
|
||
return {
|
||
events,
|
||
isLoaded,
|
||
selectedEventId,
|
||
panelOpen,
|
||
editingEventId,
|
||
ghostEmotion,
|
||
ghostCustomColor,
|
||
ghostGradientPreset,
|
||
ghostTitle,
|
||
ghostDate,
|
||
ghostNote,
|
||
ghostImage,
|
||
ghostEvent,
|
||
sortedEvents,
|
||
selectEvent,
|
||
openPanel,
|
||
closePanel,
|
||
deleteEvent,
|
||
getGlowColor
|
||
}
|
||
})
|