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: [] } },
];