"use client";
// Per-kind renderers for a source's payload. Each takes the (already ok) payload
// and renders a compact card body. Unknown kinds fall back to JSON.
import type { WeatherPayload } from "@/lib/schemas/sources/weather";
import type { NewsPayload } from "@/lib/schemas/sources/news";
import type { BriefsPayload } from "@/lib/schemas/sources/briefs";
import type { OnThisDayPayload } from "@/lib/schemas/sources/onthisday";
import type { ObsidianPayload } from "@/lib/schemas/sources/obsidian";
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";
import type { CiderPayload } from "@/lib/schemas/sources/cider";
import type { SourceKind } from "@/lib/schemas/source";
const muted = { color: "var(--text-muted)" };
const faint = { color: "var(--text-faint)" };
function dayName(iso: string) {
return new Date(iso + "T00:00:00").toLocaleDateString(undefined, { weekday: "short" });
}
function fmtTime(iso: string) {
return new Date(iso).toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
}
function aqiColor(v: number | null): string {
if (v == null) return "inherit";
if (v <= 50) return "var(--green)";
if (v <= 100) return "var(--yellow)";
if (v <= 150) return "var(--orange)";
if (v <= 200) return "var(--red)";
return "var(--purple-400)";
}
function uvLabel(v: number): string {
if (v <= 2) return "Low";
if (v <= 5) return "Moderate";
if (v <= 7) return "High";
if (v <= 10) return "Very High";
return "Extreme";
}
function WeatherBody({ p, row }: { p: WeatherPayload; row: boolean }) {
const deg = p.units === "metric" ? "°C" : "°F";
const wind = p.units === "metric" ? "km/h" : "mph";
const showFeels =
p.current.feelsLike != null && Math.abs(p.current.feelsLike - p.current.temp) >= 2;
const highPollen = p.pollen.filter((pl) => pl.level !== "Low");
const current = (
{p.alerts.map((a, i) => (
⚠ {a.event}
))}
{p.current.temp}
{deg}
{p.current.text}
{showFeels && (
feels {p.current.feelsLike}
{deg}
)}
{p.location} · {p.current.wind} {wind}
{p.current.humidity != null ? ` · ${p.current.humidity}% RH` : ""}
{p.current.uvIndex != null ? ` · UV ${p.current.uvIndex} ${uvLabel(p.current.uvIndex)}` : ""}
{p.aqi != null && (
{" "}· AQI {p.aqi} {p.aqiCategory}
)}
{(p.sunrise || p.sunset || p.moonPhase) && (
{p.sunrise ? `↑ ${fmtTime(p.sunrise)}` : ""}
{p.sunset ? ` · ↓ ${fmtTime(p.sunset)}` : ""}
{p.moonPhase ? ` · ${p.moonEmoji} ${p.moonPhase}` : ""}
)}
{highPollen.length > 0 && (
Pollen: {highPollen.map((pl) => `${pl.label} ${pl.level}`).join(" · ")}
)}
);
const forecast = (
{p.daily.map((d) => (
{dayName(d.date)}
{d.max}°
{d.min}°
{d.precipProb != null && d.precipProb > 0 && (
{d.precipProb}%
)}
))}
);
// Short/wide tiles: current condition and forecast sit side by side.
return row ? (
{current}
{forecast}
) : (
);
}
function NewsBody({ p }: { p: NewsPayload }) {
return (
{p.items.map((it, i) => (
{it.title}
· {it.source}
))}
);
}
function BriefsBody({ p }: { p: BriefsPayload }) {
const live = p.categories.filter((c) => c.stories.length > 0);
if (live.length === 0) return No briefs yet.
;
return (
{live.map((c) => (
{c.name}
{c.stories.map((s, i) => (
{s.title}
{s.sources.length > 0 && (
· {s.sources[0]}
)}
))}
))}
);
}
function Entries({ list }: { list: OnThisDayPayload["events"] }) {
return (
{list.map((e, i) => (
{e.year != null && (
{e.year}{" "}
)}
{e.link ? (
{e.text}
) : (
e.text
)}
))}
);
}
function OnThisDayBody({ p }: { p: OnThisDayPayload }) {
return (
{p.births.length > 0 && (
Born
)}
{p.deaths.length > 0 && (
Died
)}
);
}
function ObsidianBody({ p }: { p: ObsidianPayload }) {
return (
{p.notes.map((n) => (
{n.title}
{new Date(n.modified).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
))}
);
}
function shortTime(iso: string) {
return new Date(iso).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
}
function GitHubBody({ p }: { p: GitHubPayload }) {
return (
@{p.login} · {p.publicRepos} repos · {p.followers} followers
{p.events.slice(0, 8).map((e, i) => (
{e.type} {e.repo}
))}
);
}
function VercelBody({ p }: { p: VercelPayload }) {
return (
{p.deployments.map((d, i) => (
{d.url ? (
{d.name}
) : (
d.name
)}
· {d.target}
{d.state}
))}
);
}
function SocialBody({ items }: { items: Array<{ who: string; text: string; tag: string; link?: string | null }> }) {
if (items.length === 0) return Nothing recent.
;
return (
{items.map((n, i) => (
{n.who}
{n.tag}
{n.text && (
)}
))}
);
}
function calEventLabel(iso: string, allDay: boolean): string {
const d = new Date(iso);
const now = new Date();
const todayStr = now.toDateString();
const isToday = d.toDateString() === todayStr;
const datePart = isToday
? "Today"
: d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" });
if (allDay) return datePart;
const timePart = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
return `${datePart} ${timePart}`;
}
function CalendarBody({ p }: { p: CalendarPayload }) {
const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
const events = p.events.filter((e) => new Date(e.start).getTime() <= cutoff);
if (events.length === 0) return No upcoming events.
;
return (
{events.map((e, i) => (
{calEventLabel(e.start, e.allDay)}
{e.summary}
))}
);
}
function leagueName(path: string) {
return (path.split("/").pop() ?? path).toUpperCase();
}
type Game = SportsPayload["games"][number];
function gameBucket(g: Game): "live" | "today" | "upcoming" {
if (g.state === "in" || g.state === "post") return "live";
if (g.startTime) {
const d = new Date(g.startTime);
return d.toDateString() === new Date().toDateString() ? "today" : "upcoming";
}
return "today";
}
function gameLabel(g: Game): { text: string; isLive: boolean; isFinal: boolean } {
const isLive = g.state === "in";
const isFinal = g.state === "post";
if (isLive || isFinal) {
const score = g.awayScore != null && g.homeScore != null ? `${g.awayScore}–${g.homeScore}` : "—";
const suffix = isFinal
? (g.status.includes("OT") ? " F/OT" : " F")
: ` ${g.status}`;
return { text: score + suffix, isLive, isFinal };
}
if (g.startTime) {
const d = new Date(g.startTime);
const isToday = d.toDateString() === new Date().toDateString();
const datePart = isToday
? ""
: d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " ";
return { text: datePart + d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" }), isLive, isFinal };
}
return { text: g.status, isLive, isFinal };
}
const BUCKET_LABELS = { live: "Live / Final", today: "Today", upcoming: "Upcoming" } as const;
function SportsSection({ label, games }: { label: string; games: Game[] }) {
const groups: Record = {};
for (const g of games) (groups[g.league] ||= []).push(g);
return (
{label}
{Object.entries(groups).map(([league, gs]) => (
{leagueName(league)}
{gs.map((g, i) => {
const { text, isLive } = gameLabel(g);
return (
{text}
{g.away} @ {g.home}
);
})}
))}
);
}
function SportsBody({ p }: { p: SportsPayload }) {
const cutoff = Date.now() + 7 * 24 * 60 * 60 * 1000;
const games = p.games.filter((g) => !g.startTime || new Date(g.startTime).getTime() <= cutoff);
if (games.length === 0) return No games.
;
const buckets: Record<"live" | "today" | "upcoming", Game[]> = { live: [], today: [], upcoming: [] };
for (const g of games) buckets[gameBucket(g)].push(g);
const sections = (["live", "today", "upcoming"] as const).filter((k) => buckets[k].length > 0);
return (
{sections.map((k) => (
))}
);
}
function LinksBody({ p }: { p: LinksPayload }) {
if (p.links.length === 0) return No links yet.
;
return (
);
}
function AqiBody({ p }: { p: AqiPayload }) {
return (
{p.aqi ?? "—"}
{p.category}
{p.location}
{p.pm25 != null ? ` · PM2.5 ${p.pm25}` : ""}
{p.pm10 != null ? ` · PM10 ${p.pm10}` : ""}
{p.ozone != null ? ` · O₃ ${Math.round(p.ozone)}` : ""}
{p.pollen.length > 0 && (
{p.pollen.map((pl) => (
{pl.label}
{pl.level}
))}
)}
);
}
function MarketsBody({ p }: { p: MarketsPayload }) {
if (p.quotes.length === 0) return No quotes.
;
return (
{p.quotes.map((q) => {
const up = (q.changePct ?? 0) >= 0;
return (
{q.symbol}
{q.price.toLocaleString(undefined, { maximumFractionDigits: 2 })}
{q.changePct != null && (
{up ? "+" : ""}
{q.changePct}%
)}
);
})}
);
}
function HackerNewsBody({ p }: { p: HackerNewsPayload }) {
if (p.stories.length === 0) return Nothing yet.
;
return (
{p.stories.map((s, i) => (
{s.url ? (
{s.title}
) : (
s.title
)}
· {s.source}
{s.points != null ? ` ▲${s.points}` : ""}
))}
);
}
function UptimeBody({ p }: { p: UptimePayload }) {
if (p.sites.length === 0) return No URLs configured.
;
return (
{p.sites.map((s) => (
{s.url.replace(/^https?:\/\//, "")}
{s.ok ? `▲ ${s.ms}ms` : `▼ ${s.status ?? "DOWN"}`}
))}
);
}
function CiderBody({ p }: { p: CiderPayload }) {
if (!p.playing || !p.track) {
return Nothing playing.
;
}
const { name, artist, album, artworkUrl, durationMs, currentMs } = p.track;
const pct = durationMs > 0 ? Math.min(100, (currentMs / durationMs) * 100) : 0;
function fmtDuration(ms: number) {
const s = Math.floor(ms / 1000);
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
}
async function skipNext() {
await fetch("/api/cider/next", { method: "POST" });
}
return (
{artworkUrl && (
// eslint-disable-next-line @next/next/no-img-element
)}
{name}
{artist}
{album}
{fmtDuration(currentMs)}
next ›
{fmtDuration(durationMs)}
);
}
export function CardBody({
kind,
payload,
rows,
}: {
kind: SourceKind;
payload: unknown;
rows: number;
}) {
const shortTile = rows <= 1;
switch (kind) {
case "weather":
return ;
case "news":
case "feeds":
return ;
case "briefs":
return ;
case "onthisday":
return ;
case "obsidian":
return ;
case "github":
return ;
case "vercel":
return ;
case "mastodon":
return (
({
who: n.account,
text: n.text,
tag: n.type,
link: n.url,
}))}
/>
);
case "bluesky":
return (
({
who: n.author,
text: n.text,
tag: n.reason,
}))}
/>
);
case "aqi":
return ;
case "markets":
return ;
case "hackernews":
return ;
case "uptime":
return ;
case "calendar":
return ;
case "sports":
return ;
case "links":
return ;
case "cider":
return ;
default:
return (
{JSON.stringify(payload, null, 2)}
);
}
}