▍ humdrum codex / soft

Add four keyless live-stat sources: AQI, markets, HN, uptime

92a8c5d96fc51fe5494b9b02b1d280865c631274
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-09 14:53

parent b0e95007

Add four keyless live-stat sources: AQI, markets, HN, uptime

- aqi: US AQI + PM2.5/PM10/ozone (+ pollen where available) from
  Open-Meteo's air-quality API, using the same location as weather.
- markets: a stock/crypto watchlist via Yahoo Finance's chart endpoint
  (price + daily change %); symbols editable in Settings. Seeded with
  AAPL/NVDA/BTC-USD/ETH-USD.
- hackernews: HN front page (always) plus optional subreddits, sorted by
  points; keyless public JSON APIs.
- uptime: pings a list of URLs for up/down + latency; starts empty
  (add URLs in Settings), seeded disabled.

Each has a schema, module, registry entry, SOURCE_KINDS membership, a
dashboard card body, and a Settings config editor (markets/HN/uptime).
AQI/markets/HN also render as printed paper sections and slot into
PRINT_ORDER; uptime is dashboard-only. Verified all four fetch live.

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

16 files changed

.claude/settings.local.json +7 −1
@@ -79,7 +79,13 @@       "Bash(brew uninstall *)",
       "Bash(command -v zplug)",
       "Read(//opt/homebrew/Cellar/zplug/2.4.2/**)",
       "Bash(zsh -i -c 'whence -v zplug; echo \"exit=$?\"')",
-      "Bash(zsh -i -c 'print -l $fpath | grep -i zplug || echo \"no zplug in fpath\"')"
+      "Bash(zsh -i -c 'print -l $fpath | grep -i zplug || echo \"no zplug in fpath\"')",
+      "Bash(sed -n '1,60p' lib/sources/modules/weather.ts)",
+      "Bash(sed -n '1,16p' lib/schemas/sources/weather.ts)",
+      "Bash(sed -n '75,100p' scripts/seed.ts)",
+      "Bash(sed -n '330,360p' components/cards/CardBodies.tsx)",
+      "Bash(sed -n '1,20p' components/cards/CardBodies.tsx)",
+      "Bash(awk '/^else$/,/^fi$/' ~/bin/morning-briefing)"
     ]
   }
 }
app/print/page.tsx +57 −0
@@ -20,6 +20,9 @@ import type { GitHubPayload } from "@/lib/schemas/sources/github";
 import type { VercelPayload } from "@/lib/schemas/sources/vercel";
 import type { MastodonPayload } from "@/lib/schemas/sources/mastodon";
 import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky";
+import type { AqiPayload } from "@/lib/schemas/sources/aqi";
+import type { MarketsPayload } from "@/lib/schemas/sources/markets";
+import type { HackerNewsPayload } from "@/lib/schemas/sources/hackernews";
 import type { ThingsPayload } from "@/lib/schemas/sources/things";
 
 export const dynamic = "force-dynamic";
