▍ humdrum codex / soft

Feeds: first-class personal RSS ("My Feeds") source

834dc5726f0bc0931cc36e488c0033b954971ff3
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-06 12:03

parent 06821e19

Feeds: first-class personal RSS ("My Feeds") source

Issue 2. Personal RSS is now its own kind, separate from the curated News
card, seeded enabled-but-empty so it's an obvious place to add your own feeds.

- new `feeds` kind in SOURCE_KINDS; schema aliases the news shapes (RSS either
  way); feeds module + barrel registration
- extract shared RSS fetch to lib/sources/rss.ts (fetchRss); news + feeds both
  use it
- card reuses NewsBody; /print renders a "My Feeds" section; Settings feed-URL
  editor now covers news + feeds; seeded in scripts/seed.ts

Verified live under node@22: feeds source fetches RSS, card renders, /print
includes the section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

11 files changed

ISSUES.md +11 −6
@@ -16,13 +16,18 @@ configured order. Renderers added for Calendar, Scores, GitHub, Deployments (Vercel),
 Links, Mastodon, Bluesky, and Agenda (Things). New kinds show up automatically once they
 have a `SectionBody` case.
 
-## 2. Personal RSS — dedicated section
+## 2. Personal RSS — dedicated section ✅ done 2026-06-06
 
-Want a separate, clearly-labeled RSS area for personal feeds (distinct from the curated
-News card). Today you can add a second `news` source via *Add source*, but it's not obvious.
-- Make this a first-class thing: either a dedicated "My Feeds" card seeded by default, or a
-  clearer "Add RSS feed" affordance.
-- Tie this to fixing the confusing Add-source UX (issue 4).
+First-class `feeds` kind ("My Feeds"), distinct from the curated `news` card, **seeded
+enabled by default** (empty — add your own URLs in Settings → My Feeds).
+- New kind `feeds` in `SOURCE_KINDS`; schema `lib/schemas/sources/feeds.ts` aliases the
+  news shapes (RSS either way); module `lib/sources/modules/feeds.ts`.
+- Shared RSS fetch extracted to `lib/sources/rss.ts` (`fetchRss`); both `news` and `feeds`
+  modules use it.
+- Card reuses `NewsBody`; `/print` adds a "My Feeds" section; Settings feed-URL editor now
+  covers both kinds; seeded in `scripts/seed.ts`.
+- Friendlier "Add RSS feed" affordance still rides on the issue 4 Add-source UX rework; the
+  empty card currently shows the generic "Needs configuration → settings".
 
 ## 3. Calendar still has issues
 
