Files
hobbs-iac/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/page.tsx
T
jeremy e9c7e9df1a feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340
- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
2026-11-04 18:27:22 -06:00

881 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSun,
MapPin,
Minus,
Newspaper,
QrCode,
Snowflake,
Sparkles,
Sun,
Sunrise,
Sunset,
TrendingDown,
TrendingUp,
Wifi,
X,
} from "lucide-react";
import QRCode from "react-qr-code";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: "cyan" | "amber" | "violet" | "emerald" | "rose";
};
type CalendarState = "loading" | "live" | "partial" | "stale" | "error";
type CalendarView = "agenda" | "week" | "month";
type WeatherDay = {
date: string;
code: number;
condition: string;
icon: "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
high: number;
low: number;
sunrise: string;
sunset: string;
precipitationChance: number;
};
type WeatherState = "loading" | "live" | "stale" | "error";
type Headline = {
source: string;
age: string;
title: string;
tone: "cyan" | "amber" | "violet";
url?: string;
};
type Quote = {
symbol: string;
name: string;
value: string;
move: string;
direction: "up" | "down" | "flat";
seriesId?: string;
observedAt?: string;
};
type MarketState = "loading" | "live" | "partial" | "stale" | "demo";
const demoHeadlines: Headline[] = [
{ source: "OPENAI", age: "18m", title: "New tools push multimodal assistants deeper into everyday work", tone: "cyan" },
{ source: "ARS TECHNICA", age: "42m", title: "Chipmakers rethink the data-center roadmap for efficient inference", tone: "amber" },
{ source: "GOOGLE AI", age: "1h", title: "Research update explores smaller models with stronger reasoning", tone: "violet" },
{ source: "WIRED", age: "2h", title: "The next wave of personal AI devices is taking shape", tone: "cyan" },
{ source: "NVIDIA", age: "3h", title: "New accelerated-computing platform targets local AI workloads", tone: "amber" },
];
const demoQuotes: Quote[] = [
{ symbol: "S&P 500", name: "Previous close", value: "5,782.41", move: "+0.62%", direction: "up" },
{ symbol: "NASDAQ", name: "Previous close", value: "18,439.17", move: "+0.91%", direction: "up" },
{ symbol: "DOW", name: "Previous close", value: "42,114.40", move: "+0.38%", direction: "up" },
{ symbol: "WTI", name: "Crude oil", value: "$67.42", move: "-1.18%", direction: "down" },
{ symbol: "NAT GAS", name: "Henry Hub", value: "$3.12", move: "-0.54%", direction: "down" },
{ symbol: "COPPER", name: "Per pound", value: "$4.31", move: "+0.21%", direction: "up" },
];
const toneClasses = {
cyan: "bg-cyan-300",
amber: "bg-amber-300",
violet: "bg-violet-300",
emerald: "bg-emerald-300",
rose: "bg-rose-300",
};
const calendarToneColors = {
cyan: "#67e8f9",
amber: "#fcd34d",
violet: "#c4b5fd",
emerald: "#6ee7b7",
rose: "#fda4af",
};
const calendarViews: CalendarView[] = ["agenda", "week", "month"];
let organizerTimeZone = "America/Chicago";
const weatherIcons = {
clear: Sun,
"partly-cloudy": CloudSun,
cloudy: Cloud,
fog: CloudFog,
rain: CloudRain,
snow: Snowflake,
storm: CloudLightning,
};
function formatClock(date: Date | null) {
if (!date) return { time: "--:--", period: "", date: "Loading dashboard…" };
const clock = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).formatToParts(date);
return {
time: `${clock.find((part) => part.type === "hour")?.value ?? ""}:${clock.find((part) => part.type === "minute")?.value ?? ""}`,
period: clock.find((part) => part.type === "dayPeriod")?.value ?? "",
date: new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
weekday: "long",
month: "long",
day: "numeric",
}).format(date),
};
}
function organizerDateKey(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function allDayDate(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12));
}
function addDateKey(value: string, days: number) {
const date = allDayDate(value);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function addMonthsToDateKey(value: string, months: number) {
const [year, month] = value.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + months, 1, 12));
return date.toISOString().slice(0, 10);
}
function startOfWeekKey(value: string) {
const date = allDayDate(value);
const mondayOffset = (date.getUTCDay() + 6) % 7;
return addDateKey(value, -mondayOffset);
}
function weekDateKeys(value: string) {
const start = startOfWeekKey(value);
return Array.from({ length: 7 }, (_, index) => addDateKey(start, index));
}
function monthDateKeys(value: string) {
const first = `${value.slice(0, 7)}-01`;
const start = startOfWeekKey(first);
return Array.from({ length: 42 }, (_, index) => addDateKey(start, index));
}
function eventDateKey(event: CalendarEvent) {
return event.allDay ? event.start : organizerDateKey(new Date(event.start));
}
function eventOccursOnDate(event: CalendarEvent, dateKey: string) {
if (event.allDay) return event.start <= dateKey && event.end > dateKey;
const start = organizerDateKey(new Date(event.start));
const inclusiveEnd = organizerDateKey(new Date(Math.max(Date.parse(event.start), Date.parse(event.end) - 1)));
return start <= dateKey && inclusiveEnd >= dateKey;
}
function eventIsUpcoming(event: CalendarEvent, now: Date) {
if (event.allDay) return event.end > organizerDateKey(now);
return Date.parse(event.end) > now.getTime();
}
function formatDateKey(value: string, options: Intl.DateTimeFormatOptions) {
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(allDayDate(value));
}
function compactEventTime(event: CalendarEvent, dateKey: string) {
if (event.allDay) return "All day";
if (eventDateKey(event) !== dateKey) return "Continues";
return new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.start));
}
function formatSunTime(value: string) {
const time = value.split("T")[1]?.slice(0, 5);
if (!time) return "--";
const [hour, minute] = time.split(":").map(Number);
const displayHour = hour % 12 || 12;
return `${displayHour}:${String(minute).padStart(2, "0")}`;
}
function WeatherGlyph({ icon, className }: { icon: WeatherDay["icon"]; className?: string }) {
const Glyph = weatherIcons[icon] ?? CloudSun;
return <Glyph className={className} aria-hidden="true" />;
}
function formatEventRow(event: CalendarEvent, now: Date) {
const today = organizerDateKey(now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const eventDate = event.allDay ? event.start : organizerDateKey(startDate);
const isToday = eventDate === today;
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
return {
day: isToday ? "TODAY" : new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(startDate).toUpperCase(),
date: new Intl.DateTimeFormat("en-US", { timeZone, day: "2-digit" }).format(startDate),
time: event.allDay ? "ALL DAY" : new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(startDate),
};
}
function eventIsHappening(event: CalendarEvent, now: Date) {
if (event.allDay) {
const today = organizerDateKey(now);
return event.start <= today && event.end > today;
}
const time = now.getTime();
return Date.parse(event.start) <= time && Date.parse(event.end) > time;
}
function formatEventWhen(event: CalendarEvent, now: Date) {
const row = formatEventRow(event, now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
const dateLabel = event.allDay && event.start === organizerDateKey(now)
? "Today"
: new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" }).format(startDate);
return `${dateLabel} · ${event.allDay ? "All day" : row.time} · ${event.calendar}`;
}
function formatCountdown(event: CalendarEvent, now: Date) {
if (eventIsHappening(event, now)) return "NOW";
if (event.allDay) {
const today = allDayDate(organizerDateKey(now)).getTime();
const start = allDayDate(event.start).getTime();
const days = Math.max(1, Math.round((start - today) / 86_400_000));
return `${days}d`;
}
const minutes = Math.max(1, Math.round((Date.parse(event.start) - now.getTime()) / 60_000));
if (minutes < 60) return `${minutes}m`;
if (minutes < 1_440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
}
function formatObservationDate(value?: string) {
if (!value) return "";
const date = new Date(`${value}T12:00:00Z`);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date);
}
function headlineHost(value?: string) {
if (!value) return "";
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return "Article link";
}
}
function QuoteCell({ quote }: { quote: Quote }) {
const rising = quote.direction === "up";
const falling = quote.direction === "down";
const TrendIcon = rising ? TrendingUp : falling ? TrendingDown : Minus;
const movementColor = rising ? "text-emerald-300" : falling ? "text-rose-300" : "text-slate-400";
const observed = formatObservationDate(quote.observedAt);
return (
<div className="ticker-cell" tabIndex={0}>
<div className="flex items-center gap-3">
<span className="quote-symbol text-[0.78rem] font-black tracking-[0.12em] text-slate-100">{quote.symbol}</span>
<span className="quote-name text-[0.76rem] text-slate-500">{quote.name}{observed && ` · ${observed}`}</span>
</div>
<div className="mt-1 flex items-center gap-3">
<span className="quote-value text-[1.35rem] font-semibold tabular-nums text-white">{quote.value}</span>
<span className={`quote-move flex items-center gap-1 text-[0.9rem] font-bold tabular-nums ${movementColor}`}>
<TrendIcon className="h-4 w-4" strokeWidth={2.5} />
{quote.move}
</span>
</div>
</div>
);
}
export default function Home() {
const [now, setNow] = useState<Date | null>(null);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarState, setCalendarState] = useState<CalendarState>("loading");
const [calendarFeeds, setCalendarFeeds] = useState({ available: 0, total: 0 });
const [calendarView, setCalendarView] = useState<CalendarView>("agenda");
const [calendarAnchor, setCalendarAnchor] = useState("");
const [weatherDays, setWeatherDays] = useState<WeatherDay[]>([]);
const [weatherState, setWeatherState] = useState<WeatherState>("loading");
const [weatherLocation, setWeatherLocation] = useState("Sapulpa, OK");
const [headlines, setHeadlines] = useState<Headline[]>(demoHeadlines);
const [newsLive, setNewsLive] = useState(false);
const [quotes, setQuotes] = useState<Quote[]>(demoQuotes);
const [marketState, setMarketState] = useState<MarketState>("loading");
const [marketFeeds, setMarketFeeds] = useState({ available: 0, total: demoQuotes.length });
const [selectedHeadline, setSelectedHeadline] = useState<Headline | null>(null);
const hasCalendarData = useRef(false);
const hasWeatherData = useRef(false);
const hasMarketData = useRef(false);
const qrCloseRef = useRef<HTMLButtonElement>(null);
const [, setConfigurationVersion] = useState(0);
useEffect(() => {
let active = true;
void fetch("/api/config", { cache: "no-store" })
.then(async (response) => {
const payload = await response.json() as { timeZone?: string };
if (!response.ok || !active || !payload.timeZone) return;
new Intl.DateTimeFormat("en-US", { timeZone: payload.timeZone }).format(new Date());
organizerTimeZone = payload.timeZone;
const value = new Date();
setNow(value);
setCalendarAnchor(organizerDateKey(value));
setConfigurationVersion((current) => current + 1);
})
.catch(() => {
// Keep the built-in timezone when runtime configuration is unavailable.
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const updateClock = () => {
const value = new Date();
setNow(value);
setCalendarAnchor((current) => current || organizerDateKey(value));
};
const frame = window.requestAnimationFrame(updateClock);
const timer = window.setInterval(updateClock, 15_000);
return () => {
window.cancelAnimationFrame(frame);
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshWeather = async () => {
try {
const response = await fetch("/api/weather", { cache: "no-store" });
const payload = await response.json() as { location?: string; items?: WeatherDay[] };
if (!response.ok || !payload.items?.length || !active) throw new Error("Weather unavailable");
hasWeatherData.current = true;
setWeatherDays(payload.items.slice(0, 7));
setWeatherLocation(payload.location || "Sapulpa, OK");
setWeatherState("live");
} catch {
if (!active) return;
setWeatherState(hasWeatherData.current ? "stale" : "error");
}
};
void refreshWeather();
const timer = window.setInterval(refreshWeather, 30 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
const closeQrFromHistory = () => setSelectedHeadline(null);
window.addEventListener("popstate", closeQrFromHistory);
return () => window.removeEventListener("popstate", closeQrFromHistory);
}, []);
useEffect(() => {
if (!selectedHeadline) return;
const frame = window.requestAnimationFrame(() => qrCloseRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
window.addEventListener("keydown", closeOnEscape);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("keydown", closeOnEscape);
};
}, [selectedHeadline]);
useEffect(() => {
let active = true;
const refreshMarkets = async () => {
try {
const response = await fetch("/api/markets", { cache: "no-store" });
const payload = await response.json() as {
items?: Quote[];
seriesAvailable?: number;
seriesTotal?: number;
};
if (!response.ok || !payload.items?.length || !active) throw new Error("FRED unavailable");
hasMarketData.current = true;
setQuotes(payload.items);
setMarketFeeds({
available: payload.seriesAvailable ?? payload.items.length,
total: payload.seriesTotal ?? payload.items.length,
});
setMarketState(
payload.seriesAvailable === payload.seriesTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setMarketState(hasMarketData.current ? "stale" : "demo");
}
};
void refreshMarkets();
const timer = window.setInterval(refreshMarkets, 15 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshCalendar = async () => {
try {
const response = await fetch("/api/calendar", { cache: "no-store" });
const payload = await response.json() as {
items?: CalendarEvent[];
calendarsAvailable?: number;
calendarsTotal?: number;
};
if (!response.ok || !payload.items || !active) throw new Error("Calendar unavailable");
hasCalendarData.current = true;
setCalendarEvents(payload.items);
setCalendarFeeds({
available: payload.calendarsAvailable ?? 0,
total: payload.calendarsTotal ?? 0,
});
setCalendarState(
payload.calendarsAvailable === payload.calendarsTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setCalendarState(hasCalendarData.current ? "stale" : "error");
}
};
void refreshCalendar();
const timer = window.setInterval(refreshCalendar, 5 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshHeadlines = async () => {
try {
const response = await fetch("/api/headlines", { cache: "no-store" });
const payload = await response.json() as {
items?: Array<{ source: string; title: string; url: string; publishedAt: string | null }>;
};
if (!response.ok || !payload.items?.length || !active) return;
const tones: Headline["tone"][] = ["cyan", "amber", "violet"];
setHeadlines(payload.items.slice(0, 5).map((item, index) => {
const published = item.publishedAt ? Date.parse(item.publishedAt) : Number.NaN;
const minutes = Number.isNaN(published) ? null : Math.max(1, Math.round((Date.now() - published) / 60_000));
const age = minutes === null ? "new" : minutes < 60 ? `${minutes}m` : minutes < 1_440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1_440)}d`;
return { ...item, age, tone: tones[index % tones.length] };
}));
setNewsLive(true);
} catch {
// The demo headlines remain visible whenever live RSS is unavailable.
}
};
void refreshHeadlines();
const timer = window.setInterval(refreshHeadlines, 10 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
const clock = useMemo(() => formatClock(now), [now]);
const calendarNow = useMemo(() => now ?? new Date(), [now]);
const todayKey = now ? organizerDateKey(now) : "";
const activeAnchor = calendarAnchor || todayKey || "2000-01-01";
const upcomingEvents = useMemo(
() => calendarEvents.filter((event) => eventIsUpcoming(event, calendarNow)),
[calendarEvents, calendarNow],
);
const nextEvent = upcomingEvents[0];
const weekKeys = useMemo(() => weekDateKeys(activeAnchor), [activeAnchor]);
const monthKeys = useMemo(() => monthDateKeys(activeAnchor), [activeAnchor]);
const visibleDateKeys = useMemo(
() => calendarView === "week" ? weekKeys : calendarView === "month" ? monthKeys : [],
[calendarView, monthKeys, weekKeys],
);
const eventsByDate = useMemo(() => {
const result = new Map<string, CalendarEvent[]>();
for (const dateKey of visibleDateKeys) {
result.set(dateKey, calendarEvents.filter((event) => eventOccursOnDate(event, dateKey)));
}
return result;
}, [calendarEvents, visibleDateKeys]);
const currentWeather = weatherDays[0];
const weatherStatus = weatherState === "loading"
? "Updating"
: weatherState === "live"
? "Live forecast"
: weatherState === "stale"
? "Last forecast"
: "Retrying";
const calendarStatus = calendarState === "loading"
? "Syncing"
: calendarState === "live"
? `${calendarFeeds.total} calendars live`
: calendarState === "partial"
? `${calendarFeeds.available}/${calendarFeeds.total} calendars`
: calendarState === "stale"
? "Last update"
: "Retrying";
const marketStatus = marketState === "loading"
? "FRED connecting"
: marketState === "live"
? `FRED live · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "partial"
? `FRED partial · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "stale"
? "FRED last update"
: "Demo data · FRED unavailable";
const periodLabel = calendarView === "week"
? `${formatDateKey(weekKeys[0], { month: "short", day: "numeric" })} – ${formatDateKey(weekKeys[6], { month: "short", day: "numeric" })}`
: formatDateKey(`${activeAnchor.slice(0, 7)}-01`, { month: "long", year: "numeric" });
const moveCalendarPeriod = (direction: -1 | 1) => {
setCalendarAnchor((current) => {
const base = current || todayKey || activeAnchor;
return calendarView === "week" ? addDateKey(base, direction * 7) : addMonthsToDateKey(base, direction);
});
};
const handleCalendarTabKey = (event: ReactKeyboardEvent<HTMLButtonElement>, view: CalendarView) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const currentIndex = calendarViews.indexOf(view);
const offset = event.key === "ArrowRight" ? 1 : -1;
const nextView = calendarViews[(currentIndex + offset + calendarViews.length) % calendarViews.length];
setCalendarView(nextView);
window.requestAnimationFrame(() => document.getElementById(`calendar-tab-${nextView}`)?.focus());
};
const showHeadlineQr = (headline: Headline) => {
if (!headline.url) return;
const current = window.history.state;
const base = current && typeof current === "object" ? current : {};
window.history.pushState({ ...base, daylineQr: true }, "");
setSelectedHeadline(headline);
};
const closeHeadlineQr = () => {
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
return (
<main className="dashboard-shell">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<header className="dashboard-header relative z-10 flex min-h-[92px] items-center justify-between border-b border-white/8 px-8 py-4 2xl:px-12">
<div className="brand-group flex items-center gap-5">
<div className="brand-mark" aria-hidden="true"><span /><span /><span /></div>
<div>
<div className="flex items-baseline gap-3">
<h1 className="whitespace-nowrap text-[1.45rem] font-black tracking-[-0.03em] text-white">HOBBS&apos; FAMILY ORGANIZER</h1>
<span className="rounded-full border border-cyan-300/25 bg-cyan-300/8 px-2.5 py-1 text-[0.62rem] font-black tracking-[0.18em] text-cyan-200">APP</span>
</div>
<p className="mt-1 text-[0.72rem] font-semibold tracking-[0.14em] text-slate-500">YOUR FAMILY, AT A GLANCE</p>
</div>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<div className="flex items-baseline justify-end gap-2">
<span className="text-[2.65rem] font-extralight leading-none tracking-[-0.055em] text-white tabular-nums">{clock.time}</span>
<span className="text-sm font-black tracking-[0.14em] text-cyan-200">{clock.period}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-400">{clock.date}</p>
</div>
</div>
</header>
<section className="weather-strip relative z-10" aria-labelledby="weather-title">
<div className="weather-summary">
<div className="weather-summary-top">
<div className="weather-location"><MapPin className="h-3.5 w-3.5" /><span id="weather-title">{weatherLocation}</span></div>
<span className={`weather-status weather-${weatherState}`}>{weatherStatus}</span>
</div>
{currentWeather ? (
<div className="weather-now">
<WeatherGlyph icon={currentWeather.icon} className="weather-now-icon" />
<div className="min-w-0">
<div className="weather-now-temp"><span>{Math.round(currentWeather.high)}°</span><small>{Math.round(currentWeather.low)}°</small></div>
<p>{currentWeather.condition}</p>
</div>
</div>
) : (
<div className="weather-now weather-pending"><CloudSun className="weather-now-icon" /><div><div className="weather-now-temp"><span>--°</span></div><p>{weatherState === "error" ? "Forecast unavailable" : "Loading forecast…"}</p></div></div>
)}
</div>
<div className="forecast-grid" aria-label="Seven day weather forecast">
{weatherDays.length > 0 ? weatherDays.map((day) => (
<article className={`forecast-day ${day.date === todayKey ? "forecast-today" : ""}`} key={day.date}>
<div className="forecast-day-heading"><span>{day.date === todayKey ? "TODAY" : formatDateKey(day.date, { weekday: "short" }).toUpperCase()}</span><small>{formatDateKey(day.date, { month: "numeric", day: "numeric" })}</small></div>
<div className="forecast-condition"><WeatherGlyph icon={day.icon} className="forecast-icon" /><span><strong>{Math.round(day.high)}°</strong><small>{Math.round(day.low)}°</small></span></div>
<p className="forecast-rain">RAIN {Math.round(day.precipitationChance)}%</p>
<div className="sun-times" aria-label={`Sunrise ${formatSunTime(day.sunrise)}, sunset ${formatSunTime(day.sunset)}`}>
<span><Sunrise />{formatSunTime(day.sunrise)}</span><span><Sunset />{formatSunTime(day.sunset)}</span>
</div>
</article>
)) : Array.from({ length: 7 }, (_, index) => <div className="forecast-day forecast-placeholder" key={index}><span /><span /><span /></div>)}
</div>
</section>
<section className="dashboard-grid relative z-10 grid min-h-0 flex-1 grid-cols-[minmax(0,1.48fr)_minmax(420px,0.92fr)] gap-5 px-6 py-5 2xl:gap-7 2xl:px-10">
<section className="panel flex min-h-0 flex-col" aria-labelledby="calendar-title">
<div className="panel-heading calendar-heading">
<div className="flex items-center gap-3"><CalendarDays className="h-6 w-6 text-cyan-200" /><h2 id="calendar-title">Calendar</h2></div>
<div className="calendar-heading-actions">
<div className="calendar-view-tabs" role="tablist" aria-label="Calendar view">
{calendarViews.map((view) => (
<button
aria-controls={`calendar-panel-${view}`}
aria-selected={calendarView === view}
className={calendarView === view ? "is-active" : ""}
id={`calendar-tab-${view}`}
key={view}
onClick={() => setCalendarView(view)}
onKeyDown={(event) => handleCalendarTabKey(event, view)}
role="tab"
type="button"
>
{view}
</button>
))}
</div>
<div className={`source-pill source-${calendarState}`}><Wifi className="h-3.5 w-3.5" />{calendarStatus}</div>
</div>
</div>
{calendarView === "agenda" && (
<div className="calendar-view-content" id="calendar-panel-agenda" role="tabpanel" aria-labelledby="calendar-tab-agenda">
<div className="next-event-card">
{nextEvent ? (
<>
<div className="min-w-0">
<p className="eyebrow text-cyan-200">{eventIsHappening(nextEvent, calendarNow) ? "HAPPENING NOW" : "NEXT EVENT"}</p>
<h3 className="next-event-title mt-2 truncate text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{nextEvent.title}</h3>
<p className="next-event-meta mt-1 truncate text-base text-slate-400">{formatEventWhen(nextEvent, calendarNow)}</p>
</div>
<div className="next-event-countdown shrink-0 pl-6 text-right"><p className="text-[2.2rem] font-extralight tracking-[-0.05em] text-cyan-100">{formatCountdown(nextEvent, calendarNow)}</p><p className="mt-1 text-xs font-bold tracking-[0.12em] text-slate-500">{eventIsHappening(nextEvent, calendarNow) ? "IN PROGRESS" : "UNTIL START"}</p></div>
</>
) : (
<div>
<p className="eyebrow text-cyan-200">{calendarState === "loading" ? "SYNCING" : calendarState === "error" ? "RETRYING" : "CALENDAR CLEAR"}</p>
<h3 className="next-event-title mt-2 text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{calendarState === "loading" ? "Loading calendars…" : calendarState === "error" ? "Calendar temporarily unavailable" : "No upcoming events"}</h3>
<p className="next-event-meta mt-1 text-base text-slate-400">{calendarState === "error" ? "The organizer will retry automatically" : "Looking ahead 60 days"}</p>
</div>
)}
</div>
<div className="event-list mt-4 min-h-0 flex-1 overflow-hidden">
<div className="event-list-heading mb-3 flex items-center justify-between px-1"><p className="eyebrow">UP NEXT</p><p className="text-xs font-semibold text-slate-500">60-day view</p></div>
<div className="event-list-grid grid gap-2">
{upcomingEvents.slice(1, 9).map((event) => {
const row = formatEventRow(event, calendarNow);
return (
<article className="calendar-row" key={event.id} tabIndex={0} aria-label={`${event.title}, ${formatEventWhen(event, calendarNow)}`}>
<div className="calendar-date w-[76px] shrink-0 border-r border-white/8 pr-4 text-center"><p className="calendar-day text-[0.66rem] font-black tracking-[0.16em] text-slate-500">{row.day}</p><p className="calendar-date-number mt-0.5 text-2xl font-light text-slate-200 tabular-nums">{row.date}</p></div>
<div className="calendar-accent" style={{ backgroundColor: calendarToneColors[event.tone] }} aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-4"><h3 className="calendar-event-title truncate text-[1.03rem] font-bold text-slate-100">{event.title}</h3><span className="calendar-event-time shrink-0 text-sm font-bold text-slate-300 tabular-nums">{row.time}</span></div>
<p className="calendar-detail"><span className="calendar-dot" style={{ backgroundColor: calendarToneColors[event.tone] }} /><span>{event.calendar}</span>{event.location && <><span>·</span><span className="truncate">{event.location}</span></>}</p>
</div>
</article>
);
})}
{!upcomingEvents.length && calendarState !== "loading" && <div className="calendar-empty"><div><CalendarDays className="mx-auto mb-3 h-7 w-7 text-slate-600" /><p>{calendarState === "error" ? "Calendar feed unavailable" : "Nothing scheduled"}</p></div></div>}
</div>
</div>
</div>
)}
{calendarView !== "agenda" && (
<div className="calendar-view-content" id={`calendar-panel-${calendarView}`} role="tabpanel" aria-labelledby={`calendar-tab-${calendarView}`}>
<div className="calendar-period-bar">
<button onClick={() => moveCalendarPeriod(-1)} type="button" aria-label={`Previous ${calendarView}`}><ChevronLeft /></button>
<p>{periodLabel}</p>
<button className="calendar-today-button" onClick={() => setCalendarAnchor(todayKey || activeAnchor)} type="button">Today</button>
<button onClick={() => moveCalendarPeriod(1)} type="button" aria-label={`Next ${calendarView}`}><ChevronRight /></button>
</div>
{calendarView === "week" && (
<div className="week-grid" role="grid" aria-label={periodLabel}>
{weekKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
return (
<section className={`week-day ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<div className="week-day-heading"><span>{formatDateKey(dateKey, { weekday: "short" }).toUpperCase()}</span><strong>{formatDateKey(dateKey, { day: "numeric" })}</strong></div>
<div className="week-events">
{dayEvents.slice(0, 3).map((event) => (
<article className="week-event" key={`${dateKey}-${event.id}`} tabIndex={0} title={`${event.title} · ${compactEventTime(event, dateKey)}`} style={{ borderLeftColor: calendarToneColors[event.tone] }}>
<strong>{event.title}</strong><span>{compactEventTime(event, dateKey)}</span>
</article>
))}
{!dayEvents.length && <span className="calendar-no-events">—</span>}
{dayEvents.length > 3 && <span className="calendar-more">+{dayEvents.length - 3} more</span>}
</div>
</section>
);
})}
</div>
)}
{calendarView === "month" && (
<div className="month-grid" role="grid" aria-label={periodLabel}>
{weekDateKeys("2024-01-01").map((dateKey) => <div className="month-weekday" key={dateKey} role="columnheader">{formatDateKey(dateKey, { weekday: "short" }).slice(0, 2).toUpperCase()}</div>)}
{monthKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
const outsideMonth = dateKey.slice(0, 7) !== activeAnchor.slice(0, 7);
return (
<section className={`month-cell ${outsideMonth ? "is-outside" : ""} ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<span className="month-date">{formatDateKey(dateKey, { day: "numeric" })}</span>
<div className="month-events">
{dayEvents.slice(0, 2).map((event) => (
<div className="month-event" key={`${dateKey}-${event.id}`} title={event.title}><span style={{ backgroundColor: calendarToneColors[event.tone] }} />{event.title}</div>
))}
{dayEvents.length > 2 && <span className="calendar-more">+{dayEvents.length - 2}</span>}
</div>
</section>
);
})}
</div>
)}
</div>
)}
</section>
<aside className="panel flex min-h-0 flex-col" aria-labelledby="headlines-title">
<div className="panel-heading">
<div className="flex items-center gap-3"><Newspaper className="h-6 w-6 text-violet-200" /><h2 id="headlines-title">AI + Tech</h2></div>
<div className="source-pill"><Wifi className="h-3.5 w-3.5" />{newsLive ? "RSS live" : "RSS demo"}</div>
</div>
<div className="min-h-0 flex-1 divide-y divide-white/7 overflow-hidden">
{headlines.map((headline, index) => (
<button
className="headline-row w-full text-left"
disabled={!headline.url}
key={headline.title}
onClick={() => showHeadlineQr(headline)}
type="button"
aria-label={headline.url ? `${headline.title}. Show QR code for this article.` : headline.title}
>
<div className={`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${toneClasses[headline.tone]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2 text-[0.65rem] font-black tracking-[0.12em]">
<span className="flex items-center gap-2"><span className="text-slate-400">{headline.source}</span><span className="text-slate-700">•</span><span className="text-slate-600">{headline.age}</span></span>
{headline.url && <span className="headline-qr-cue"><QrCode className="h-3 w-3" />SCAN</span>}
</div>
<h3 className={`mt-1.5 font-semibold leading-[1.28] text-slate-100 ${index === 0 ? "text-[1.12rem]" : "text-[1rem]"}`}>{headline.title}</h3>
</div>
</button>
))}
</div>
<div className="phone-handoff mt-3 flex items-center justify-between rounded-2xl border border-violet-300/12 bg-violet-300/[0.045] px-4 py-3">
<div className="flex items-center gap-3"><Sparkles className="h-5 w-5 text-violet-200" /><div><p className="text-sm font-bold text-slate-200">Phone handoff</p><p className="text-xs text-slate-500">Select a live headline, then scan</p></div></div>
<span className="text-xs font-black tracking-[0.12em] text-violet-200">READY</span>
</div>
</aside>
</section>
<footer className="dashboard-footer relative z-10 border-t border-white/8 bg-[#050911]/92 py-3 backdrop-blur-xl">
<div className="footer-meta mb-2 flex items-center justify-between px-8 2xl:px-12">
<div className="flex items-center gap-2"><span className={`pulse-dot market-pulse-${marketState}`} /><span className="text-[0.66rem] font-black tracking-[0.16em] text-slate-400">MARKETS + COMMODITIES</span><span className="rounded-full border border-white/8 px-2 py-0.5 text-[0.6rem] font-black tracking-[0.12em] text-slate-600">LATEST FRED OBSERVATION</span></div>
<span className="text-[0.68rem] font-semibold text-slate-600">{marketStatus}</span>
</div>
<div className="ticker-window" aria-label="Market ticker">
<div className="ticker-track">
<div className="flex shrink-0">{quotes.map((quote) => <QuoteCell key={`a-${quote.symbol}`} quote={quote} />)}</div>
<div className="flex shrink-0" aria-hidden="true">{quotes.map((quote) => <QuoteCell key={`b-${quote.symbol}`} quote={quote} />)}</div>
</div>
</div>
</footer>
{selectedHeadline?.url && (
<div className="qr-overlay" role="presentation" onClick={closeHeadlineQr}>
<section
className="qr-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="qr-headline-title"
onClick={(event) => event.stopPropagation()}
>
<button className="qr-close" onClick={closeHeadlineQr} ref={qrCloseRef} type="button">
<X className="h-4 w-4" />Close
</button>
<div className="qr-code-frame" aria-label={`QR code for ${selectedHeadline.title}`}>
<QRCode
bgColor="#ffffff"
fgColor="#071018"
level="M"
size={264}
value={selectedHeadline.url}
viewBox="0 0 256 256"
/>
</div>
<div className="qr-copy">
<div className="qr-source"><QrCode className="h-4 w-4" />{selectedHeadline.source}<span>•</span>{headlineHost(selectedHeadline.url)}</div>
<h2 id="qr-headline-title">{selectedHeadline.title}</h2>
<p>Open your phone camera and point it at the code. Press Back on the Shield remote when you are finished.</p>
</div>
</section>
</div>
)}
</main>
);
}