@@ -29,6 +32,9 @@ const SECTION_TITLE: Partial<Record<SourceKind, string>> = {
   news: "Headlines",
   briefs: "News Briefs",
   feeds: "My Feeds",
+  aqi: "Air Quality",
+  markets: "Markets",
+  hackernews: "Hacker News",
   onthisday: "On This Day",
   obsidian: "From the Vault",
   calendar: "Calendar",
@@ -136,6 +142,57 @@         <>
           {p.items.slice(0, cap).map((it, i) => (
             <p className="paper-item" key={i}>
               {it.title} <span className="src">— {it.source}</span>
+            </p>
+          ))}
+        </>
+      );
+    }
+    case "aqi": {
+      const p = payload as AqiPayload;
+      if (p.aqi == null && p.pollen.length === 0) return null;
+      return (
+        <>
+          <p className="paper-item">
+            <span className="yr">AQI {p.aqi ?? "—"}</span> {p.category}
+            {p.pm25 != null && <span className="src"> — PM2.5 {p.pm25}</span>}
+          </p>
+          {p.pollen.map((pl) => (
+            <p className="paper-item" key={pl.label}>
+              {pl.label} <span className="src">— {pl.level}</span>
+            </p>
+          ))}
+        </>
+      );
+    }
+    case "markets": {
+      const p = payload as MarketsPayload;
+      if (p.quotes.length === 0) return null;
+      return (
+        <>
+          {p.quotes.map((q) => (
+            <p className="paper-item" key={q.symbol}>
+              <span className="yr">{q.symbol}</span>{" "}
+              {q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}
+              {q.changePct != null && (
+                <span className="src">
+                  {" "}
+                  — {q.changePct >= 0 ? "+" : ""}
+                  {q.changePct}%
+                </span>
+              )}
+            </p>
+          ))}
+        </>
+      );
+    }
+    case "hackernews": {
+      const p = payload as HackerNewsPayload;
+      if (p.stories.length === 0) return null;
+      return (
+        <>
+          {p.stories.map((s, i) => (
+            <p className="paper-item" key={i}>
+              {s.title} <span className="src">— {s.source}</span>
             </p>
           ))}
         </>
components/SettingsView.tsx +58 −1
@@ -256,7 +256,10 @@     source.kind === "feeds" ||
     source.kind === "obsidian" ||
     source.kind === "calendar" ||
     source.kind === "sports" ||
-    source.kind === "links";
+    source.kind === "links" ||
+    source.kind === "markets" ||
+    source.kind === "hackernews" ||
+    source.kind === "uptime";
 
   return (
     <div className="p-3" style={{ border: "1px solid var(--border)", borderRadius: 6 }}>
@@ -471,6 +474,60 @@         </Field>
         <p className="text-xs" style={{ color: "var(--text-faint)" }}>
           ESPN paths: basketball/nba · baseball/mlb · football/nfl · hockey/nhl · soccer/eng.1
         </p>
+      </div>
+    );
+  }
+  if (state.source.kind === "markets") {
+    const symbols = Array.isArray(cfg.symbols) ? (cfg.symbols as string[]).join("\n") : "";
+    const lines = (v: string) => v.split("\n").map((s) => s.trim().toUpperCase()).filter(Boolean);
+    return (
+      <div className="mt-3 space-y-2">
+        <Field label="Symbols (one per line — crypto as PAIR, e.g. BTC-USD)">
+          <textarea
+            rows={5}
+            defaultValue={symbols}
+            placeholder={"AAPL\nNVDA\nBTC-USD\nETH-USD"}
+            className="w-full px-3 py-2 text-xs font-mono"
+            style={inputStyle}
+            onBlur={(e) => onPatch({ config: { ...cfg, symbols: lines(e.target.value) } })}
+          />
+        </Field>
+      </div>
+    );
+  }
+  if (state.source.kind === "hackernews") {
+    const subs = Array.isArray(cfg.subreddits) ? (cfg.subreddits as string[]).join("\n") : "";
+    const lines = (v: string) => v.split("\n").map((s) => s.trim().replace(/^r\//, "")).filter(Boolean);
+    return (
+      <div className="mt-3 space-y-2">
+        <Field label="Subreddits (optional, one per line — HN is always included)">
+          <textarea
+            rows={3}
+            defaultValue={subs}
+            placeholder={"programming\ntechnology"}
+            className="w-full px-3 py-2 text-xs font-mono"
+            style={inputStyle}
+            onBlur={(e) => onPatch({ config: { ...cfg, subreddits: lines(e.target.value) } })}
+          />
+        </Field>
+      </div>
+    );
+  }
+  if (state.source.kind === "uptime") {
+    const urls = Array.isArray(cfg.urls) ? (cfg.urls as string[]).join("\n") : "";
+    const lines = (v: string) => v.split("\n").map((s) => s.trim()).filter(Boolean);
+    return (
+      <div className="mt-3 space-y-2">
+        <Field label="URLs to ping (one per line)">
+          <textarea
+            rows={5}
+            defaultValue={urls}
+            placeholder={"https://humdrum.one\nhttps://screamer.humdrum.one"}
+            className="w-full px-3 py-2 text-xs font-mono"
+            style={inputStyle}
+            onBlur={(e) => onPatch({ config: { ...cfg, urls: lines(e.target.value) } })}
+          />
+        </Field>
       </div>
     );
   }
components/cards/CardBodies.tsx +107 −0
@@ -12,6 +12,10 @@ import type { GitHubPayload } from "@/lib/schemas/sources/github";
 import type { VercelPayload } from "@/lib/schemas/sources/vercel";
 import type { MastodonPayload } from "@/lib/schemas/sources/mastodon";
 import type { BlueskyPayload } from "@/lib/schemas/sources/bluesky";
+import type { AqiPayload } from "@/lib/schemas/sources/aqi";
+import type { MarketsPayload } from "@/lib/schemas/sources/markets";
+import type { HackerNewsPayload } from "@/lib/schemas/sources/hackernews";
+import type { UptimePayload } from "@/lib/schemas/sources/uptime";
 import type { CalendarPayload } from "@/lib/schemas/sources/calendar";
 import type { SportsPayload } from "@/lib/schemas/sources/sports";
 import type { LinksPayload } from "@/lib/schemas/sources/links";
@@ -319,6 +323,101 @@     </div>
   );
 }
 
+function AqiBody({ p }: { p: AqiPayload }) {
+  return (
+    <div className="space-y-2">
+      <div className="flex items-baseline gap-3">
+        <span className="text-4xl" style={{ fontFamily: "var(--font-display)" }}>
+          {p.aqi ?? "—"}
+        </span>
+        <span style={muted}>{p.category}</span>
+      </div>
+      <p className="text-xs" style={faint}>
+        {p.location}
+        {p.pm25 != null ? ` · PM2.5 ${p.pm25}` : ""}
+        {p.pm10 != null ? ` · PM10 ${p.pm10}` : ""}
+        {p.ozone != null ? ` · O₃ ${Math.round(p.ozone)}` : ""}
+      </p>
+      {p.pollen.length > 0 && (
+        <ul className="space-y-0.5">
+          {p.pollen.map((pl) => (
+            <li key={pl.label} className="text-sm flex justify-between">
+              <span>{pl.label}</span>
+              <span style={muted}>{pl.level}</span>
+            </li>
+          ))}
+        </ul>
+      )}
+    </div>
+  );
+}
+
+function MarketsBody({ p }: { p: MarketsPayload }) {
+  if (p.quotes.length === 0) return <p className="text-sm" style={faint}>No quotes.</p>;
+  return (
+    <ul className="space-y-1">
+      {p.quotes.map((q) => {
+        const up = (q.changePct ?? 0) >= 0;
+        return (
+          <li key={q.symbol} className="flex items-baseline justify-between text-sm">
+            <span className="font-medium">{q.symbol}</span>
+            <span className="flex items-baseline gap-2">
+              <span>{q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}</span>
+              {q.changePct != null && (
+                <span className="text-xs tabular-nums" style={{ color: up ? "var(--green)" : "var(--red)" }}>
+                  {up ? "+" : ""}
+                  {q.changePct}%
+                </span>
+              )}
+            </span>
+          </li>
+        );
+      })}
+    </ul>
+  );
+}
+
+function HackerNewsBody({ p }: { p: HackerNewsPayload }) {
+  if (p.stories.length === 0) return <p className="text-sm" style={faint}>Nothing yet.</p>;
+  return (
+    <ul className="space-y-2">
+      {p.stories.map((s, i) => (
+        <li key={i} className="text-sm leading-snug">
+          {s.url ? (
+            <a href={s.url} target="_blank" rel="noreferrer" style={{ color: "var(--text)" }}>
+              {s.title}
+            </a>
+          ) : (
+            s.title
+          )}
+          <span className="ml-1 text-xs" style={faint}>
+            · {s.source}
+            {s.points != null ? ` ▲${s.points}` : ""}
+          </span>
+        </li>
+      ))}
+    </ul>
+  );
+}
+
+function UptimeBody({ p }: { p: UptimePayload }) {
+  if (p.sites.length === 0) return <p className="text-sm" style={faint}>No URLs configured.</p>;
+  return (
+    <ul className="space-y-1">
+      {p.sites.map((s) => (
+        <li key={s.url} className="flex items-baseline justify-between text-sm">
+          <span className="truncate" style={{ maxWidth: "70%" }}>
+            {s.url.replace(/^https?:\/\//, "")}
+          </span>
+          <span className="text-xs" style={{ color: s.ok ? "var(--green)" : "var(--red)" }}>
+            {s.ok ? `▲ ${s.ms}ms` : `▼ ${s.status ?? "DOWN"}`}
+          </span>
+        </li>
+      ))}
+    </ul>
+  );
+}
+
 export function CardBody({
   kind,
   payload,
@@ -366,6 +465,14 @@             tag: n.reason,
           }))}
         />
       );
