diff --git a/src/pages/Settings/PreferencesPanel.tsx b/src/pages/Settings/PreferencesPanel.tsx index 4b8deee..56484fb 100644 --- a/src/pages/Settings/PreferencesPanel.tsx +++ b/src/pages/Settings/PreferencesPanel.tsx @@ -221,7 +221,7 @@ export function usePreferencesItems(): SettingItem[] { kind: "select", display: () => cacheModeLabel(prefs().episodeCacheMode), help: () => - `How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below).\nCount: keep the N most recent episodes (see Cache Count below).\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`, + `How the Feed and My Shows episode lists are bounded.\nDate: keep episodes from the last N days (see Cache Days below); Fetch More reveals the next 2 weeks per press.\nCount: keep the N most recent episodes (see Cache Count below); Fetch More pages in 50-episode chunks.\nFetch More always pages beyond this bound — these episodes are volatile and don't persist.\nType: select\nDefault: date\nCurrent: ${cacheModeLabel(prefs().episodeCacheMode)}\nCycle with j/k; Enter to apply.`, cycle: (dir) => { const idx = CACHE_MODE_LABELS.findIndex( (s) => s.value === prefs().episodeCacheMode, diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 6895ab9..51ca302 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -26,12 +26,17 @@ import { useDownloadStore } from "./download"; import { useAppStore } from "./app"; import { DownloadStatus } from "../types/episode"; -/** Max episodes to load per page/chunk */ +/** Max episodes to load per page/chunk (count mode only — date mode steps + * by FETCH_MORE_WINDOW_DAYS instead). */ const MAX_EPISODES_REFRESH = 50; /** Max episodes to fetch on initial subscribe */ const MAX_EPISODES_SUBSCRIBE = 20; +/** Fetch-more step in date mode: each press reveals the next two weeks of + * episodes past the oldest loaded one, instead of a fixed episode count. */ +const FETCH_MORE_WINDOW_DAYS = 14; + /** Per-feed fetch timeout — a hung feed must not stall a refresh batch or * the background refresh loop. */ const FETCH_TIMEOUT_MS = 20_000; @@ -127,6 +132,13 @@ function episodeKeepFn(prefs: { return (ep: Episode) => episodeInWindow(ep, now, days); } +/** Timestamp for window math — undated episodes sort/compare as NEWEST + * (Infinity) so they can never be excluded by a date cutoff. */ +const epTs = (ep: Episode): number => { + const t = ep.pubDate?.getTime(); + return t === undefined || Number.isNaN(t) ? Infinity : t; +}; + /** Save feeds to file (async, fire-and-forget). */ function saveFeeds(feeds: Feed[]): void { const prefs = useAppStore().state().preferences; @@ -864,10 +876,36 @@ function createFeedStore() { } const currentCount = episodeLoadCount.get(feedId) ?? feed.episodes.length; - const newCount = Math.min( - currentCount + MAX_EPISODES_REFRESH, - cached.length, - ); + const prefs = useAppStore().state().preferences; + + // Date mode: each press reveals the next FETCH_MORE_WINDOW_DAYS band + // past the oldest loaded episode — a daily show gains ~2 weeks of + // episodes, a weekly show gains its next 2, never a fixed count. + // Count mode keeps the fixed MAX_EPISODES_REFRESH chunk. + let newCount: number; + if (prefs.episodeCacheMode === "date") { + const ref = cached[Math.max(0, currentCount - 1)]; + const refTs = ref ? epTs(ref) : Infinity; + if (Number.isFinite(refTs)) { + const cutoff = refTs - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000; + newCount = currentCount; + while ( + newCount < cached.length && + epTs(cached[newCount]) >= cutoff + ) { + newCount++; + } + } else { + newCount = currentCount; + } + // Date mode always advances at least one episode: a sparse band + // (a show that went quiet) must not wedge the button into a + // no-op while hasMoreEpisodes still reports true. + newCount = Math.max(newCount, currentCount + 1); + } else { + newCount = currentCount + MAX_EPISODES_REFRESH; + } + newCount = Math.min(newCount, cached.length); if (newCount <= currentCount) return; // nothing more to load diff --git a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md index 15f1313..ebaf622 100644 --- a/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md +++ b/tasks/bounded-feed-lifecycle/02-volatile-episode-merge.md @@ -36,7 +36,7 @@ deliverables: - Unchanged detection must compare the FETCHED window against the corresponding prefix of the existing list, i.e. keep a small `sameRefreshWindow(existing: Episode[], fetched: Episode[])` helper next to (and replacing the use of) `sameEpisodes`: `fetched.length === 0 → true`; otherwise compare id-sets of `fetched` and `existing.slice(0, fetched.length)`. Rationale: with union semantics `merged` legitimately contains episodes beyond the fetched window, so comparing full lists would bump `lastUpdated` on every refresh and resurrect the order-flapping bug `tests/feed-refresh.test.ts` guards. - Return unmodified `prev` when every feed's window is unchanged (preserve the existing identity-no-save contract); on change, set `{ ...f, episodes: merged, lastUpdated: new Date() }`. - Delete the now-unused `sameEpisodes` if nothing else references it (grep first: `grep sameEpisodes src tests`). - - `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering); everything else (window growth by `MAX_EPISODES_REFRESH`, `hasMoreEpisodes` comparing `episodeLoadCount < cached.length`) works unchanged against the filtered cache. + - `loadMoreEpisodesForFeed`: window-filter the cold-refetch cache the same way after `parseEpisodesIncremental` (it's unsorted there — wrap with `sortEpisodesReverseChronological` before filtering). Fetch-more stepping is mode-dependent: DATE mode advances the loaded window by a `FETCH_MORE_WINDOW_DAYS` (14) band past the oldest loaded episode — a daily show gains ~2 weeks of episodes per press, not a fixed count — with a +1 minimum so a sparse band can't wedge the button into a no-op; COUNT mode keeps the fixed `MAX_EPISODES_REFRESH` (50) chunk. `hasMoreEpisodes` still compares `episodeLoadCount < cached.length`. - `tests/feed-volatile-merge.test.ts` (reworked) — see tests section. steps: @@ -57,7 +57,8 @@ tests: - input arrays not mutated. - Store integration (harness per `tests/feed-refresh.test.ts`: temp `XDG_CONFIG_HOME` BEFORE imports, `Bun.serve` on port 0 serving generated RSS, fake timers): - Refresh-keeps-volatile-window: serve 3 episodes at t0, `addFeed`; then serve the same 3 plus 2 new ones, `refreshFeed`. Assert `feed.episodes.length === 5` AND `lastUpdated` advanced AND a second identical refresh leaves `lastUpdated` untouched (window-compare, not union-compare). - - Boundary: a 25-day-old episode loads; a 31-day-old episode is neither visible nor cached. + - Boundary: a 25-day-old episode loads; a 70-day-old episode is neither visible nor cached initially, but fetch-more surfaces it (volatile). + - Date stepping: 30 episodes at 3-day spacing — each fetch-more press reveals the next 2-week band (24 → 28 → 30), NOT a fixed 50-chunk. - Out-of-window never cached: 600 items at 2h spacing span ~50 days — only the in-window tail is loadable (fewer than the old 500 cap), `hasMoreEpisodes` flips false there. - No count ceiling: 600 items at 1h spacing (all within 25 days) are ALL loadable — the bound is the date, not a number. - Clock constraint: these tests run under fake timers, and a large `vi.advanceTimersByTime` (past ~5 days of fake time) makes Bun 1.3.8 hang every subsequent network fetch — the boundary is pinned with relative pubDates, never by moving the clock across it. diff --git a/tests/feed-pagination.test.ts b/tests/feed-pagination.test.ts index 97c42a4..dac3f79 100644 --- a/tests/feed-pagination.test.ts +++ b/tests/feed-pagination.test.ts @@ -3,12 +3,10 @@ * row in a drilled show's episode list (My Shows depth 1) and the Feed * page's row. * - * addFeed caches every episode inside the lifecycle window (the last - * EPISODE_WINDOW_DAYS days — the date bound, not a count) while exposing - * only the first MAX_EPISODES_SUBSCRIBE (20) episodes. `hasMoreEpisodes` - * reports when the cache holds more than the loaded window; - * `loadMoreEpisodes` advances that window in MAX_EPISODES_REFRESH (50) - * chunks until it is exhausted. This pins: + * Runs in COUNT cache mode: `loadMoreEpisodes` advances the loaded window in + * fixed MAX_EPISODES_REFRESH (50) chunks until the cache is exhausted. + * (Date-mode fetch-more steps by a two-week window instead — that contract + * is pinned in feed-volatile-merge.test.ts.) This pins: * 1. A freshly subscribed feed with a longer cache reports hasMoreEpisodes. * 2. loadMoreEpisodes grows that feed's episodes from the cache (no refetch * needed) and hasMoreEpisodes flips false once the window reaches the end. @@ -26,6 +24,7 @@ const configHome = mkdtempSync(join(tmpdir(), "podtui-pagination-")); process.env.XDG_CONFIG_HOME = configHome; import { useFeedStore } from "../src/stores/feed"; +import { useAppStore } from "../src/stores/app"; import type { Podcast } from "../src/types/podcast"; const HOUR = 3600 * 1000; @@ -73,6 +72,11 @@ const makePodcast = (feedUrl: string): Podcast => ({ }); beforeAll(() => { + // Chunk-based stepping is count-mode behavior (see header comment). + useAppStore().updatePreferences({ + episodeCacheMode: "count", + episodeCacheCount: 25, + }); server = Bun.serve({ port: 0, fetch(req) { @@ -89,6 +93,7 @@ beforeAll(() => { afterAll(() => { // Leave the shared singleton as we found it (see addedFeedIds note). + useAppStore().updatePreferences({ episodeCacheMode: "date" }); const store = useFeedStore(); for (const id of addedFeedIds) store.removeFeed(id); server?.stop(true); diff --git a/tests/feed-volatile-merge.test.ts b/tests/feed-volatile-merge.test.ts index 0ba7daa..1e69d25 100644 --- a/tests/feed-volatile-merge.test.ts +++ b/tests/feed-volatile-merge.test.ts @@ -296,6 +296,41 @@ test("date mode boundary: 25 days in, 70 days out", async () => { // ── count mode ──────────────────────────────────────────────────────────── +test("date mode: fetch-more steps by a two-week window, not a count", async () => { + const store = useFeedStore(); + const now = Date.now(); + // 30 episodes at 3-day spacing span 87 days. The 60-day cache window + // holds the first 21 (subscribe shows 20); fetch-more then reveals the + // next 2-week band per press — 3-day cadence → ~4 episodes per band — + // NOT a fixed 50-episode chunk (which would load all 30 at once). + servedEpisodes = Array.from({ length: 30 }, (_, i) => ({ + title: `Ep ${30 - i}`, + date: new Date(now - i * 3 * DAY).toISOString(), + })); + const feedUrl = `http://127.0.0.1:${server!.port}/date-step.xml`; + const feed = await store.addFeed(makePodcast(feedUrl), "test-source"); + expect(feed).not.toBeNull(); + const id = feed!.id; + addedFeedIds.push(id); + + expect(store.getFeed(id)!.episodes.length).toBe(20); + + // Press 1: oldest loaded is 57d old → cutoff 71d → i=20..23 (60–69d). + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(24); + expect(store.hasMoreEpisodes(id)).toBe(true); + + // Press 2: oldest loaded is 69d old → cutoff 83d → i=24..27 (72–81d). + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(28); + expect(store.hasMoreEpisodes(id)).toBe(true); + + // Press 3: oldest loaded is 81d old → cutoff 95d → i=28..29 (84–87d). + await store.loadMoreEpisodes(id); + expect(store.getFeed(id)!.episodes.length).toBe(30); + expect(store.hasMoreEpisodes(id)).toBe(false); +}); + test("count mode: only N most-recent episodes are visible, but fetch-more goes beyond", async () => { const store = useFeedStore(); const app = useAppStore();