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:
@@ -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' 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user