Watch article history over SSE so the tab stays current while open.

This commit is contained in:
seb
2026-08-05 09:48:30 +02:00
parent 60e6858fd5
commit 44d69e541c
3 changed files with 75 additions and 4 deletions

View File

@@ -108,6 +108,12 @@ export class ApiClient {
get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`), get: (sku) => this.#json('GET', `/api/article/${enc(sku)}`),
/** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */ /** GET /api/article/{sku}/history → { history: [{ id, date, text }, ...] } */
history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`), history: (sku) => this.#json('GET', `/api/article/${enc(sku)}/history`),
/**
* Load history and refetch on SSE `article-<sku>`.
* `onHistory(history)` / `onError(err)` — returns unwatch().
*/
historyWatch: (sku, onHistory, onError) =>
this.watchArticleHistory(sku, onHistory, onError),
/** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */ /** PATCH /api/article/{sku} — body: at least one of { name, unitamount, unitSi } */
update: async (sku, body) => { update: async (sku, body) => {
const nameChange = body != null && Object.hasOwn(body, 'name'); const nameChange = body != null && Object.hasOwn(body, 'name');

View File

@@ -72,12 +72,67 @@ export class StorageManager extends ApiClient {
#events = null; #events = null;
/** @type {ReturnType<typeof createCache> | null} */ /** @type {ReturnType<typeof createCache> | null} */
#articleIndexCache = null; #articleIndexCache = null;
/** @type {Map<string, Set<() => void>>} sku → refreshers */
#historyWatchers = new Map();
constructor(baseUrl = window.location.origin) { constructor(baseUrl = window.location.origin) {
super(baseUrl); super(baseUrl);
this.#initCaches(); this.#initCaches();
} }
/**
* Fetch article history, then refetch whenever SSE emits `article-<sku>`.
* @param {string} sku
* @param {(history: unknown[]) => void} onHistory
* @param {(err: unknown) => void} onError
* @returns {() => void} unwatch
*/
watchArticleHistory(sku, onHistory, onError) {
const key = String(sku);
let active = true;
const refresh = () =>
this.article.history(key).then((res) => {
if (!active) return;
requireObject(res, 'GET /api/article/{sku}/history');
onHistory(requireArray(res.history, 'history'));
});
const run = () =>
void refresh().catch((err) => {
if (!active) return;
onError(err);
});
let watchers = this.#historyWatchers.get(key);
if (!watchers) {
watchers = new Set();
this.#historyWatchers.set(key, watchers);
this.#events?.addEventListener(`article-${key}`, this.#onArticleHistorySignal);
}
watchers.add(run);
run();
return () => {
active = false;
const set = this.#historyWatchers.get(key);
if (!set) return;
set.delete(run);
if (set.size === 0) {
this.#historyWatchers.delete(key);
this.#events?.removeEventListener(`article-${key}`, this.#onArticleHistorySignal);
}
};
}
#onArticleHistorySignal = (event) => {
const sku = event.type.slice('article-'.length);
const watchers = this.#historyWatchers.get(sku);
if (!watchers) return;
for (const run of watchers) run();
};
/** Patch local articleIndex name if already loaded; no-op otherwise. */ /** Patch local articleIndex name if already loaded; no-op otherwise. */
setArticleIndexName(sku, name) { setArticleIndexName(sku, name) {
const articles = this.#articleIndexCache.value; const articles = this.#articleIndexCache.value;
@@ -147,6 +202,9 @@ export class StorageManager extends ApiClient {
console.error('[storageMgr] articles SSE refresh failed', err); console.error('[storageMgr] articles SSE refresh failed', err);
}); });
}); });
for (const sku of this.#historyWatchers.keys()) {
es.addEventListener(`article-${sku}`, this.#onArticleHistorySignal);
}
es.onerror = () => { es.onerror = () => {
console.error('[storageMgr] events stream error'); console.error('[storageMgr] events stream error');
}; };

View File

@@ -17,13 +17,20 @@ export default class ArticleHistoryTab extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this.state = { history: null }; this.state = { history: null };
this.unwatchHistory = null;
} }
componentDidMount() { componentDidMount() {
void this.context.article this.unwatchHistory = this.context.article.historyWatch(
.history(this.props.sku) this.props.sku,
.then((res) => this.setState({ history: res.history })) (history) => this.setState({ history }),
.catch(notifyError); notifyError,
);
}
componentWillUnmount() {
this.unwatchHistory?.();
this.unwatchHistory = null;
} }
render() { render() {