From 61dd2f3a5d4f9e863d632979fa9f91270869acf0 Mon Sep 17 00:00:00 2001 From: seb Date: Wed, 14 Oct 2026 13:45:17 +0200 Subject: [PATCH] u --- .agents/skills/backend/SKILL.md | 38 +- src/api/quote.js | 18 +- src/automats/quoteAutomat.js | 1088 ++++++++++++++++++++++++++++++- src/automats/sseAutomat.js | 7 + 4 files changed, 1108 insertions(+), 43 deletions(-) diff --git a/.agents/skills/backend/SKILL.md b/.agents/skills/backend/SKILL.md index 237072a..204df12 100644 --- a/.agents/skills/backend/SKILL.md +++ b/.agents/skills/backend/SKILL.md @@ -21,16 +21,16 @@ Params: path values first, then `query:` and `body:` (`body?` = optional body). - `T3` = `{ article: T4 }` - `T4` = `{ id: int, sku: str, name: str, slug: str, desc: str|null, seo: str|null, track_stock: bool, unitamount: num|null, weight: num|null, unit: T39, brand: T37|null, categoryIds: int[], photos: T34[], eans: str[], prices: T46[], suppliers: T16[] }` - `T5` = `{ delivery: T6 }` -- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T13[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }` +- `T6` = `{ id: int, number: str, type: str, supplierId: int|null, date: str, lines: T28[], files: T14[], externalNumber?: str|null, createdAt?: str, ledger?: T50 }` - `T7` = `{ id: int, number: str, type: str, date: str, lines: T28[], shippings: T36[], entityId?: int|null, externalNumber?: str|null, createdAt?: str|null, lineCount?: int, packageCount?: int }` - `T8` = `{ name: str, path: str, totalSizeBytes: int, timestamp: str|null, manifest?: T9|null }` - `T9` = `{ id: str, timestamp: str, dateKey: str, monthKey: str, dbProvider: str, files: T17, totalSizeBytes: int, durationMs: int }` -- `T10` = `{ labeltemplate: T11 }` -- `T11` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }` -- `T12` = `{ quote: T15 }` -- `T13` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }` -- `T14` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }` -- `T15` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str }` +- `T10` = `{ quote: T11 }` +- `T11` = `{ id: int, number: str, type: str, entityId: int|null, date: str, lines: T28[], createdAt?: str, positions?: int, grossTotal?: num, totalQuantity?: num, customerName?: str|null }` +- `T12` = `{ labeltemplate: T13 }` +- `T13` = `{ id: int, name: str, width: num, height: num, dpi: int, type: str, content: T24, thumb?: str|null }` +- `T14` = `{ id: int, manifestId: int, fileId: int, sort: int, hash: str, mimetype: str, name: str, size: int, created?: str, modified?: str, thumbavif?: str|null, thumbx?: int|null, thumby?: int|null, meta?: { [k]: str }|null }` +- `T15` = `{ id: int, name: str, pairingpin?: str|null, token?: str|null, pairedAt?: str|null, clientserial?: str|null, rootCategoryId?: int|null, syncRootArticles?: bool, lastseenAt?: str|null, updatedAt?: str|null, categoryIds?: int[] }` - `T16` = `{ id: int, articleId: int, entityId: int, pu: num, sort: int, supplierName?: str|null, sku?: str|null, stock?: num|null, priceId?: int|null, price?: T53|null }` - `T17` = `{ vfs: T51, storage: T51, database: T44 }` - `T18` = `{ id: int, entityId: int, addressType: str, firstName?: str|null, name?: str|null, street?: str|null, streetNumber?: str|null, zip?: str|null, city?: str|null, county?: str|null }` @@ -511,19 +511,19 @@ _none_ - Returns: `200 { labeltemplates: { id: int, name: str, width: num, height: num, dpi: int, type: str, thumb?: str|null }[] }` #### Create label template — `POST /labeltemplate` - Params: `body: { name: str, width: num, height: num, dpi: int, type: str, content: T25 }` -- Returns: `201 T10` +- Returns: `201 T12` #### Delete label template — `DELETE /labeltemplate/{id}` - Params: `id: int` - Returns: `204 void` #### Get label template by ID — `GET /labeltemplate/{id}` - Params: `id: int` -- Returns: `200 T10` +- Returns: `200 T12` #### Update label template (partial) — `PATCH /labeltemplate/{id}` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }` -- Returns: `200 T10` +- Returns: `200 T12` #### Update label template — `PUT /labeltemplate/{id}` - Params: `id: int, body: { name?: str, width?: num, height?: num, dpi?: int, type?: str, content?: T25 }` -- Returns: `200 T10` +- Returns: `200 T12` ### Section: /ledger @@ -664,16 +664,16 @@ _none_ #### List POS terminals — `GET /posterminal` _none_ -- Returns: `200 { posterminals: T14[] }` +- Returns: `200 { posterminals: T15[] }` #### Create POS terminal — `PUT /posterminal` - Params: `body: { name: str, rootCategoryId?: int|null, syncRootArticles?: bool }` -- Returns: `200 { posterminal: T14 }` +- Returns: `200 { posterminal: T15 }` #### Delete POS terminal — `DELETE /posterminal/{id}` - Params: `id: int` - Returns: `204 void` #### Update POS terminal — `PATCH /posterminal/{id}` - Params: `id: int, body: { name?: str, rootCategoryId?: int|null, syncRootArticles?: bool, pairingpin?: str|null }` -- Returns: `200 { posterminal: T14 }` +- Returns: `200 { posterminal: T15 }` #### Disable all categories on terminal — `DELETE /posterminal/{id}/all` - Params: `id: int` - Returns: `200 T55` @@ -747,20 +747,20 @@ _none_ ### Section: /quote #### List quotes — `GET /quote` -_none_ -- Returns: `200 { quotes: { id: int, number: str, type: str, entityId: int|null, date: str, createdAt?: str }[] }` +- Params: `query: offset?: int, limit?: int, search?: str, seekMonths?: int` +- Returns: `200 { quotes: T11[], total: int, unfilteredTotal: int, offset: int, limit: int }` #### Create quote — `POST /quote` - Params: `body: { number: str, date: str, lines: T33[], entityId?: int|null }` -- Returns: `201 T12` +- Returns: `201 T10` #### Delete quote — `DELETE /quote/{id}` - Params: `id: int` - Returns: `204 void` #### Get quote — `GET /quote/{id}` - Params: `id: int` -- Returns: `200 T12` +- Returns: `200 T10` #### Update quote — `PATCH /quote/{id}` - Params: `id: int, body: { number?: str, date?: str, entityId?: int|null, lines?: T33[] }` -- Returns: `200 T12` +- Returns: `200 T10` ### Section: /serper diff --git a/src/api/quote.js b/src/api/quote.js index e8eb5a7..50ff05e 100644 --- a/src/api/quote.js +++ b/src/api/quote.js @@ -7,10 +7,22 @@ import { http } from './http.js'; * LineInput = { sku, amount, net, tax, rebate?, comment? } */ export const quote = { - /** GET /api/quote → { quotes: QuoteSummary[] } */ + /** + * GET /api/quote?offset=0&limit=50&search=...&seekMonths=-1 + * @param {{ offset?: number, limit?: number, search?: string, seekMonths?: number }} [params] + * → { quotes: QuoteSummary[], total: number, unfilteredTotal: number, offset: number, limit: number } + */ // #region GET /api/quote — list - list() { - return http('/api/quote'); + list({ offset = 0, limit = 50, search, seekMonths } = {}) { + const params = new URLSearchParams(); + if (offset != null) params.set('offset', String(Math.max(0, Math.floor(Number(offset) || 0)))); + if (limit != null) params.set('limit', String(Math.max(1, Math.floor(Number(limit) || 50)))); + if (search && String(search).trim()) params.set('search', String(search).trim()); + if (seekMonths != null && Number(seekMonths) < 0) { + params.set('seekMonths', String(Math.floor(Number(seekMonths)))); + } + const qs = params.toString(); + return http(`/api/quote${qs ? `?${qs}` : ''}`); }, // #endregion diff --git a/src/automats/quoteAutomat.js b/src/automats/quoteAutomat.js index 21b30eb..9bff67b 100644 --- a/src/automats/quoteAutomat.js +++ b/src/automats/quoteAutomat.js @@ -1,39 +1,1085 @@ /** * @file quoteAutomat.js - * @description Manages cached sales quotes (Angebote) (`GET /api/quote`) - * for the active company. Built via `createListAutomat`. + * @description Manages cached and chunk-paginated company sales quotes (Angebote) (`GET /api/quote`). + * + * Implements a multi-filter chunk cache: + * - Each filter combination has its own isolated cache entry (total, items array, loadedChunks). + * - Total chunk capacity across all filter combinations is bounded by a global LRU limit of 50 chunks. + * - When filter settings change, previously loaded chunks remain cached in memory up to the 50-chunk limit. + * - Switching back to a previously used filter is instant with zero network requests if its chunks are in RAM. + * - Real-time stats and events can be observed via `subscribeChunkStats` or the dev ChunkCacheMonitor. * * Upstream Triggers: - * - `authAutomat`: Via `createListAutomat` — triggers `load()` on `'authed'` with - * a `selectedCompanyId`, and `reset()` on `'anon'`. - * - UI / Routes: Manual calls to `load()` or `reload()`. + * - `authAutomat`: Triggers `actions.reset()` on `'anon'` or when `selectedCompanyId` changes. + * - UI / Routes: Calls `getItems()`, `requireChunk(chunkIndex)`, `loadChunk(chunkIndex)`, or `reload()`. */ +import { Automat } from 'react-automat'; import { quote as quoteApi } from '../api'; -import { createListAutomat } from './createListAutomat'; +import authAutomat from './authAutomat'; /** - * @typedef {Object} QuoteSummary - * @property {string|number} id Quote id. + * @typedef {Object} QuoteLine + * @property {number} id + * @property {string|null} sku + * @property {string|null} name + * @property {number} pu + * @property {number} amount + * @property {number} net + * @property {number} rebate + * @property {number} taxrate + * @property {string|null} comment + */ + +/** + * @typedef {Object} Quote + * @property {number} id Quote id. * @property {string} number Quote number. - * @property {string} type Always `'quote'`. - * @property {string|number|null} entityId Customer id. - * @property {string} date Expiry date (ISO). + * @property {'quote'} type Always `'quote'`. + * @property {number|null} entityId Customer id. + * @property {string|null} customerName Customer first and last name. + * @property {string|null} externalNumber External reference. + * @property {string} date Expiry/offer date (ISO). + * @property {string} createdAt Creation timestamp (ISO). + * @property {number} [positions] Pre-computed line item count. + * @property {number} [grossTotal] Pre-computed gross sum. + * @property {number} [totalQuantity] Pre-computed sum of line amounts. + * @property {QuoteLine[]} [lines] Quote line items. */ /** - * Company sales quotes (`GET /api/quote`). - * - * @type {import('react-automat').Automat} + * @typedef {Object} QuoteAutomatState + * @property {Quote[]|null} items Pre-filled array of quotes/nulls, initially null. + * @property {number} total Total unpaged count for pagination calculation. + * @property {number} unfilteredTotal Unfiltered total count. + * @property {string} filter Search term. + * @property {number} seekMonths Seek month count. + * @property {number} seekYears Seek year count. + * @property {number} negativeSeekMonths Negative seek months offset. + * @property {'idle'|'loading'|'ready'|'failed'} status Load lifecycle state. + * @property {Error|string|null} error Last load error. */ -export const quoteAutomat = createListAutomat({ - name: 'quote', - fetch: async () => { - const data = await quoteApi.list(); - return data.quotes ?? []; + +export const CHUNK_SIZE = 50; +export const MAX_LRU_CHUNKS = 50; +const PREFETCH_CHUNKS = 1; +const CHUNK_DEBOUNCE_MS = 130; + +const initialState = { + items: null, + total: 0, + unfilteredTotal: 0, + filter: '', + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + status: 'idle', + error: null, +}; + +let automat; +let queryEpoch = 0; +let filterDebounceTimer = null; +let chunkDebounceTimer = null; +let currentEventTick = 0; +let lastRequireChunkTick = -1; + +const pendingTargetChunks = new Set(); +let pendingChunkPromise = null; +let pendingChunkResolve = null; +let pendingChunkReject = null; + +// Multi-Filter Cache & Global LRU Management +const filterCaches = new Map(); +const lruChunks = new Map(); +const inflightChunks = new Map(); + +// Dev / Observability: Chunk event history and subscriber registry +const MAX_CHUNK_EVENTS = 150; +let nextEventId = 0; +const chunkEvents = []; +const chunkStatsListeners = new Set(); + +export function makeLruKey(filterKey, chunkIndex) { + return `${filterKey}:::${chunkIndex}`; +} + +export function parseLruKey(key) { + const [filterKey, chunkStr] = key.split(':::'); + return { filterKey, chunkIndex: Number(chunkStr) }; +} + +// MARK: pure filter helpers +export function getFilterParams(state = automat?.state || initialState) { + const filter = String(state.filter ?? '').trim(); + const negativeSeekMonths = (state.negativeSeekMonths != null && Number(state.negativeSeekMonths) < 0) + ? Number(state.negativeSeekMonths) + : 0; + return { filter, negativeSeekMonths }; +} + +export function getFilterKey(state = automat?.state || initialState) { + const { filter, negativeSeekMonths } = getFilterParams(state); + return JSON.stringify({ f: filter, s: negativeSeekMonths }); +} + +export function formatFilterLabel(filterKeyOrState) { + let f, s; + if (typeof filterKeyOrState === 'string') { + try { + const parsed = JSON.parse(filterKeyOrState); + f = parsed.f; + s = parsed.s; + } catch { + return filterKeyOrState; + } + } else if (filterKeyOrState && typeof filterKeyOrState === 'object') { + if ('f' in filterKeyOrState) { + f = filterKeyOrState.f; + s = filterKeyOrState.s; + } else { + const params = getFilterParams(filterKeyOrState); + f = params.filter; + s = params.negativeSeekMonths; + } + } + + const parts = []; + if (f) parts.push(`Suche: „${f}“`); + if (s < 0) parts.push(`Seek: ${formatNegativeSeek(s)}`); + if (parts.length === 0) return 'Standard (Alle Angebote)'; + return parts.join(' · '); +} + +function countNonNullItems(items) { + if (!Array.isArray(items)) return 0; + let count = 0; + for (let i = 0; i < items.length; i++) { + if (items[i] != null) count++; + } + return count; +} + +function getOrCreateFilterCache(filterKey, filterState = automat?.state || initialState) { + let entry = filterCaches.get(filterKey); + if (!entry) { + const params = getFilterParams(filterState); + entry = { + key: filterKey, + label: formatFilterLabel(params), + params, + total: 0, + unfilteredTotal: automat?.state?.unfilteredTotal || 0, + items: null, + loadedChunks: new Set(), + status: 'idle', + error: null, + lastAccessed: Date.now(), + epoch: queryEpoch, + }; + filterCaches.set(filterKey, entry); + } + return entry; +} + +function touchChunkLRU(filterKey, chunkIndex) { + const key = makeLruKey(filterKey, chunkIndex); + const existing = lruChunks.get(key); + if (existing) { + lruChunks.delete(key); + lruChunks.set(key, { ...existing, accessedAt: Date.now() }); + } +} + +function registerChunkLRU(filterKey, chunkIndex, quoteCount = 0) { + const key = makeLruKey(filterKey, chunkIndex); + if (lruChunks.has(key)) { + lruChunks.delete(key); + } + const now = Date.now(); + lruChunks.set(key, { + filterKey, + chunkIndex, + loadedAt: now, + accessedAt: now, + quoteCount, + }); + + while (lruChunks.size > MAX_LRU_CHUNKS) { + const oldestKey = lruChunks.keys().next().value; + const oldestInfo = lruChunks.get(oldestKey); + lruChunks.delete(oldestKey); + if (oldestInfo) { + evictChunkFromMemory(oldestInfo.filterKey, oldestInfo.chunkIndex, 'lru'); + } + } +} + +function evictChunkFromMemory(filterKey, chunkIndex, reason = 'manual') { + const entry = filterCaches.get(filterKey); + if (!entry) return; + + if (entry.loadedChunks.has(chunkIndex)) { + entry.loadedChunks.delete(chunkIndex); + if (Array.isArray(entry.items)) { + const offset = chunkIndex * CHUNK_SIZE; + for (let i = offset; i < offset + CHUNK_SIZE && i < entry.items.length; i++) { + entry.items[i] = null; + } + } + + const currentKey = getFilterKey(automat.state); + if (filterKey === currentKey) { + automat.setState({ items: [...entry.items] }); + } + + const eventType = reason === 'lru' ? 'lru_evict' : 'evict'; + const msg = reason === 'lru' + ? `LRU-Eviction: Chunk #${chunkIndex} von Filter "${entry.label}" verworfen (${lruChunks.size}/${MAX_LRU_CHUNKS} im Cache)` + : `Chunk #${chunkIndex} von Filter "${entry.label}" verworfen`; + + recordChunkEvent({ + type: eventType, + chunkIndex, + filterKey, + message: msg, + }); + notifyChunkStats(); + } +} + +function recordChunkEvent(entry) { + const event = { + id: ++nextEventId, + time: Date.now(), + epoch: queryEpoch, + filterKey: entry.filterKey ?? getFilterKey(automat?.state), + ...entry, + }; + chunkEvents.push(event); + if (chunkEvents.length > MAX_CHUNK_EVENTS) { + chunkEvents.shift(); + } + notifyChunkStats(); +} + +function notifyChunkStats() { + if (chunkStatsListeners.size === 0) return; + const stats = getChunkStats(); + for (const listener of chunkStatsListeners) { + try { + listener(stats); + } catch (e) { + console.error('[quoteAutomat] chunkStatsListener error', e); + } + } +} + +export function getChunkStats() { + const currentKey = getFilterKey(automat?.state); + const activeEntry = filterCaches.get(currentKey); + + const total = activeEntry?.total ?? automat?.state?.total ?? 0; + const activeLoaded = activeEntry?.loadedChunks ?? new Set(); + const chunkCount = total > 0 ? Math.ceil(total / CHUNK_SIZE) : 0; + + const activeInflight = []; + for (const key of inflightChunks.keys()) { + const info = parseLruKey(key); + if (info.filterKey === currentKey) { + activeInflight.push(info.chunkIndex); + } + } + + const cachedFiltersSummary = Array.from(filterCaches.values()).map((fc) => ({ + key: fc.key, + label: fc.label, + params: fc.params, + total: fc.total, + loadedChunksCount: fc.loadedChunks.size, + loadedChunks: Array.from(fc.loadedChunks).sort((a, b) => a - b), + chunkCount: fc.total > 0 ? Math.ceil(fc.total / CHUNK_SIZE) : 0, + loadedItemsCount: countNonNullItems(fc.items), + isActive: fc.key === currentKey, + lastAccessed: fc.lastAccessed, + items: fc.items, + status: fc.status, + })); + + const lruEntries = []; + let rank = 1; + for (const [key, val] of lruChunks.entries()) { + const fc = filterCaches.get(val.filterKey); + lruEntries.push({ + key, + filterKey: val.filterKey, + filterLabel: fc?.label || val.filterKey, + chunkIndex: val.chunkIndex, + loadedAt: val.loadedAt, + accessedAt: val.accessedAt, + quoteCount: val.quoteCount, + lruRank: rank++, + }); + } + + const items = activeEntry?.items ?? automat?.state?.items ?? null; + const loadedItemsCount = countNonNullItems(items); + + return { + chunkSize: CHUNK_SIZE, + maxLruChunks: MAX_LRU_CHUNKS, + lruChunksCount: lruChunks.size, + total, + unfilteredTotal: activeEntry?.unfilteredTotal ?? automat?.state?.unfilteredTotal ?? total, + chunkCount, + loadedChunks: Array.from(activeLoaded).sort((a, b) => a - b), + inflightChunks: activeInflight.sort((a, b) => a - b), + pendingChunks: Array.from(pendingTargetChunks).sort((a, b) => a - b), + loadedItemsCount, + status: automat?.state?.status ?? 'idle', + queryEpoch, + filter: automat?.state?.filter ?? '', + negativeSeekMonths: automat?.state?.negativeSeekMonths ?? 0, + items, + activeFilterKey: currentKey, + activeFilterLabel: formatFilterLabel(currentKey), + filterCaches: cachedFiltersSummary, + lruEntries, + events: [...chunkEvents], + }; +} + +export function subscribeChunkStats(listener) { + chunkStatsListeners.add(listener); + try { + listener(getChunkStats()); + } catch (e) { + console.error(e); + } + return () => { + chunkStatsListeners.delete(listener); + }; +} + +function clearChunkDebounce() { + if (chunkDebounceTimer) { + clearTimeout(chunkDebounceTimer); + chunkDebounceTimer = null; + } + if (pendingTargetChunks.size > 0) { + pendingTargetChunks.clear(); + notifyChunkStats(); + } + pendingChunkPromise = null; + pendingChunkResolve = null; + pendingChunkReject = null; +} + +function _executeRequiredChunks(chunks, targetEpoch, prefetch = true, targetFilterKey = getFilterKey(automat.state)) { + if (targetEpoch !== queryEpoch) { + return Promise.resolve(automat.state.items); + } + + const chunkList = Array.from(chunks).sort((a, b) => a - b); + if (chunkList.length === 0) { + return Promise.resolve(automat.state.items); + } + + recordChunkEvent({ + type: 'execute', + chunks: chunkList, + filterKey: targetFilterKey, + message: `Lade Chunks [${chunkList.join(', ')}] für "${formatFilterLabel(targetFilterKey)}" (Epoch ${targetEpoch})`, + }); + + const promises = []; + for (const c of chunkList) { + promises.push(actions.loadChunk(c, targetEpoch, targetFilterKey)); + } + + return Promise.all(promises).then(() => { + if (targetEpoch !== queryEpoch) return automat.state.items; + if (prefetch) { + const maxChunk = chunkList[chunkList.length - 1]; + const entry = filterCaches.get(targetFilterKey); + const total = entry?.total || automat.state.total || 0; + for (let c = maxChunk + 1; c <= maxChunk + PREFETCH_CHUNKS; c++) { + if (total > 0 && c * CHUNK_SIZE >= total) { + break; + } + recordChunkEvent({ + type: 'prefetch', + chunkIndex: c, + filterKey: targetFilterKey, + message: `Prefetch Chunk #${c} für "${formatFilterLabel(targetFilterKey)}"`, + }); + void actions.loadChunk(c, targetEpoch, targetFilterKey); + } + } + return automat.state.items; + }); +} + +export function formatNegativeSeek(months) { + if (!months || months >= 0) return ''; + const total = Math.abs(months); + const years = Math.floor(total / 12); + const remMonths = total % 12; + const parts = []; + if (years > 0) { + parts.push(`${years} ${years === 1 ? 'Jahr' : 'Jahre'}`); + } + if (remMonths > 0) { + parts.push(`${remMonths} ${remMonths === 1 ? 'Monat' : 'Monate'}`); + } + return `-${parts.join(' ')}`; +} + +function buildQueryParamsFor(filterParams, offset = 0, limit = CHUNK_SIZE) { + const params = { offset, limit }; + const trimmed = String(filterParams.filter ?? '').trim(); + if (trimmed) params.search = trimmed; + if (filterParams.negativeSeekMonths != null && Number(filterParams.negativeSeekMonths) < 0) { + params.seekMonths = Number(filterParams.negativeSeekMonths); + } + return params; +} + +async function fetchQuotesPageFor(filterParams, offset = 0, limit = CHUNK_SIZE) { + const params = buildQueryParamsFor(filterParams, offset, limit); + const res = await quoteApi.list(params); + const quotes = Array.isArray(res?.quotes) ? res.quotes : []; + const total = Number(res?.total ?? quotes.length); + const unfilteredTotal = res?.unfilteredTotal != null ? Number(res.unfilteredTotal) : total; + return { quotes, total, unfilteredTotal }; +} + +function onFilterChange(immediate = false) { + if (filterDebounceTimer) { + clearTimeout(filterDebounceTimer); + filterDebounceTimer = null; + } + if (immediate) { + applyFilterReload(); + } else { + filterDebounceTimer = setTimeout(() => { + filterDebounceTimer = null; + applyFilterReload(); + }, 200); + } +} + +function applyFilterReload() { + queryEpoch++; + clearChunkDebounce(); + + const currentKey = getFilterKey(automat.state); + const cached = filterCaches.get(currentKey); + + if (cached && cached.items !== null) { + cached.lastAccessed = Date.now(); + for (const c of cached.loadedChunks) { + touchChunkLRU(currentKey, c); + } + + automat.setState({ + items: cached.items, + total: cached.total, + unfilteredTotal: cached.unfilteredTotal ?? automat.state.unfilteredTotal ?? 0, + status: cached.status || 'ready', + error: cached.error || null, + }); + + recordChunkEvent({ + type: 'filter_hit', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${cached.label}" (Cache-Hit: ${cached.loadedChunks.size} Chunks / ${countNonNullItems(cached.items)} Angebote im RAM)`, + }); + notifyChunkStats(); + + if (!cached.loadedChunks.has(0)) { + void actions.loadChunk(0, queryEpoch, currentKey); + } + } else { + const entry = getOrCreateFilterCache(currentKey, automat.state); + entry.lastAccessed = Date.now(); + + automat.setState({ + items: null, + total: 0, + unfilteredTotal: automat.state.unfilteredTotal ?? 0, + status: 'loading', + error: null, + }); + + recordChunkEvent({ + type: 'filter_miss', + epoch: queryEpoch, + filterKey: currentKey, + message: `Filter gewechselt: "${entry.label}" (Cache-Miss: Lade Chunk #0)`, + }); + notifyChunkStats(); + + void actions.loadChunk(0, queryEpoch, currentKey); + } +} + +const actions = { + getItems() { + if (automat.state.items === null) { + void actions.requireChunk(0, 0, { prefetch: false, immediate: true }); + return null; + } + return automat.state.items; }, - sort: (items) => - [...items].sort((a, b) => String(b.date).localeCompare(String(a.date))), + + requireChunk(chunkIndex, endOrOptions = chunkIndex, maybeOptions = {}) { + let start; + let end; + let options = {}; + + if (Array.isArray(chunkIndex)) { + start = chunkIndex[0] ?? 0; + end = chunkIndex[chunkIndex.length - 1] ?? start; + if (typeof endOrOptions === 'object' && endOrOptions !== null) { + options = endOrOptions; + } + } else { + start = Number(chunkIndex) || 0; + if (typeof endOrOptions === 'object' && endOrOptions !== null) { + end = start; + options = endOrOptions; + } else { + end = Number(endOrOptions ?? start) || start; + if (typeof maybeOptions === 'object' && maybeOptions !== null) { + options = maybeOptions; + } + } + } + + start = Math.max(0, Math.floor(start)); + end = Math.max(start, Math.floor(end)); + const prefetch = options.prefetch !== false; + const currentKey = getFilterKey(automat.state); + + if (options.immediate || automat.state.items === null) { + clearChunkDebounce(); + const chunks = new Set(); + for (let c = start; c <= end; c++) chunks.add(c); + recordChunkEvent({ + type: 'require_immediate', + chunks: Array.from(chunks), + filterKey: currentKey, + message: `Sofortige Anforderung: Chunks ${start}..${end} (${formatFilterLabel(currentKey)})`, + }); + return _executeRequiredChunks(chunks, queryEpoch, prefetch, currentKey); + } + + if (lastRequireChunkTick !== currentEventTick) { + pendingTargetChunks.clear(); + lastRequireChunkTick = currentEventTick; + if (typeof queueMicrotask === 'function') { + queueMicrotask(() => { + currentEventTick++; + }); + } else { + Promise.resolve().then(() => { + currentEventTick++; + }); + } + } + + for (let c = start; c <= end; c++) { + pendingTargetChunks.add(c); + } + recordChunkEvent({ + type: 'pending', + chunks: Array.from(pendingTargetChunks), + filterKey: currentKey, + message: `Debounce vorgemerkt (${CHUNK_DEBOUNCE_MS}ms): Chunks [${Array.from(pendingTargetChunks).join(', ')}]`, + }); + notifyChunkStats(); + + if (chunkDebounceTimer) { + clearTimeout(chunkDebounceTimer); + chunkDebounceTimer = null; + } + + if (!pendingChunkPromise) { + pendingChunkPromise = new Promise((resolve, reject) => { + pendingChunkResolve = resolve; + pendingChunkReject = reject; + }); + } + + const captureEpoch = queryEpoch; + chunkDebounceTimer = setTimeout(() => { + chunkDebounceTimer = null; + const chunksToLoad = new Set(pendingTargetChunks); + pendingTargetChunks.clear(); + notifyChunkStats(); + + const resolve = pendingChunkResolve; + const reject = pendingChunkReject; + pendingChunkPromise = null; + pendingChunkResolve = null; + pendingChunkReject = null; + + recordChunkEvent({ + type: 'debounce_fire', + chunks: Array.from(chunksToLoad), + filterKey: currentKey, + message: `Debounce abgelaufen: Starte Chunks [${Array.from(chunksToLoad).join(', ')}]`, + }); + + _executeRequiredChunks(chunksToLoad, captureEpoch, true, currentKey) + .then((res) => resolve?.(res)) + .catch((err) => reject?.(err)); + }, CHUNK_DEBOUNCE_MS); + + return pendingChunkPromise; + }, + + loadChunk(chunkIndex, targetEpoch = queryEpoch, targetFilterKey = getFilterKey(automat.state)) { + const index = Math.max(0, Number(chunkIndex) || 0); + const entry = getOrCreateFilterCache(targetFilterKey, automat.state); + + if (targetEpoch !== queryEpoch && targetFilterKey !== getFilterKey(automat.state)) { + return Promise.resolve(entry.items); + } + + if (entry.loadedChunks.has(index)) { + touchChunkLRU(targetFilterKey, index); + recordChunkEvent({ + type: 'hit', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Cache-Hit: Chunk #${index} für Filter "${entry.label}" bereits im Speicher`, + }); + return Promise.resolve(targetFilterKey === getFilterKey(automat.state) ? automat.state.items : entry.items); + } + + const inflightKey = makeLruKey(targetFilterKey, index); + if (inflightChunks.has(inflightKey)) { + recordChunkEvent({ + type: 'inflight_hit', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Inflight-Hit: Chunk #${index} für Filter "${entry.label}" lädt bereits`, + }); + return inflightChunks.get(inflightKey); + } + + const offset = index * CHUNK_SIZE; + const limit = CHUNK_SIZE; + const isActiveFilter = targetFilterKey === getFilterKey(automat.state); + + if (index === 0 && (entry.items === null || (isActiveFilter && automat.state.items === null))) { + entry.status = 'loading'; + entry.error = null; + if (isActiveFilter) { + automat.setState({ status: 'loading', error: null }); + } + } + + const startTime = Date.now(); + recordChunkEvent({ + type: 'start', + chunkIndex: index, + filterKey: targetFilterKey, + message: `Netzwerk-Request: Lade Chunk #${index} (Offset ${offset}..${offset + limit - 1}) für "${entry.label}"`, + }); + + const filterParams = entry.params || getFilterParams(automat.state); + const promise = fetchQuotesPageFor(filterParams, offset, limit) + .then(({ quotes, total, unfilteredTotal }) => { + const currentTotal = total > 0 ? total : 0; + entry.total = currentTotal; + entry.unfilteredTotal = unfilteredTotal; + + if (entry.items === null || entry.items.length !== currentTotal) { + entry.items = new Array(currentTotal).fill(null); + } else { + entry.items = [...entry.items]; + } + + for (let i = 0; i < quotes.length; i++) { + const idx = offset + i; + if (idx < entry.items.length) { + entry.items[idx] = quotes[i]; + } + } + + entry.loadedChunks.add(index); + entry.status = 'ready'; + entry.error = null; + entry.lastAccessed = Date.now(); + + registerChunkLRU(targetFilterKey, index, quotes.length); + + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { + automat.setState({ + items: entry.items, + total: currentTotal, + unfilteredTotal, + status: 'ready', + error: null, + }); + } + + const duration = Date.now() - startTime; + recordChunkEvent({ + type: 'loaded', + chunkIndex: index, + filterKey: targetFilterKey, + count: quotes.length, + duration, + message: `Chunk #${index} geladen (${quotes.length} Angebote in ${duration}ms) für "${entry.label}"`, + }); + + return currentActive ? automat.state.items : entry.items; + }) + .catch((err) => { + entry.status = 'failed'; + entry.error = err; + const currentActive = targetFilterKey === getFilterKey(automat.state); + if (currentActive) { + if (index === 0 && automat.state.items === null) { + automat.setState({ status: 'failed', error: err }); + } + } + const duration = Date.now() - startTime; + console.error(`[quoteAutomat] loadChunk(${index}, ${targetFilterKey}) error:`, err); + recordChunkEvent({ + type: 'error', + chunkIndex: index, + filterKey: targetFilterKey, + duration, + error: err?.message || String(err), + message: `Fehler bei Chunk #${index} ("${entry.label}"): ${err?.message || err}`, + }); + throw err; + }) + .finally(() => { + inflightChunks.delete(inflightKey); + notifyChunkStats(); + }); + + inflightChunks.set(inflightKey, promise); + notifyChunkStats(); + return promise; + }, + + filter(query) { + automat.setState({ filter: String(query ?? '') }); + onFilterChange(false); + }, + + addSeekMonth() { + const seekMonths = (automat.state.seekMonths ?? 0) + 1; + const seekYears = automat.state.seekYears ?? 0; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekMonths, negativeSeekMonths: totalMonths }); + onFilterChange(true); + }, + + addSeekYear() { + const seekMonths = automat.state.seekMonths ?? 0; + const seekYears = (automat.state.seekYears ?? 0) + 1; + const totalMonths = -(seekMonths + seekYears * 12); + automat.setState({ seekYears, negativeSeekMonths: totalMonths }); + onFilterChange(true); + }, + + addNegativeSeek(months) { + if (months === -12) { + actions.addSeekYear(); + } else if (months === -1) { + actions.addSeekMonth(); + } else { + const current = Math.min(0, Number(automat.state.negativeSeekMonths ?? 0)); + const next = current + months; + automat.setState({ negativeSeekMonths: Math.min(0, next) }); + onFilterChange(true); + } + }, + + resetNegativeSeek() { + automat.setState({ seekMonths: 0, seekYears: 0, negativeSeekMonths: 0 }); + onFilterChange(true); + }, + + resetFilters() { + automat.setState({ + filter: '', + seekMonths: 0, + seekYears: 0, + negativeSeekMonths: 0, + }); + onFilterChange(true); + }, + + load() { + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + ensureLoaded() { + if (automat.state.items !== null) { + return Promise.resolve(automat.state.items); + } + return actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + }, + + async reload(onlyCurrent = false) { + queryEpoch++; + clearChunkDebounce(); + + if (onlyCurrent) { + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + } else { + actions.clearCache(); + } + + await actions.loadChunk(0, queryEpoch, getFilterKey(automat.state)); + return automat.state.items; + }, + + replace(items) { + clearChunkDebounce(); + const currentKey = getFilterKey(automat.state); + actions.clearFilterCache(currentKey); + + const count = Array.isArray(items) ? items.length : 0; + const entry = getOrCreateFilterCache(currentKey, automat.state); + entry.items = items; + entry.total = count; + entry.status = 'ready'; + entry.error = null; + + const chunkCount = count > 0 ? Math.ceil(count / CHUNK_SIZE) : 0; + for (let c = 0; c < chunkCount; c++) { + entry.loadedChunks.add(c); + registerChunkLRU(currentKey, c, Math.min(CHUNK_SIZE, count - c * CHUNK_SIZE)); + } + + automat.setState({ + items, + total: count, + unfilteredTotal: count, + status: 'ready', + error: null, + }); + notifyChunkStats(); + }, + + patchQuote(quoteId, patch) { + if (quoteId == null || !patch) return; + + for (const [fKey, entry] of filterCaches.entries()) { + if (Array.isArray(entry.items)) { + let changed = false; + const newItems = entry.items.map((item) => { + if (item && item.id === quoteId) { + changed = true; + return { ...item, ...patch }; + } + return item; + }); + if (changed) { + entry.items = newItems; + if (fKey === getFilterKey(automat.state)) { + automat.setState({ items: newItems }); + } + } + } + } + notifyChunkStats(); + }, + + evictChunk(chunkIndex, filterKey = getFilterKey(automat.state)) { + const key = makeLruKey(filterKey, chunkIndex); + lruChunks.delete(key); + evictChunkFromMemory(filterKey, chunkIndex, 'manual'); + }, + + clearFilterCache(filterKey) { + const entry = filterCaches.get(filterKey); + if (entry) { + for (const c of entry.loadedChunks) { + lruChunks.delete(makeLruKey(filterKey, c)); + } + entry.items = null; + entry.loadedChunks.clear(); + entry.status = 'idle'; + entry.error = null; + + if (filterKey === getFilterKey(automat.state)) { + automat.setState({ items: null, status: 'idle', error: null }); + } + + recordChunkEvent({ + type: 'clear_filter', + filterKey, + message: `Filter-Cache "${entry.label}" geleert`, + }); + notifyChunkStats(); + } + }, + + clearCache() { + clearChunkDebounce(); + lruChunks.clear(); + inflightChunks.clear(); + + for (const entry of filterCaches.values()) { + entry.items = null; + entry.loadedChunks.clear(); + entry.status = 'idle'; + entry.error = null; + } + + recordChunkEvent({ + type: 'clear_all', + message: 'Gesamter Quote-Chunk-Cache geleert', + }); + notifyChunkStats(); + + automat.setState({ + items: null, + status: 'idle', + error: null, + }); + }, + + prefetchAllChunks(filterKey = getFilterKey(automat.state)) { + const entry = filterCaches.get(filterKey); + if (!entry || entry.total <= 0) return; + const chunkCount = Math.ceil(entry.total / CHUNK_SIZE); + const toLoad = []; + for (let c = 0; c < chunkCount; c++) { + if (!entry.loadedChunks.has(c)) { + toLoad.push(c); + } + } + if (toLoad.length === 0) return; + + recordChunkEvent({ + type: 'prefetch_all', + filterKey, + chunks: toLoad, + message: `Starte Vorladen aller ${toLoad.length} fehlenden Chunks für "${entry.label}"`, + }); + + for (const c of toLoad) { + void actions.loadChunk(c, queryEpoch, filterKey); + } + }, + + reset() { + if (filterDebounceTimer) { + clearTimeout(filterDebounceTimer); + filterDebounceTimer = null; + } + clearChunkDebounce(); + filterCaches.clear(); + lruChunks.clear(); + inflightChunks.clear(); + + recordChunkEvent({ + type: 'reset', + message: 'Quote-Automat und sämtliche Caches zurückgesetzt', + }); + notifyChunkStats(); + automat.setState(initialState); + }, +}; + +export const quoteAutomat = new Automat(initialState, actions, { name: 'quote' }); +automat = quoteAutomat; + +export function getItems() { + return actions.getItems(); +} + +export function requireChunk(chunkIndex, endOrOptions, maybeOptions) { + return actions.requireChunk(chunkIndex, endOrOptions, maybeOptions); +} + +quoteAutomat.getItems = getItems; +quoteAutomat.requireChunk = requireChunk; +quoteAutomat.CHUNK_SIZE = CHUNK_SIZE; +quoteAutomat.MAX_LRU_CHUNKS = MAX_LRU_CHUNKS; +quoteAutomat.getChunkStats = getChunkStats; +quoteAutomat.subscribeChunkStats = subscribeChunkStats; +quoteAutomat.evictChunk = actions.evictChunk; +quoteAutomat.clearFilterCache = actions.clearFilterCache; +quoteAutomat.clearCache = actions.clearCache; +quoteAutomat.prefetchAllChunks = actions.prefetchAllChunks; +quoteAutomat.getFilterKey = getFilterKey; +quoteAutomat.formatFilterLabel = formatFilterLabel; + +// Reset on logout or company change +let lastCompanyId = null; +authAutomat.subscribe((auth) => { + const companyId = auth.user?.selectedCompanyId ?? null; + if (auth.status === 'anon') { + actions.reset(); + lastCompanyId = null; + return; + } + if (lastCompanyId !== null && companyId !== lastCompanyId) { + actions.reset(); + } + if (auth.status === 'authed' && companyId != null) { + lastCompanyId = companyId; + } }); +/** Selects current quotes */ +export function selectQuotes(state = automat.state) { + return state.items; +} + +/** Selects total unpaged count */ +export function selectQuoteTotal(state = automat.state) { + return state.total; +} + +/** Selects unfiltered total count */ +export function selectQuoteUnfilteredTotal(state = automat.state) { + return state.unfilteredTotal ?? 0; +} + +/** Selects negative seek offset in months */ +export function selectNegativeSeekMonths(state = automat.state) { + return state.negativeSeekMonths; +} + +/** Selects seek month count */ +export function selectSeekMonths(state = automat.state) { + return state.seekMonths ?? 0; +} + +/** Selects seek year count */ +export function selectSeekYears(state = automat.state) { + return state.seekYears ?? 0; +} + +/** + * Calculates client pagination metadata. + */ +export function selectQuotePagination(state = automat.state) { + const total = state.total ?? 0; + return { + total, + offset: 0, + limit: CHUNK_SIZE, + currentPage: 1, + pageCount: Math.max(1, Math.ceil(total / CHUNK_SIZE)), + }; +} + export default quoteAutomat; diff --git a/src/automats/sseAutomat.js b/src/automats/sseAutomat.js index 2f048bc..6f0ab87 100644 --- a/src/automats/sseAutomat.js +++ b/src/automats/sseAutomat.js @@ -33,6 +33,7 @@ import attributeAutomat from './attributeAutomat'; import propertyAutomat from './propertyAutomat'; import supplierAutomat from './supplierAutomat'; import orderAutomat from './orderAutomat'; +import quoteAutomat from './quoteAutomat'; import { handleDraftRequest, handleDraftEvent } from './draftSync'; @@ -245,6 +246,12 @@ function connect() { }); }); + addTrackedListener(es, 'quote', () => { + void quoteAutomat.actions.reload().catch((err) => { + console.error('[sse] quote refresh failed', err); + }); + }); + addTrackedListener(es, 'shipment', () => { void orderAutomat.actions.reload().catch((err) => { console.error('[sse] shipment refresh failed', err);