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:
2026-08-12 10:13:20 -04:00
parent e09ae15e32
commit deac6081ca
23 changed files with 2226 additions and 169 deletions

75
src/stores/activity.ts Normal file
View 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;
}