Client-Logging, getrennte Rate-Limits und Ladezustand für Bilder

Auf dem Testserver lief die App beim Scrollen langer Timelines in ein 429:
nach jeder Scroll-Pause ging sofort ein PUT /settings raus, und jedes
sichtbare Vorschaubild zählte auf dasselbe Limit von 300/min. Danach
blieben Bilder leer und "Einstellungen konnten nicht gespeichert werden"
erschien.

Backend: Medien-Reads bekommen einen eigenen Limiter (media, 1200/min),
POST /client-logs nimmt Warnungen und Fehler der App an (client-logs,
30/min) und schreibt sie in den täglichen Kanal `client`; Exceptions
tragen user_id, path und method im Kontext.

Frontend: Einstellungen gehen frühestens 1,5 s nach der letzten Änderung
und nur bei geändertem Inhalt zum Server, 429/Netzfehler werden still mit
Backoff wiederholt, nur eine Ablehnung wird gemeldet. Bilder laufen durch
ein Gate (6 parallel, Retry-After, 3 Versuche) und fallen auf einen
Platzhalter statt auf einen kaputten Pfad zurück. Neuer clientLog-Service
mit IndexedDB-Puffer, Dedupe und Batch-Versand, globaler Fehlerfang im
Boot, Protokoll im Entwickler-Tab. Drei pulsierende Punkte statt weißer
Fläche in Timeline-Punkt, Key Image und Medienkacheln.

Dokumentation in documentation/logging.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-25 09:31:35 +00:00
parent 522deffa1a
commit ab23c9f4ef
28 changed files with 1689 additions and 78 deletions

View file

