From e9c7e9df1adabcc3d9e846d0dd44c2ef0296f7c0 Mon Sep 17 00:00:00 2001 From: jeremy Date: Wed, 4 Nov 2026 18:27:22 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3 --- CLAUDE.md | 2 + compose/hf-pdocker-02/hobbs-organizer/.env | 21 + .../hf-pdocker-02/hobbs-organizer/README.md | 15 + .../hobbs-organizer/compose.override.yaml | 12 + .../hobbs-organizer/provision-shield.sh | 17 + .../.dockerignore | 42 + .../.gitignore | 46 + .../.npmrc | 3 + .../Dockerfile | 39 + .../MIGRATION.md | 80 + .../README.md | 250 + .../RELEASE-NOTES.md | 12 + .../SHA256SUMS.txt | 64 + .../VERSION | 1 + .../app/api/calendar/route.ts | 297 + .../app/api/config/route.ts | 17 + .../app/api/headlines/route.ts | 126 + .../app/api/health/route.ts | 23 + .../app/api/markets/route.ts | 134 + .../app/api/weather/route.ts | 183 + .../app/globals.css | 765 ++ .../app/layout.tsx | 23 + .../app/page.tsx | 880 +++ .../compose.yaml | 58 + .../container/start.mjs | 106 + .../eslint.config.mjs | 37 + .../examples/Caddyfile | 7 + .../examples/nginx.conf | 18 + .../next.config.mjs | 24 + .../package-lock.json | 6768 +++++++++++++++++ .../package.json | 34 + .../postcss.config.mjs | 7 + .../proxy.ts | 35 + .../public/favicon.svg | 6 + .../scripts/backup.ps1 | 76 + .../scripts/backup.sh | 61 + .../scripts/configure.ps1 | 194 + .../scripts/configure.sh | 182 + .../scripts/restore.ps1 | 97 + .../scripts/restore.sh | 68 + .../secrets/.gitignore | 4 + .../secrets/calendar_ical_urls.example.json | 3 + .../Hobbs-Family-Organizer-Local-v1.4.apk | Bin 0 -> 25222 bytes .../shield/README.md | 39 + .../shield/configure-shield.ps1 | 227 + .../shield/restore-hosted-site.ps1 | 37 + .../shield/source/.gitignore | 6 + .../shield/source/BUILDING.md | 13 + .../shield/source/app/build.gradle | 37 + .../shield/source/app/proguard-rules.pro | 1 + .../source/app/src/main/AndroidManifest.xml | 53 + .../java/com/dayline/tv/DashboardConfig.java | 245 + .../java/com/dayline/tv/DaylineScheduler.java | 80 + .../java/com/dayline/tv/MainActivity.java | 399 + .../java/com/dayline/tv/ScheduleReceiver.java | 19 + .../app/src/main/res/drawable/ic_dayline.xml | 11 + .../app/src/main/res/drawable/tv_banner.xml | 17 + .../app/src/main/res/values/strings.xml | 4 + .../source/app/src/main/res/values/styles.xml | 13 + .../main/res/xml/data_extraction_rules.xml | 9 + .../main/res/xml/network_security_config.xml | 9 + .../com/dayline/tv/DashboardConfigTest.java | 42 + .../shield/source/build.gradle | 3 + .../shield/source/gradle.properties | 3 + .../shield/source/settings.gradle | 18 + .../tsconfig.json | 59 + .../vendor/shadcn-tailwind-4.13.0.LICENSE.md | 21 + .../vendor/shadcn-tailwind-4.13.0.css | 629 ++ 68 files changed, 12831 insertions(+) create mode 100644 compose/hf-pdocker-02/hobbs-organizer/.env create mode 100644 compose/hf-pdocker-02/hobbs-organizer/README.md create mode 100644 compose/hf-pdocker-02/hobbs-organizer/compose.override.yaml create mode 100644 compose/hf-pdocker-02/hobbs-organizer/provision-shield.sh create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.dockerignore create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.gitignore create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.npmrc create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/Dockerfile create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/MIGRATION.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/README.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/SHA256SUMS.txt create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/calendar/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/config/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/headlines/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/markets/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/weather/route.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/globals.css create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/layout.tsx create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/page.tsx create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/compose.yaml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/container/start.mjs create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/eslint.config.mjs create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/examples/Caddyfile create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/examples/nginx.conf create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/next.config.mjs create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package-lock.json create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/package.json create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/postcss.config.mjs create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/proxy.ts create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/public/favicon.svg create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/backup.ps1 create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/backup.sh create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/configure.ps1 create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/configure.sh create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/restore.ps1 create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/scripts/restore.sh create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/secrets/.gitignore create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/secrets/calendar_ical_urls.example.json create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/Hobbs-Family-Organizer-Local-v1.4.apk create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/README.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/configure-shield.ps1 create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/restore-hosted-site.ps1 create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/.gitignore create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/BUILDING.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/build.gradle create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/proguard-rules.pro create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/AndroidManifest.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/java/com/dayline/tv/DashboardConfig.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/java/com/dayline/tv/DaylineScheduler.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/java/com/dayline/tv/MainActivity.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/java/com/dayline/tv/ScheduleReceiver.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/drawable/ic_dayline.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/drawable/tv_banner.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/values/strings.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/values/styles.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/main/res/xml/network_security_config.xml create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/app/src/test/java/com/dayline/tv/DashboardConfigTest.java create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/build.gradle create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/gradle.properties create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/shield/source/settings.gradle create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/tsconfig.json create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/vendor/shadcn-tailwind-4.13.0.LICENSE.md create mode 100644 tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/vendor/shadcn-tailwind-4.13.0.css diff --git a/CLAUDE.md b/CLAUDE.md index b8f07e5..8783b21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ hf-pdocker-02 (7.6GB RAM, i7-6700T, HDD) ├── [REMOVED] Actual Budget -- decommissioned 2026-07-13 (data kept at /mnt/docker-data/configs/actual-budget) ├── [COMPOSE] n8n + n8n-db (PostgreSQL) -- workflow automation ├── [COMPOSE] CouchDB (:5984) -- Obsidian LiveSync backend for the Second Brain vault. LAN-only. LOCAL disk (NOT NFS). +├── [COMPOSE] **Hobbs Family Organizer (:8340)** -- Shield TV family dashboard (calendar/weather/headlines/markets), deployed 2026-11-04, replaces the ChatGPT-Sites-hosted `dayline-tv` page. Next.js 16 / Node 22, stateless, image **built locally** (`hobbs-family-organizer:1.0.0`, Watchtower disabled). Source = `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/`; deploy dir `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer` (bundle + `compose.override.yaml` + `.env`, repo copy `compose/hf-pdocker-02/hobbs-organizer/`). **Trusted-LAN mode** (empty token, per user). Secrets on LOCAL `/var/lib/hobbs-organizer/secrets/` (root 600 -- NFS root_squash would block start.mjs). Both Shields (.179, .186) run APK v1.4 pointed at it. See memory `hobbs-family-organizer`. ├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host, **v1.12.1** (was v1.7.0 from 2023 until 2026-10-26) ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB @@ -194,6 +195,7 @@ compose/ # Standalone Compose definitions (deployed per-node) - MCPO: :8001 - Trivy: :4954 - CouchDB: :5984 -- Obsidian LiveSync backend. DB `secondbrain`; admin creds in Vaultwarden ("CouchDB - Obsidian LiveSync"). LAN-only (no Traefik route). Data on LOCAL disk `/var/lib/couchdb` (NFS breaks CouchDB file locking). **`local.ini` must be mounted READ-WRITE** -- with `:ro` CouchDB exits 1 and logs nothing. Auth required on everything except `/_up` (left open for the blackbox probe). Vault repo: git.hobbs.farm/jeremy/second-brain (private); vault lives at `C:\Users\Jeremy\second-brain` on the Windows box. +- Hobbs Family Organizer: :8340 -- Shield TV dashboard, `GET /api/health` (JSON, `calendarConfigurationValid`). LAN-only, no auth (trusted-LAN mode), no Traefik route. **bart (192.168.86.167):** - Farm Agent: :8889 diff --git a/compose/hf-pdocker-02/hobbs-organizer/.env b/compose/hf-pdocker-02/hobbs-organizer/.env new file mode 100644 index 0000000..366b7b8 --- /dev/null +++ b/compose/hf-pdocker-02/hobbs-organizer/.env @@ -0,0 +1,21 @@ +# Hobbs' Family Organizer - nonsecret runtime settings +# Copy this file to .env. Keep private calendar URLs and the access token in +# ./secrets; never put either value in this file. + +# Docker publishes the app on this server address and port. Set the bind +# address to a specific LAN interface if you do not want to listen everywhere. +HOBBS_BIND_ADDRESS=0.0.0.0 +HOBBS_HTTP_PORT=8340 + +# Display and calendar defaults. +ORGANIZER_TIMEZONE=America/Chicago + +# Seven-day forecast defaults for Sapulpa, Oklahoma. +WEATHER_LOCATION="Sapulpa, OK" +WEATHER_LATITUDE=35.9987 +WEATHER_LONGITUDE=-96.11417 +NWS_FORECAST_URL=https://api.weather.gov/gridpoints/TSA/37,98/forecast + +# The National Weather Service asks clients to identify themselves. Replace +# the example contact with an address monitored by the server operator. +WEATHER_USER_AGENT="Hobbs-Family-Organizer/1.0 (self-hosted)" diff --git a/compose/hf-pdocker-02/hobbs-organizer/README.md b/compose/hf-pdocker-02/hobbs-organizer/README.md new file mode 100644 index 0000000..17ce4fa --- /dev/null +++ b/compose/hf-pdocker-02/hobbs-organizer/README.md @@ -0,0 +1,15 @@ +# Hobbs Family Organizer (Shield TV dashboard) — hf-pdocker-02 + +Deployed 2026-11-04. URL: http://192.168.86.100:8340/ (LAN only, trusted-LAN mode = no token). + +- App source / release bundle: `tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/` (repo root). +- Deploy dir on NAS: `/mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer/` = the release bundle + + `compose.override.yaml` + `.env` from this folder. Image is built locally (`hobbs-family-organizer:1.0.0`). +- Secrets on LOCAL disk of .100 (root:root 600): `/var/lib/hobbs-organizer/secrets/` + - `calendar_ical_urls.json` — JSON array of private Google Calendar basic.ics URLs (treat as passwords; never commit) + - `organizer_access_token.txt` — empty = trusted-LAN mode. Put a 32+ char token here for bearer mode. +- Redeploy: `cd /mnt/docker-data/compose/hf-pdocker-02/hobbs-organizer && docker compose up -d --build --wait` +- Health: `curl http://192.168.86.100:8340/api/health` +- Shields (package `com.dayline.tv`, APK v1.4) read `files/base_url` + `files/allow_insecure_http` + provisioned over ADB `run-as`. adb client lives on .100 (`apt install adb`). +- Shield provisioning (bash port of configure-shield.ps1, run on .100): `provision-shield.sh ` diff --git a/compose/hf-pdocker-02/hobbs-organizer/compose.override.yaml b/compose/hf-pdocker-02/hobbs-organizer/compose.override.yaml new file mode 100644 index 0000000..5f39141 --- /dev/null +++ b/compose/hf-pdocker-02/hobbs-organizer/compose.override.yaml @@ -0,0 +1,12 @@ +# Homelab overrides (hf-pdocker-02). Secrets live on LOCAL disk, not NFS: +# start.mjs reads them as root, and NFS root_squash would deny that. +# Mode: trusted-LAN (empty token file) per user 2026-11-04. +services: + organizer: + labels: + com.centurylinklabs.watchtower.enable: "false" +secrets: + calendar_ical_urls: + file: /var/lib/hobbs-organizer/secrets/calendar_ical_urls.json + organizer_access_token: + file: /var/lib/hobbs-organizer/secrets/organizer_access_token.txt diff --git a/compose/hf-pdocker-02/hobbs-organizer/provision-shield.sh b/compose/hf-pdocker-02/hobbs-organizer/provision-shield.sh new file mode 100644 index 0000000..1102f33 --- /dev/null +++ b/compose/hf-pdocker-02/hobbs-organizer/provision-shield.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +# Usage: provision-shield.sh (bash port of configure-shield.ps1, trusted-LAN http) +set -euo pipefail +D="$1:5555"; URL="$2"; PKG=com.dayline.tv; APK=/home/glyptic/hobbs-shield/Hobbs-Family-Organizer-Local-v1.4.apk +adb connect "$D" >/dev/null; [ "$(adb -s "$D" get-state)" = device ] +echo "before: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)" +adb -s "$D" shell run-as $PKG ls files +adb -s "$D" install -r "$APK" +echo "after: $(adb -s "$D" shell dumpsys package $PKG | grep -m1 versionName)" +put() { printf '%s' "$2" | adb -s "$D" shell "run-as $PKG sh -c 'umask 077; cat > files/.$1.new && chmod 600 files/.$1.new && mv files/.$1.new files/$1'"; } +put base_url "$URL" +adb -s "$D" shell "run-as $PKG rm -f files/access_token" +put allow_insecure_http enabled +adb -s "$D" shell run-as $PKG ls -l files +FG=$(adb -s "$D" shell dumpsys activity activities | grep -m1 mResumedActivity); echo "foreground: $FG"; echo "$FG" | grep -q -E "com.dayline.tv|launcher|tvlauncher|projengmenu" || { echo SKIP-LAUNCH: TV in use; exit 0; } +adb -s "$D" shell am force-stop $PKG +adb -s "$D" shell am start -W -n $PKG/.MainActivity | grep -E 'Status|Activity' diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.dockerignore b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.dockerignore new file mode 100644 index 0000000..dc4c5fb --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.dockerignore @@ -0,0 +1,42 @@ +.git +.gitignore +.openai +.agents +.codex +.env +.env.* +!.env.example +.next +.next-build +.sites-runtime +.vinext +.wrangler +cloudflare-env.d.ts +backups +components +coverage +db +dist +drizzle +examples +hooks +lib +node_modules +outputs +shield +scripts +secrets/*.json +secrets/*.txt +!secrets/*.example.json +!secrets/*.example.txt +vite.config.ts +worker +work +MIGRATION.md +README.md +RELEASE-NOTES.md +VERSION +*.log +*.tar +*.tar.gz +*.zip diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.gitignore b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.gitignore new file mode 100644 index 0000000..4d63745 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.gitignore @@ -0,0 +1,46 @@ +# Dependencies +/node_modules/ +/.pnp +.pnp.* +# Keep authored Yarn tooling if the project adopts Yarn. +/.yarn/* +!/.yarn/patches/ +!/.yarn/plugins/ +!/.yarn/releases/ +!/.yarn/versions/ + +# Generated builds and framework state +/dist/ +/.next/ +/.next-build/ +/.vinext/ +/out/ +next-env.d.ts +*.tsbuildinfo + +# Test output +/coverage/ + +# Package-manager logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# Local environment and keys +.env* +.dev.vars* +*.pem + +# Checkout-local tool state +/.wrangler/ +/.sites-runtime/ + +# Workspace scaffolding and publication archives +/.agents/ +/.codex/ +/outputs/ +/work/ + +# OS metadata +.DS_Store diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.npmrc b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.npmrc new file mode 100644 index 0000000..f4568ad --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/Dockerfile b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/Dockerfile new file mode 100644 index 0000000..138fde6 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/Dockerfile @@ -0,0 +1,39 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-bookworm-slim AS dependencies +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund + +FROM dependencies AS build +WORKDIR /app +COPY . . +RUN npm run build && test -f .next-build/BUILD_ID + +FROM node:22-bookworm-slim AS production-dependencies +WORKDIR /app +ENV NODE_ENV=production +COPY package.json package-lock.json ./ +RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund + +FROM node:22-bookworm-slim AS runtime +RUN groupadd --system --gid 10001 organizer \ + && useradd --system --uid 10001 --gid 10001 --home-dir /nonexistent --shell /usr/sbin/nologin organizer +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + HOSTNAME=0.0.0.0 \ + PORT=3000 +COPY --from=production-dependencies --chown=10001:10001 /app/node_modules/ ./node_modules/ +COPY --from=build --chown=10001:10001 /app/.next-build/ ./.next-build/ +COPY --from=build --chown=10001:10001 /app/public/ ./public/ +COPY --from=build --chown=10001:10001 /app/container/start.mjs ./container/start.mjs +COPY --from=build --chown=10001:10001 /app/package.json /app/next.config.mjs ./ +RUN mkdir -p /app/.next-build/cache && chown -R 10001:10001 /app/.next-build +# The tiny supervisor starts as root only long enough to read Docker secrets, +# then launches Next.js as uid/gid 10001. Compose grants only SETUID/SETGID. +USER 0:0 +EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] +CMD ["node", "container/start.mjs"] diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/MIGRATION.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/MIGRATION.md new file mode 100644 index 0000000..8af505f --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/MIGRATION.md @@ -0,0 +1,80 @@ +# Migration and rollback + +This procedure keeps the hosted dashboard and both Shields working until the local service has passed its checks. + +## 1. Prepare the server + +1. Extract the release bundle on the internal container host. +2. Regenerate the private Google Calendar ICS URLs that were used during development. +3. Run `scripts/configure.sh` or `scripts/configure.ps1`. +4. Review `.env`, especially the bind address, port, timezone, and NWS grid. +5. Start the container with `docker compose up -d --build --wait`. + +Do not expose port 8080 to the public Internet. + +## 2. Validate before touching a Shield + +Check liveness: + +```bash +curl -fsS http://SERVER-IP:8080/api/health +``` + +In trusted-LAN mode, open `http://SERVER-IP:8080/` and verify: + +- calendar events appear in agenda, week, and month views; +- the seven-day forecast and sunrise/sunset values appear; +- the FRED ticker reports live or partial data; +- AI/tech headlines appear and their QR dialogs scan correctly. + +In bearer mode, validate with a client capable of adding the `Authorization` header or proceed with one Shield as the test client. + +## 3. Upgrade one Shield first + +Use the Shield with the least operational impact as the canary: + +```powershell +& $adb -s DEVICE:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk +``` + +The `-r` upgrade is important. It preserves app-private files, including the legacy hosted token and the `daily_wake_0630_enabled` schedule flag. + +Configure the local server: + +```powershell +.\shield\configure-shield.ps1 ` + -Device DEVICE:5555 ` + -BaseUrl http://SERVER-IP:8080/ ` + -AllowInsecureHttp ` + -AdbPath $adb +``` + +Add `-TokenFile .\secrets\organizer_access_token.txt` if bearer mode is enabled. Omit `-AllowInsecureHttp` for HTTPS. + +Verify remote navigation, automatic refresh, QR dialogs, reboot behavior, and the 6:30 AM alarm on any Shield where that schedule was already enabled. + +## 4. Upgrade the second Shield + +After the canary has run successfully, repeat the APK upgrade and configuration with the other device address. + +## 5. Rollback + +The local cutover does not delete the old hosted access token. To switch a Shield back: + +```powershell +.\shield\restore-hosted-site.ps1 -Device DEVICE:5555 -AdbPath $adb +``` + +This removes `base_url`, `access_token`, and `allow_insecure_http`, then relaunches the same app against the hosted Site. + +To roll back the server release, restore the previous release directory and its protected configuration backup, then run `docker compose up -d --build --wait`. + +## 6. After the observation period + +Once the local deployment is stable: + +- keep the hosted Site only as long as you want the rollback option; +- retain an encrypted configuration backup; +- keep the prebuilt signed APK with the release archive; +- document the server address/DNS record and certificate renewal process; +- periodically review outbound-feed failures in `docker compose logs organizer`. diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/README.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/README.md new file mode 100644 index 0000000..ca2d349 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/README.md @@ -0,0 +1,250 @@ +# Hobbs' Family Organizer App — self-hosted package + +This release moves the organizer off ChatGPT Sites and onto your own container host. The dashboard, API routes, calendar parser, weather, FRED market ticker, RSS headlines, QR handoff, and Shield wrapper are included. + +The local deployment does **not** require ChatGPT, an OpenAI account, or an OpenAI API key. It is stateless and has no database or persistent application volume. + +## What still uses the Internet + +The container needs outbound DNS and HTTPS for: + +- `calendar.google.com` — private Google Calendar ICS feeds +- `api.weather.gov` — seven-day National Weather Service forecast +- `fred.stlouisfed.org` — public FRED series pages used by the ticker +- the configured OpenAI, Google AI, DeepMind, NVIDIA, Ars Technica, and Wired RSS feeds + +No inbound Internet access is required. Keep the service on your LAN, VPN, or behind your existing reverse proxy. + +## Package contents + +- `Dockerfile` — production Node 22 image +- `compose.yaml` — hardened Compose deployment on port 8080 by default +- `app/` — complete Next.js dashboard and API source +- `container/start.mjs` — validates secrets, then drops the app to uid/gid 10001 +- `scripts/configure.*` — creates runtime secrets without printing them +- `scripts/backup.*` and `scripts/restore.*` — configuration backup and restore +- `secrets/` — ignored runtime secret location and a calendar template +- `shield/` — signed v1.4 APK, provisioning scripts, and Android source +- `MIGRATION.md` — ordered cutover and rollback procedure + +## Requirements + +- A Linux, Windows, or macOS host with Docker Engine/Desktop and Docker Compose v2 +- Outbound HTTPS from the container host +- One to ten private Google Calendar `basic.ics` URLs +- For Shield deployment: Android Platform Tools (`adb`) and network debugging enabled on the Shield + +The image builds for both `linux/amd64` and `linux/arm64` when the selected Node base image supports the host architecture. + +## Quick start + +### Linux or macOS + +```bash +chmod +x scripts/*.sh +./scripts/configure.sh --mode trusted-lan +docker compose config --quiet +docker compose up -d --build --wait +curl -fsS http://127.0.0.1:8080/api/health +``` + +### Windows PowerShell + +```powershell +Set-ExecutionPolicy -Scope Process Bypass +.\scripts\configure.ps1 -Mode trusted-lan +docker compose config --quiet +docker compose up -d --build --wait +Invoke-RestMethod http://127.0.0.1:8080/api/health +``` + +The configuration script: + +1. copies `.env.example` to `.env` if needed; +2. prompts privately for each Google Calendar ICS URL; +3. writes `secrets/calendar_ical_urls.json` with restrictive permissions; +4. creates `secrets/organizer_access_token.txt`, empty for trusted-LAN mode or populated with a random token for bearer mode. + +After startup, open `http://SERVER-IP:8080/` or the HTTPS name provided by your reverse proxy. + +## Access modes + +### Trusted LAN + +Use this when firewall/VLAN/VPN rules already restrict the dashboard: + +```bash +./scripts/configure.sh --mode trusted-lan +``` + +```powershell +.\scripts\configure.ps1 -Mode trusted-lan +``` + +The token file exists but is empty. Anyone who can reach the published port can view calendar details, so bind to the intended interface and enforce access in your network or reverse proxy. + +### Bearer token + +Use this for the Shield or a reverse proxy that can inject an authorization header: + +```bash +./scripts/configure.sh --mode bearer --rotate-token +``` + +```powershell +.\scripts\configure.ps1 -Mode bearer -RotateToken +``` + +Every route except `/api/health` then requires: + +```text +Authorization: Bearer TOKEN +``` + +The Shield wrapper injects this header into every same-origin request. An ordinary browser address bar cannot add it, so browser access in bearer mode should go through an authenticated reverse proxy that injects the header. The configuration scripts intentionally do not print the token. + +## Configuration + +Edit `.env` for nonsecret settings: + +| Setting | Default | Purpose | +|---|---|---| +| `HOBBS_BIND_ADDRESS` | `0.0.0.0` | Host interface used by Docker | +| `HOBBS_HTTP_PORT` | `8080` | Published HTTP port | +| `ORGANIZER_TIMEZONE` | `America/Chicago` | Clock and calendar timezone | +| `WEATHER_LOCATION` | `Sapulpa, OK` | Display label | +| `WEATHER_LATITUDE` | `35.9987` | Sunrise/sunset latitude | +| `WEATHER_LONGITUDE` | `-96.11417` | Sunrise/sunset longitude | +| `NWS_FORECAST_URL` | `https://api.weather.gov/gridpoints/TSA/37,98/forecast` | NWS grid forecast | +| `WEATHER_USER_AGENT` | example value | NWS client identifier; add a monitored contact | + +Keep these secrets out of `.env`, Git, image layers, and tickets: + +- `secrets/calendar_ical_urls.json` +- `secrets/organizer_access_token.txt` + +Private Google Calendar URLs act like passwords. Because the current URLs were previously shared during development, reset/regenerate each private ICS URL in Google Calendar before the final production cutover, then run the configure script again. + +## Operations + +Status and logs: + +```bash +docker compose ps +docker compose logs --tail=200 organizer +curl -fsS http://127.0.0.1:8080/api/health +``` + +The health endpoint never calls Google, FRED, NWS, or RSS providers. It reports liveness plus booleans for calendar configuration and authentication, but no secret values. + +Stop and start: + +```bash +docker compose stop +docker compose start +``` + +Update after replacing the release files: + +```bash +docker compose build --pull +docker compose up -d --wait +docker image prune +``` + +`docker image prune` is optional and removes only unused images after confirmation. + +## Backup and restore + +The app has no database. Back up only `.env` and the two runtime secret files. + +Linux/macOS: + +```bash +./scripts/backup.sh +./scripts/restore.sh --force backups/hobbs-organizer-config-YYYYMMDD-HHMMSS.tar.gz +``` + +Windows: + +```powershell +.\scripts\backup.ps1 +.\scripts\restore.ps1 -Archive .\backups\hobbs-organizer-config-YYYYMMDD-HHMMSS-XXXXXXXX.zip -Force +``` + +Backup archives contain private calendar URLs and possibly the bearer token. Store them in an encrypted or tightly access-controlled backup system. + +## Reverse proxy and HTTPS + +HTTPS with a stable internal DNS name is recommended, for example `organizer.home.arpa`. Sample Caddy and Nginx configurations are in `examples/`. + +If you use an internal certificate authority, install its root certificate in the Shield's user certificate store. The wrapper trusts system and user-installed CAs but always rejects certificate errors; it has no trust-all bypass. + +For direct LAN HTTP, the Shield configuration script requires both an explicit `-AllowInsecureHttp` switch and a private IP, `.local`, `.lan`, or `.home.arpa` host. + +## NVIDIA Shield deployment + +Install Android Platform Tools, enable network debugging, and authorize the computer on the Shield. Upgrading the existing package preserves the hosted rollback token and the per-device 6:30 AM schedule flag. + +```powershell +$adb = "C:\path\to\platform-tools\adb.exe" +& $adb connect 192.168.86.179:5555 +& $adb -s 192.168.86.179:5555 install -r .\shield\Hobbs-Family-Organizer-Local-v1.4.apk +``` + +For HTTPS with bearer authentication: + +```powershell +.\shield\configure-shield.ps1 ` + -Device 192.168.86.179:5555 ` + -BaseUrl https://organizer.home.arpa/ ` + -TokenFile .\secrets\organizer_access_token.txt ` + -AdbPath $adb +``` + +For trusted-LAN HTTP: + +```powershell +.\shield\configure-shield.ps1 ` + -Device 192.168.86.179:5555 ` + -BaseUrl http://192.168.86.50:8080/ ` + -AllowInsecureHttp ` + -AdbPath $adb +``` + +The script streams the token through ADB standard input and never places it in the process arguments. Repeat for the second Shield using its address. Do not change the package ID: `com.dayline.tv` is intentionally preserved so existing app-private data survives `adb install -r`. + +The wrapper is debuggable so the scripts can use Android `run-as` to provision private files. After the migration, disable network debugging on each Shield and enable it again only during controlled maintenance. + +To return a Shield to the existing hosted Site: + +```powershell +.\shield\restore-hosted-site.ps1 -Device 192.168.86.179:5555 -AdbPath $adb +``` + +That removes only the local URL/token flags and keeps the old hosted `service_token` intact. + +## Build from source + +Container application: + +```bash +npm ci +npm run lint +npm run build +npm start +``` + +For local `npm start`, set `CALENDAR_ICAL_URLS` to a JSON array and optionally set `ORGANIZER_ACCESS_TOKEN`. The Compose path is preferred because it loads secrets from files and applies the security profile. + +Android source is under `shield/source/`. It targets Android SDK 35, Java 17, and Gradle 8.9/Android Gradle Plugin 8.7.3. The included APK is already signed with the same certificate as the installed v1.3 app. The private signing key is deliberately not included in this package. + +## Troubleshooting + +- `calendarConfigurationValid: false`: rerun `scripts/configure.*` and confirm the secret contains a JSON array of Google `basic.ics` URLs. +- Dashboard returns `401`: token mode is enabled; provision the same token to the Shield or reverse proxy. +- Weather is empty: verify the NWS grid URL, outbound HTTPS, DNS, and `WEATHER_USER_AGENT`. +- FRED shows demo data: allow outbound HTTPS to `fred.stlouisfed.org`; the feed uses public pages and no API key. +- Shield shows setup required: confirm the APK is installed and rerun `shield/configure-shield.ps1`. +- Shield rejects HTTPS: install the internal CA certificate or use a certificate trusted by Android; never bypass validation. +- Container does not become healthy: run `docker compose logs organizer` and validate that both secret files exist. diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md new file mode 100644 index 0000000..752f96f --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/RELEASE-NOTES.md @@ -0,0 +1,12 @@ +# Release 1.0.0 + +Built and validated on 2026-11-04. + +- Replaced the hosted Cloudflare/Vinext runtime with a standard production Next.js container. +- Added runtime-only calendar secrets and optional bearer authentication. +- Added configurable timezone and weather settings, bounded feed responses, redirect validation, health checks, and hardened Compose settings. +- Included cross-platform configuration, backup, restore, and migration documentation. +- Included NVIDIA Shield wrapper v1.4 (`com.dayline.tv`, version code 5) with local URL support, exact-origin enforcement, optional bearer injection, controlled private-LAN HTTP, internal-CA support, and hosted rollback. +- Preserved the existing app identity, signing certificate, private app data, and per-device schedule flag during in-place upgrades. + +The current hosted dashboard and installed Shield applications are not changed merely by extracting this package. Follow `MIGRATION.md` when the internal server URL is ready. diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/SHA256SUMS.txt b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/SHA256SUMS.txt new file mode 100644 index 0000000..a8f80be --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/SHA256SUMS.txt @@ -0,0 +1,64 @@ +0800fd869da63e37610ecf417fefa701f70d06abe559909145fe0ceaee95415e app/api/config/route.ts +0faf67bca2879084e9259dca3e1ddb911a4714669f81b4b23684f07f275f4012 .dockerignore +13c79859132210fd64a0bc38f8a6528a33c9b4d74ee84f41596e2907a1cee533 scripts/backup.ps1 +150dc144c5ff007803cf7660e174a0122200bfa5a4caea0bc32a56affa74065d RELEASE-NOTES.md +1834aab6e34acc20ae57b1a28d327c2d6fd1206a158f170dc5438251ed64038b app/api/health/route.ts +2247e2fb201b7e4d47de515b8c01c189cc2580a5ca53339b671674feef588713 shield/source/app/src/main/res/values/styles.xml +297319b72a62826c2beebcf67a3916c60b647635ff43e3bf1dcbc5b45ac9de21 shield/source/app/src/main/java/com/dayline/tv/ScheduleReceiver.java +350a753aff373eb283dc82c4722435f66f0cfeddb7ed3561251495a165ddfaa0 .env.example +3e8863288c2d43dd33d2a71de317dcf9198f09932ea8d61e0def1b846a865435 public/favicon.svg +4386abbca613dcc781ba5128acdb9c319ce29b76044854b2a947efcd11676235 shield/configure-shield.ps1 +45de38714943c75220b77c95bac7356a5b81c519ac02f9055d23498d3b0fb725 app/api/headlines/route.ts +4906e019df437c43d409ddb6e3ee27295d93d1dce6ffdc870f017759acc31d92 app/layout.tsx +498ff029fd558e35acabab74d9db8280d688a2c2d7061d7a92799f57fefba9b4 scripts/configure.sh +4d0a6cf06aee2721b65d93228ce1feb0368e94cc7e01c0bb3ca25ae0b1a05559 shield/source/settings.gradle +50244a55348072213daeddf6f1ead7ae09720cbc27e5d3b4ecf36af72425325e secrets/.gitignore +5556c7b137de362d770d473ed7d9e83aeaec1aaee243a1dcacbd524e13d27aee app/api/weather/route.ts +59854984853104df5c353e2f681a15fc7924742f9a2e468c29af248dce45ce03 VERSION +60e0cf332d6ca64095b4e3b2d2e0f2f4089266effac57e18e5a1a56122e3bcfa app/globals.css +6bffd3afe4faf1626ad81d1313a358c24e765accbaad19076ac2850b3863942d tsconfig.json +6f51557541aaab18fa69b61fa3d5afbb74ae494abf7a248ea6043719fbf7e6e4 shield/source/app/src/test/java/com/dayline/tv/DashboardConfigTest.java +7017c28c214eca8e0b4ce01f5c7106aac631d0169e895caff8ba07b11f01a799 container/start.mjs +70250691787e467bbbca3d97530d60d6a07718dcdfa66006fc7a3d98e59e5a6f shield/source/app/src/main/res/drawable/ic_dayline.xml +73096b003fedba8491cfdaca71cbccb0246f765971c2935ffa1117e20dfb523d next-env.d.ts +73a5fe77f5aa78693aa65609c1d7ec19f8210bdefe843818e8a2fdaf0f6e96f4 shield/README.md +751ae6b9ae65e578a7f609a55f6bf8dd7269a4fd4602cffa8d3bed1fc3b1a1a2 MIGRATION.md +79a1a184c6c3e65c1189f20b36bacaa77a825a8f365a4907e645b43274f765c7 examples/nginx.conf +7b299d3d3b16699ddda397c0b2373b3af4f25f8fc9ccc9b3d9e64ef083bc1c21 postcss.config.mjs +7cd80af2e968119afd7b1803b6ac51b51ea010b8da57c3606ea1ef8071a86068 .npmrc +7defc63c4c4528c51442f2f772ff96970af9d86a213366e3f254d45fd265615a shield/source/app/src/main/java/com/dayline/tv/MainActivity.java +81577b6a53a2759c0559cdaf8e05e37ae1e91354c450333974fd32a105b64c9f shield/source/app/src/main/java/com/dayline/tv/DaylineScheduler.java +88da59fb1cbec9af0d81f2d654af5827a93b77232a82ec25b3528a4b3ebfed7a scripts/configure.ps1 +8bf7d6fe745ad8082cd9c0edc4af97d8a4c547ff7aa6e2cb543467bfc70359f1 app/api/markets/route.ts +8cb3d6b6680242d9d18f3b0ea86c901c30a21e7646d8a85887c4b27791aad490 proxy.ts +92638a43482e2938e1a461ca5efdd7a8b7175e79ade8b9a79af288c3ed680fca .gitignore +95128f47a746eea8b710a1ba708f20e2f83f59cf8fc06d10cbf8abcc3bec56b8 shield/source/.gitignore +9779e64941ef00fdb75ecd9ccbf290bad2a4924ea0e1688bb1c617884e2753b6 shield/source/build.gradle +9ccf53e8c648736adbf95a27d70ace297c7e3b56d6e48e878194f98c51b36b21 shield/source/app/src/main/res/xml/data_extraction_rules.xml +9f71fa926f764c9d6579a62a530b783bbec894c409381cb6047c21ad0697f88e shield/source/app/src/main/res/xml/network_security_config.xml +a14c2d6fb5ba9925cba04633226a3b93e6c5566ca6b550dbfaead7ab3fa93b24 vendor/shadcn-tailwind-4.13.0.LICENSE.md +a1bad3fc1c31829cdb85451911e5cab98dac778c64842f8b58f38e15853e2ae5 scripts/restore.ps1 +a52b91e3ed169cad985e6f70644bd13f6dd88fffaaa8d9f1ca47928418495412 shield/Hobbs-Family-Organizer-Local-v1.4.apk +a7e608e83587d8b9378d430dfa4e5f944ad031cf13598d9f26f190ed43e21992 compose.yaml +a87af2bc93b15147531d9ab768d5b47491ceaf0c62875295cf861e21c13ac711 Dockerfile +bc39cd36a8474161c64cdbbbcf70fae1865f5926597cd0085d8b433653b82144 scripts/restore.sh +bd57e28eba96a29974e2a15aa1c27820d17809a37dbc6a08d1c0a1ac828ac425 eslint.config.mjs +be5c30e2fe156ded97fc0e6ac42e2150eb6695d6b59d21a5237572e12295bd33 package-lock.json +c0d416f28accc223a693a47d38ba274d1f46c196309ee5d061a0f218ad8af1db shield/source/BUILDING.md +c9d434920bf4d3188dec353c295f1307521ad20299a7bb76cf6978a928381017 shield/source/app/proguard-rules.pro +c9f120abb901e2767512ba629a56a51dc3fd68b947eb8392ad36a90136248907 examples/Caddyfile +cc38bc495ca4e4d7fa2fe7c9c1112f36af7a63fbf4fc3e7a088e215942403385 shield/source/gradle.properties +cd4cc80f038ef1b4b7b9102fe701203684506fe003951b2ceebd0c19eff3632f app/api/calendar/route.ts +cf618a848c283c1ab6525fb9bb1a4c86285b12a8284bd2d947bde54514981027 app/page.tsx +dd6fcf0d3388de060d9d1822e87c80846c01eb2dcf32906c26d9c41b0a2c0d65 README.md +e316b4d4fe3185bfc25a8f88a094e99114db7a07e6d62f7e816e37cd077b626a next.config.mjs +e52c0aad9c35df9c5d7933e60315c3b66ae1ca3faee735d07afca499ce688dbe package.json +e7049dd99c0af5246d9ca4ae4f2ee14b361c21ad1136658b65c1821ebf2b4599 shield/source/app/src/main/res/values/strings.xml +e855e4c4653adcd315a47906679607b185fff8d4303310b4f847cf6271f92248 shield/source/app/build.gradle +e8bc6ecdd7bf7e86623adf2b788303efadba2927664e855312820bbc99c8f665 shield/source/app/src/main/java/com/dayline/tv/DashboardConfig.java +ea9853f33652e0c76ddfba1b7810ca2962ecbb1a334b4f76464c06c04453f8ea scripts/backup.sh +eb47dafa34b01137544c631e675b8f749f4431c09ca96e676b62ca123419c15e shield/source/app/src/main/AndroidManifest.xml +eb8de54b2f07ed84358822e00f8325e5b896f782b9f7eaccefb81bae853cfa7a secrets/calendar_ical_urls.example.json +f221d959e80b4738fba93b4b4f01472b11cccaf6043ba7f6f9376f6768dad28e vendor/shadcn-tailwind-4.13.0.css +f6e5c76522ab3d6c8dcbed63b59cde25e115b46786aae2e7a0b1746130a4a91b shield/restore-hosted-site.ps1 +fae716f0342674996a6318ed43c4d07f66044d931c735101012f013b51acb0a0 shield/source/app/src/main/res/drawable/tv_banner.xml diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/calendar/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/calendar/route.ts new file mode 100644 index 0000000..14ac6d9 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/calendar/route.ts @@ -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(); + + 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>> => 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" }, + }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/config/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/config/route.ts new file mode 100644 index 0000000..a4753b6 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/config/route.ts @@ -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" } }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/headlines/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/headlines/route.ts new file mode 100644 index 0000000..e4d196d --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/headlines/route.ts @@ -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(//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(/]+href=["']([^"']+)["'][^>]*>/i)?.[1]; + if (atomLink) return decodeEntities(atomLink); + return extract(block, ["link", "guid"]); +} + +function parseFeed(xml: string, source: string): FeedItem[] { + const blocks = xml.match(/]*)?>[\s\S]*?<\/item>/gi) + ?? xml.match(/]*)?>[\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", + }, + }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts new file mode 100644 index 0000000..52daa1e --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/health/route.ts @@ -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" } }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/markets/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/markets/route.ts new file mode 100644 index 0000000..567cd0c --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/markets/route.ts @@ -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 = { + 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(/]*)?>[\s\S]*?<\/tr>/gi) ?? []) { + const rawDate = row.match(/]*>\s*([^<]+?):(?: |\s)*<\/td>/i)?.[1]?.trim(); + const rawValue = row.match(/]*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>>[] = []; + 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", + }, + }, + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/weather/route.ts b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/weather/route.ts new file mode 100644 index 0000000..703b5bb --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/api/weather/route.ts @@ -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(); + + 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" } }, + ); + } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/globals.css b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/globals.css new file mode 100644 index 0000000..5c4877b --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/globals.css @@ -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; } +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/layout.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/layout.tsx new file mode 100644 index 0000000..8776070 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/page.tsx b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/page.tsx new file mode 100644 index 0000000..6d99902 --- /dev/null +++ b/tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0/app/page.tsx @@ -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