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
This commit is contained in:
2026-11-04 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,880 @@
"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>
);
}