@ -11,7 +11,7 @@ export default defineConfig((ctx) => {
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [],
boot: ['errorLog'],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-file#css
css: ['app.scss'],

View file

@ -0,0 +1,54 @@
import { defineBoot } from '#q-app/wrappers'
import { apiJson } from 'src/services/apiClient'
import { clientLog, restoreClientLog, setClientLogTransport } from 'src/services/clientLog'
import { APP_VERSION } from 'src/config/appVersion'
import { useAuthStore } from 'stores/auth'
/**
* Catches what nobody else caught and connects the client log to the API.
* Runs once at start-up, before the router mounts anything.
*/
export default defineBoot(({ app }) => {
app.config.errorHandler = (error, instance, info) => {
clientLog.error('vue', error?.message || String(error), {
error: error?.stack ? error.stack.split('\n').slice(0, 5).join(' | ') : undefined,
component: instance?.$options?.name || instance?.$?.type?.__name || null,
info
})
if (import.meta.env.DEV) console.error(error)
}
window.addEventListener('error', (event) => {
// Failed <img>/<script> loads arrive here as well, without an error object.
if (event.target && event.target !== window) {
const target = event.target
clientLog.warn('resource', `Ressource konnte nicht geladen werden: ${target.tagName?.toLowerCase()}`, {
src: target.currentSrc || target.src || target.href || null
})
return
}
clientLog.error('window', event.message || 'Unbekannter Fehler', {
error: event.error?.stack ? event.error.stack.split('\n').slice(0, 5).join(' | ') : undefined,
source: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : null
})
}, true)
window.addEventListener('unhandledrejection', (event) => {
const reason = event.reason
clientLog.error('promise', reason?.message || String(reason), reason instanceof Error ? reason : { reason })
})
setClientLogTransport({
appVersion: APP_VERSION,
// Demo users have no server account; their log stays in the browser.
enabled: () => useAuthStore().currentUser?.mode === 'remote',
send: (batch, { keepalive } = {}) => apiJson('/client-logs', {
method: 'POST',
body: batch,
keepalive,
log: false
})
})
restoreClientLog()
})

View file

@ -140,6 +140,38 @@
<div class="settings-row__hint">{{ APP_VERSION }}</div>
</div>
</div>
<div class="settings-section__divider" />
<!-- Protokoll -->
<div class="settings-row">
<div>
<div class="settings-row__label">Protokoll</div>
<div class="settings-row__hint">
{{ logSummary }}
{{ logsAreShipped ? 'Warnungen und Fehler werden an den Server übertragen.' : 'Bleibt in diesem Browser.' }}
</div>
</div>
<div class="settings-row__control settings-log__actions">
<q-btn flat dense no-caps size="sm" icon="content_copy" label="Kopieren" :disable="!logEntries.length" @click="copyLog" />
<q-btn flat dense no-caps size="sm" icon="delete_sweep" label="Leeren" :disable="!logEntries.length" @click="clearLog" />
</div>
</div>
<div class="settings-log" :class="{ 'settings-log--dark': isDark }">
<div v-if="!recentLogEntries.length" class="settings-log__empty">Keine Einträge in dieser Sitzung.</div>
<div
v-for="entry in recentLogEntries"
:key="entry.id"
class="settings-log__entry"
:class="`settings-log__entry--${entry.level}`"
>
<span class="settings-log__time">{{ formatLogTime(entry.lastAt) }}</span>
<span class="settings-log__scope">{{ entry.scope }}</span>
<span class="settings-log__message">
{{ entry.message }}<span v-if="entry.count > 1" class="settings-log__count">×{{ entry.count }}</span>
</span>
<span v-if="entry.context" class="settings-log__context">{{ formatLogContext(entry.context) }}</span>
</div>
</div>
</div>
</ModalCard>
</template>
@ -149,7 +181,9 @@ import { ref, computed } from 'vue'
import { useQuasar } from 'quasar'
import ModalCard from 'components/ModalCard.vue'
import { useSettingsStore, ACCENT_COLORS } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import { APP_VERSION } from 'src/config/appVersion'
import { clearClientLog, logEntries } from 'src/services/clientLog'
defineProps({ open: { type: Boolean, default: false } })
defineEmits(['close'])
@ -189,9 +223,123 @@ function selectAccent(value) {
function onAppearanceChange(e) {
settingsStore.appearance = e.target.value
}
// --- Protokoll -------------------------------------------------------------
const authStore = useAuthStore()
const LOG_VIEW_LIMIT = 50
const logsAreShipped = computed(() => authStore.currentUser?.mode === 'remote')
const recentLogEntries = computed(() => [...logEntries.value].slice(-LOG_VIEW_LIMIT).reverse())
const logSummary = computed(() => {
const errors = logEntries.value.filter(entry => entry.level === 'error').length
const warnings = logEntries.value.filter(entry => entry.level === 'warning').length
if (!errors && !warnings) return 'Keine Warnungen oder Fehler.'
return `${errors} Fehler, ${warnings} Warnungen.`
})
function formatLogTime(timestamp) {
return new Date(timestamp).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
function formatLogContext(context) {
return Object.entries(context)
.map(([key, value]) => `${key}: ${value}`)
.join(' · ')
}
async function copyLog() {
const text = JSON.stringify({
appVersion: APP_VERSION,
exportedAt: new Date().toISOString(),
entries: logEntries.value
}, null, 2)
try {
await navigator.clipboard.writeText(text)
$q.notify({ type: 'positive', message: 'Protokoll kopiert.', timeout: 2000 })
} catch {
$q.notify({ type: 'warning', message: 'Zwischenablage nicht verfügbar.', timeout: 3000 })
}
}
async function clearLog() {
await clearClientLog()
}
</script>
<style scoped>
.settings-log__actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.settings-log {
margin-top: 10px;
max-height: 240px;
overflow: auto;
border-radius: 8px;
background: rgba(128, 128, 128, 0.08);
border: 1px solid rgba(128, 128, 128, 0.15);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11px;
line-height: 1.45;
}
.settings-log--dark {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.settings-log__empty {
padding: 10px 12px;
opacity: 0.55;
}
.settings-log__entry {
display: grid;
grid-template-columns: auto auto 1fr;
column-gap: 8px;
padding: 5px 10px;
border-left: 3px solid rgba(128, 128, 128, 0.35);
}
.settings-log__entry + .settings-log__entry {
border-top: 1px solid rgba(128, 128, 128, 0.1);
}
.settings-log__entry--warning {
border-left-color: #f59e0b;
}
.settings-log__entry--error {
border-left-color: #ef4444;
}
.settings-log__time {
opacity: 0.55;
white-space: nowrap;
}
.settings-log__scope {
opacity: 0.75;
font-weight: 600;
}
.settings-log__message {
overflow-wrap: anywhere;
}
.settings-log__count {
margin-left: 6px;
opacity: 0.6;
}
.settings-log__context {
grid-column: 1 / -1;
opacity: 0.6;
overflow-wrap: anywhere;
}
.settings-section__title {
font-size: 18px;
font-weight: 700;

View file

@ -75,8 +75,8 @@
<img :src="keyImageSrc" class="event-panel__image" alt="" />
</template>
<!-- Shown while the image of the newly opened event is loading -->
<div v-else class="event-panel__image-skeleton" aria-hidden="true">
<q-spinner size="28px" />
<div v-else class="event-panel__image-skeleton">
<LoadingDots size="8px" label="Bild wird geladen" />
</div>
</button>
<input
@ -384,8 +384,18 @@
playsinline
preload="metadata"
/>
<div v-else class="event-panel__media-fallback">
<q-icon :name="item.type === 'video' ? 'movie' : 'image'" size="28px" />
<div v-else-if="mediaLoadState[item.id] === 'loading'" class="event-panel__media-fallback">
<LoadingDots size="6px" label="Bild wird geladen" />
</div>
<div
v-else
class="event-panel__media-fallback"
:title="mediaLoadState[item.id] === 'failed' ? 'Bild konnte nicht geladen werden' : undefined"
>
<q-icon
:name="mediaLoadState[item.id] === 'failed' ? 'broken_image' : (item.type === 'video' ? 'movie' : 'image')"
size="28px"
/>
</div>
<!-- Library-style type marker -->
<span v-if="item.type === 'video'" class="event-panel__media-play" aria-hidden="true">
@ -530,6 +540,7 @@ import { formatCalendarDate } from 'src/utils/calendarDate'
import { EVENT_CATEGORIES, findEventCategory } from 'src/config/eventCategories'
import ConfirmDialog from 'components/ConfirmDialog.vue'
import VoiceNotesCard from 'components/VoiceNotesCard.vue'
import LoadingDots from 'components/LoadingDots.vue'
import { formatDuration } from 'composables/useVoiceRecorder'
const $q = useQuasar()
@ -675,6 +686,8 @@ const mediaGalleryOpen = ref(false)
const mediaGalleryIndex = ref(0)
// Poster / image sources per item (resolved through the image cache).
const mediaSrcById = ref({})
// 'loading' while the source is fetched, 'failed' when it could not be.
const mediaLoadState = ref({})
// Full video sources per remote item, fetched only when the gallery shows them.
const videoSrcById = ref({})
@ -734,6 +747,11 @@ watch(
// For videos this is the poster frame; a video without one has no image
// source and the tile falls back to an icon or its own first frame.
const pending = items.filter(item => !mediaSrcById.value[item.id])
mediaLoadState.value = Object.fromEntries(
pending
.filter(item => item.blob ? item.type !== 'video' : Boolean(item.previewUrl || item.thumbnailUrl || item.src))
.map(item => [item.id, 'loading'])
)
const entries = await Promise.all(pending.map(async (item) => [
item.id,
item.blob && item.type !== 'video'
@ -745,6 +763,11 @@ watch(
...mediaSrcById.value,
...Object.fromEntries(entries.filter(([, src]) => Boolean(src)))
}
mediaLoadState.value = Object.fromEntries(
entries
.filter(([id, src]) => !src && mediaLoadState.value[id] === 'loading')
.map(([id]) => [id, 'failed'])
)
},
{ deep: true, immediate: true }
)

View file

@ -23,6 +23,10 @@
class="glow-dot__image"
alt=""
/>
<!-- The photo is on its way: pulsing dots instead of a blank disc -->
<span v-else-if="imageLoading" class="glow-dot__loading">
<LoadingDots :size="loadingDotSize" label="Bild wird geladen" />
</span>
</div>
<!-- An event without a title still needs a label; the date stands in -->
<div v-if="!isGhost" class="glow-dot__label" :style="labelStyle">
@ -38,6 +42,7 @@ import { computed } from 'vue'
import { useEventsStore } from 'stores/events'
import { useSettingsStore } from 'stores/settings'
import { useImageCache } from 'composables/useImageCache'
import LoadingDots from 'components/LoadingDots.vue'
import { emotionToYPercent, isLowerHalf } from 'src/utils/timelinePosition'
import { findEventCategory } from 'src/config/eventCategories'
@ -55,7 +60,7 @@ const settingsStore = useSettingsStore()
// Resolve image: cached thumbnail from IndexedDB or fetch & cache
const imageUrl = computed(() => props.event.image || null)
const eventId = computed(() => props.event.id)
const { resolvedSrc: imageSrc } = useImageCache(imageUrl, eventId)
const { resolvedSrc: imageSrc, loading: imageLoading } = useImageCache(imageUrl, eventId)
const fl = computed(() => settingsStore.floatingLines)
const glowColor = computed(() => eventsStore.getGlowColor(props.event))
@ -63,6 +68,8 @@ const glowColor = computed(() => eventsStore.getGlowColor(props.event))
// CSS diameter derived from settings (circleRadius is half-size in px)
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const dotSize = computed(() => 2 * fl.value.circleRadius / dpr)
// Three dots have to fit inside the disc with room to spare.
const loadingDotSize = computed(() => `${Math.max(2, Math.min(5, Math.round(dotSize.value / 8)))}px`)
// Y position from emotion; shared with the shader points, see utils/timelinePosition
const yPercent = computed(() => emotionToYPercent(props.event.emotion))
@ -195,6 +202,15 @@ function onSelect() {
border-radius: 50%;
}
.glow-dot__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(0, 0, 0, 0.45);
}
/* States */
.glow-dot--ghost {
opacity: 1;

View file

@ -0,0 +1,59 @@
<template>
<span
class="loading-dots"
:style="{ '--dot-size': size }"
role="status"
:aria-label="label"
>
<i /><i /><i />
</span>
</template>
<script setup>
/**
* Three pulsing dots for spots where an image or a file is on its way and a
* blank frame would look broken. Uses the current text colour.
*/
defineProps({
size: { type: String, default: '6px' },
label: { type: String, default: 'Wird geladen' }
})
</script>
<style scoped>
.loading-dots {
display: inline-flex;
align-items: center;
gap: calc(var(--dot-size) * 0.8);
line-height: 0;
}
.loading-dots i {
width: var(--dot-size);
height: var(--dot-size);
border-radius: 50%;
background: currentColor;
opacity: 0.25;
animation: loading-dots-pulse 1.1s ease-in-out infinite;
}
.loading-dots i:nth-child(2) {
animation-delay: 0.18s;
}
.loading-dots i:nth-child(3) {
animation-delay: 0.36s;
}
@keyframes loading-dots-pulse {
0%, 80%, 100% { opacity: 0.25; transform: scale(0.85); }
40% { opacity: 0.9; transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
.loading-dots i {
animation: none;
opacity: 0.5;
}
}
</style>

View file

@ -113,6 +113,7 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useQuasar } from 'quasar'
import { useEventsStore } from 'stores/events'
import { resolveFullRes } from 'composables/useImageCache'
import { clientLog } from 'src/services/clientLog'
import {
MAX_VOICE_NOTE_MS,
extensionForAudioType,
@ -237,7 +238,7 @@ async function saveRecording({ blob, mimeType, durationMs }) {
})
eventsStore.saveGhostNow()
} catch (error) {
console.warn('Voice note save failed:', error)
clientLog.warn('voice', 'Voice note save failed', error)
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gespeichert werden.' })
} finally {
saving.value = false
@ -267,7 +268,7 @@ async function confirmDelete() {
await eventsStore.deleteGhostVoiceNote(note.id)
eventsStore.saveGhostNow()
} catch (error) {
console.warn('Voice note delete failed:', error)
clientLog.warn('voice', 'Voice note delete failed', error)
$q.notify({ type: 'negative', message: 'Die Sprachnotiz konnte nicht gelöscht werden.' })
}
}

View file

@ -1,9 +1,18 @@
import { ref, unref, watch } from 'vue'
import { db } from 'src/db'
import { API_BASE, apiFetch } from 'src/services/apiClient'
import { API_BASE, apiFetch, retryAfterMs } from 'src/services/apiClient'
import { clientLog } from 'src/services/clientLog'
const THUMB_SIZE = 200
// How many protected images may be in flight at once. A long timeline asks
// for one thumbnail per visible event; without a cap they all hit the API in
// the same instant.
const MAX_PARALLEL_FETCHES = 6
// Attempts per image before the caller is told it failed.
const MAX_ATTEMPTS = 3
const RETRY_BASE_MS = 1500
// In-memory cache: avoids repeated IndexedDB reads and blob URL creation.
// Shared across all component instances, therefore scoped by user — the URL
// alone is not a safe key, because two users' images can share a path and a
@ -30,7 +39,7 @@ export function setImageCacheScope(userId) {
.where('userId')
.equals(previousScope)
.delete()
.catch(e => console.warn('Image cache purge failed:', e))
.catch(e => clientLog.warn('image', 'Bild-Cache konnte nicht geleert werden', e))
}
}
@ -50,10 +59,112 @@ function cacheKey(imageUrl) {
return `${API_BASE}|${scopeUserId ?? 'anonymous'}|${imageUrl}`
}
function isProtectedApiImageUrl(imageUrl) {
return imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/')
export function isProtectedApiImageUrl(imageUrl) {
return typeof imageUrl === 'string'
&& (imageUrl.startsWith('/event-media/') || imageUrl.startsWith('/settings/media/'))
}
// --- Request gate ----------------------------------------------------------
let inFlight = 0
const waiting = []
// After a 429 every image request waits until this time before continuing.
let pausedUntil = 0
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function acquireSlot() {
if (inFlight < MAX_PARALLEL_FETCHES) {
inFlight += 1
return
}
await new Promise(resolve => waiting.push(resolve))
inFlight += 1
}
function releaseSlot() {
inFlight -= 1
const next = waiting.shift()
if (next) next()
}
class ImageFetchError extends Error {
constructor(status, retryAfter = null, cause = null) {
super(cause?.message ? `Fetch failed: ${cause.message}` : `Fetch failed: ${status}`)
this.name = 'ImageFetchError'
this.status = status
this.retryAfterMs = retryAfter
this.cause = cause
}
}
function isTransientStatus(status) {
return status === 0 || status === 408 || status === 429 || status >= 500
}
/**
* Fetches one image through the gate: bounded concurrency, and after a 429
* everyone waits for the Retry-After before the next attempt.
*/
async function fetchImage(imageUrl) {
const protectedUrl = isProtectedApiImageUrl(imageUrl)
if (!protectedUrl) {
const response = await fetch(imageUrl)
if (!response.ok) throw new ImageFetchError(response.status)
return response
}
await acquireSlot()
try {
const wait = pausedUntil - Date.now()
if (wait > 0) await sleep(wait)
let response
try {
response = await apiFetch(imageUrl, { log: false })
} catch (error) {
throw new ImageFetchError(0, null, error)
}
if (response.status === 429) {
const retryAfter = retryAfterMs(response) ?? RETRY_BASE_MS * 2
pausedUntil = Math.max(pausedUntil, Date.now() + retryAfter)
throw new ImageFetchError(429, retryAfter)
}
if (!response.ok) throw new ImageFetchError(response.status)
return response
} finally {
releaseSlot()
}
}
/**
* `fetchImage` with retries for the temporary failures. Logs once per
* image when it gives up.
*/
async function fetchImageWithRetry(imageUrl) {
let lastError = null
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
return await fetchImage(imageUrl)
} catch (error) {
lastError = error
if (!isTransientStatus(error.status ?? 0) || attempt === MAX_ATTEMPTS) break
await sleep(Math.max(error.retryAfterMs ?? 0, RETRY_BASE_MS * attempt))
}
}
clientLog.warn('image', `Bild konnte nicht geladen werden (${lastError?.status || 'keine Antwort'})`, {
status: lastError?.status ?? 0,
url: imageUrl.split('?')[0]
})
throw lastError
}
// --- Thumbnails ------------------------------------------------------------
/**
* Create a thumbnail (THUMB_SIZE x THUMB_SIZE) from a source image blob.
* Returns a new Blob (JPEG, quality 0.8).
@ -98,10 +209,7 @@ function createThumbnail(blob) {
* Fetch an image from URL, cache thumbnail in IndexedDB, return blob URL.
*/
async function fetchAndCache(imageUrl, eventId) {
const response = isProtectedApiImageUrl(imageUrl)
? await apiFetch(imageUrl)
: await fetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const response = await fetchImageWithRetry(imageUrl)
const blob = await response.blob()
// Create thumbnail
@ -142,7 +250,7 @@ async function getCachedImage(imageUrl) {
return blobUrl
}
} catch (e) {
console.warn('Image cache read failed:', e)
clientLog.warn('image', 'Bild-Cache konnte nicht gelesen werden', e)
}
return null
}
@ -150,15 +258,20 @@ async function getCachedImage(imageUrl) {
/**
* Composable: resolves an event's image to a displayable src.
* - Checks memory cache → IndexedDB cache → fetches & caches thumbnail.
* - Returns reactive `resolvedSrc` ref.
* - `resolvedSrc` is null while loading and after a failure, so the
* template can show a placeholder instead of a broken image.
* - `loading` is true from the first look-up until the answer, `failed`
* once every attempt is used up.
*/
export function useImageCache(imageUrl, eventId) {
const resolvedSrc = ref(null)
const loading = ref(false)
const failed = ref(false)
let requestId = 0
async function resolve(nextUrl = unref(imageUrl)) {
const currentRequestId = ++requestId
failed.value = false
if (!nextUrl) {
resolvedSrc.value = null
@ -174,26 +287,34 @@ export function useImageCache(imageUrl, eventId) {
return
}
loading.value = true
// 2. IndexedDB cache
const cached = await getCachedImage(nextUrl)
if (currentRequestId !== requestId) return
if (cached) {
if (currentRequestId !== requestId) return
resolvedSrc.value = cached
loading.value = false
return
}
// 3. Fetch, create thumbnail, cache
loading.value = true
try {
const blobUrl = await fetchAndCache(nextUrl, unref(eventId))
if (currentRequestId !== requestId) return
resolvedSrc.value = blobUrl
} catch (e) {
// Fallback: use original URL directly (works when online)
console.warn('Image cache failed, using direct URL:', e)
if (currentRequestId !== requestId) return
resolvedSrc.value = nextUrl
if (isProtectedApiImageUrl(nextUrl)) {
// The path alone is useless as an <img> src (it needs the token),
// so the tile stays empty rather than showing a broken image.
resolvedSrc.value = null
failed.value = true
} else {
// Public URL: let the browser try directly.
clientLog.warn('image', 'Bild-Cache übersprungen, direkte URL wird verwendet', e)
resolvedSrc.value = nextUrl
}
} finally {
if (currentRequestId === requestId) loading.value = false
}
@ -203,13 +324,15 @@ export function useImageCache(imageUrl, eventId) {
resolve(nextUrl)
}, { immediate: true })
return { resolvedSrc, loading }
return { resolvedSrc, loading, failed, retry: () => resolve() }
}
/**
* Resolve full-res image for EventPanel (no thumbnail, just cache check).
* Returns the original URL — browser Cache-Control handles caching.
* When offline, falls back to cached thumbnail.
* Returns a blob URL for protected images and the original URL for public
* ones. When the image cannot be fetched it falls back to the cached
* thumbnail, and to null when there is none — never to a path the
* <img> could not load anyway.
*/
export async function resolveFullRes(imageUrl) {
if (!imageUrl) return null
@ -219,13 +342,12 @@ export async function resolveFullRes(imageUrl) {
if (memoryCache.has(key)) return memoryCache.get(key)
try {
const response = await apiFetch(imageUrl)
if (!response.ok) throw new Error(`Fetch failed: ${response.status}`)
const response = await fetchImageWithRetry(imageUrl)
const blobUrl = URL.createObjectURL(await response.blob())
memoryCache.set(key, blobUrl)
return blobUrl
} catch (error) {
console.warn('Full-res image load failed:', error)
} catch {
return await getCachedImage(imageUrl)
}
}
@ -251,6 +373,12 @@ export async function clearEventImages(eventId) {
}
await db.scopedImageCache.where('eventId').equals(eventId).delete()
} catch (e) {
console.warn('Clear event images failed:', e)
clientLog.warn('image', 'Bild-Cache des Events konnte nicht geleert werden', e)
}
}
/** Test hook: forget every cached blob and the 429 pause. */
export function resetImageCacheForTests() {
purgeMemoryCache()
pausedUntil = 0
}

View file

@ -45,3 +45,14 @@ db.version(4).stores({
eventMedia: 'id, eventId, userId, createdAt',
meta: 'key'
})
db.version(5).stores({
events: 'id, userId, [userId+date], updatedAt, syncStatus',
syncQueue: '++queueId, userId, eventId, action, createdAt',
scopedImageCache: 'key, userId, eventId, type, cachedAt',
eventMedia: 'id, eventId, userId, createdAt',
// Warnings and errors from the browser, kept until they were shipped to
// the server and for the developer view (see services/clientLog.js).
clientLogs: 'id, occurredAt, shipped',
meta: 'key'
})

View file

@ -1,4 +1,5 @@
import { db } from 'src/db'
import { clientLog } from 'src/services/clientLog'
function normalizeApiBase(base) {
const trimmedBase = String(base).replace(/\/+$/, '')
@ -33,11 +34,12 @@ function resolveApiBase() {
export const API_BASE = resolveApiBase()
export class ApiError extends Error {
constructor(message, { status = 0, errors = null } = {}) {
constructor(message, { status = 0, errors = null, retryAfterMs = null } = {}) {
super(message)
this.name = 'ApiError'
this.status = status
this.errors = errors
this.retryAfterMs = retryAfterMs
}
}
@ -103,8 +105,32 @@ async function readResponsePayload(response) {
}
}
/**
* Reads the Retry-After header of a 429/503 as milliseconds, or null.
*/
export function retryAfterMs(response) {
const header = response?.headers?.get?.('Retry-After')
if (!header) return null
const seconds = Number(header)
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000)
const at = Date.parse(header)
return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null
}
function logFailedResponse(path, options, response, payload) {
// 401 ends the session elsewhere and is not worth a log line per request.
if (response.status === 401) return
const method = options.method || 'GET'
const level = response.status === 429 || response.status >= 500 ? 'error' : 'warning'
clientLog[level === 'error' ? 'error' : 'warn']('api', `${method} ${path.split('?')[0]} → ${response.status}`, {
status: response.status,
message: typeof payload?.message === 'string' ? payload.message : undefined,
retryAfter: retryAfterMs(response) ?? undefined
})
}
export async function apiFetch(path, options = {}) {
const { auth = true, body, headers = {}, ...fetchOptions } = options
const { auth = true, body, headers = {}, log = true, ...fetchOptions } = options
const token = auth ? await getToken() : null
if (auth && !token) {
@ -122,16 +148,29 @@ export async function apiFetch(path, options = {}) {
console.debug('API request:', requestUrl)
}
const response = await fetch(requestUrl, {
...fetchOptions,
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
headers: {
Accept: 'application/json',
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers
let response
try {
response = await fetch(requestUrl, {
...fetchOptions,
body: body && !isFormData && typeof body !== 'string' ? JSON.stringify(body) : body,
headers: {
Accept: 'application/json',
...(!isFormData ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers
}
})
} catch (error) {
// No response at all: offline, DNS, CORS, aborted. The caller decides
// whether to retry; here it is only recorded.
if (log) {
clientLog.warn('api', `${fetchOptions.method || 'GET'} ${path.split('?')[0]} → keine Antwort`, {
error: error?.message,
online: typeof navigator !== 'undefined' ? navigator.onLine : undefined
})
}
})
throw error
}
if (response.status === 401 && auth) {
await handleUnauthorized(token)
@ -145,9 +184,11 @@ export async function apiJson(path, options = {}) {
const payload = await readResponsePayload(response)
if (!response.ok) {
if (options.log !== false) logFailedResponse(path, options, response, payload)
throw new ApiError(payload?.message || 'Die API-Anfrage ist fehlgeschlagen.', {
status: response.status,
errors: payload?.errors ?? null
errors: payload?.errors ?? null,
retryAfterMs: retryAfterMs(response)
})
}

View file

@ -0,0 +1,269 @@
import { ref } from 'vue'
import { db } from 'src/db'
/**
* Browser-side log: warnings and errors the app runs into, kept in a ring
* buffer for the developer view, mirrored to IndexedDB so they survive a
* reload, and shipped in batches to POST /client-logs once a transport is
* wired up (see boot/errorLog.js). Info entries stay local.
*
* Keep this module free of store imports: apiClient logs through it, and the
* stores import apiClient.
*/
export const LOG_LEVELS = ['info', 'warning', 'error']
const SHIPPED_LEVELS = new Set(['warning', 'error'])
const MEMORY_LIMIT = 200
const STORED_LIMIT = 300
const SHIP_BATCH_SIZE = 50
const SHIP_DELAY_MS = 5000
const SHIP_RETRY_MS = 30000
// The same message from the same place within this window bumps a counter
// instead of adding a line: one scroll through a long timeline can fail a
// hundred thumbnails for the same reason.
const DEDUPE_WINDOW_MS = 10000
export const SESSION_ID = typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(16).slice(2)}`
/** Most recent entries, newest last. Reactive for the developer view. */
export const logEntries = ref([])
let nextId = 1
let transport = null
let shipTimer = null
let shipping = false
let shipBlocked = false
let storageReady = false
function currentUrl() {
if (typeof window === 'undefined') return null
return `${window.location.pathname}${window.location.hash}`
}
/**
* Flattens whatever the caller hands over (an Error, an ApiError, a plain
* object) into one level of scalars the server accepts.
*/
export function normalizeContext(detail) {
if (detail === undefined || detail === null) return null
if (detail instanceof Error || (typeof detail === 'object' && 'message' in detail && 'stack' in detail)) {
return compactContext({
error: detail.name && detail.name !== 'Error' ? `${detail.name}: ${detail.message}` : detail.message,
status: detail.status,
stack: typeof detail.stack === 'string' ? detail.stack.split('\n').slice(0, 4).join(' | ') : undefined
})
}
if (typeof detail !== 'object') return { detail: String(detail) }
return compactContext(Object.fromEntries(
Object.entries(detail).map(([key, value]) => [key, scalar(value)])
))
}
function scalar(value) {
if (value === null || value === undefined) return undefined
if (typeof value === 'string') return value.length > 500 ? `${value.slice(0, 497)}…` : value
if (typeof value === 'number' || typeof value === 'boolean') return value
if (value instanceof Error) return value.message
try {
const json = JSON.stringify(value)
return json.length > 500 ? `${json.slice(0, 497)}…` : json
} catch {
return String(value)
}
}
function compactContext(context) {
const entries = Object.entries(context).filter(([, value]) => value !== undefined && value !== '')
return entries.length ? Object.fromEntries(entries.slice(0, 20)) : null
}
function sameEntry(a, b) {
return a.level === b.level && a.scope === b.scope && a.message === b.message
}
/**
* Records one entry. Returns it (or the entry whose counter it bumped).
*/
export function logEvent(level, scope, message, detail) {
if (!LOG_LEVELS.includes(level)) level = 'warning'
const now = Date.now()
const entries = logEntries.value
const last = entries[entries.length - 1]
if (last && sameEntry(last, { level, scope, message }) && now - last.lastAt < DEDUPE_WINDOW_MS) {
last.count += 1
last.lastAt = now
logEntries.value = [...entries]
persistEntry(last).catch(() => {})
return last
}
const entry = {
id: nextId++,
level,
scope,
message: String(message),
context: normalizeContext(detail),
url: currentUrl(),
occurredAt: now,
lastAt: now,
count: 1,
shipped: SHIPPED_LEVELS.has(level) ? 0 : 1
}
logEntries.value = [...entries, entry].slice(-MEMORY_LIMIT)
persistEntry(entry).catch(() => {})
if (!entry.shipped) scheduleShip()
return entry
}
export const clientLog = {
info: (scope, message, detail) => logEvent('info', scope, message, detail),
warn: (scope, message, detail) => logEvent('warning', scope, message, detail),
error: (scope, message, detail) => logEvent('error', scope, message, detail)
}
// --- IndexedDB mirror ------------------------------------------------------
async function persistEntry(entry) {
if (!db.clientLogs) return
await db.clientLogs.put({ ...entry, context: entry.context ?? null })
if (entry.count === 1) {
const total = await db.clientLogs.count()
if (total > STORED_LIMIT) {
const stale = await db.clientLogs.orderBy('occurredAt').limit(total - STORED_LIMIT).primaryKeys()
await db.clientLogs.bulkDelete(stale)
}
}
}
/**
* Loads what an earlier session left behind, so a crash before the batch
* went out still reaches the server, and the developer view shows it.
*/
export async function restoreClientLog() {
if (storageReady || !db.clientLogs) return
storageReady = true
try {
const stored = await db.clientLogs.orderBy('occurredAt').toArray()
if (!stored.length) return
const current = logEntries.value
const known = new Set(current.map(entry => entry.id))
nextId = Math.max(nextId, ...stored.map(entry => entry.id + 1))
logEntries.value = [...stored.filter(entry => !known.has(entry.id)), ...current]
.sort((a, b) => a.occurredAt - b.occurredAt)
.slice(-MEMORY_LIMIT)
if (stored.some(entry => !entry.shipped)) scheduleShip()
} catch (error) {
console.warn('Client log restore failed:', error)
}
}
export async function clearClientLog() {
logEntries.value = []
if (db.clientLogs) await db.clientLogs.clear().catch(() => {})
}
// --- Shipping --------------------------------------------------------------
/**
* Wires the sender. `enabled()` says whether this user's entries may leave
* the browser (remote accounts only); `send(batch)` performs the request.
*/
export function setClientLogTransport(next) {
transport = next
if (transport) scheduleShip()
}
function scheduleShip(delay = SHIP_DELAY_MS) {
if (!transport || shipTimer) return
shipTimer = setTimeout(() => {
shipTimer = null
shipClientLog().catch(() => {})
}, delay)
}
/**
* Sends everything not yet shipped. Failures are silent and retried later;
* a log that logs its own failures never stops.
*/
export async function shipClientLog({ keepalive = false } = {}) {
if (!transport || shipping || shipBlocked) return false
if (typeof navigator !== 'undefined' && navigator.onLine === false) return false
let enabled = false
try {
enabled = Boolean(transport.enabled())
} catch {
enabled = false
}
if (!enabled) return false
const pending = logEntries.value.filter(entry => !entry.shipped).slice(0, SHIP_BATCH_SIZE)
if (!pending.length) return false
shipping = true
try {
await transport.send({
appVersion: transport.appVersion ?? null,
sessionId: SESSION_ID,
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent.slice(0, 400) : null,
entries: pending.map(entry => ({
level: entry.level,
scope: entry.scope,
message: entry.count > 1 ? `${entry.message} (×${entry.count})` : entry.message,
url: entry.url,
occurredAt: new Date(entry.lastAt).toISOString(),
context: entry.context
}))
}, { keepalive })
for (const entry of pending) entry.shipped = 1
logEntries.value = [...logEntries.value]
if (db.clientLogs) {
await db.clientLogs.bulkPut(pending.map(entry => ({ ...entry }))).catch(() => {})
}
if (logEntries.value.some(entry => !entry.shipped)) scheduleShip(0)
return true
} catch (error) {
// 401/403/422: the server will not take these, stop trying this session.
if ([401, 403, 422].includes(error?.status)) shipBlocked = true
else scheduleShip(SHIP_RETRY_MS)
return false
} finally {
shipping = false
}
}
/** Test hook: forget the transport and any scheduled batch. */
export function resetClientLogForTests() {
if (shipTimer) clearTimeout(shipTimer)
shipTimer = null
shipping = false
shipBlocked = false
transport = null
storageReady = false
logEntries.value = []
nextId = 1
}
if (typeof window !== 'undefined') {
window.addEventListener('online', () => scheduleShip(0))
// A batch still waiting when the tab closes goes out with keepalive.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') shipClientLog({ keepalive: true }).catch(() => {})
})
}

View file

@ -1,6 +1,7 @@
import { ref } from 'vue'
import { db } from 'src/db'
import { apiJson } from 'src/services/apiClient'
import { clientLog } from 'src/services/clientLog'
/**
* Outbound write queue for remote users.
@ -161,7 +162,7 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
const userId = currentUserId()
pendingCount.value = userId ? await db.syncQueue.where('userId').equals(userId).count() : 0
} catch (error) {
console.warn('Sync queue count failed:', error)
clientLog.warn('sync', 'Sync queue count failed', error)
}
}
@ -355,7 +356,7 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
try {
await onRejected(item, error)
} catch (handlerError) {
console.warn('Sync rejection handler failed:', handlerError)
clientLog.warn('sync', 'Sync rejection handler failed', handlerError)
}
}
continue
@ -371,12 +372,12 @@ export function createSyncQueue({ getUserId, onApplied, onRejected, onBlocked })
try {
await onApplied(item, payload)
} catch (handlerError) {
console.warn('Sync apply handler failed:', handlerError)
clientLog.warn('sync', 'Sync apply handler failed', handlerError)
}
}
}
} catch (error) {
console.warn('Sync queue processing failed:', error)
clientLog.warn('sync', 'Sync queue processing failed', error)
inFlightId = null
scheduleRetry()
} finally {

View file

@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { clearToken, getToken, loginRemote, onUnauthorized, revokeToken, setToken } from 'src/services/apiClient'
import { setImageCacheScope } from 'src/composables/useImageCache'
import { clientLog } from 'src/services/clientLog'
export const AUTH_STORAGE_KEY = 'thatsme-auth'
@ -156,7 +157,7 @@ export const useAuthStore = defineStore('auth', () => {
return true
} catch (error) {
console.warn('Remote login failed:', error)
clientLog.warn('auth', 'Remote login failed', error)
lastError.value = error?.status === 429
? 'Zu viele Fehlversuche. Bitte kurz warten und erneut versuchen.'
: error?.status === 422
@ -212,7 +213,7 @@ export const useAuthStore = defineStore('auth', () => {
try {
await revokeToken(token)
} catch (error) {
console.warn('Token revoke failed:', error)
clientLog.warn('auth', 'Token revoke failed', error)
}
}

View file

@ -7,6 +7,7 @@ import { createSyncQueue, isLocalMediaId, isOnline, isSyncing, newLocalMediaId,
import { useSettingsStore, DEFAULT_EMOTION_GRADIENT_START, DEFAULT_EMOTION_GRADIENT_END } from 'stores/settings'
import { useAuthStore } from 'stores/auth'
import { compareCalendarDates } from 'src/utils/calendarDate'
import { clientLog } from 'src/services/clientLog'
// Color interpolation
function lerpColor(a, b, t) {
@ -342,7 +343,7 @@ export const useEventsStore = defineStore('events', () => {
return added
})
.catch((error) => {
console.warn('Sync queue write failed:', error)
clientLog.warn('events', 'Sync queue write failed', error)
lastError.value = 'Die Änderung konnte nicht zwischengespeichert werden.'
if (editingEventId.value === entry.eventId) saveState.value = 'error'
return false
@ -467,7 +468,7 @@ export const useEventsStore = defineStore('events', () => {
const userId = events.value[idx]?.userId ?? item.userId
db.eventMedia.delete(localId)
.then(() => db.eventMedia.put({ ...merged, eventId, userId }))
.catch(e => console.warn('Dexie media replace failed:', e))
.catch(e => clientLog.warn('events', 'Dexie media replace failed', e))
if (editingEventId.value === eventId) {
withPersistSuppressed(() => {
@ -487,7 +488,7 @@ export const useEventsStore = defineStore('events', () => {
}
async function applyQueueRejection(item, error) {
console.warn('Sync item rejected:', item.action, item.eventId, error)
clientLog.warn('events', `Sync item rejected: ${item.action} ${item.eventId}`, error)
switch (item.action) {
case 'create':
@ -631,7 +632,7 @@ export const useEventsStore = defineStore('events', () => {
if (isStaleSession(session)) return
events.value = stored
} catch (e) {
console.warn('Event load failed:', e)
clientLog.warn('events', 'Event load failed', e)
if (isStaleSession(session)) return
// A failed load is not an empty account: fall back to whatever is
@ -650,7 +651,7 @@ export const useEventsStore = defineStore('events', () => {
.between([userId, Dexie.minKey], [userId, Dexie.maxKey])
.toArray()
} catch (e) {
console.warn('Dexie cache read failed:', e)
clientLog.warn('events', 'Dexie cache read failed', e)
return []
}
}
@ -660,7 +661,7 @@ export const useEventsStore = defineStore('events', () => {
// nested in `media`) cannot be structured-cloned by IndexedDB.
function dbPut(event) {
const row = JSON.parse(JSON.stringify({ ...toRaw(event), userId: event.userId ?? authStore.currentUserId }))
db.events.put(row).catch(e => console.warn('Dexie put failed:', e))
db.events.put(row).catch(e => clientLog.warn('events', 'Dexie put failed', e))
}
function dbDelete(id) {
@ -668,7 +669,7 @@ export const useEventsStore = defineStore('events', () => {
await db.events.delete(id)
await db.eventMedia.where('eventId').equals(id).delete()
}
remove().catch(e => console.warn('Dexie delete failed:', e))
remove().catch(e => clientLog.warn('events', 'Dexie delete failed', e))
}
// Plain copies of media items. Blobs (local videos, recordings) are kept
@ -738,7 +739,7 @@ export const useEventsStore = defineStore('events', () => {
})))
}
persist().catch(e => console.warn('Dexie media persist failed:', e))
persist().catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
}
async function loadEventMedia(event) {
@ -767,7 +768,7 @@ export const useEventsStore = defineStore('events', () => {
applyMediaToDraft(restored)
})
} catch (e) {
console.warn('Dexie media load failed:', e)
clientLog.warn('events', 'Dexie media load failed', e)
}
}
@ -1113,7 +1114,7 @@ export const useEventsStore = defineStore('events', () => {
}
dbPut(events.value[current])
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[current].userId })
.catch(e => console.warn('Dexie media persist failed:', e))
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
// The title may have been set just now; the image path itself comes
// from the upload.
queueEventWrite('update', events.value[current])
@ -1158,7 +1159,7 @@ export const useEventsStore = defineStore('events', () => {
}
dbPut(events.value[idx])
db.eventMedia.bulkPut(items.map(item => ({ ...item, eventId: targetEventId, userId: events.value[idx].userId })))
.catch(e => console.warn('Dexie media persist failed:', e))
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
}
return items
@ -1210,7 +1211,7 @@ export const useEventsStore = defineStore('events', () => {
}
dbPut(events.value[idx])
db.eventMedia.put({ ...item, eventId: targetEventId, userId: events.value[idx].userId })
.catch(e => console.warn('Dexie media persist failed:', e))
.catch(e => clientLog.warn('events', 'Dexie media persist failed', e))
}
return item

View file

@ -1,12 +1,20 @@
import { defineStore } from 'pinia'
import { defineStore, getActivePinia } from 'pinia'
import { ref, watch } from 'vue'
import { apiJson } from 'src/services/apiClient'
import { isTransientError } from 'src/services/syncQueue'
import { clientLog } from 'src/services/clientLog'
import { WAVE_PRESETS, findWavePreset, isBuiltInPresetId } from 'src/config/wavePresets'
import { normalizeBackgroundImage } from 'src/config/backgrounds'
import { useAuthStore } from 'stores/auth'
const STORAGE_KEY_PREFIX = 'thatsme-settings'
const PERSIST_DELAY_MS = 250
// The server copy waits a little longer: scrolling the timeline changes the
// stored scroll position several times a second, and each change used to be
// its own PUT. Identical payloads are not sent twice.
const REMOTE_PERSIST_DELAY_MS = 1500
const REMOTE_RETRY_BASE_MS = 3000
const REMOTE_RETRY_MAX_MS = 60000
export const DEFAULT_EMOTION_GRADIENT_START = '#2d2e83'
export const DEFAULT_EMOTION_GRADIENT_END = '#3aaa35'
export const DEFAULT_TIMELINE_ZOOM = 1
@ -141,7 +149,16 @@ export const useSettingsStore = defineStore('settings', () => {
const stored = loadFromStorage(authStore.currentUserId)
const initialActivePreset = stored?.presets?.find(preset => preset.id === stored?.activePresetId)
const initialSettings = initialActivePreset?.settings ?? stored
// The Pinia this store belongs to. A remote send that fires after the
// store was replaced (tests create one per case) must not go out.
const ownPinia = getActivePinia()
let persistTimer = null
let remoteTimer = null
let remoteInFlight = false
let remoteDirty = false
let remoteAttempts = 0
let lastSentSettings = null
let lastLocalSettings = null
let isApplyingSettings = false
// Incremented on every user switch so a late settings response can be
// recognised as belonging to a session that has already ended.
@ -213,30 +230,107 @@ export const useSettingsStore = defineStore('settings', () => {
if (!authStore.currentUserId || isApplyingSettings) return
const storedSettings = createStoredSettings()
const serialized = JSON.stringify(storedSettings)
// An immediate save followed by the watcher's scheduled one: the second
// has nothing new and must not push the server write out again.
if (serialized === lastLocalSettings) return
lastLocalSettings = serialized
try {
localStorage.setItem(getStorageKey(authStore.currentUserId), JSON.stringify(storedSettings))
localStorage.setItem(getStorageKey(authStore.currentUserId), serialized)
} catch (error) {
// Private mode, full quota, or a torn-down environment: the remote
// copy below still happens, the local mirror is best effort.
console.warn('Settings could not be written to localStorage:', error)
clientLog.warn('settings', 'Einstellungen konnten nicht lokal gespeichert werden', error)
}
if (isRemoteUser()) {
const generation = settingsGeneration
if (isRemoteUser()) scheduleRemotePersist()
}
apiJson('/settings', {
function scheduleRemotePersist(delay = REMOTE_PERSIST_DELAY_MS) {
remoteDirty = true
if (remoteTimer) clearTimeout(remoteTimer)
remoteTimer = setTimeout(() => {
remoteTimer = null
sendRemoteSettings()
}, delay)
}
function resetRemotePersist() {
if (remoteTimer) clearTimeout(remoteTimer)
remoteTimer = null
remoteDirty = false
remoteAttempts = 0
lastSentSettings = null
lastLocalSettings = null
}
/**
* Pushes the current settings to the server. One request at a time; a
* change that arrives while one is in flight goes out afterwards. A
* network or server hiccup is retried quietly with growing pauses, only a
* rejection of the payload itself is shown to the user.
*/
async function sendRemoteSettings() {
if (remoteInFlight) return
if (getActivePinia() !== ownPinia) {
remoteDirty = false
return
}
if (!authStore.currentUserId || !isRemoteUser()) {
remoteDirty = false
return
}
const storedSettings = createStoredSettings()
const serialized = JSON.stringify(storedSettings)
if (serialized === lastSentSettings) {
remoteDirty = false
return
}
const generation = settingsGeneration
remoteInFlight = true
remoteDirty = false
try {
await apiJson('/settings', {
method: 'PUT',
body: { settings: storedSettings }
}).catch((error) => {
console.warn('Remote settings persist failed:', error)
if (generation !== settingsGeneration) return
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
})
if (generation !== settingsGeneration) return
lastSentSettings = serialized
remoteAttempts = 0
} catch (error) {
if (generation !== settingsGeneration) return
if (isTransientError(error)) {
remoteAttempts += 1
const backoff = Math.min(REMOTE_RETRY_BASE_MS * 2 ** (remoteAttempts - 1), REMOTE_RETRY_MAX_MS)
const delay = Math.max(backoff, error?.retryAfterMs ?? 0)
clientLog.warn('settings', 'Einstellungen noch nicht übertragen, neuer Versuch folgt', {
status: error?.status ?? 0,
attempt: remoteAttempts,
retryInMs: delay
})
scheduleRemotePersist(delay)
} else {
clientLog.error('settings', 'Server hat die Einstellungen abgelehnt', error)
lastError.value = 'Die Einstellungen konnten nicht gespeichert werden.'
}
} finally {
remoteInFlight = false
if (remoteDirty && !remoteTimer) scheduleRemotePersist()
}
}
if (typeof window !== 'undefined') {
window.addEventListener('online', () => {
if (remoteDirty) scheduleRemotePersist(0)
})
}
function schedulePersist() {
if (isApplyingSettings) return
if (persistTimer) clearTimeout(persistTimer)
@ -258,6 +352,7 @@ export const useSettingsStore = defineStore('settings', () => {
// next switch must not overwrite the settings of whoever is active then.
settingsGeneration += 1
const generation = settingsGeneration
resetRemotePersist()
isApplyingSettings = true
lastError.value = null
@ -275,7 +370,7 @@ export const useSettingsStore = defineStore('settings', () => {
localStorage.setItem(getStorageKey(userId), JSON.stringify(nextStored))
}
} catch (error) {
console.warn('Remote settings load failed:', error)
clientLog.warn('settings', 'Einstellungen konnten nicht vom Server geladen werden', error)
if (generation !== settingsGeneration) return
// The locally cached settings from `loadFromStorage` stay in
@ -343,7 +438,7 @@ export const useSettingsStore = defineStore('settings', () => {
try {
await apiJson('/settings/media/background', { method: 'DELETE' })
} catch (error) {
console.warn('Remote settings background delete failed:', error)
clientLog.warn('settings', 'Remote settings background delete failed', error)
}
}

View file

@ -1,3 +1,4 @@
import { clientLog } from 'src/services/clientLog'
/**
* Re-encode a recording as 16-bit PCM WAV.
*
@ -29,7 +30,7 @@ export async function blobToWav(blob, { sampleRate = WAV_SAMPLE_RATE } = {}) {
const mono = await downmix(decoded, sampleRate, OfflineCtor)
return new Blob([encodeWav(mono, sampleRate)], { type: 'audio/wav' })
} catch (error) {
console.warn('WAV conversion failed:', error)
clientLog.warn('voice', 'WAV conversion failed', error)
return null
} finally {
context?.close?.().catch(() => {})

View file

@ -0,0 +1,128 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { db } from 'src/db'
import {
clientLog,
logEntries,
normalizeContext,
resetClientLogForTests,
restoreClientLog,
setClientLogTransport,
shipClientLog
} from 'src/services/clientLog'
import { flush, waitFor } from './helpers'
beforeEach(async () => {
resetClientLogForTests()
await db.clientLogs.clear()
})
afterEach(() => {
resetClientLogForTests()
})
describe('client log — collecting', () => {
it('bumps a counter for the same message instead of adding a line', () => {
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
expect(logEntries.value).toHaveLength(2)
expect(logEntries.value[0]).toMatchObject({ level: 'warning', scope: 'image', count: 3, shipped: 0 })
expect(logEntries.value[1]).toMatchObject({ level: 'error', scope: 'api', count: 1, shipped: 0 })
})
it('keeps info entries local and flattens errors into scalar context', () => {
const entry = clientLog.info('sync', 'Warteschlange geleert')
expect(entry.shipped).toBe(1)
const error = Object.assign(new Error('Too Many Requests'), { status: 429 })
error.name = 'ApiError'
expect(normalizeContext(error)).toMatchObject({ error: 'ApiError: Too Many Requests', status: 429 })
expect(normalizeContext({ nested: { a: 1 }, ok: true, skipped: undefined, empty: '' }))
.toEqual({ nested: '{"a":1}', ok: true })
expect(normalizeContext(null)).toBeNull()
})
})
describe('client log — shipping', () => {
it('sends unshipped warnings and errors as one batch and marks them', async () => {
const send = vi.fn(async () => {})
clientLog.error('api', 'PUT /settings → 429', { status: 429 })
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.warn('image', 'Bild konnte nicht geladen werden (429)')
clientLog.info('sync', 'nur lokal')
setClientLogTransport({ appVersion: '0.0.1', enabled: () => true, send })
expect(await shipClientLog()).toBe(true)
expect(send).toHaveBeenCalledTimes(1)
const [batch] = send.mock.calls[0]
expect(batch.appVersion).toBe('0.0.1')
expect(batch.entries).toHaveLength(2)
expect(batch.entries[0]).toMatchObject({
level: 'error',
scope: 'api',
message: 'PUT /settings → 429',
context: { status: 429 }
})
expect(batch.entries[1].message).toBe('Bild konnte nicht geladen werden (429) (×2)')
expect(batch.entries[0].occurredAt).toMatch(/^\d{4}-\d{2}-\d{2}T/)
expect(logEntries.value.every(entry => entry.shipped === 1)).toBe(true)
// Nothing left: the next call sends nothing.
expect(await shipClientLog()).toBe(false)
expect(send).toHaveBeenCalledTimes(1)
})
it('keeps everything in the browser when the transport says no (demo user)', async () => {
const send = vi.fn(async () => {})
clientLog.error('window', 'crash')
setClientLogTransport({ enabled: () => false, send })
expect(await shipClientLog()).toBe(false)
expect(send).not.toHaveBeenCalled()
expect(logEntries.value[0].shipped).toBe(0)
})
it('stops after the server rejects the batch, but retries after a hiccup', async () => {
const rejected = vi.fn(async () => {
throw Object.assign(new Error('Unprocessable'), { status: 422 })
})
clientLog.error('window', 'crash')
setClientLogTransport({ enabled: () => true, send: rejected })
expect(await shipClientLog()).toBe(false)
expect(await shipClientLog()).toBe(false)
expect(rejected).toHaveBeenCalledTimes(1)
resetClientLogForTests()
const flaky = vi.fn(async () => {
throw Object.assign(new Error('Server error'), { status: 500 })
})
clientLog.error('window', 'crash again')
setClientLogTransport({ enabled: () => true, send: flaky })
expect(await shipClientLog()).toBe(false)
expect(await shipClientLog()).toBe(false)
expect(flaky).toHaveBeenCalledTimes(2)
expect(logEntries.value[0].shipped).toBe(0)
})
it('restores unshipped entries from IndexedDB after a reload', async () => {
clientLog.error('window', 'Absturz vor dem Versand')
await waitFor(() => false, { rounds: 3 }).catch(() => {})
await flush(2)
expect(await db.clientLogs.count()).toBe(1)
// A reload: memory is gone, the table is not.
resetClientLogForTests()
expect(logEntries.value).toHaveLength(0)
await restoreClientLog()
expect(logEntries.value).toHaveLength(1)
expect(logEntries.value[0]).toMatchObject({ message: 'Absturz vor dem Versand', shipped: 0 })
})
})

View file

@ -0,0 +1,174 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { db } from 'src/db'
import { deferred } from './helpers'
let apiFetchMock = vi.fn()
vi.mock('src/services/apiClient', async () => {
const actual = await vi.importActual('src/services/apiClient')
return {
...actual,
API_BASE: 'http://api.test',
apiFetch: (...args) => apiFetchMock(...args)
}
})
const { resolveFullRes, useImageCache, setImageCacheScope, resetImageCacheForTests } = await import('composables/useImageCache')
const { logEntries, resetClientLogForTests } = await import('src/services/clientLog')
function imageResponse() {
return new Response(new Blob(['jpeg'], { type: 'image/jpeg' }), { status: 200 })
}
function statusResponse(status, headers = {}) {
return new Response(null, { status, headers })
}
let objectUrls = 0
beforeEach(async () => {
apiFetchMock = vi.fn()
objectUrls = 0
vi.stubGlobal('URL', Object.assign(URL, {
createObjectURL: () => `blob:mock-${++objectUrls}`,
revokeObjectURL: () => {}
}))
resetClientLogForTests()
resetImageCacheForTests()
setImageCacheScope('image-user')
await db.scopedImageCache.clear()
// Timers and the clock the gate compares against; fake-indexeddb keeps
// the real event loop (setImmediate).
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] })
})
/**
* Lets the promise chains behind the gate and the IndexedDB look-ups run to
* the end. setImmediate is real, so each round drains the microtasks that
* came before it.
*/
async function settle(rounds = 6) {
for (let i = 0; i < rounds; i++) {
await new Promise(resolve => setImmediate(resolve))
}
}
/** Advances the fake clock, then lets everything it woke up finish. */
async function advance(ms) {
await vi.advanceTimersByTimeAsync(ms)
await settle()
}
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
setImageCacheScope(null)
})
describe('image cache — protected images', () => {
it('waits out a 429 and delivers the image on the next attempt', async () => {
apiFetchMock
.mockResolvedValueOnce(statusResponse(429, { 'Retry-After': '2' }))
.mockResolvedValueOnce(imageResponse())
const pending = resolveFullRes('/event-media/7/preview')
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(1)
// Retry-After says two seconds; nothing happens before that.
await advance(1500)
expect(apiFetchMock).toHaveBeenCalledTimes(1)
await advance(600)
expect(await pending).toBe('blob:mock-1')
expect(apiFetchMock).toHaveBeenCalledTimes(2)
expect(logEntries.value).toHaveLength(0)
})
it('gives up after three server errors with null, not with a path the img cannot load', async () => {
apiFetchMock.mockResolvedValue(statusResponse(500))
const pending = resolveFullRes('/event-media/7/preview')
await settle()
await advance(1500)
await advance(3000)
expect(await pending).toBeNull()
expect(apiFetchMock).toHaveBeenCalledTimes(3)
expect(logEntries.value).toHaveLength(1)
expect(logEntries.value[0]).toMatchObject({
level: 'warning',
scope: 'image',
context: { status: 500, url: '/event-media/7/preview' }
})
})
it('does not retry a 404', async () => {
apiFetchMock.mockResolvedValue(statusResponse(404))
const pending = resolveFullRes('/event-media/9/original')
await settle()
expect(await pending).toBeNull()
expect(apiFetchMock).toHaveBeenCalledTimes(1)
})
it('lets at most six image requests run at once', async () => {
const gates = []
let inFlight = 0
let peak = 0
apiFetchMock.mockImplementation(() => {
inFlight += 1
peak = Math.max(peak, inFlight)
const gate = deferred()
gates.push(gate)
return gate.promise.then(() => {
inFlight -= 1
return imageResponse()
})
})
const all = Promise.all(Array.from({ length: 10 }, (_, i) => resolveFullRes(`/event-media/${i}/thumb`)))
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(6)
for (const gate of gates.splice(0)) gate.resolve()
await settle()
expect(apiFetchMock).toHaveBeenCalledTimes(10)
for (const gate of gates.splice(0)) gate.resolve()
const results = await all
expect(results.every(src => src?.startsWith('blob:mock-'))).toBe(true)
expect(peak).toBe(6)
})
})
describe('image cache — composable', () => {
it('reports a failure instead of showing a broken image', async () => {
apiFetchMock.mockResolvedValue(statusResponse(404))
const { resolvedSrc, loading, failed } = useImageCache('/event-media/3/thumb', 'e3')
await nextTick()
expect(loading.value).toBe(true)
await settle(10)
expect(resolvedSrc.value).toBeNull()
expect(failed.value).toBe(true)
expect(loading.value).toBe(false)
})
it('falls back to the direct URL for public images', async () => {
vi.stubGlobal('fetch', vi.fn(async () => statusResponse(500)))
const { resolvedSrc, failed } = useImageCache('https://cdn.test/photo.jpg', 'e4')
await settle(10)
// A 500 is retried twice before the cache gives up on it.
await advance(1500)
await advance(3000)
expect(resolvedSrc.value).toBe('https://cdn.test/photo.jpg')
expect(failed.value).toBe(false)
})
})

View file

@ -0,0 +1,133 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { createApiRouter } from './helpers'
let api = createApiRouter()
vi.mock('src/services/apiClient', () => ({
API_BASE: 'http://api.test',
apiJson: (...args) => api.apiJson(...args),
apiFetch: vi.fn(),
retryAfterMs: () => null,
getToken: vi.fn(async () => 'token'),
setToken: vi.fn(async () => {}),
clearToken: vi.fn(async () => {}),
onUnauthorized: vi.fn(() => () => {}),
revokeToken: vi.fn(async () => {}),
loginRemote: vi.fn(),
ApiError: class ApiError extends Error {}
}))
const { useAuthStore } = await import('stores/auth')
const { useSettingsStore } = await import('stores/settings')
const { resetClientLogForTests, logEntries } = await import('src/services/clientLog')
function apiError(status, extra = {}) {
return Object.assign(new Error(`HTTP ${status}`), { status, ...extra })
}
describe('settings store — the server copy is written sparingly', () => {
let settingsStore
let puts
beforeEach(async () => {
setActivePinia(createPinia())
api = createApiRouter()
resetClientLogForTests()
puts = []
// Only the timers: IndexedDB needs the real event loop.
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
api.always('/settings', (url, options) => {
if (options.method !== 'PUT') return Promise.resolve({ data: null })
puts.push(options.body.settings)
return Promise.resolve({ data: options.body.settings })
})
const authStore = useAuthStore()
settingsStore = useSettingsStore()
authStore.currentUserId = 'remote-user-1'
// The GET at start-up and the watchers behind it.
await vi.advanceTimersByTimeAsync(2000)
puts.length = 0
})
afterEach(() => {
vi.useRealTimers()
})
it('bundles a burst of scroll positions into one PUT and skips identical payloads', async () => {
for (let i = 1; i <= 20; i++) {
settingsStore.saveTimelineScrollLeft(i * 10, true)
await vi.advanceTimersByTimeAsync(100)
}
// Two seconds of scrolling, not a single request yet.
expect(puts).toHaveLength(0)
await vi.advanceTimersByTimeAsync(1500)
expect(puts).toHaveLength(1)
expect(puts[0].timelineScrollLeft).toBe(200)
// The same value again changes nothing on the server.
settingsStore.saveTimelineScrollLeft(200, true)
await vi.advanceTimersByTimeAsync(3000)
expect(puts).toHaveLength(1)
settingsStore.saveTimelineScrollLeft(260, true)
await vi.advanceTimersByTimeAsync(1500)
expect(puts).toHaveLength(2)
expect(puts[1].timelineScrollLeft).toBe(260)
})
it('retries a 429 quietly instead of telling the user the settings were lost', async () => {
let attempts = 0
api = createApiRouter()
api.always('/settings', (url, options) => {
if (options.method !== 'PUT') return Promise.resolve({ data: null })
attempts += 1
if (attempts === 1) return Promise.reject(apiError(429, { retryAfterMs: 2000 }))
return Promise.resolve({ data: options.body.settings })
})
settingsStore.saveTimelineScrollLeft(50, true)
await vi.advanceTimersByTimeAsync(1500)
expect(attempts).toBe(1)
expect(settingsStore.lastError).toBeNull()
expect(logEntries.value.at(-1)).toMatchObject({ level: 'warning', scope: 'settings', context: { status: 429, attempt: 1 } })
// Backoff (3 s) outlasts the Retry-After here; then it goes out again.
await vi.advanceTimersByTimeAsync(2500)
expect(attempts).toBe(1)
await vi.advanceTimersByTimeAsync(600)
expect(attempts).toBe(2)
expect(settingsStore.lastError).toBeNull()
// A change made while the retry was pending is included, not lost.
settingsStore.saveTimelineScrollLeft(80, true)
await vi.advanceTimersByTimeAsync(1500)
expect(attempts).toBe(3)
})
it('reports a payload the server rejects', async () => {
api = createApiRouter()
api.always('/settings', (url, options) => (
options.method === 'PUT'
? Promise.reject(apiError(422))
: Promise.resolve({ data: null })
))
settingsStore.saveTimelineScrollLeft(50, true)
await vi.advanceTimersByTimeAsync(1500)
expect(settingsStore.lastError).toBe('Die Einstellungen konnten nicht gespeichert werden.')
expect(logEntries.value.at(-1)).toMatchObject({ level: 'error', scope: 'settings' })
})
it('sends nothing more once another store has taken over', async () => {
settingsStore.saveTimelineScrollLeft(50, true)
// A new Pinia, as the next test would create it.
setActivePinia(createPinia())
await vi.advanceTimersByTimeAsync(3000)
expect(puts).toHaveLength(0)
})
})