app/print/page.tsx +3 −1
@@ -23,6 +23,7 @@
 // Classic newspaper heading per kind; falls back to the source's own label.
 const SECTION_TITLE: Partial<Record<SourceKind, string>> = {
   news: "Headlines",
+  feeds: "My Feeds",
   onthisday: "On This Day",
   obsidian: "From the Vault",
   calendar: "Calendar",
@@ -55,7 +56,8 @@
 // Render a section's body for its kind. Returns null when there's nothing to show.
 function SectionBody({ kind, payload }: { kind: SourceKind; payload: unknown }) {
   switch (kind) {
-    case "news": {
+    case "news":
+    case "feeds": {
       const p = payload as NewsPayload;
       if (p.items.length === 0) return null;
       return (
components/SettingsView.tsx +1 −1
@@ -382,7 +382,7 @@   state: SourceState;
   onPatch: (patch: Record<string, unknown>) => void;
 }) {
   const cfg = state.source.config as Record<string, unknown>;
-  if (state.source.kind === "news") {
+  if (state.source.kind === "news" || state.source.kind === "feeds") {
     const feeds = Array.isArray(cfg.feeds) ? (cfg.feeds as string[]).join("\n") : "";
     return (
       <div className="mt-3 space-y-2">
components/cards/CardBodies.tsx +1 −0
@@ -307,6 +307,7 @@   switch (kind) {
     case "weather":
       return <WeatherBody p={payload as WeatherPayload} row={shortTile} />;
     case "news":
+    case "feeds":
       return <NewsBody p={payload as NewsPayload} />;
     case "onthisday":
       return <OnThisDayBody p={payload as OnThisDayPayload} />;
lib/schemas/source.ts +1 −0
@@ -5,6 +5,7 @@ // "todos" is local data (no fetcher) — the others pull from the network/disk.
 export const SOURCE_KINDS = [
   "weather",
   "news",
+  "feeds",
   "onthisday",
   "sports",
   "calendar",
lib/schemas/sources/feeds.ts +11 −0
@@ -0,0 +1,11 @@
+// "feeds" = personal RSS, the user's own subscriptions — distinct from the
+// curated "news" card. Same shape as news (it's RSS either way), re-exported
+// under feeds names so the kinds stay independent (own config, card, section).
+
+import { NewsConfig, NewsPayload } from "./news";
+
+export const FeedsConfig = NewsConfig;
+export type FeedsConfig = import("./news").NewsConfig;
+
+export const FeedsPayload = NewsPayload;
+export type FeedsPayload = import("./news").NewsPayload;
lib/sources/modules/feeds.ts +23 −0
@@ -0,0 +1,23 @@
+import type { SourceModule } from "../registry";
+import { fetchRss } from "../rss";
+import {
+  FeedsConfig,
+  FeedsPayload,
+  type FeedsConfig as Config,
+  type FeedsPayload as Payload,
+} from "@/lib/schemas/sources/feeds";
+
+// "My Feeds" — the user's personal RSS subscriptions, separate from the curated
+// News card. Same RSS fetch; distinct kind so it gets its own card + config.
+export const feedsModule: SourceModule<Config, Payload> = {
+  kind: "feeds",
+  label: "My Feeds",
+  keyless: true,
+  defaultRefreshSeconds: 1800,
+  configSchema: FeedsConfig,
+  payloadSchema: FeedsPayload,
+
+  isConfigured: (config) => config.feeds.length > 0,
+
+  fetch: ({ config, signal }) => fetchRss(config, signal),
+};
lib/sources/modules/index.ts +2 −0
@@ -2,6 +2,7 @@ import type { SourceModule } from "../registry";
 import { weatherModule } from "./weather";
 import { onThisDayModule } from "./onthisday";
 import { newsModule } from "./news";
+import { feedsModule } from "./feeds";
 import { obsidianModule } from "./obsidian";
 import { githubModule } from "./github";
 import { vercelModule } from "./vercel";
@@ -19,6 +20,7 @@ export const sourceModules: SourceModule[] = [
   weatherModule as SourceModule,
   onThisDayModule as SourceModule,
   newsModule as SourceModule,
+  feedsModule as SourceModule,
   obsidianModule as SourceModule,
   githubModule as SourceModule,
   vercelModule as SourceModule,
lib/sources/modules/news.ts +2 −52
@@ -1,5 +1,5 @@
-import Parser from "rss-parser";
 import type { SourceModule } from "../registry";
+import { fetchRss } from "../rss";
 import {
   NewsConfig,
   NewsPayload,
@@ -7,16 +7,6 @@   type NewsConfig as Config,
   type NewsPayload as Payload,
 } from "@/lib/schemas/sources/news";
 
-const parser = new Parser();
-
-function hostname(url: string): string {
-  try {
-    return new URL(url).hostname.replace(/^www\./, "");
-  } catch {
-    return url;
-  }
-}
-
 export const newsModule: SourceModule<Config, Payload> = {
   kind: "news",
   label: "News",
@@ -27,45 +17,5 @@   payloadSchema: NewsPayload,
 
   isConfigured: (config) => config.feeds.length > 0,
 
-  async fetch({ config, signal }) {
-    const results = await Promise.allSettled(
-      config.feeds.map(async (url) => {
-        // Fetch ourselves so the AbortSignal/timeout is honored, then parse text.
-        const res = await fetch(url, {
-          signal,
-          headers: { "User-Agent": "PersonalDashboard/0.1" },
-        });
-        if (!res.ok) throw new Error(`${hostname(url)} ${res.status}`);
-        const xml = await res.text();
-        const feed = await parser.parseString(xml);
-        const source = feed.title || hostname(url);
-        return (feed.items ?? []).map((it) => ({
-          title: (it.title ?? "Untitled").trim(),
-          link: it.link ?? "",
-          source,
-          isoDate: it.isoDate ?? null,
-        }));
-      }),
-    );
-
-    const items = results
-      .filter((r): r is PromiseFulfilledResult<Payload["items"]> => r.status === "fulfilled")
-      .flatMap((r) => r.value)
-      .filter((it) => it.link)
-      .sort((a, b) => {
-        const ta = a.isoDate ? Date.parse(a.isoDate) : 0;
-        const tb = b.isoDate ? Date.parse(b.isoDate) : 0;
-        return tb - ta;
-      })
-      .slice(0, config.limit);
-
-    if (items.length === 0) {
-      const firstErr = results.find((r) => r.status === "rejected") as
-        | PromiseRejectedResult
-        | undefined;
-      if (firstErr) throw new Error(String(firstErr.reason?.message ?? firstErr.reason));
-    }
-
-    return { items };
-  },
+  fetch: ({ config, signal }) => fetchRss(config, signal),
 };
lib/sources/rss.ts +60 −0
@@ -0,0 +1,60 @@
+// Shared RSS fetch used by both the "news" (curated) and "feeds" (personal)
+// source modules — same fetch/parse/sort/slice over a list of feed URLs.
+
+import Parser from "rss-parser";
+import type { NewsConfig, NewsPayload } from "@/lib/schemas/sources/news";
+
+const parser = new Parser();
+
+function hostname(url: string): string {
+  try {
+    return new URL(url).hostname.replace(/^www\./, "");
+  } catch {
+    return url;
+  }
+}
+
+export async function fetchRss(
+  config: NewsConfig,
+  signal?: AbortSignal,
+): Promise<NewsPayload> {
+  const results = await Promise.allSettled(
+    config.feeds.map(async (url) => {
+      // Fetch ourselves so the AbortSignal/timeout is honored, then parse text.
+      const res = await fetch(url, {
+        signal,
+        headers: { "User-Agent": "PersonalDashboard/0.1" },
+      });
+      if (!res.ok) throw new Error(`${hostname(url)} ${res.status}`);
+      const xml = await res.text();
+      const feed = await parser.parseString(xml);
+      const source = feed.title || hostname(url);
+      return (feed.items ?? []).map((it) => ({
+        title: (it.title ?? "Untitled").trim(),
+        link: it.link ?? "",
+        source,
+        isoDate: it.isoDate ?? null,
+      }));
+    }),
+  );
+
+  const items = results
+    .filter((r): r is PromiseFulfilledResult<NewsPayload["items"]> => r.status === "fulfilled")
+    .flatMap((r) => r.value)
+    .filter((it) => it.link)
+    .sort((a, b) => {
+      const ta = a.isoDate ? Date.parse(a.isoDate) : 0;
+      const tb = b.isoDate ? Date.parse(b.isoDate) : 0;
+      return tb - ta;
+    })
+    .slice(0, config.limit);
+
+  if (items.length === 0) {
+    const firstErr = results.find((r) => r.status === "rejected") as
+      | PromiseRejectedResult
+      | undefined;
+    if (firstErr) throw new Error(String(firstErr.reason?.message ?? firstErr.reason));
+  }
+
+  return { items };
+}
scripts/seed.ts +2 −0
@@ -43,6 +43,8 @@       ],
       limit: 14,
     },
   },
+  // Personal RSS — starts empty; add your own feed URLs in Settings → My Feeds.
+  { kind: "feeds", label: "My Feeds", enabled: true, refreshSeconds: 1800, size: "tall", config: { feeds: [], limit: 14 } },
   { kind: "sports", label: "Scores", enabled: false, refreshSeconds: 600, size: "md", config: { leagues: ["basketball/nba"], teams: [] } },
   { kind: "github", label: "GitHub", enabled: false, refreshSeconds: 900, size: "md", config: {} },
   { kind: "vercel", label: "Vercel", enabled: false, refreshSeconds: 600, size: "md", config: {} },