+    case "aqi":
+      return <AqiBody p={payload as AqiPayload} />;
+    case "markets":
+      return <MarketsBody p={payload as MarketsPayload} />;
+    case "hackernews":
+      return <HackerNewsBody p={payload as HackerNewsPayload} />;
+    case "uptime":
+      return <UptimeBody p={payload as UptimePayload} />;
     case "calendar":
       return <CalendarBody p={payload as CalendarPayload} />;
     case "sports":
lib/edition-data.ts +4 −1
@@ -105,13 +105,16 @@ // what matters first thing in the morning. Weather is the lead paragraph and is
 // not listed here. Kinds not listed fall to the end (in source order). `links`
 // is intentionally absent — it never belongs in the printed edition.
 const PRINT_ORDER: SourceKind[] = [
+  "aqi", // air quality — pairs with the weather hero
   "calendar", // today's events
   "todos", // agenda / tasks
+  "markets", // watchlist
   "sports", // scores
   "feeds", // my feeds
+  "hackernews", // tech reading
   "onthisday",
   "obsidian", // from the vault
-  "briefs", // news briefs — rendered as the trailing full-page spread
+  "briefs", // news briefs — flow last, as trailing column cards
 ];
 
 function printRank(kind: SourceKind): number {
lib/schemas/source.ts +4 −0
@@ -14,6 +14,10 @@   "mastodon",
   "bluesky",
   "github",
   "vercel",
+  "aqi",
+  "markets",
+  "hackernews",
+  "uptime",
   "obsidian",
   "todos",
   "links",
lib/schemas/sources/aqi.ts +27 −0
@@ -0,0 +1,27 @@
+import { z } from "zod";
+
+// Air quality + pollen for a location (Open-Meteo air-quality API, keyless).
+// Location resolves from config → app settings → env, same as weather.
+export const AqiConfig = z.object({
+  lat: z.number().optional(),
+  lon: z.number().optional(),
+  label: z.string().optional(),
+});
+export type AqiConfig = z.infer<typeof AqiConfig>;
+
+export const AqiPollen = z.object({
+  label: z.string(),
+  value: z.number(), // grains/m³
+  level: z.string(), // Low / Moderate / High / Very High
+});
+
+export const AqiPayload = z.object({
+  location: z.string(),
+  aqi: z.number().nullable(), // US AQI
+  category: z.string(), // Good / Moderate / Unhealthy …
+  pm25: z.number().nullable(),
+  pm10: z.number().nullable(),
+  ozone: z.number().nullable(),
+  pollen: z.array(AqiPollen).default([]),
+});
+export type AqiPayload = z.infer<typeof AqiPayload>;
lib/schemas/sources/hackernews.ts +22 −0
@@ -0,0 +1,22 @@
+import { z } from "zod";
+
+// Top stories from Hacker News (always) plus any configured subreddits.
+// Both are keyless public JSON APIs.
+export const HackerNewsConfig = z.object({
+  limit: z.number().int().min(1).max(30).default(10),
+  subreddits: z.array(z.string()).default([]),
+});
+export type HackerNewsConfig = z.infer<typeof HackerNewsConfig>;
+
+export const FeedStory = z.object({
+  source: z.string(), // "HN" | "r/<sub>"
+  title: z.string(),
+  points: z.number().nullable(),
+  comments: z.number().nullable(),
+  url: z.string().nullable(),
+});
+
+export const HackerNewsPayload = z.object({
+  stories: z.array(FeedStory).default([]),
+});
+export type HackerNewsPayload = z.infer<typeof HackerNewsPayload>;
lib/schemas/sources/markets.ts +21 −0
@@ -0,0 +1,21 @@
+import { z } from "zod";
+
+// A watchlist of stock / crypto symbols (Yahoo Finance chart endpoint, keyless).
+// Crypto uses Yahoo's pair form, e.g. BTC-USD, ETH-USD.
+export const MarketsConfig = z.object({
+  symbols: z.array(z.string()).default([]),
+});
+export type MarketsConfig = z.infer<typeof MarketsConfig>;
+
+export const MarketQuote = z.object({
+  symbol: z.string(),
+  name: z.string(),
+  price: z.number(),
+  changePct: z.number().nullable(),
+  currency: z.string(),
+});
+
+export const MarketsPayload = z.object({
+  quotes: z.array(MarketQuote).default([]),
+});
+export type MarketsPayload = z.infer<typeof MarketsPayload>;
lib/schemas/sources/uptime.ts +19 −0
@@ -0,0 +1,19 @@
+import { z } from "zod";
+
+// Ping a list of URLs and report up/down + response time. Keyless.
+export const UptimeConfig = z.object({
+  urls: z.array(z.string()).default([]),
+});
+export type UptimeConfig = z.infer<typeof UptimeConfig>;
+
+export const UptimeSite = z.object({
+  url: z.string(),
+  ok: z.boolean(),
+  status: z.number().nullable(),
+  ms: z.number().nullable(),
+});
+
+export const UptimePayload = z.object({
+  sites: z.array(UptimeSite).default([]),
+});
+export type UptimePayload = z.infer<typeof UptimePayload>;
lib/sources/modules/aqi.ts +111 −0
@@ -0,0 +1,111 @@
+import type { SourceModule } from "../registry";
+import { getSettings } from "@/lib/store";
+import {
+  AqiConfig,
+  AqiPayload,
+  type AqiConfig as Config,
+  type AqiPayload as Payload,
+} from "@/lib/schemas/sources/aqi";
+
+// Same location resolution as weather: config → settings → env.
+function resolveLocation(config: Config): { lat: number; lon: number; label: string } | null {
+  if (config.lat != null && config.lon != null) {
+    return { lat: config.lat, lon: config.lon, label: config.label ?? "Custom" };
+  }
+  const loc = getSettings().location;
+  if (loc) return { lat: loc.lat, lon: loc.lon, label: loc.label || "Home" };
+  const envLat = process.env.WEATHER_LAT;
+  const envLon = process.env.WEATHER_LON;
+  if (envLat && envLon) return { lat: Number(envLat), lon: Number(envLon), label: "Home" };
+  return null;
+}
+
+function aqiCategory(aqi: number | null): string {
+  if (aqi == null) return "—";
+  if (aqi <= 50) return "Good";
+  if (aqi <= 100) return "Moderate";
+  if (aqi <= 150) return "Unhealthy (sensitive)";
+  if (aqi <= 200) return "Unhealthy";
+  if (aqi <= 300) return "Very unhealthy";
+  return "Hazardous";
+}
+
+// Pollen is reported in grains/m³ (Europe-only in Open-Meteo). Rough banding.
+function pollenLevel(v: number): string {
+  if (v < 10) return "Low";
+  if (v < 30) return "Moderate";
+  if (v < 70) return "High";
+  return "Very High";
+}
+
+const POLLENS: Array<[string, string]> = [
+  ["grass_pollen", "Grass"],
+  ["birch_pollen", "Birch"],
+  ["alder_pollen", "Alder"],
+  ["ragweed_pollen", "Ragweed"],
+  ["mugwort_pollen", "Mugwort"],
+  ["olive_pollen", "Olive"],
+];
+
+export const aqiModule: SourceModule<Config, Payload> = {
+  kind: "aqi",
+  label: "Air Quality",
+  keyless: true,
+  defaultRefreshSeconds: 3600,
+  configSchema: AqiConfig,
+  payloadSchema: AqiPayload,
+
+  isConfigured: (config) => resolveLocation(config) !== null,
+
+  async fetch({ config, signal }) {
+    const place = resolveLocation(config);
+    if (!place) throw new Error("No location set — add one in Settings.");
+
+    const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
+    url.searchParams.set("latitude", String(place.lat));
+    url.searchParams.set("longitude", String(place.lon));
+    url.searchParams.set("timezone", "auto");
+    url.searchParams.set("current", "us_aqi,pm2_5,pm10,ozone");
+    url.searchParams.set("hourly", POLLENS.map(([k]) => k).join(","));
+
+    const res = await fetch(url, { signal });
+    if (!res.ok) throw new Error(`Air quality ${res.status}`);
+    const j = (await res.json()) as {
+      current?: {
+        time: string;
+        us_aqi?: number | null;
+        pm2_5?: number | null;
+        pm10?: number | null;
+        ozone?: number | null;
+      };
+      hourly?: { time: string[] } & Record<string, (number | null)[]>;
+    };
+
+    const c = j.current ?? ({} as NonNullable<typeof j.current>);
+    const aqi = c.us_aqi ?? null;
+
+    // Pollen: pick the hourly slot nearest the current hour; keep non-zero types.
+    const pollen: Payload["pollen"] = [];
+    if (j.hourly?.time?.length) {
+      const nowHour = (c.time ?? j.hourly.time[0]).slice(0, 13);
+      let idx = j.hourly.time.findIndex((t) => t.slice(0, 13) === nowHour);
+      if (idx < 0) idx = 0;
+      for (const [key, label] of POLLENS) {
+        const v = j.hourly[key]?.[idx];
+        if (typeof v === "number" && v > 0) {
+          pollen.push({ label, value: Math.round(v), level: pollenLevel(v) });
+        }
+      }
+    }
+
+    return {
+      location: place.label,
+      aqi,
+      category: aqiCategory(aqi),
+      pm25: c.pm2_5 ?? null,
+      pm10: c.pm10 ?? null,
+      ozone: c.ozone ?? null,
+      pollen,
+    };
+  },
+};
lib/sources/modules/hackernews.ts +75 −0
@@ -0,0 +1,75 @@
+import type { SourceModule } from "../registry";
+import {
+  HackerNewsConfig,
+  HackerNewsPayload,
+  type HackerNewsConfig as Config,
+  type HackerNewsPayload as Payload,
+} from "@/lib/schemas/sources/hackernews";
+
+type Story = Payload["stories"][number];
+
+async function fetchHN(limit: number, signal?: AbortSignal): Promise<Story[]> {
+  const res = await fetch(`https://hn.algolia.com/api/v1/search?tags=front_page&hitsPerPage=${limit}`, {
+    signal,
+  });
+  if (!res.ok) return [];
+  const j = (await res.json()) as {
+    hits: Array<{ objectID: string; title: string; points?: number; num_comments?: number; url?: string }>;
+  };
+  return j.hits.map((h) => ({
+    source: "HN",
+    title: h.title,
+    points: h.points ?? null,
+    comments: h.num_comments ?? null,
+    url: h.url ?? `https://news.ycombinator.com/item?id=${h.objectID}`,
+  }));
+}
+
+async function fetchSub(sub: string, limit: number, signal?: AbortSignal): Promise<Story[]> {
+  try {
+    const res = await fetch(`https://www.reddit.com/r/${encodeURIComponent(sub)}/top.json?t=day&limit=${limit}`, {
+      headers: { "User-Agent": "PersonalDashboard/1.0" },
+      signal,
+    });
+    if (!res.ok) return [];
+    const j = (await res.json()) as {
+      data?: { children?: Array<{ data?: { title?: string; ups?: number; num_comments?: number; permalink?: string; url?: string } }> };
+    };
+    return (j.data?.children ?? []).map((c) => {
+      const d = c.data ?? {};
+      return {
+        source: `r/${sub}`,
+        title: d.title ?? "",
+        points: d.ups ?? null,
+        comments: d.num_comments ?? null,
+        url: d.permalink ? `https://www.reddit.com${d.permalink}` : d.url ?? null,
+      };
+    });
+  } catch {
+    return [];
+  }
+}
+
+export const hackerNewsModule: SourceModule<Config, Payload> = {
+  kind: "hackernews",
+  label: "Hacker News",
+  keyless: true,
+  defaultRefreshSeconds: 1800,
+  configSchema: HackerNewsConfig,
+  payloadSchema: HackerNewsPayload,
+
+  isConfigured: () => true, // HN always works; subreddits are optional extras
+
+  async fetch({ config, signal }) {
+    const lists = await Promise.all([
+      fetchHN(config.limit, signal),
+      ...config.subreddits.map((s) => fetchSub(s.trim(), config.limit, signal)),
+    ]);
+    const stories = lists
+      .flat()
+      .filter((s) => s.title)
+      .sort((a, b) => (b.points ?? 0) - (a.points ?? 0))
+      .slice(0, config.limit);
+    return { stories };
+  },
+};
lib/sources/modules/index.ts +8 −0
@@ -9,6 +9,10 @@ import { githubModule } from "./github";
 import { vercelModule } from "./vercel";
 import { mastodonModule } from "./mastodon";
 import { blueskyModule } from "./bluesky";
+import { aqiModule } from "./aqi";
+import { marketsModule } from "./markets";
+import { hackerNewsModule } from "./hackernews";
+import { uptimeModule } from "./uptime";
 import { calendarModule } from "./calendar";
 import { sportsModule } from "./sports";
 import { thingsModule } from "./things";
@@ -28,6 +32,10 @@   githubModule as SourceModule,
   vercelModule as SourceModule,
   mastodonModule as SourceModule,
   blueskyModule as SourceModule,
+  aqiModule as SourceModule,
+  marketsModule as SourceModule,
+  hackerNewsModule as SourceModule,
+  uptimeModule as SourceModule,
   calendarModule as SourceModule,
   sportsModule as SourceModule,
   thingsModule as SourceModule,
lib/sources/modules/markets.ts +66 −0
@@ -0,0 +1,66 @@
+import type { SourceModule } from "../registry";
+import {
+  MarketsConfig,
+  MarketsPayload,
+  type MarketsConfig as Config,
+  type MarketsPayload as Payload,
+} from "@/lib/schemas/sources/markets";
+
+const CHART = "https://query1.finance.yahoo.com/v8/finance/chart";
+
+// Yahoo's chart endpoint carries everything we need in `meta` and works for both
+// equities and crypto pairs (BTC-USD). One request per symbol; failures are
+// skipped rather than failing the whole card.
+async function quote(symbol: string, signal?: AbortSignal): Promise<Payload["quotes"][number] | null> {
+  try {
+    const res = await fetch(`${CHART}/${encodeURIComponent(symbol)}?range=5d&interval=1d`, {
+      headers: { "User-Agent": "Mozilla/5.0 (PersonalDashboard)" },
+      signal,
+    });
+    if (!res.ok) return null;
+    const j = (await res.json()) as {
+      chart?: { result?: Array<{ meta?: Record<string, unknown> }> };
+    };
+    const meta = j.chart?.result?.[0]?.meta as
+      | {
+          regularMarketPrice?: number;
+          chartPreviousClose?: number;
+          previousClose?: number;
+          currency?: string;
+          shortName?: string;
+          symbol?: string;
+        }
+      | undefined;
+    if (!meta || typeof meta.regularMarketPrice !== "number") return null;
+
+    const price = meta.regularMarketPrice;
+    const prev = meta.chartPreviousClose ?? meta.previousClose;
+    const changePct = typeof prev === "number" && prev !== 0 ? ((price - prev) / prev) * 100 : null;
+
+    return {
+      symbol: meta.symbol ?? symbol,
+      name: meta.shortName ?? symbol,
+      price,
+      changePct: changePct == null ? null : Math.round(changePct * 100) / 100,
+      currency: meta.currency ?? "USD",
+    };
+  } catch {
+    return null;
+  }
+}
+
+export const marketsModule: SourceModule<Config, Payload> = {
+  kind: "markets",
+  label: "Markets",
+  keyless: true,
+  defaultRefreshSeconds: 900,
+  configSchema: MarketsConfig,
+  payloadSchema: MarketsPayload,
+
+  isConfigured: (config) => config.symbols.length > 0,
+
+  async fetch({ config, signal }) {
+    const results = await Promise.all(config.symbols.map((s) => quote(s.trim().toUpperCase(), signal)));
+    return { quotes: results.filter((q): q is NonNullable<typeof q> => q !== null) };
+  },
+};
lib/sources/modules/uptime.ts +43 −0
@@ -0,0 +1,43 @@
+import type { SourceModule } from "../registry";
+import {
+  UptimeConfig,
+  UptimePayload,
+  type UptimeConfig as Config,
+  type UptimePayload as Payload,
+} from "@/lib/schemas/sources/uptime";
+
+const TIMEOUT_MS = 8000;
+
+async function ping(url: string, parentSignal?: AbortSignal): Promise<Payload["sites"][number]> {
+  const target = /^https?:\/\//.test(url) ? url : `https://${url}`;
+  const ctrl = new AbortController();
+  const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS);
+  const onAbort = () => ctrl.abort();
+  parentSignal?.addEventListener("abort", onAbort);
+  const started = Date.now();
+  try {
+    const res = await fetch(target, { method: "GET", redirect: "follow", signal: ctrl.signal });
+    return { url, ok: res.ok, status: res.status, ms: Date.now() - started };
+  } catch {
+    return { url, ok: false, status: null, ms: null };
+  } finally {
+    clearTimeout(timer);
+    parentSignal?.removeEventListener("abort", onAbort);
+  }
+}
+
+export const uptimeModule: SourceModule<Config, Payload> = {
+  kind: "uptime",
+  label: "Uptime",
+  keyless: true,
+  defaultRefreshSeconds: 600,
+  configSchema: UptimeConfig,
+  payloadSchema: UptimePayload,
+
+  isConfigured: (config) => config.urls.length > 0,
+
+  async fetch({ config, signal }) {
+    const sites = await Promise.all(config.urls.map((u) => ping(u.trim(), signal)));
+    return { sites };
+  },
+};
scripts/seed.ts +13 −0
@@ -68,6 +68,19 @@   { kind: "github", label: "GitHub", enabled: false, refreshSeconds: 900, size: "md", config: {} },
   { kind: "vercel", label: "Vercel", enabled: false, refreshSeconds: 600, size: "md", config: {} },
   { kind: "mastodon", label: "Mastodon", enabled: false, refreshSeconds: 600, size: "md", config: {} },
   { kind: "bluesky", label: "Bluesky", enabled: false, refreshSeconds: 600, size: "md", config: {} },
+  // Keyless live-stat sources. Air quality + Hacker News work out of the box;
+  // Markets seeds a starter watchlist; Uptime starts empty (add URLs in Settings).
+  { kind: "aqi", label: "Air Quality", enabled: true, refreshSeconds: 3600, size: "md", config: {} },
+  {
+    kind: "markets",
+    label: "Markets",
+    enabled: true,
+    refreshSeconds: 900,
+    size: "md",
+    config: { symbols: ["AAPL", "NVDA", "BTC-USD", "ETH-USD"] },
+  },
+  { kind: "hackernews", label: "Hacker News", enabled: true, refreshSeconds: 1800, size: "tall", config: { limit: 10, subreddits: [] } },
+  { kind: "uptime", label: "Uptime", enabled: false, refreshSeconds: 600, size: "md", config: { urls: [] } },
   { kind: "obsidian", label: "Obsidian", enabled: true, refreshSeconds: 600, size: "md", config: { vault, limit: 8 } },
   { kind: "links", label: "Links", enabled: true, refreshSeconds: 86_400, size: "wide", config: { links: [] } },
 ];