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: {} },