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,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(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'|'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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*([^<]+?):(?: |\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(/−/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' 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