Offline - Alle Schreibzugriffe von API-Nutzern laufen über eine Warteschlange in IndexedDB (services/syncQueue.js): FIFO, zusammengefasste Updates, Wiederholung bei Netz/Sichtbarkeit/Backoff, 4xx wird als Fehler gemeldet. - Medien-Uploads liegen mit Bytes in der Queue, erscheinen sofort mit Marker und werden nach dem Upload durch das Server-Objekt ersetzt. - PWA-Modus mit Service Worker für die App-Hülle; API nie gecacht. npm run build erzeugt dist/pwa. - Speicherzustand im Panel: ausstehend / Fehler mit Wiederholen-Knopf, Verbindungsanzeige im Header. Event - Event entsteht erst mit dem ersten Eintrag; jede Löschung fragt nach. - Key Image als 4:3-Rahmen, Galerie mit Video, mehrere Sprachnotizen mit Notiz, Live-Pegel beim Aufnehmen, WAV-Umwandlung für lokale Aufnahmen. - Neue Felder: Zeitraum, Kategorie, Personen, Meilenstein; Stimmung als kompakte Zeile mit ausklappbarer Punktfarbe. Oberfläche - Untere Leiste: Darstellung und Zoom links, Jahres-Pille mittig, Plus rechts. - Logos und Goldpalette, Login neu, zehn Wellen-Presets mit Vorschau, Hintergrundbilder als Kacheln, Kontomenü und Einstellungen bereinigt, Escape schließt Ebenen, größere Touch-Ziele. - Alten Sync-Service und ungenutzte Legacy-Seiten entfernt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
139 lines
3.8 KiB
JavaScript
139 lines
3.8 KiB
JavaScript
import { nextTick } from 'vue'
|
|
|
|
/**
|
|
* A promise whose settling the test controls, used to hold an API response
|
|
* open across a user switch.
|
|
*/
|
|
export function deferred() {
|
|
let resolve
|
|
let reject
|
|
const promise = new Promise((res, rej) => {
|
|
resolve = res
|
|
reject = rej
|
|
})
|
|
return { promise, resolve, reject }
|
|
}
|
|
|
|
/**
|
|
* Lets Vue's watchers, the promise chains behind them and the IndexedDB
|
|
* writes settle. IndexedDB resolves on real event-loop turns, so flushing
|
|
* microtasks alone is not enough.
|
|
*/
|
|
export async function flush(times = 6) {
|
|
for (let i = 0; i < times; i++) {
|
|
await nextTick()
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits until `predicate` holds, flushing between checks. The sync queue
|
|
* moves through several IndexedDB turns per item, and how many a run needs
|
|
* depends on machine load; polling keeps the tests from guessing.
|
|
*/
|
|
export async function waitFor(predicate, { rounds = 60, label = 'condition' } = {}) {
|
|
for (let i = 0; i < rounds; i++) {
|
|
if (predicate()) return
|
|
await flush(1)
|
|
}
|
|
if (!predicate()) throw new Error(`waitFor: ${label} not met in time`)
|
|
}
|
|
|
|
/**
|
|
* Lets a store's sync queue finish what it is sending. Called after a test:
|
|
* a request still in flight when the test ends would land in the next
|
|
* test's API mock. The first flushes give a write that was just scheduled
|
|
* (closing the panel persists the draft) time to reach the queue at all.
|
|
*/
|
|
export async function drainQueue(store) {
|
|
await flush(3)
|
|
await waitFor(
|
|
() => store.pendingSyncCount === 0 && !store.isSyncing,
|
|
{ rounds: 100, label: 'queue drained' }
|
|
).catch(() => {})
|
|
}
|
|
|
|
/**
|
|
* Routes mocked `apiJson` calls by URL.
|
|
*
|
|
* Several stores talk to the API during start-up — the settings store fires
|
|
* `/settings` alongside the events store's `/events` — so responses have to
|
|
* be matched on the request, not on call order.
|
|
*/
|
|
export function createApiRouter() {
|
|
const handlers = []
|
|
const calls = []
|
|
|
|
function apiJson(url, options = {}) {
|
|
calls.push({ url, options })
|
|
|
|
const handler = handlers.find(entry => entry.matches(url, options))
|
|
if (!handler) {
|
|
return Promise.resolve({ data: [], links: { next: null } })
|
|
}
|
|
|
|
return handler.respond(url, options)
|
|
}
|
|
|
|
return {
|
|
apiJson,
|
|
calls,
|
|
/** Queue a response for the next matching request. */
|
|
once(matcher, respond) {
|
|
const entry = {
|
|
matches: (url, options) => toMatcher(matcher)(url, options) && !entry.used,
|
|
respond: (url, options) => {
|
|
entry.used = true
|
|
return typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond)
|
|
}
|
|
}
|
|
handlers.push(entry)
|
|
return entry
|
|
},
|
|
/** Queue a response for every matching request. */
|
|
always(matcher, respond) {
|
|
handlers.push({
|
|
matches: toMatcher(matcher),
|
|
respond: (url, options) => (typeof respond === 'function' ? respond(url, options) : Promise.resolve(respond))
|
|
})
|
|
},
|
|
callsTo(matcher) {
|
|
const match = toMatcher(matcher)
|
|
return calls.filter(call => match(call.url, call.options))
|
|
}
|
|
}
|
|
}
|
|
|
|
function toMatcher(matcher) {
|
|
if (typeof matcher === 'function') return matcher
|
|
if (matcher instanceof RegExp) return url => matcher.test(url)
|
|
return url => url === matcher || url.startsWith(matcher)
|
|
}
|
|
|
|
/** A remote event payload in the shape the API returns. */
|
|
export function remoteEvent(overrides = {}) {
|
|
return {
|
|
id: 'e1',
|
|
title: 'Event',
|
|
date: '2025-01-01',
|
|
endDate: null,
|
|
location: '',
|
|
category: null,
|
|
people: [],
|
|
emotion: 0,
|
|
milestone: false,
|
|
customColor: null,
|
|
gradientPreset: null,
|
|
image: null,
|
|
keyImageTitle: '',
|
|
media: [],
|
|
note: '',
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
...overrides
|
|
}
|
|
}
|
|
|
|
export function page(events) {
|
|
return { data: events, links: { next: null } }
|
|
}
|