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,297 @@
import ICAL from "ical.js";
type CalendarTone = "cyan" | "amber" | "violet" | "emerald" | "rose";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const tones: CalendarTone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
export const dynamic = "force-dynamic";
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
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 addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function parseFeedUrls() {
const raw = process.env.CALENDAR_ICAL_URLS;
if (!raw) return [];
const values = JSON.parse(raw) as unknown;
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar feed configuration is invalid");
}
return values.map((value) => {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar feed host is not allowed");
}
return url.toString();
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
function expandCalendar(
ics: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = sourceName.includes("@") ? "Personal" : sourceName;
const tone = tones[feedIndex % tones.length];
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, items };
}
async function readCalendar(
url: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), feedIndex, windowStart, horizon, windowStartDate, horizonDate);
}
export async function GET() {
let urls: string[];
try {
urls = parseFeedUrls();
} catch {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
if (!urls.length) {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
const now = Date.now();
const windowStart = now - 45 * 24 * 60 * 60 * 1_000;
const horizon = now + 60 * 24 * 60 * 60 * 1_000;
const today = organizerDate(new Date(now));
const windowStartDate = addCalendarDays(today, -45);
const horizonDate = addCalendarDays(today, 60);
const results = await Promise.allSettled(
urls.map((url, index) => readCalendar(url, index, windowStart, horizon, windowStartDate, horizonDate)),
);
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
const items = successful
.flatMap((result) => result.value.items)
.filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index)
.sort((left, right) => {
const leftValue = left.allDay ? `${left.start}T00:00:00` : left.start;
const rightValue = right.allDay ? `${right.start}T00:00:00` : right.start;
return leftValue.localeCompare(rightValue);
})
.slice(0, 160);
return Response.json(
{
items,
calendarsAvailable: successful.length,
calendarsTotal: urls.length,
updatedAt: new Date().toISOString(),
},
{
status: successful.length ? 200 : 503,
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=240" },
},
);
}
@@ -0,0 +1,17 @@
export const dynamic = "force-dynamic";
export async function GET() {
const configuredTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
let timeZone = "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: configuredTimeZone }).format(new Date());
timeZone = configuredTimeZone;
} catch {
// Keep a valid default if a deployment typo reaches the process.
}
return Response.json(
{ timeZone },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}
@@ -0,0 +1,126 @@
type FeedSource = {
name: string;
url: string;
};
type FeedItem = {
source: string;
title: string;
url: string;
publishedAt: string | null;
};
const feeds: FeedSource[] = [
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
];
const maxFeedBytes = 1024 * 1024;
export const dynamic = "force-dynamic";
function decodeEntities(value: string) {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/\s+/g, " ")
.trim();
}
function extract(block: string, names: string[]) {
for (const name of names) {
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
if (match?.[1]) return decodeEntities(match[1]);
}
return "";
}
function extractLink(block: string) {
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
if (atomLink) return decodeEntities(atomLink);
return extract(block, ["link", "guid"]);
}
function parseFeed(xml: string, source: string): FeedItem[] {
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
?? [];
return blocks.slice(0, 6).flatMap((block) => {
const title = extract(block, ["title"]);
const url = extractLink(block);
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
const parsedDate = rawDate ? new Date(rawDate) : null;
if (!title || !url) return [];
return [{
source,
title: title.slice(0, 180),
url,
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
}];
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Headline feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
async function readFeed(feed: FeedSource) {
const response = await fetch(feed.url, {
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
signal: AbortSignal.timeout(6_000),
});
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
return parseFeed(await readLimitedText(response), feed.name);
}
export async function GET() {
const results = await Promise.allSettled(feeds.map(readFeed));
const items = results
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
.slice(0, 10);
return Response.json(
{
items,
updatedAt: new Date().toISOString(),
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
feedsTotal: feeds.length,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
},
},
);
}
@@ -0,0 +1,23 @@
export const dynamic = "force-dynamic";
export async function GET() {
let calendarConfigurationValid = false;
try {
const parsed = JSON.parse(process.env.CALENDAR_ICAL_URLS || "[]") as unknown;
calendarConfigurationValid = Array.isArray(parsed) && parsed.length > 0;
} catch {
calendarConfigurationValid = false;
}
return Response.json(
{
status: "ok",
service: "hobbs-family-organizer",
version: "1.0.0",
calendarConfigurationValid,
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
checkedAt: new Date().toISOString(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,134 @@
type Direction = "up" | "down" | "flat";
type FredSeries = {
id: string;
symbol: string;
name: string;
currency: boolean;
decimals: number;
multiplier?: number;
};
type Observation = {
date: string;
value: number;
};
const series: FredSeries[] = [
{ id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 },
{ id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 },
{ id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 },
{ id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 },
{ id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 },
{ id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 },
];
const maxResponseBytes = 1024 * 1024;
const monthNumbers: Record<string, string> = {
Jan: "01",
Feb: "02",
Mar: "03",
Apr: "04",
May: "05",
Jun: "06",
Jul: "07",
Aug: "08",
Sep: "09",
Oct: "10",
Nov: "11",
Dec: "12",
};
export const dynamic = "force-dynamic";
function normalizeDate(value: string) {
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) return value;
const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/);
if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`;
return null;
}
function parseSeriesPage(html: string) {
const observations: Observation[] = [];
for (const row of html.match(/<tr(?:\s[^>]*)?>[\s\S]*?<\/tr>/gi) ?? []) {
const rawDate = row.match(/<td[^>]*>\s*([^<]+?):(?:&nbsp;|\s)*<\/td>/i)?.[1]?.trim();
const rawValue = row.match(/<td[^>]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1];
if (!rawDate || !rawValue) continue;
const date = normalizeDate(rawDate);
const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/&minus;/g, "-").trim());
if (!date || !Number.isFinite(value)) continue;
observations.push({ date, value });
}
return observations.sort((left, right) => left.date.localeCompare(right.date));
}
function formatValue(value: number, config: FredSeries) {
const formatted = new Intl.NumberFormat("en-US", {
minimumFractionDigits: config.decimals,
maximumFractionDigits: config.decimals,
}).format(value);
return config.currency ? `$${formatted}` : formatted;
}
async function readSeries(config: FredSeries) {
const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org");
const response = await fetch(url, {
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)",
},
signal: AbortSignal.timeout(12_000),
});
if (!response.ok) throw new Error(`${config.id} returned ${response.status}`);
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const html = await response.text();
if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const observations = parseSeriesPage(html);
if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`);
const previous = observations.at(-2)!;
const latest = observations.at(-1)!;
const multiplier = config.multiplier ?? 1;
const value = latest.value * multiplier;
const move = ((latest.value - previous.value) / previous.value) * 100;
const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat";
return {
symbol: config.symbol,
name: config.name,
value: formatValue(value, config),
move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`,
direction,
seriesId: config.id,
observedAt: latest.date,
};
}
export async function GET() {
const results: PromiseSettledResult<Awaited<ReturnType<typeof readSeries>>>[] = [];
for (let index = 0; index < series.length; index += 2) {
const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries));
results.push(...batch);
}
const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
return Response.json(
{
items,
seriesAvailable: items.length,
seriesTotal: series.length,
updatedAt: new Date().toISOString(),
},
{
status: items.length ? 200 : 503,
headers: {
"Cache-Control": "public, max-age=900, stale-while-revalidate=21600",
"X-Content-Type-Options": "nosniff",
},
},
);
}
@@ -0,0 +1,183 @@
type WeatherIcon = "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
type NwsPeriod = {
number?: number;
startTime?: string;
isDaytime?: boolean;
temperature?: number;
temperatureUnit?: string;
shortForecast?: string;
probabilityOfPrecipitation?: { value?: number | null };
};
type NwsForecast = {
properties?: { periods?: NwsPeriod[] };
};
type DayAggregate = {
date: string;
high?: number;
low?: number;
temperatures: number[];
condition: string;
icon: WeatherIcon;
precipitationChance: number;
};
function numericSetting(name: string, fallback: number, minimum: number, maximum: number) {
const value = Number(process.env[name] ?? fallback);
return Number.isFinite(value) && value >= minimum && value <= maximum ? value : fallback;
}
const latitude = numericSetting("WEATHER_LATITUDE", 35.9987, -90, 90);
const longitude = numericSetting("WEATHER_LONGITUDE", -96.11417, -180, 180);
const location = (process.env.WEATHER_LOCATION || "Sapulpa, OK").trim().slice(0, 100);
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
const userAgent = (process.env.WEATHER_USER_AGENT || "Hobbs-Family-Organizer/1.0 (self-hosted)").trim().slice(0, 200);
function configuredForecastUrl() {
const value = new URL(process.env.NWS_FORECAST_URL || "https://api.weather.gov/gridpoints/TSA/37,98/forecast");
if (value.protocol !== "https:" || value.hostname !== "api.weather.gov") {
throw new Error("NWS forecast URL must use https://api.weather.gov");
}
return value;
}
export const dynamic = "force-dynamic";
function weatherIcon(condition: string): WeatherIcon {
const value = condition.toLowerCase();
if (value.includes("thunder")) return "storm";
if (value.includes("snow") || value.includes("sleet") || value.includes("ice")) return "snow";
if (value.includes("rain") || value.includes("shower") || value.includes("drizzle")) return "rain";
if (value.includes("fog") || value.includes("haze")) return "fog";
if (value.includes("partly") || value.includes("mostly sunny") || value.includes("mostly clear")) return "partly-cloudy";
if (value.includes("cloud") || value.includes("overcast")) return "cloudy";
return "clear";
}
function fahrenheit(value: number, unit?: string) {
return unit?.toUpperCase() === "C" ? (value * 9) / 5 + 32 : value;
}
function normalizeDegrees(value: number) {
return ((value % 360) + 360) % 360;
}
function normalizeHours(value: number) {
return ((value % 24) + 24) % 24;
}
function dayOfYear(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
const current = Date.UTC(year, month - 1, day);
return Math.floor((current - Date.UTC(year, 0, 0)) / 86_400_000);
}
function solarTime(dateKey: string, sunrise: boolean) {
const radians = (degrees: number) => degrees * Math.PI / 180;
const degrees = (value: number) => value * 180 / Math.PI;
const longitudeHour = longitude / 15;
const approximateTime = dayOfYear(dateKey) + (((sunrise ? 6 : 18) - longitudeHour) / 24);
const meanAnomaly = (0.9856 * approximateTime) - 3.289;
const trueLongitude = normalizeDegrees(
meanAnomaly + (1.916 * Math.sin(radians(meanAnomaly))) + (0.02 * Math.sin(radians(2 * meanAnomaly))) + 282.634,
);
let rightAscension = normalizeDegrees(degrees(Math.atan(0.91764 * Math.tan(radians(trueLongitude)))));
rightAscension += Math.floor(trueLongitude / 90) * 90 - Math.floor(rightAscension / 90) * 90;
rightAscension /= 15;
const sinDeclination = 0.39782 * Math.sin(radians(trueLongitude));
const cosDeclination = Math.cos(Math.asin(sinDeclination));
const cosHour = (
Math.cos(radians(90.833)) - (sinDeclination * Math.sin(radians(latitude)))
) / (cosDeclination * Math.cos(radians(latitude)));
if (cosHour > 1 || cosHour < -1) return "";
const hourAngle = (sunrise ? 360 - degrees(Math.acos(cosHour)) : degrees(Math.acos(cosHour))) / 15;
const localMeanTime = hourAngle + rightAscension - (0.06571 * approximateTime) - 6.622;
const utcHours = normalizeHours(localMeanTime - longitudeHour);
const [year, month, day] = dateKey.split("-").map(Number);
const instant = new Date(Date.UTC(year, month - 1, day, 0, Math.round(utcHours * 60)));
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(instant);
const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? "00";
return `${dateKey}T${part("hour")}:${part("minute")}`;
}
function dailyForecast(periods: NwsPeriod[]) {
const days = new Map<string, DayAggregate>();
for (const period of periods) {
if (!period.startTime || typeof period.temperature !== "number") continue;
const date = period.startTime.slice(0, 10);
const temperature = fahrenheit(period.temperature, period.temperatureUnit);
const condition = period.shortForecast?.trim() || "Clear";
const existing = days.get(date) ?? {
date,
temperatures: [],
condition,
icon: weatherIcon(condition),
precipitationChance: 0,
};
existing.temperatures.push(temperature);
if (period.isDaytime) {
existing.high = temperature;
existing.condition = condition;
existing.icon = weatherIcon(condition);
} else {
existing.low = temperature;
}
existing.precipitationChance = Math.max(
existing.precipitationChance,
Number(period.probabilityOfPrecipitation?.value) || 0,
);
days.set(date, existing);
}
return [...days.values()].slice(0, 7).map((day) => ({
date: day.date,
code: 0,
condition: day.condition,
icon: day.icon,
high: day.high ?? Math.max(...day.temperatures),
low: day.low ?? Math.min(...day.temperatures),
sunrise: solarTime(day.date, true),
sunset: solarTime(day.date, false),
precipitationChance: day.precipitationChance,
}));
}
export async function GET() {
try {
const response = await fetch(configuredForecastUrl(), {
headers: {
Accept: "application/geo+json",
"User-Agent": userAgent,
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`NWS returned ${response.status}`);
const payload = await response.json() as NwsForecast;
const items = dailyForecast(payload.properties?.periods ?? []);
if (items.length < 7) throw new Error("NWS forecast did not include seven days");
return Response.json(
{ location, latitude, longitude, source: "National Weather Service", items, updatedAt: new Date().toISOString() },
{ headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=1800" } },
);
} catch (error) {
console.error("Weather refresh failed", error instanceof Error ? error.message : "Unknown error");
return Response.json(
{ location, items: [], updatedAt: new Date().toISOString() },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
@@ -0,0 +1,765 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../vendor/shadcn-tailwind-4.13.0.css";
:root {
color-scheme: dark;
--background: #070b12;
--foreground: #f8fafc;
--card: #0c121d;
--card-foreground: #f8fafc;
--popover: #0c121d;
--popover-foreground: #f8fafc;
--primary: #a5f3fc;
--primary-foreground: #071018;
--secondary: #121a28;
--secondary-foreground: #e2e8f0;
--muted: #121a28;
--muted-foreground: #64748b;
--accent: #162132;
--accent-foreground: #f8fafc;
--destructive: #fb7185;
--border: #ffffff12;
--input: #ffffff16;
--ring: #67e8f9;
--radius: 1rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 3px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html,
body {
min-height: 100%;
margin: 0;
background: #070b12;
}
body {
overflow: hidden;
color: var(--foreground);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dashboard-shell {
position: relative;
display: flex;
min-height: 100vh;
height: 100vh;
flex-direction: column;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 24% 0%, rgb(34 211 238 / 7%), transparent 34%),
radial-gradient(circle at 82% 18%, rgb(167 139 250 / 7%), transparent 30%),
linear-gradient(145deg, #080d16 0%, #060a11 48%, #080c14 100%);
}
.dashboard-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
opacity: 0.24;
background-image:
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
.ambient {
position: absolute;
z-index: -1;
width: 34vw;
height: 34vw;
border-radius: 999px;
filter: blur(120px);
opacity: 0.08;
animation: ambient-drift 22s ease-in-out infinite alternate;
}
.ambient-one { left: -14vw; top: 22vh; background: #22d3ee; }
.ambient-two { right: -12vw; top: 10vh; background: #8b5cf6; animation-delay: -9s; }
.brand-mark {
display: grid;
grid-template-columns: repeat(3, 7px);
align-items: end;
gap: 4px;
width: 43px;
height: 43px;
padding: 9px;
border: 1px solid rgb(103 232 249 / 22%);
border-radius: 13px;
background: linear-gradient(145deg, rgb(103 232 249 / 13%), rgb(103 232 249 / 3%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), 0 10px 30px rgb(0 0 0 / 18%);
}
.brand-mark span { display: block; border-radius: 99px; background: linear-gradient(to top, #22d3ee, #a5f3fc); }
.brand-mark span:nth-child(1) { height: 46%; }
.brand-mark span:nth-child(2) { height: 100%; }
.brand-mark span:nth-child(3) { height: 72%; }
.panel {
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
padding: 20px;
background: linear-gradient(155deg, rgb(17 25 39 / 88%), rgb(9 15 25 / 86%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 24px 70px rgb(0 0 0 / 16%);
backdrop-filter: blur(20px);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-heading h2 { font-size: 1.1rem; font-weight: 800; letter-spacing: -0.02em; color: #f8fafc; }
.source-pill {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 6px 10px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.source-demo { border-color: rgb(251 191 36 / 15%); color: #fde68a; background: rgb(251 191 36 / 5%); }
.source-live {
border-color: rgb(52 211 153 / 18%);
color: #a7f3d0;
background: rgb(52 211 153 / 6%);
}
.source-loading { border-color: rgb(103 232 249 / 15%); color: #a5f3fc; background: rgb(103 232 249 / 5%); }
.source-partial,
.source-stale,
.source-error {
border-color: rgb(251 191 36 / 18%);
color: #fde68a;
background: rgb(251 191 36 / 6%);
}
.weather-strip {
display: grid;
min-height: 112px;
grid-template-columns: minmax(190px, 0.9fr) minmax(0, 5.1fr);
gap: 9px;
margin: 14px 24px 0;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 20px;
padding: 9px;
background: linear-gradient(105deg, rgb(34 211 238 / 6%), rgb(9 15 25 / 86%) 42%, rgb(251 191 36 / 4%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 18px 50px rgb(0 0 0 / 12%);
backdrop-filter: blur(18px);
}
.weather-summary {
min-width: 0;
border: 1px solid rgb(103 232 249 / 12%);
border-radius: 14px;
padding: 10px 12px;
background: rgb(103 232 249 / 4%);
}
.weather-summary-top,
.forecast-day-heading,
.weather-location,
.weather-now,
.forecast-condition,
.sun-times,
.sun-times span {
display: flex;
align-items: center;
}
.weather-summary-top,
.forecast-day-heading,
.sun-times { justify-content: space-between; }
.weather-location {
min-width: 0;
gap: 5px;
color: #cbd5e1;
font-size: 0.64rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-location span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.weather-status {
flex: none;
color: #6ee7b7;
font-size: 0.54rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-loading,
.weather-stale,
.weather-error { color: #fcd34d; }
.weather-now { gap: 10px; margin-top: 8px; }
.weather-now-icon { width: 38px; height: 38px; flex: none; color: #fde68a; stroke-width: 1.65; }
.weather-now-temp { display: flex; align-items: baseline; gap: 7px; line-height: 1; }
.weather-now-temp span { color: #fff; font-size: 1.65rem; font-weight: 300; letter-spacing: -0.06em; }
.weather-now-temp small { color: #64748b; font-size: 0.9rem; font-weight: 650; }
.weather-now p { overflow: hidden; margin-top: 4px; color: #94a3b8; font-size: 0.67rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.weather-pending { opacity: 0.72; }
.forecast-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.forecast-day {
min-width: 0;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px 8px;
background: rgb(255 255 255 / 2.2%);
}
.forecast-today {
border-color: rgb(103 232 249 / 24%);
background: rgb(103 232 249 / 5.5%);
}
.forecast-day-heading span { color: #94a3b8; font-size: 0.59rem; font-weight: 900; letter-spacing: 0.09em; }
.forecast-today .forecast-day-heading span { color: #a5f3fc; }
.forecast-day-heading small { color: #475569; font-size: 0.54rem; font-weight: 700; }
.forecast-condition { justify-content: center; gap: 8px; margin-top: 5px; }
.forecast-icon { width: 25px; height: 25px; flex: none; color: #fde68a; stroke-width: 1.6; }
.forecast-condition > span { display: flex; align-items: baseline; gap: 4px; }
.forecast-condition strong { color: #f8fafc; font-size: 1rem; font-weight: 650; }
.forecast-condition small { color: #64748b; font-size: 0.68rem; font-weight: 650; }
.forecast-rain { margin-top: 3px; color: #64748b; font-size: 0.49rem; font-weight: 800; letter-spacing: 0.08em; text-align: center; }
.sun-times { gap: 4px; margin-top: 5px; color: #64748b; font-size: 0.51rem; font-weight: 650; }
.sun-times span { min-width: 0; gap: 2px; }
.sun-times svg { width: 10px; height: 10px; flex: none; color: #fcd34d; }
.forecast-placeholder { display: grid; align-content: center; gap: 8px; opacity: 0.45; }
.forecast-placeholder span { height: 7px; border-radius: 999px; background: rgb(148 163 184 / 12%); }
.forecast-placeholder span:nth-child(2) { width: 70%; }
.forecast-placeholder span:nth-child(3) { width: 48%; }
.calendar-heading-actions,
.calendar-view-tabs,
.calendar-period-bar {
display: flex;
align-items: center;
}
.calendar-heading-actions { gap: 10px; }
.calendar-view-tabs {
gap: 2px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 2px;
background: rgb(255 255 255 / 3%);
}
.calendar-view-tabs button {
border: 0;
border-radius: 999px;
padding: 5px 10px;
color: #64748b;
background: transparent;
font-size: 0.62rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.calendar-view-tabs button.is-active {
color: #cffafe;
background: rgb(34 211 238 / 13%);
box-shadow: inset 0 0 0 1px rgb(103 232 249 / 18%);
}
.calendar-view-tabs button:focus-visible,
.calendar-period-bar button:focus-visible,
.week-event:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgb(103 232 249 / 60%), 0 0 0 5px rgb(34 211 238 / 12%);
}
.calendar-view-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.calendar-period-bar {
min-height: 34px;
justify-content: flex-end;
gap: 6px;
margin-bottom: 8px;
}
.calendar-period-bar p {
min-width: 150px;
margin-right: auto;
color: #cbd5e1;
font-size: 0.77rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.calendar-period-bar button {
display: grid;
width: 30px;
height: 28px;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 8px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
}
.calendar-period-bar button svg { width: 15px; height: 15px; }
.calendar-period-bar .calendar-today-button { width: auto; padding: 0 10px; font-size: 0.58rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
.week-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.week-day {
min-width: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px;
background: rgb(255 255 255 / 2%);
}
.week-day.is-today { border-color: rgb(103 232 249 / 26%); background: rgb(103 232 249 / 4.5%); }
.week-day-heading { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid rgb(255 255 255 / 6%); padding-bottom: 6px; }
.week-day-heading span { color: #64748b; font-size: 0.57rem; font-weight: 900; letter-spacing: 0.08em; }
.week-day-heading strong { color: #cbd5e1; font-size: 0.92rem; font-weight: 500; }
.week-day.is-today .week-day-heading span,
.week-day.is-today .week-day-heading strong { color: #a5f3fc; }
.week-events { display: grid; gap: 5px; margin-top: 7px; }
.week-event { min-width: 0; border: 1px solid rgb(255 255 255 / 6%); border-left: 3px solid; border-radius: 7px; padding: 6px; background: rgb(2 6 13 / 32%); }
.week-event strong,
.week-event span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.week-event strong { color: #e2e8f0; font-size: 0.65rem; font-weight: 750; }
.week-event span { margin-top: 2px; color: #64748b; font-size: 0.55rem; font-weight: 650; }
.calendar-no-events { display: block; padding-top: 8px; color: #334155; font-size: 0.78rem; text-align: center; }
.calendar-more { color: #67e8f9; font-size: 0.54rem; font-weight: 800; }
.month-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto repeat(6, minmax(0, 1fr));
gap: 3px;
}
.month-weekday { padding: 0 5px 3px; color: #475569; font-size: 0.55rem; font-weight: 900; letter-spacing: 0.1em; text-align: right; }
.month-cell { min-width: 0; overflow: hidden; border: 1px solid rgb(255 255 255 / 6%); border-radius: 8px; padding: 4px 5px; background: rgb(255 255 255 / 1.8%); }
.month-cell.is-outside { opacity: 0.34; }
.month-cell.is-today { border-color: rgb(103 232 249 / 45%); background: rgb(103 232 249 / 6%); box-shadow: inset 0 0 0 1px rgb(103 232 249 / 10%); }
.month-date { display: block; color: #94a3b8; font-size: 0.61rem; font-weight: 750; line-height: 1; text-align: right; }
.month-cell.is-today .month-date { color: #cffafe; }
.month-events { display: grid; gap: 2px; margin-top: 3px; }
.month-event { display: flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; color: #cbd5e1; font-size: 0.53rem; font-weight: 650; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.month-event span { width: 5px; height: 5px; flex: none; border-radius: 999px; }
.next-event-card {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 128px;
border: 1px solid rgb(103 232 249 / 14%);
border-radius: 18px;
padding: 19px 22px;
background: linear-gradient(100deg, rgb(34 211 238 / 10%), transparent 58%), rgb(255 255 255 / 2%);
}
.eyebrow { font-size: 0.66rem; font-weight: 900; letter-spacing: 0.18em; color: #64748b; }
.calendar-row,
.headline-row,
.ticker-cell {
outline: none;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.calendar-row {
display: flex;
min-height: 72px;
align-items: center;
gap: 14px;
border: 1px solid transparent;
border-radius: 15px;
padding: 8px 13px;
background: rgb(255 255 255 / 2.3%);
}
.calendar-accent {
width: 4px;
height: 38px;
flex: none;
border-radius: 999px;
}
.calendar-detail {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
margin-top: 2px;
color: #64748b;
font-size: 0.78rem;
}
.calendar-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
}
.calendar-empty {
display: grid;
min-height: 210px;
place-items: center;
border: 1px dashed rgb(255 255 255 / 10%);
border-radius: 16px;
color: #64748b;
text-align: center;
}
.calendar-row:focus-visible,
.headline-row:focus-visible,
.ticker-cell:focus-visible {
border-color: rgb(103 232 249 / 65%);
background: rgb(103 232 249 / 7%);
box-shadow: 0 0 0 3px rgb(34 211 238 / 12%);
}
.calendar-row:focus-visible { transform: translateX(4px); }
.headline-row {
display: flex;
align-items: flex-start;
gap: 13px;
border: 1px solid transparent;
border-radius: 12px;
padding: 13px 10px;
cursor: pointer;
}
.headline-row:disabled { cursor: default; }
.headline-qr-cue {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid rgb(196 181 253 / 16%);
border-radius: 999px;
padding: 2px 6px;
color: #c4b5fd;
background: rgb(196 181 253 / 5%);
font-size: 0.54rem;
letter-spacing: 0.1em;
}
.headline-row:focus-visible { transform: translateX(-3px); }
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 0 0 rgb(52 211 153 / 45%);
animation: pulse-ring 2.4s ease-out infinite;
}
.market-pulse-loading,
.market-pulse-demo,
.market-pulse-stale,
.market-pulse-partial {
background: #fbbf24;
box-shadow: 0 0 0 0 rgb(251 191 36 / 45%);
}
.ticker-window {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 3.5%, black 96.5%, transparent);
}
.ticker-track { display: flex; width: max-content; animation: ticker-scroll 38s linear infinite; }
.ticker-cell {
width: 290px;
flex: none;
border: 1px solid transparent;
border-right-color: rgb(255 255 255 / 8%);
padding: 4px 24px;
}
.qr-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgb(2 6 13 / 88%);
backdrop-filter: blur(18px);
}
.qr-dialog {
position: relative;
display: grid;
width: min(820px, calc(100vw - 48px));
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 30px;
border: 1px solid rgb(196 181 253 / 24%);
border-radius: 28px;
padding: 26px;
background:
radial-gradient(circle at 12% 16%, rgb(34 211 238 / 10%), transparent 38%),
linear-gradient(145deg, #111a2a, #090f1a);
box-shadow: 0 36px 120px rgb(0 0 0 / 60%), inset 0 1px 0 rgb(255 255 255 / 7%);
}
.qr-code-frame {
display: grid;
place-items: center;
border-radius: 20px;
padding: 17px;
background: #fff;
box-shadow: 0 18px 55px rgb(34 211 238 / 14%);
}
.qr-code-frame svg { display: block; width: 264px; height: 264px; }
.qr-copy { min-width: 0; padding-right: 8px; }
.qr-source {
display: flex;
align-items: center;
gap: 8px;
color: #c4b5fd;
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.qr-source span { color: #475569; }
.qr-copy h2 {
margin: 12px 0 10px;
color: #fff;
font-size: 1.65rem;
font-weight: 750;
line-height: 1.16;
letter-spacing: -0.035em;
}
.qr-copy p { margin: 0; color: #94a3b8; font-size: 0.92rem; line-height: 1.55; }
.qr-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 999px;
padding: 7px 11px;
color: #cbd5e1;
background: rgb(255 255 255 / 5%);
font-size: 0.7rem;
font-weight: 800;
}
.qr-close:focus-visible {
outline: none;
border-color: rgb(103 232 249 / 70%);
box-shadow: 0 0 0 4px rgb(34 211 238 / 14%);
color: #fff;
}
@keyframes ticker-scroll { to { transform: translateX(-50%); } }
@keyframes pulse-ring { 70% { box-shadow: 0 0 0 8px rgb(52 211 153 / 0%); } 100% { box-shadow: 0 0 0 0 rgb(52 211 153 / 0%); } }
@keyframes ambient-drift { from { transform: translate3d(0, -2%, 0) scale(0.96); } to { transform: translate3d(5%, 5%, 0) scale(1.07); } }
@media (max-width: 1280px) {
.dashboard-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 60px 86px minmax(0, 1fr) 56px; }
.dashboard-header { min-height: 60px; padding: 7px 14px; }
.brand-group { gap: 11px; }
.brand-mark { width: 35px; height: 35px; padding: 7px; border-radius: 11px; }
.weather-strip { min-height: 0; margin: 5px 11px 0; gap: 5px; border-radius: 14px; padding: 5px; }
.weather-summary { border-radius: 10px; padding: 5px 8px; }
.weather-location { gap: 3px; font-size: 0.51rem; }
.weather-location svg { width: 10px; height: 10px; }
.weather-status { font-size: 0.45rem; }
.weather-now { gap: 6px; margin-top: 4px; }
.weather-now-icon { width: 27px; height: 27px; }
.weather-now-temp { gap: 4px; }
.weather-now-temp span { font-size: 1.15rem; }
.weather-now-temp small { font-size: 0.66rem; }
.weather-now p { margin-top: 2px; font-size: 0.52rem; }
.forecast-grid { gap: 4px; }
.forecast-day { border-radius: 9px; padding: 3px 5px; }
.forecast-day-heading span { font-size: 0.48rem; }
.forecast-day-heading small { font-size: 0.44rem; }
.forecast-condition { gap: 4px; margin-top: 2px; }
.forecast-icon { width: 17px; height: 17px; }
.forecast-condition > span { gap: 3px; }
.forecast-condition strong { font-size: 0.76rem; }
.forecast-condition small { font-size: 0.53rem; }
.forecast-rain { margin-top: 1px; font-size: 0.4rem; }
.sun-times { gap: 2px; margin-top: 2px; font-size: 0.43rem; }
.sun-times svg { width: 8px; height: 8px; }
.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(310px, 0.9fr); gap: 8px; padding: 7px 11px; }
.panel { padding: 10px; border-radius: 16px; }
.panel-heading { margin-bottom: 7px; }
.panel-heading h2 { font-size: 0.96rem; }
.source-pill { gap: 5px; padding: 4px 8px; font-size: 0.57rem; }
.calendar-heading-actions { gap: 6px; }
.calendar-view-tabs button { padding: 4px 7px; font-size: 0.51rem; }
.calendar-period-bar { min-height: 25px; gap: 4px; margin-bottom: 5px; }
.calendar-period-bar p { min-width: 120px; font-size: 0.59rem; }
.calendar-period-bar button { width: 25px; height: 23px; border-radius: 6px; }
.calendar-period-bar button svg { width: 13px; height: 13px; }
.calendar-period-bar .calendar-today-button { padding-inline: 7px; font-size: 0.48rem; }
.next-event-card { min-height: 66px; padding: 7px 11px; border-radius: 13px; }
.next-event-title { margin-top: 3px; font-size: 1.22rem; }
.next-event-meta { margin-top: 1px; font-size: 0.73rem; }
.next-event-countdown { padding-left: 12px; }
.next-event-countdown > p:first-child { font-size: 1.45rem; }
.next-event-countdown > p:last-child { margin-top: 0; font-size: 0.56rem; }
.eyebrow { font-size: 0.55rem; }
.event-list { margin-top: 7px; }
.event-list-heading { margin-bottom: 4px; }
.event-list-heading > p:last-child { font-size: 0.62rem; }
.event-list-grid { gap: 4px; }
.calendar-row { min-height: 43px; gap: 7px; border-radius: 10px; padding: 3px 7px; }
.calendar-date { width: 53px; padding-right: 7px; }
.calendar-day { font-size: 0.51rem; }
.calendar-date-number { margin-top: 0; font-size: 1.12rem; line-height: 1.05; }
.calendar-accent { width: 3px; height: 25px; }
.calendar-event-title { font-size: 0.83rem; }
.calendar-event-time { font-size: 0.69rem; }
.calendar-detail { gap: 4px; margin-top: 0; font-size: 0.62rem; }
.calendar-dot { width: 5px; height: 5px; }
.week-grid { gap: 4px; }
.week-day { border-radius: 9px; padding: 5px; }
.week-day-heading { padding-bottom: 4px; }
.week-day-heading span { font-size: 0.47rem; }
.week-day-heading strong { font-size: 0.72rem; }
.week-events { gap: 4px; margin-top: 5px; }
.week-event { border-radius: 6px; padding: 4px; }
.week-event strong { font-size: 0.55rem; }
.week-event span { margin-top: 1px; font-size: 0.47rem; }
.calendar-more { font-size: 0.47rem; }
.month-grid { gap: 2px; }
.month-weekday { padding: 0 3px 2px; font-size: 0.45rem; }
.month-cell { border-radius: 6px; padding: 3px 4px; }
.month-date { font-size: 0.5rem; }
.month-events { gap: 1px; margin-top: 2px; }
.month-event { gap: 3px; font-size: 0.44rem; }
.month-event span { width: 4px; height: 4px; }
.headline-row { gap: 8px; padding: 5px 6px; border-radius: 9px; }
.headline-row > div:first-child { width: 7px; height: 7px; }
.headline-row h3 { margin-top: 3px; font-size: 0.79rem; line-height: 1.2; }
.headline-row:first-child h3 { font-size: 0.87rem; }
.headline-qr-cue { padding: 1px 5px; font-size: 0.48rem; }
.phone-handoff { margin-top: 6px; padding: 7px 10px; border-radius: 12px; }
.phone-handoff p:first-child { font-size: 0.76rem; }
.phone-handoff p:last-child { font-size: 0.64rem; }
.dashboard-footer { overflow: hidden; padding-block: 2px; }
.footer-meta { min-height: 14px; margin-bottom: 0; padding-inline: 14px; line-height: 1; }
.ticker-cell { display: flex; width: 224px; height: 36px; flex-direction: column; justify-content: center; padding: 0 16px; }
.ticker-cell > div:first-child { min-height: 11px; line-height: 1; }
.ticker-cell > div:nth-child(2) { min-height: 18px; margin-top: 1px; line-height: 1; }
.quote-symbol { font-size: 0.63rem; line-height: 1; }
.quote-name { font-size: 0.58rem; line-height: 1; }
.quote-value { font-size: 0.96rem; line-height: 1; }
.quote-move { font-size: 0.68rem; line-height: 1; }
.quote-move svg { width: 12px; height: 12px; }
.qr-dialog { width: min(720px, calc(100vw - 28px)); gap: 20px; border-radius: 20px; padding: 18px; }
.qr-code-frame { border-radius: 14px; padding: 12px; }
.qr-code-frame svg { width: 220px; height: 220px; }
.qr-copy h2 { margin-block: 8px; font-size: 1.22rem; }
.qr-copy p { font-size: 0.72rem; line-height: 1.42; }
.qr-source { gap: 6px; font-size: 0.56rem; }
.qr-close { top: 9px; right: 9px; padding: 5px 8px; font-size: 0.62rem; }
}
@media (max-width: 700px) {
body { overflow: auto; }
.dashboard-shell { display: flex; height: auto; min-height: 100vh; overflow: visible; }
.dashboard-shell > section { grid-template-columns: 1fr; }
.weather-strip { min-height: 208px; margin: 10px; }
.weather-summary { min-height: 70px; }
.forecast-grid { grid-template-columns: repeat(7, minmax(76px, 1fr)); overflow-x: auto; }
.panel { min-height: 560px; }
.qr-dialog { grid-template-columns: 1fr; text-align: center; }
.qr-code-frame { width: max-content; margin: 0 auto; }
.qr-copy { padding: 0; }
.qr-source { justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.ticker-track,
.pulse-dot,
.ambient { animation: none; }
}
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Hobbs' Family Organizer App",
description: "A private Hobbs family organizer for calendars, weather, markets, commodities, and AI and technology headlines.",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}
@@ -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>
);
}