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.
7.4 KiB
7.4 KiB
04. Add a shared activity store and global top-right loading indicator
meta: id: bounded-feed-lifecycle-04 feature: bounded-feed-lifecycle priority: P2 depends_on: [bounded-feed-lifecycle-03] tags: [implementation, tests-required]
objective:
- One global indicator, always in the top-right corner of the app, visible whenever ANYTHING is being loaded or downloaded: feed refreshes (all-feeds and single-feed), fetch-more, subscribe fetches, searches, and episode downloads. Per-page spinners stay as-is; this adds the global signal that activity is happening anywhere.
background (read this before touching code):
src/components/Shell.tsxrenders the whole chrome: one full-width content row (LayerGraph[nav.activeTab()]()/PaneRow) plus a bottom status/command bar. There is no header row — the top-right corner belongs to whatever page is active, so the indicator must be an ABSOLUTE-POSITIONED overlay drawn after the content so it paints on top (opentuiboxsupportsposition="absolute",top,right).- Existing activity signals (read them, don't recreate per-store bookkeeping):
useFeedStore().isLoadingFeeds()/.isLoadingMore();useSearchStore().isSearching()(src/stores/search.ts);useDownloadStore().getActiveCount()and.getQueue().length(src/stores/download.ts). Gaps these don't cover: singlerefreshFeed,addFeed's subscribe fetch, iTunes feed resolution insideaddFeed— hence the activity store. src/components/LoadingIndicator.tsxis the braille spinner (proplabel?: string); reuse it inside the overlay.- Activity tracking must be leak-proof: every
beginpaired with anendvia a token, PLUS atrack(promise, label)helper that auto-ends on settle so callers can't strand the counter. - Task 03 added the incremental per-feed apply inside
refreshAllFeeds; wire activity around the whole batch (isLoadingFeedsalready brackets it — prefer reusing the signal, adding explicitbegin/endONLY where no signal exists). - Style: Solid +
@opentui/solidJSX (noclassName; props likefg,paddingRight,position); store files tab-indented with semicolons; components matchLoadingIndicator.tsxconventions. Style imports use@/alias in components, relative paths in stores.
deliverables:
- New
src/stores/activity.ts:- Signals:
count(number),labels(string[]). - Actions:
beginActivity(label: string): () => void(returns the matching end function; each call adds the label, ending removes that exact instance — duplicates allowed),track<T>(p: Promise<T>, label: string): Promise<T>(begins, ends infinally, re-throws). - Computed:
isActive(): boolean(count() > 0). - Singleton +
useActivityStore()accessor, mirroringsrc/stores/download.ts's module pattern.
- Signals:
- Wire the gaps in
src/stores/feed.ts(only where no existing signal covers the operation):refreshFeed:await activity.track(...)around the fetch+apply, label"Refreshing".addFeed: wrap the directory-resolve +fetchEpisodesstretch, label"Subscribing".- Do NOT wrap
refreshAllFeeds/loadMoreEpisodes*—isLoadingFeeds/isLoadingMorealready cover them (double-counting just lengthens the spinner's on-time cosmetically; the point is no visual gap).
- New
src/components/GlobalActivityIndicator.tsx:- Computes active state from:
feedStore.isLoadingFeeds() || feedStore.isLoadingMore() || searchStore.isSearching() || downloadStore.getActiveCount() + downloadStore.getQueue().length > 0 || activity.isActive(). - Label selection: downloads in flight →
Downloading N(+M queuedwhen queue non-empty); else the activity store's latest label +…(e.g.Refreshing…); elseLoading…. - Renders
<LoadingIndicator label={…} />inside<box position="absolute" top={0} right={0} paddingRight={1}>; renders nothing (returnsnull) when inactive so it never eats layout when idle.
- Computes active state from:
src/components/Shell.tsx: mount<GlobalActivityIndicator />as the LAST child of the root<box flexDirection="column" …>(after the content row, bottom bar, and help overlay so it paints on top).tests/global-activity-indicator.test.tsx(new) — see tests section.
steps:
- Read
src/stores/download.ts,src/stores/search.ts,src/components/LoadingIndicator.tsx, and the render JSX ofsrc/components/Shell.tsx. - Write
src/stores/activity.ts(small; ~60 lines). - Wire
refreshFeed/addFeedinsrc/stores/feed.tsviauseActivityStore().track(...). Import cycle note:activity.tsmust import NOTHING from other stores (pure counter) sofeed.tsimporting it is safe. - Write
src/components/GlobalActivityIndicator.tsx; mount it inShell.tsxlast (paints on top). - Write tests; run new tests, full suite, lint; manual smoke per validation.
tests:
- New
tests/global-activity-indicator.test.tsx(component-test conventions: copy the render harness fromtests/feed-refresh-spinner.test.tsx— tempXDG_CONFIG_HOMEbefore imports; if a jsdom-like setup is used there, reuse it as-is):- Activity store unit asserts: two
begins →isActive()true; ending one → still true; ending both → false.track(failingPromise)still decrements (rejects propagate, counter returns to baseline). - Component asserts: render
<GlobalActivityIndicator />in isolation —- idle → no text rendered;
useActivityStore().beginActivity("Refreshing")→ spinner/label present in rendered output; matching end → gone;- with the download store: enqueue via
downloadStore.startDownload-equivalent the waytests/download-unsubscribed.test.tsdoes (assert indicator renders whilegetActiveCount() + queue > 0); skip actual network by following that test's existing mocking pattern.
- Activity store unit asserts: two
- Existing suites must pass:
feed-refresh-spinner.test.tsx(per-page spinners unchanged), fullbun test.
acceptance_criteria:
- Indicator visible in the top-right overlay while any of: all-feeds refresh, single-feed refresh, fetch-more, subscribe fetch, search, active/queued download — and hidden when none are active.
- Counter never strands: every completed/failed tracked operation returns
isActive()to its prior value (proven by thetrackrejection test). - Idle UI unchanged: when inactive the overlay renders nothing and occupies zero layout.
bun testfull suite passes;bun run lintclean.
validation:
bun test tests/global-activity-indicator.test.tsx tests/feed-refresh-spinner.test.tsxbun testbun run lint- Manual smoke:
bun start; (a) on cold boot with subscriptions, the top-right spinner appears during startup refresh and disappears when done; (b) pressron Feed — spinner appears; (c) download an episode from Search —Downloadinglabel shows while the transfer runs; (d) leave idle — top-right is empty.
notes:
- Depends on 03 only for ordering cleanliness — the activity wiring hooks onto the restructured refresh paths; nothing in 03's API is required beyond the store exporting the same signals.
- The overlay intentionally does NOT replace per-pane spinners (
Refreshing…in Feed/MyShows/Discover/Search stay) — removing those is out of scope. - If
position="absolute"proves unavailable for text-draw ordering in@opentui/solid, the fallback is a dedicated 1-row header (height={1}) above the content row with the indicator right-aligned — only take this path with evidence (broken render), and note the tradeoff (loses one row of content height) in the commit message.