refactor(feed): episode windows, source registry, dead interface

episode-windows.ts: keep-fn, date-band walk (was copy-pasted twice),
sameRefreshWindow — one pure module. source-registry.ts owns source
CRUD + its persistence. Dead members with zero callers deleted:
updateFeed, togglePinned, removeSource, setFilter/selectedFeedId.
Feed persistence routed through the persist scheduler.
This commit is contained in:
2026-09-03 08:33:32 -04:00
parent ca46de4d70
commit 9143078b12
2 changed files with 170 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
/**
* Episode cache-window math — shared by the feed store's refresh, retention,
* and fetch-more paging paths. Pure module: no Solid, no store imports.
*/
import type { Episode } from "../types/episode";
import { episodeSignature } from "./episode-merge";
import { episodeInWindow } from "./feeds-persistence";
/** Floor on the visible episode window for a subscribed show: at least this
* many most-recent episodes always load, regardless of a stricter count or
* date cache bound. */
const MIN_EPISODES_PER_SHOW = 5;
/** 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;
/** Timestamp for window math — undated episodes sort/compare as NEWEST
* (Infinity) so they can never be excluded by a date cutoff. */
export const episodeTs = (ep: Episode): number => {
const t = ep.pubDate?.getTime();
return t === undefined || Number.isNaN(t) ? Infinity : t;
};
/** Read the episode cache bound from preferences: a closure that decides
* whether the episode at `index` (0 = newest, after sort) is kept. The five
* most-recent episodes of a subscribed show always stay (MIN_EPISODES_PER_SHOW),
* overriding a stricter count or date bound so every show surfaces at least
* five episodes. */
export function episodeKeepFn(
prefs: {
episodeCacheMode: "date" | "count";
episodeCacheCount: number;
episodeCacheDays: number;
},
now?: Date,
): (ep: Episode, index: number) => boolean {
const at = now ?? new Date();
if (prefs.episodeCacheMode === "count") {
const count = Math.max(1, prefs.episodeCacheCount);
return (_ep: Episode, index: number) =>
index < Math.max(count, MIN_EPISODES_PER_SHOW);
}
const days = Math.max(1, prefs.episodeCacheDays);
return (ep: Episode, index: number) =>
index < MIN_EPISODES_PER_SHOW || episodeInWindow(ep, at, days);
}
/** Date-mode fetch-more cutoff: the oldest loaded episode's pubDate minus the
* 2-week band. With nothing loaded (a show whose episodes all fall outside
* the cache window), the band anchors at the cache-window edge (now minus
* the configured days) — a dormant show can't drag in arbitrarily old
* episodes just because the button is pressed. */
export const dateFetchMoreCutoff = (
cached: Episode[],
loaded: number,
windowDays: number,
): number => {
if (loaded > 0) {
const t = episodeTs(cached[loaded - 1]);
if (Number.isFinite(t)) {
return t - FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000;
}
}
// Nothing loaded: the band extends FETCH_MORE_WINDOW_DAYS before the
// cache-window edge (e.g. 60d → reveals the 6074d slice).
return (
Date.now() -
Math.max(1, windowDays) * 24 * 3600 * 1000 -
FETCH_MORE_WINDOW_DAYS * 24 * 3600 * 1000
);
};
/** Episodes the date band adds past the loaded window: count forward while
* each next cached episode still falls on/after the cutoff. The single
* implementation behind both fetch-more paths (one feed / all feeds) — an
* empty band adds nothing, which doubles as the "has more" guard. */
export function dateBandCount(
cached: Episode[],
loaded: number,
cutoff: number,
): number {
let count = loaded;
while (count < cached.length && episodeTs(cached[count]) >= cutoff) {
count++;
}
return count;
}
/** True when the freshly fetched window matches the corresponding PREFIX of
* the existing episode list (id-set equality, order-insensitive). With
* union semantics the merged list legitimately contains episodes BEYOND the
* fetched window, so unchanged-detection must compare the fetched window
* against the existing list's prefix — comparing full lists would bump
* `lastUpdated` on every refresh. When ids drifted between refreshes (the
* one-time positional-id migration, or a feed that rotates enclosure URLs)
* the id sets differ for the SAME content, so a content-signature
* comparison decides: an unchanged feed stays unchanged. */
export function sameRefreshWindow(
existing: Episode[],
fetched: Episode[],
): boolean {
if (fetched.length === 0) return true;
const prefix = existing.slice(0, fetched.length);
const ids = new Set(prefix.map((e) => e.id));
if (fetched.every((e) => ids.has(e.id))) return true;
if (prefix.length !== fetched.length) return false;
const signatures = new Set(prefix.map(episodeSignature));
return fetched.every((e) => signatures.has(episodeSignature(e)));
}

View File

@@ -0,0 +1,59 @@
/**
* Source registry — owns the podcast source list and its immediate file
* persistence. The feed store seeds it at boot and wires the loaded list in.
*/
import { createSignal } from "solid-js";
import type { PodcastSource } from "../types/source";
import { saveSourcesToFile } from "./feeds-persistence";
/** Create a source registry around the given initial list. Every mutation
* persists immediately (async, fire-and-forget) — source edits are rare
* and must not sit in a debounce window across a process exit. */
export function createSourceRegistry(initial: PodcastSource[]) {
const [sources, setSources] = createSignal<PodcastSource[]>([...initial]);
/** Swap in a fully rebuilt list WITHOUT persisting — the boot-time
* loader saves only when its migration actually changed data. */
const replaceAll = (list: PodcastSource[]): void => {
setSources(list);
};
const addSource = (source: Omit<PodcastSource, "id">): PodcastSource => {
const newSource: PodcastSource = {
...source,
id: crypto.randomUUID(),
};
setSources((prev) => {
const updated = [...prev, newSource];
saveSourcesToFile(updated);
return updated;
});
return newSource;
};
const updateSource = (
sourceId: string,
updates: Partial<PodcastSource>,
): void => {
setSources((prev) => {
const updated = prev.map((source) =>
source.id === sourceId ? { ...source, ...updates } : source,
);
saveSourcesToFile(updated);
return updated;
});
};
const toggleSource = (sourceId: string): void => {
setSources((prev) => {
const updated = prev.map((s) =>
s.id === sourceId ? { ...s, enabled: !s.enabled } : s,
);
saveSourcesToFile(updated);
return updated;
});
};
return { sources, replaceAll, addSource, updateSource, toggleSource };
}