feat(feed): bound feed lifecycle to 30-day window with nonblocking refresh
Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
This commit is contained in:
75
src/stores/activity.ts
Normal file
75
src/stores/activity.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Activity store for PodTUI
|
||||
*
|
||||
* Shared leak-proof activity counter: any store can surface "something is
|
||||
* loading/downloading" to the global top-right indicator. beginActivity
|
||||
* returns an end token that removes exactly THAT instance, so concurrent
|
||||
* overlapping activities compose correctly; prefer track() so callers
|
||||
* cannot strand the counter.
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js";
|
||||
|
||||
/** Create activity store */
|
||||
function createActivityStore() {
|
||||
const [count, setCount] = createSignal(0);
|
||||
const [labels, setLabels] = createSignal<string[]>([]);
|
||||
|
||||
/** Begin a tracked activity and return its end function. Every begin
|
||||
* MUST be paired with exactly one call of the returned end (via the
|
||||
* token); prefer track() so the pairing is automatic. Duplicate labels
|
||||
* are allowed — each end removes exactly one instance (found by
|
||||
* indexOf). */
|
||||
const beginActivity = (label: string): (() => void) => {
|
||||
setLabels((prev) => [...prev, label]);
|
||||
setCount((c) => c + 1);
|
||||
let ended = false;
|
||||
return () => {
|
||||
if (ended) return;
|
||||
ended = true;
|
||||
setLabels((prev) => {
|
||||
const idx = prev.indexOf(label);
|
||||
if (idx === -1) return prev;
|
||||
const next = [...prev];
|
||||
next.splice(idx, 1);
|
||||
return next;
|
||||
});
|
||||
setCount((c) => Math.max(0, c - 1));
|
||||
};
|
||||
};
|
||||
|
||||
/** Track a promise: begin an activity, auto-end when it settles, and
|
||||
* re-throw on rejection so the caller's error handling is untouched. */
|
||||
const track = async <T,>(p: Promise<T>, label: string): Promise<T> => {
|
||||
const end = beginActivity(label);
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
end();
|
||||
}
|
||||
};
|
||||
|
||||
/** True while at least one activity is in flight */
|
||||
const isActive = (): boolean => count() > 0;
|
||||
|
||||
return {
|
||||
// State
|
||||
count,
|
||||
labels,
|
||||
// Actions
|
||||
beginActivity,
|
||||
track,
|
||||
// Getters
|
||||
isActive,
|
||||
};
|
||||
}
|
||||
|
||||
/** Singleton activity store */
|
||||
let activityStoreInstance: ReturnType<typeof createActivityStore> | null = null;
|
||||
|
||||
export function useActivityStore() {
|
||||
if (!activityStoreInstance) {
|
||||
activityStoreInstance = createActivityStore();
|
||||
}
|
||||
return activityStoreInstance;
|
||||
}
|
||||
Reference in New Issue
Block a user