feat: self-host Hobbs Family Organizer (Shield TV dashboard) on hf-pdocker-02:8340

- tv app/Hobbs-Family-Organizer-Self-Hosted-v1.0.0: release bundle (Next.js app + Shield APK v1.4 + source)
- compose/hf-pdocker-02/hobbs-organizer: override (local-disk secrets, watchtower off), .env (port 8340), Shield provisioning script
- CLAUDE.md: service tree + internal ports
Trusted-LAN mode. Secrets live only on .100 /var/lib/hobbs-organizer/secrets (not in git).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E9NnVEico3WvFxLyjy1iW3
This commit is contained in:
2026-11-04 18:27:22 -06:00
parent 122ea392d1
commit e9c7e9df1a
68 changed files with 12831 additions and 0 deletions
@@ -0,0 +1,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
@@ -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
@@ -0,0 +1,3 @@
audit=false
fund=false
update-notifier=false
@@ -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"]
@@ -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`.
@@ -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.
@@ -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.
@@ -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
@@ -0,0 +1 @@
1.0.0
@@ -0,0 +1,297 @@
import ICAL from "ical.js";
type CalendarTone = "cyan" | "amber" | "violet" | "emerald" | "rose";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: CalendarTone;
};
const tones: CalendarTone[] = ["cyan", "amber", "violet", "emerald", "rose"];
const maxFeedBytes = 5 * 1024 * 1024;
const maxOccurrencesPerEvent = 10_000;
export const dynamic = "force-dynamic";
function cleanText(value: unknown, fallback: string, maxLength: number) {
return String(value ?? fallback).replace(/\s+/g, " ").trim().slice(0, maxLength) || fallback;
}
function calendarDate(value: ICAL.Time) {
return `${String(value.year).padStart(4, "0")}-${String(value.month).padStart(2, "0")}-${String(value.day).padStart(2, "0")}`;
}
function organizerDate(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: process.env.ORGANIZER_TIMEZONE || "America/Chicago",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function addCalendarDays(isoDate: string, days: number) {
const [year, month, day] = isoDate.split("-").map(Number);
const value = new Date(Date.UTC(year, month - 1, day + days));
return value.toISOString().slice(0, 10);
}
function hashId(value: string) {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(36);
}
function parseFeedUrls() {
const raw = process.env.CALENDAR_ICAL_URLS;
if (!raw) return [];
const values = JSON.parse(raw) as unknown;
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar feed configuration is invalid");
}
return values.map((value) => {
const url = new URL(String(value));
if (url.protocol !== "https:" || url.hostname !== "calendar.google.com" || !url.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar feed host is not allowed");
}
return url.toString();
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Calendar feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Calendar feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
function componentString(component: ICAL.Component, name: string) {
const value = component.getFirstPropertyValue(name);
return typeof value === "string" ? value : "";
}
function isCancelled(event: ICAL.Event) {
return componentString(event.component, "status").toUpperCase() === "CANCELLED";
}
function detach(component: ICAL.Component) {
return new ICAL.Component(structuredClone(component.toJSON()));
}
function eventIsInWindow(
start: ICAL.Time,
end: ICAL.Time,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
if (start.isDate) {
return calendarDate(end) > windowStartDate && calendarDate(start) <= horizonDate;
}
return end.toJSDate().getTime() > windowStart && start.toJSDate().getTime() <= horizon;
}
function occurrenceIsPastHorizon(start: ICAL.Time, horizon: number, horizonDate: string) {
return start.isDate ? calendarDate(start) > horizonDate : start.toJSDate().getTime() > horizon;
}
function serializeOccurrence(
event: ICAL.Event,
start: ICAL.Time,
end: ICAL.Time,
calendar: string,
tone: CalendarTone,
feedIndex: number,
) {
const allDay = start.isDate;
const startValue = allDay ? calendarDate(start) : start.toJSDate().toISOString();
const endValue = allDay ? calendarDate(end) : end.toJSDate().toISOString();
const uid = cleanText(event.uid, "event", 180);
const location = cleanText(event.location, "", 120);
return {
id: hashId(`${feedIndex}|${uid}|${startValue}`),
calendar,
title: cleanText(event.summary, "Busy", 140),
...(location ? { location } : {}),
start: startValue,
end: endValue,
allDay,
tone,
} satisfies CalendarEvent;
}
function expandCalendar(
ics: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
const root = new ICAL.Component(ICAL.parse(ics));
for (const timezone of root.getAllSubcomponents("vtimezone")) {
try {
ICAL.TimezoneService.register(timezone);
} catch {
// A feed may repeat a timezone that was already registered.
}
}
const sourceName = cleanText(root.getFirstPropertyValue("x-wr-calname"), `Calendar ${feedIndex + 1}`, 50);
const calendar = sourceName.includes("@") ? "Personal" : sourceName;
const tone = tones[feedIndex % tones.length];
const components = root.getAllSubcomponents("vevent");
const exceptions = new Map<string, ICAL.Component[]>();
for (const component of components) {
if (!component.hasProperty("recurrence-id")) continue;
const uid = componentString(component, "uid");
if (!uid) continue;
exceptions.set(uid, [...(exceptions.get(uid) ?? []), component]);
}
const items: CalendarEvent[] = [];
for (const component of components) {
if (component.hasProperty("recurrence-id")) continue;
try {
const event = new ICAL.Event(detach(component));
for (const exception of exceptions.get(event.uid) ?? []) {
event.relateException(detach(exception));
}
if (isCancelled(event)) continue;
if (!event.isRecurring()) {
if (eventIsInWindow(event.startDate, event.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(event, event.startDate, event.endDate, calendar, tone, feedIndex));
}
continue;
}
const iterator = event.iterator();
for (let count = 0; count < maxOccurrencesPerEvent; count += 1) {
const occurrence = iterator.next();
if (!occurrence) break;
if (occurrenceIsPastHorizon(occurrence, horizon, horizonDate)) break;
const details = event.getOccurrenceDetails(occurrence);
if (isCancelled(details.item)) continue;
if (eventIsInWindow(details.startDate, details.endDate, windowStart, horizon, windowStartDate, horizonDate)) {
items.push(serializeOccurrence(details.item, details.startDate, details.endDate, calendar, tone, feedIndex));
}
}
} catch {
// Ignore a malformed event while keeping the rest of the calendar usable.
}
}
return { calendar, items };
}
async function readCalendar(
url: string,
feedIndex: number,
windowStart: number,
horizon: number,
windowStartDate: string,
horizonDate: string,
) {
let currentUrl = url;
let response: Response | undefined;
for (let redirectCount = 0; redirectCount <= 3; redirectCount += 1) {
const validatedUrl = new URL(currentUrl);
if (validatedUrl.protocol !== "https:" || validatedUrl.hostname !== "calendar.google.com" || !validatedUrl.pathname.endsWith("/basic.ics")) {
throw new Error("Calendar redirect host is not allowed");
}
response = await fetch(validatedUrl, {
headers: { Accept: "text/calendar" },
redirect: "manual",
signal: AbortSignal.timeout(10_000),
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
const location = response.headers.get("location");
if (!location || redirectCount === 3) throw new Error("Calendar feed redirected too many times");
currentUrl = new URL(location, validatedUrl).toString();
}
if (!response) throw new Error("Calendar feed request failed");
if (!response.ok) throw new Error("Calendar feed request failed");
return expandCalendar(await readLimitedText(response), feedIndex, windowStart, horizon, windowStartDate, horizonDate);
}
export async function GET() {
let urls: string[];
try {
urls = parseFeedUrls();
} catch {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
if (!urls.length) {
return Response.json({ items: [], calendarsAvailable: 0, calendarsTotal: 0, updatedAt: new Date().toISOString() }, { status: 503 });
}
const now = Date.now();
const windowStart = now - 45 * 24 * 60 * 60 * 1_000;
const horizon = now + 60 * 24 * 60 * 60 * 1_000;
const today = organizerDate(new Date(now));
const windowStartDate = addCalendarDays(today, -45);
const horizonDate = addCalendarDays(today, 60);
const results = await Promise.allSettled(
urls.map((url, index) => readCalendar(url, index, windowStart, horizon, windowStartDate, horizonDate)),
);
const successful = results.filter((result): result is PromiseFulfilledResult<Awaited<ReturnType<typeof readCalendar>>> => result.status === "fulfilled");
const items = successful
.flatMap((result) => result.value.items)
.filter((item, index, all) => all.findIndex((candidate) => candidate.id === item.id) === index)
.sort((left, right) => {
const leftValue = left.allDay ? `${left.start}T00:00:00` : left.start;
const rightValue = right.allDay ? `${right.start}T00:00:00` : right.start;
return leftValue.localeCompare(rightValue);
})
.slice(0, 160);
return Response.json(
{
items,
calendarsAvailable: successful.length,
calendarsTotal: urls.length,
updatedAt: new Date().toISOString(),
},
{
status: successful.length ? 200 : 503,
headers: { "Cache-Control": "private, max-age=60, stale-while-revalidate=240" },
},
);
}
@@ -0,0 +1,17 @@
export const dynamic = "force-dynamic";
export async function GET() {
const configuredTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
let timeZone = "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: configuredTimeZone }).format(new Date());
timeZone = configuredTimeZone;
} catch {
// Keep a valid default if a deployment typo reaches the process.
}
return Response.json(
{ timeZone },
{ headers: { "Cache-Control": "public, max-age=3600" } },
);
}
@@ -0,0 +1,126 @@
type FeedSource = {
name: string;
url: string;
};
type FeedItem = {
source: string;
title: string;
url: string;
publishedAt: string | null;
};
const feeds: FeedSource[] = [
{ name: "OPENAI", url: "https://openai.com/news/rss.xml" },
{ name: "GOOGLE AI", url: "https://blog.google/technology/ai/rss/" },
{ name: "DEEPMIND", url: "https://deepmind.google/blog/rss.xml" },
{ name: "NVIDIA", url: "https://nvidianews.nvidia.com/cats/generative_al.xml" },
{ name: "ARS TECHNICA", url: "https://feeds.arstechnica.com/arstechnica/technology-lab" },
{ name: "WIRED", url: "https://www.wired.com/feed/tag/ai/latest/rss" },
];
const maxFeedBytes = 1024 * 1024;
export const dynamic = "force-dynamic";
function decodeEntities(value: string) {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1")
.replace(/<[^>]+>/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)))
.replace(/\s+/g, " ")
.trim();
}
function extract(block: string, names: string[]) {
for (const name of names) {
const match = block.match(new RegExp(`<${name}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${name}>`, "i"));
if (match?.[1]) return decodeEntities(match[1]);
}
return "";
}
function extractLink(block: string) {
const atomLink = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1];
if (atomLink) return decodeEntities(atomLink);
return extract(block, ["link", "guid"]);
}
function parseFeed(xml: string, source: string): FeedItem[] {
const blocks = xml.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi)
?? xml.match(/<entry(?:\s[^>]*)?>[\s\S]*?<\/entry>/gi)
?? [];
return blocks.slice(0, 6).flatMap((block) => {
const title = extract(block, ["title"]);
const url = extractLink(block);
const rawDate = extract(block, ["pubDate", "published", "updated", "dc:date"]);
const parsedDate = rawDate ? new Date(rawDate) : null;
if (!title || !url) return [];
return [{
source,
title: title.slice(0, 180),
url,
publishedAt: parsedDate && !Number.isNaN(parsedDate.getTime()) ? parsedDate.toISOString() : null,
}];
});
}
async function readLimitedText(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxFeedBytes) throw new Error("Headline feed is too large");
if (!response.body) return "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
let total = 0;
let text = "";
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
total += chunk.value.byteLength;
if (total > maxFeedBytes) {
await reader.cancel();
throw new Error("Headline feed is too large");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
}
async function readFeed(feed: FeedSource) {
const response = await fetch(feed.url, {
headers: { Accept: "application/atom+xml, application/rss+xml, application/xml, text/xml" },
signal: AbortSignal.timeout(6_000),
});
if (!response.ok) throw new Error(`${feed.name} returned ${response.status}`);
return parseFeed(await readLimitedText(response), feed.name);
}
export async function GET() {
const results = await Promise.allSettled(feeds.map(readFeed));
const items = results
.flatMap((result) => result.status === "fulfilled" ? result.value : [])
.filter((item, index, all) => all.findIndex((candidate) => candidate.url === item.url || candidate.title === item.title) === index)
.sort((a, b) => (Date.parse(b.publishedAt ?? "") || 0) - (Date.parse(a.publishedAt ?? "") || 0))
.slice(0, 10);
return Response.json(
{
items,
updatedAt: new Date().toISOString(),
feedsAvailable: results.filter((result) => result.status === "fulfilled").length,
feedsTotal: feeds.length,
},
{
headers: {
"Cache-Control": "public, max-age=300, stale-while-revalidate=900",
},
},
);
}
@@ -0,0 +1,23 @@
export const dynamic = "force-dynamic";
export async function GET() {
let calendarConfigurationValid = false;
try {
const parsed = JSON.parse(process.env.CALENDAR_ICAL_URLS || "[]") as unknown;
calendarConfigurationValid = Array.isArray(parsed) && parsed.length > 0;
} catch {
calendarConfigurationValid = false;
}
return Response.json(
{
status: "ok",
service: "hobbs-family-organizer",
version: "1.0.0",
calendarConfigurationValid,
authenticationEnabled: Boolean(process.env.ORGANIZER_ACCESS_TOKEN),
checkedAt: new Date().toISOString(),
},
{ headers: { "Cache-Control": "no-store" } },
);
}
@@ -0,0 +1,134 @@
type Direction = "up" | "down" | "flat";
type FredSeries = {
id: string;
symbol: string;
name: string;
currency: boolean;
decimals: number;
multiplier?: number;
};
type Observation = {
date: string;
value: number;
};
const series: FredSeries[] = [
{ id: "SP500", symbol: "S&P 500", name: "Index", currency: false, decimals: 2 },
{ id: "NASDAQCOM", symbol: "NASDAQ", name: "Index", currency: false, decimals: 2 },
{ id: "DJIA", symbol: "DOW", name: "Index", currency: false, decimals: 2 },
{ id: "DCOILWTICO", symbol: "WTI", name: "USD / barrel", currency: true, decimals: 2 },
{ id: "DHHNGSP", symbol: "NAT GAS", name: "USD / MMBtu", currency: true, decimals: 2 },
{ id: "PCOPPUSDM", symbol: "COPPER", name: "USD / lb", currency: true, decimals: 2, multiplier: 1 / 2_204.62262185 },
];
const maxResponseBytes = 1024 * 1024;
const monthNumbers: Record<string, string> = {
Jan: "01",
Feb: "02",
Mar: "03",
Apr: "04",
May: "05",
Jun: "06",
Jul: "07",
Aug: "08",
Sep: "09",
Oct: "10",
Nov: "11",
Dec: "12",
};
export const dynamic = "force-dynamic";
function normalizeDate(value: string) {
const iso = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (iso) return value;
const monthly = value.match(/^([A-Z][a-z]{2})\s+(\d{4})$/);
if (monthly && monthNumbers[monthly[1]]) return `${monthly[2]}-${monthNumbers[monthly[1]]}-01`;
return null;
}
function parseSeriesPage(html: string) {
const observations: Observation[] = [];
for (const row of html.match(/<tr(?:\s[^>]*)?>[\s\S]*?<\/tr>/gi) ?? []) {
const rawDate = row.match(/<td[^>]*>\s*([^<]+?):(?:&nbsp;|\s)*<\/td>/i)?.[1]?.trim();
const rawValue = row.match(/<td[^>]*class=["'][^"']*\bseries-obs\b[^"']*["'][^>]*>([\s\S]*?)<\/td>/i)?.[1];
if (!rawDate || !rawValue) continue;
const date = normalizeDate(rawDate);
const value = Number(rawValue.replace(/<[^>]+>/g, "").replace(/,/g, "").replace(/&minus;/g, "-").trim());
if (!date || !Number.isFinite(value)) continue;
observations.push({ date, value });
}
return observations.sort((left, right) => left.date.localeCompare(right.date));
}
function formatValue(value: number, config: FredSeries) {
const formatted = new Intl.NumberFormat("en-US", {
minimumFractionDigits: config.decimals,
maximumFractionDigits: config.decimals,
}).format(value);
return config.currency ? `$${formatted}` : formatted;
}
async function readSeries(config: FredSeries) {
const url = new URL(`/series/${encodeURIComponent(config.id)}`, "https://fred.stlouisfed.org");
const response = await fetch(url, {
headers: {
Accept: "text/html,application/xhtml+xml",
"User-Agent": "Dayline-TV/1.0 (+https://fred.stlouisfed.org)",
},
signal: AbortSignal.timeout(12_000),
});
if (!response.ok) throw new Error(`${config.id} returned ${response.status}`);
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (declaredLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const html = await response.text();
if (new TextEncoder().encode(html).byteLength > maxResponseBytes) throw new Error(`${config.id} response is too large`);
const observations = parseSeriesPage(html);
if (observations.length < 2) throw new Error(`${config.id} has fewer than two observations`);
const previous = observations.at(-2)!;
const latest = observations.at(-1)!;
const multiplier = config.multiplier ?? 1;
const value = latest.value * multiplier;
const move = ((latest.value - previous.value) / previous.value) * 100;
const direction: Direction = move > 0.004 ? "up" : move < -0.004 ? "down" : "flat";
return {
symbol: config.symbol,
name: config.name,
value: formatValue(value, config),
move: `${move > 0 ? "+" : ""}${move.toFixed(2)}%`,
direction,
seriesId: config.id,
observedAt: latest.date,
};
}
export async function GET() {
const results: PromiseSettledResult<Awaited<ReturnType<typeof readSeries>>>[] = [];
for (let index = 0; index < series.length; index += 2) {
const batch = await Promise.allSettled(series.slice(index, index + 2).map(readSeries));
results.push(...batch);
}
const items = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
return Response.json(
{
items,
seriesAvailable: items.length,
seriesTotal: series.length,
updatedAt: new Date().toISOString(),
},
{
status: items.length ? 200 : 503,
headers: {
"Cache-Control": "public, max-age=900, stale-while-revalidate=21600",
"X-Content-Type-Options": "nosniff",
},
},
);
}
@@ -0,0 +1,183 @@
type WeatherIcon = "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
type NwsPeriod = {
number?: number;
startTime?: string;
isDaytime?: boolean;
temperature?: number;
temperatureUnit?: string;
shortForecast?: string;
probabilityOfPrecipitation?: { value?: number | null };
};
type NwsForecast = {
properties?: { periods?: NwsPeriod[] };
};
type DayAggregate = {
date: string;
high?: number;
low?: number;
temperatures: number[];
condition: string;
icon: WeatherIcon;
precipitationChance: number;
};
function numericSetting(name: string, fallback: number, minimum: number, maximum: number) {
const value = Number(process.env[name] ?? fallback);
return Number.isFinite(value) && value >= minimum && value <= maximum ? value : fallback;
}
const latitude = numericSetting("WEATHER_LATITUDE", 35.9987, -90, 90);
const longitude = numericSetting("WEATHER_LONGITUDE", -96.11417, -180, 180);
const location = (process.env.WEATHER_LOCATION || "Sapulpa, OK").trim().slice(0, 100);
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
const userAgent = (process.env.WEATHER_USER_AGENT || "Hobbs-Family-Organizer/1.0 (self-hosted)").trim().slice(0, 200);
function configuredForecastUrl() {
const value = new URL(process.env.NWS_FORECAST_URL || "https://api.weather.gov/gridpoints/TSA/37,98/forecast");
if (value.protocol !== "https:" || value.hostname !== "api.weather.gov") {
throw new Error("NWS forecast URL must use https://api.weather.gov");
}
return value;
}
export const dynamic = "force-dynamic";
function weatherIcon(condition: string): WeatherIcon {
const value = condition.toLowerCase();
if (value.includes("thunder")) return "storm";
if (value.includes("snow") || value.includes("sleet") || value.includes("ice")) return "snow";
if (value.includes("rain") || value.includes("shower") || value.includes("drizzle")) return "rain";
if (value.includes("fog") || value.includes("haze")) return "fog";
if (value.includes("partly") || value.includes("mostly sunny") || value.includes("mostly clear")) return "partly-cloudy";
if (value.includes("cloud") || value.includes("overcast")) return "cloudy";
return "clear";
}
function fahrenheit(value: number, unit?: string) {
return unit?.toUpperCase() === "C" ? (value * 9) / 5 + 32 : value;
}
function normalizeDegrees(value: number) {
return ((value % 360) + 360) % 360;
}
function normalizeHours(value: number) {
return ((value % 24) + 24) % 24;
}
function dayOfYear(dateKey: string) {
const [year, month, day] = dateKey.split("-").map(Number);
const current = Date.UTC(year, month - 1, day);
return Math.floor((current - Date.UTC(year, 0, 0)) / 86_400_000);
}
function solarTime(dateKey: string, sunrise: boolean) {
const radians = (degrees: number) => degrees * Math.PI / 180;
const degrees = (value: number) => value * 180 / Math.PI;
const longitudeHour = longitude / 15;
const approximateTime = dayOfYear(dateKey) + (((sunrise ? 6 : 18) - longitudeHour) / 24);
const meanAnomaly = (0.9856 * approximateTime) - 3.289;
const trueLongitude = normalizeDegrees(
meanAnomaly + (1.916 * Math.sin(radians(meanAnomaly))) + (0.02 * Math.sin(radians(2 * meanAnomaly))) + 282.634,
);
let rightAscension = normalizeDegrees(degrees(Math.atan(0.91764 * Math.tan(radians(trueLongitude)))));
rightAscension += Math.floor(trueLongitude / 90) * 90 - Math.floor(rightAscension / 90) * 90;
rightAscension /= 15;
const sinDeclination = 0.39782 * Math.sin(radians(trueLongitude));
const cosDeclination = Math.cos(Math.asin(sinDeclination));
const cosHour = (
Math.cos(radians(90.833)) - (sinDeclination * Math.sin(radians(latitude)))
) / (cosDeclination * Math.cos(radians(latitude)));
if (cosHour > 1 || cosHour < -1) return "";
const hourAngle = (sunrise ? 360 - degrees(Math.acos(cosHour)) : degrees(Math.acos(cosHour))) / 15;
const localMeanTime = hourAngle + rightAscension - (0.06571 * approximateTime) - 6.622;
const utcHours = normalizeHours(localMeanTime - longitudeHour);
const [year, month, day] = dateKey.split("-").map(Number);
const instant = new Date(Date.UTC(year, month - 1, day, 0, Math.round(utcHours * 60)));
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).formatToParts(instant);
const part = (type: string) => parts.find((entry) => entry.type === type)?.value ?? "00";
return `${dateKey}T${part("hour")}:${part("minute")}`;
}
function dailyForecast(periods: NwsPeriod[]) {
const days = new Map<string, DayAggregate>();
for (const period of periods) {
if (!period.startTime || typeof period.temperature !== "number") continue;
const date = period.startTime.slice(0, 10);
const temperature = fahrenheit(period.temperature, period.temperatureUnit);
const condition = period.shortForecast?.trim() || "Clear";
const existing = days.get(date) ?? {
date,
temperatures: [],
condition,
icon: weatherIcon(condition),
precipitationChance: 0,
};
existing.temperatures.push(temperature);
if (period.isDaytime) {
existing.high = temperature;
existing.condition = condition;
existing.icon = weatherIcon(condition);
} else {
existing.low = temperature;
}
existing.precipitationChance = Math.max(
existing.precipitationChance,
Number(period.probabilityOfPrecipitation?.value) || 0,
);
days.set(date, existing);
}
return [...days.values()].slice(0, 7).map((day) => ({
date: day.date,
code: 0,
condition: day.condition,
icon: day.icon,
high: day.high ?? Math.max(...day.temperatures),
low: day.low ?? Math.min(...day.temperatures),
sunrise: solarTime(day.date, true),
sunset: solarTime(day.date, false),
precipitationChance: day.precipitationChance,
}));
}
export async function GET() {
try {
const response = await fetch(configuredForecastUrl(), {
headers: {
Accept: "application/geo+json",
"User-Agent": userAgent,
},
signal: AbortSignal.timeout(8_000),
});
if (!response.ok) throw new Error(`NWS returned ${response.status}`);
const payload = await response.json() as NwsForecast;
const items = dailyForecast(payload.properties?.periods ?? []);
if (items.length < 7) throw new Error("NWS forecast did not include seven days");
return Response.json(
{ location, latitude, longitude, source: "National Weather Service", items, updatedAt: new Date().toISOString() },
{ headers: { "Cache-Control": "public, max-age=900, stale-while-revalidate=1800" } },
);
} catch (error) {
console.error("Weather refresh failed", error instanceof Error ? error.message : "Unknown error");
return Response.json(
{ location, items: [], updatedAt: new Date().toISOString() },
{ status: 503, headers: { "Cache-Control": "no-store" } },
);
}
}
@@ -0,0 +1,765 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "../vendor/shadcn-tailwind-4.13.0.css";
:root {
color-scheme: dark;
--background: #070b12;
--foreground: #f8fafc;
--card: #0c121d;
--card-foreground: #f8fafc;
--popover: #0c121d;
--popover-foreground: #f8fafc;
--primary: #a5f3fc;
--primary-foreground: #071018;
--secondary: #121a28;
--secondary-foreground: #e2e8f0;
--muted: #121a28;
--muted-foreground: #64748b;
--accent: #162132;
--accent-foreground: #f8fafc;
--destructive: #fb7185;
--border: #ffffff12;
--input: #ffffff16;
--ring: #67e8f9;
--radius: 1rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--radius-sm: calc(var(--radius) - 6px);
--radius-md: calc(var(--radius) - 3px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 6px);
--font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
* { box-sizing: border-box; }
html,
body {
min-height: 100%;
margin: 0;
background: #070b12;
}
body {
overflow: hidden;
color: var(--foreground);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.dashboard-shell {
position: relative;
display: flex;
min-height: 100vh;
height: 100vh;
flex-direction: column;
overflow: hidden;
isolation: isolate;
background:
radial-gradient(circle at 24% 0%, rgb(34 211 238 / 7%), transparent 34%),
radial-gradient(circle at 82% 18%, rgb(167 139 250 / 7%), transparent 30%),
linear-gradient(145deg, #080d16 0%, #060a11 48%, #080c14 100%);
}
.dashboard-shell::before {
content: "";
position: absolute;
inset: 0;
z-index: -2;
opacity: 0.24;
background-image:
linear-gradient(rgb(255 255 255 / 2.4%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2.4%) 1px, transparent 1px);
background-size: 64px 64px;
mask-image: linear-gradient(to bottom, black, transparent 72%);
}
.ambient {
position: absolute;
z-index: -1;
width: 34vw;
height: 34vw;
border-radius: 999px;
filter: blur(120px);
opacity: 0.08;
animation: ambient-drift 22s ease-in-out infinite alternate;
}
.ambient-one { left: -14vw; top: 22vh; background: #22d3ee; }
.ambient-two { right: -12vw; top: 10vh; background: #8b5cf6; animation-delay: -9s; }
.brand-mark {
display: grid;
grid-template-columns: repeat(3, 7px);
align-items: end;
gap: 4px;
width: 43px;
height: 43px;
padding: 9px;
border: 1px solid rgb(103 232 249 / 22%);
border-radius: 13px;
background: linear-gradient(145deg, rgb(103 232 249 / 13%), rgb(103 232 249 / 3%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 8%), 0 10px 30px rgb(0 0 0 / 18%);
}
.brand-mark span { display: block; border-radius: 99px; background: linear-gradient(to top, #22d3ee, #a5f3fc); }
.brand-mark span:nth-child(1) { height: 46%; }
.brand-mark span:nth-child(2) { height: 100%; }
.brand-mark span:nth-child(3) { height: 72%; }
.panel {
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 22px;
padding: 20px;
background: linear-gradient(155deg, rgb(17 25 39 / 88%), rgb(9 15 25 / 86%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 24px 70px rgb(0 0 0 / 16%);
backdrop-filter: blur(20px);
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.panel-heading h2 { font-size: 1.1rem; font-weight: 800; letter-spacing: -0.02em; color: #f8fafc; }
.source-pill {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 6px 10px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.source-demo { border-color: rgb(251 191 36 / 15%); color: #fde68a; background: rgb(251 191 36 / 5%); }
.source-live {
border-color: rgb(52 211 153 / 18%);
color: #a7f3d0;
background: rgb(52 211 153 / 6%);
}
.source-loading { border-color: rgb(103 232 249 / 15%); color: #a5f3fc; background: rgb(103 232 249 / 5%); }
.source-partial,
.source-stale,
.source-error {
border-color: rgb(251 191 36 / 18%);
color: #fde68a;
background: rgb(251 191 36 / 6%);
}
.weather-strip {
display: grid;
min-height: 112px;
grid-template-columns: minmax(190px, 0.9fr) minmax(0, 5.1fr);
gap: 9px;
margin: 14px 24px 0;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 20px;
padding: 9px;
background: linear-gradient(105deg, rgb(34 211 238 / 6%), rgb(9 15 25 / 86%) 42%, rgb(251 191 36 / 4%));
box-shadow: inset 0 1px 0 rgb(255 255 255 / 5%), 0 18px 50px rgb(0 0 0 / 12%);
backdrop-filter: blur(18px);
}
.weather-summary {
min-width: 0;
border: 1px solid rgb(103 232 249 / 12%);
border-radius: 14px;
padding: 10px 12px;
background: rgb(103 232 249 / 4%);
}
.weather-summary-top,
.forecast-day-heading,
.weather-location,
.weather-now,
.forecast-condition,
.sun-times,
.sun-times span {
display: flex;
align-items: center;
}
.weather-summary-top,
.forecast-day-heading,
.sun-times { justify-content: space-between; }
.weather-location {
min-width: 0;
gap: 5px;
color: #cbd5e1;
font-size: 0.64rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-location span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.weather-status {
flex: none;
color: #6ee7b7;
font-size: 0.54rem;
font-weight: 850;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.weather-loading,
.weather-stale,
.weather-error { color: #fcd34d; }
.weather-now { gap: 10px; margin-top: 8px; }
.weather-now-icon { width: 38px; height: 38px; flex: none; color: #fde68a; stroke-width: 1.65; }
.weather-now-temp { display: flex; align-items: baseline; gap: 7px; line-height: 1; }
.weather-now-temp span { color: #fff; font-size: 1.65rem; font-weight: 300; letter-spacing: -0.06em; }
.weather-now-temp small { color: #64748b; font-size: 0.9rem; font-weight: 650; }
.weather-now p { overflow: hidden; margin-top: 4px; color: #94a3b8; font-size: 0.67rem; font-weight: 650; text-overflow: ellipsis; white-space: nowrap; }
.weather-pending { opacity: 0.72; }
.forecast-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.forecast-day {
min-width: 0;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px 8px;
background: rgb(255 255 255 / 2.2%);
}
.forecast-today {
border-color: rgb(103 232 249 / 24%);
background: rgb(103 232 249 / 5.5%);
}
.forecast-day-heading span { color: #94a3b8; font-size: 0.59rem; font-weight: 900; letter-spacing: 0.09em; }
.forecast-today .forecast-day-heading span { color: #a5f3fc; }
.forecast-day-heading small { color: #475569; font-size: 0.54rem; font-weight: 700; }
.forecast-condition { justify-content: center; gap: 8px; margin-top: 5px; }
.forecast-icon { width: 25px; height: 25px; flex: none; color: #fde68a; stroke-width: 1.6; }
.forecast-condition > span { display: flex; align-items: baseline; gap: 4px; }
.forecast-condition strong { color: #f8fafc; font-size: 1rem; font-weight: 650; }
.forecast-condition small { color: #64748b; font-size: 0.68rem; font-weight: 650; }
.forecast-rain { margin-top: 3px; color: #64748b; font-size: 0.49rem; font-weight: 800; letter-spacing: 0.08em; text-align: center; }
.sun-times { gap: 4px; margin-top: 5px; color: #64748b; font-size: 0.51rem; font-weight: 650; }
.sun-times span { min-width: 0; gap: 2px; }
.sun-times svg { width: 10px; height: 10px; flex: none; color: #fcd34d; }
.forecast-placeholder { display: grid; align-content: center; gap: 8px; opacity: 0.45; }
.forecast-placeholder span { height: 7px; border-radius: 999px; background: rgb(148 163 184 / 12%); }
.forecast-placeholder span:nth-child(2) { width: 70%; }
.forecast-placeholder span:nth-child(3) { width: 48%; }
.calendar-heading-actions,
.calendar-view-tabs,
.calendar-period-bar {
display: flex;
align-items: center;
}
.calendar-heading-actions { gap: 10px; }
.calendar-view-tabs {
gap: 2px;
border: 1px solid rgb(255 255 255 / 8%);
border-radius: 999px;
padding: 2px;
background: rgb(255 255 255 / 3%);
}
.calendar-view-tabs button {
border: 0;
border-radius: 999px;
padding: 5px 10px;
color: #64748b;
background: transparent;
font-size: 0.62rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.calendar-view-tabs button.is-active {
color: #cffafe;
background: rgb(34 211 238 / 13%);
box-shadow: inset 0 0 0 1px rgb(103 232 249 / 18%);
}
.calendar-view-tabs button:focus-visible,
.calendar-period-bar button:focus-visible,
.week-event:focus-visible {
outline: none;
box-shadow: 0 0 0 2px rgb(103 232 249 / 60%), 0 0 0 5px rgb(34 211 238 / 12%);
}
.calendar-view-content {
display: flex;
min-height: 0;
flex: 1;
flex-direction: column;
}
.calendar-period-bar {
min-height: 34px;
justify-content: flex-end;
gap: 6px;
margin-bottom: 8px;
}
.calendar-period-bar p {
min-width: 150px;
margin-right: auto;
color: #cbd5e1;
font-size: 0.77rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.calendar-period-bar button {
display: grid;
width: 30px;
height: 28px;
place-items: center;
border: 1px solid rgb(255 255 255 / 9%);
border-radius: 8px;
color: #94a3b8;
background: rgb(255 255 255 / 3%);
}
.calendar-period-bar button svg { width: 15px; height: 15px; }
.calendar-period-bar .calendar-today-button { width: auto; padding: 0 10px; font-size: 0.58rem; font-weight: 850; letter-spacing: 0.08em; text-transform: uppercase; }
.week-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 5px;
}
.week-day {
min-width: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 7%);
border-radius: 12px;
padding: 7px;
background: rgb(255 255 255 / 2%);
}
.week-day.is-today { border-color: rgb(103 232 249 / 26%); background: rgb(103 232 249 / 4.5%); }
.week-day-heading { display: flex; align-items: baseline; justify-content: space-between; border-bottom: 1px solid rgb(255 255 255 / 6%); padding-bottom: 6px; }
.week-day-heading span { color: #64748b; font-size: 0.57rem; font-weight: 900; letter-spacing: 0.08em; }
.week-day-heading strong { color: #cbd5e1; font-size: 0.92rem; font-weight: 500; }
.week-day.is-today .week-day-heading span,
.week-day.is-today .week-day-heading strong { color: #a5f3fc; }
.week-events { display: grid; gap: 5px; margin-top: 7px; }
.week-event { min-width: 0; border: 1px solid rgb(255 255 255 / 6%); border-left: 3px solid; border-radius: 7px; padding: 6px; background: rgb(2 6 13 / 32%); }
.week-event strong,
.week-event span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.week-event strong { color: #e2e8f0; font-size: 0.65rem; font-weight: 750; }
.week-event span { margin-top: 2px; color: #64748b; font-size: 0.55rem; font-weight: 650; }
.calendar-no-events { display: block; padding-top: 8px; color: #334155; font-size: 0.78rem; text-align: center; }
.calendar-more { color: #67e8f9; font-size: 0.54rem; font-weight: 800; }
.month-grid {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: repeat(7, minmax(0, 1fr));
grid-template-rows: auto repeat(6, minmax(0, 1fr));
gap: 3px;
}
.month-weekday { padding: 0 5px 3px; color: #475569; font-size: 0.55rem; font-weight: 900; letter-spacing: 0.1em; text-align: right; }
.month-cell { min-width: 0; overflow: hidden; border: 1px solid rgb(255 255 255 / 6%); border-radius: 8px; padding: 4px 5px; background: rgb(255 255 255 / 1.8%); }
.month-cell.is-outside { opacity: 0.34; }
.month-cell.is-today { border-color: rgb(103 232 249 / 45%); background: rgb(103 232 249 / 6%); box-shadow: inset 0 0 0 1px rgb(103 232 249 / 10%); }
.month-date { display: block; color: #94a3b8; font-size: 0.61rem; font-weight: 750; line-height: 1; text-align: right; }
.month-cell.is-today .month-date { color: #cffafe; }
.month-events { display: grid; gap: 2px; margin-top: 3px; }
.month-event { display: flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; color: #cbd5e1; font-size: 0.53rem; font-weight: 650; line-height: 1.2; text-overflow: ellipsis; white-space: nowrap; }
.month-event span { width: 5px; height: 5px; flex: none; border-radius: 999px; }
.next-event-card {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 128px;
border: 1px solid rgb(103 232 249 / 14%);
border-radius: 18px;
padding: 19px 22px;
background: linear-gradient(100deg, rgb(34 211 238 / 10%), transparent 58%), rgb(255 255 255 / 2%);
}
.eyebrow { font-size: 0.66rem; font-weight: 900; letter-spacing: 0.18em; color: #64748b; }
.calendar-row,
.headline-row,
.ticker-cell {
outline: none;
transition: border-color 160ms ease, background-color 160ms ease, transform 160ms ease;
}
.calendar-row {
display: flex;
min-height: 72px;
align-items: center;
gap: 14px;
border: 1px solid transparent;
border-radius: 15px;
padding: 8px 13px;
background: rgb(255 255 255 / 2.3%);
}
.calendar-accent {
width: 4px;
height: 38px;
flex: none;
border-radius: 999px;
}
.calendar-detail {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
margin-top: 2px;
color: #64748b;
font-size: 0.78rem;
}
.calendar-dot {
width: 7px;
height: 7px;
flex: none;
border-radius: 999px;
}
.calendar-empty {
display: grid;
min-height: 210px;
place-items: center;
border: 1px dashed rgb(255 255 255 / 10%);
border-radius: 16px;
color: #64748b;
text-align: center;
}
.calendar-row:focus-visible,
.headline-row:focus-visible,
.ticker-cell:focus-visible {
border-color: rgb(103 232 249 / 65%);
background: rgb(103 232 249 / 7%);
box-shadow: 0 0 0 3px rgb(34 211 238 / 12%);
}
.calendar-row:focus-visible { transform: translateX(4px); }
.headline-row {
display: flex;
align-items: flex-start;
gap: 13px;
border: 1px solid transparent;
border-radius: 12px;
padding: 13px 10px;
cursor: pointer;
}
.headline-row:disabled { cursor: default; }
.headline-qr-cue {
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid rgb(196 181 253 / 16%);
border-radius: 999px;
padding: 2px 6px;
color: #c4b5fd;
background: rgb(196 181 253 / 5%);
font-size: 0.54rem;
letter-spacing: 0.1em;
}
.headline-row:focus-visible { transform: translateX(-3px); }
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 999px;
background: #34d399;
box-shadow: 0 0 0 0 rgb(52 211 153 / 45%);
animation: pulse-ring 2.4s ease-out infinite;
}
.market-pulse-loading,
.market-pulse-demo,
.market-pulse-stale,
.market-pulse-partial {
background: #fbbf24;
box-shadow: 0 0 0 0 rgb(251 191 36 / 45%);
}
.ticker-window {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 3.5%, black 96.5%, transparent);
}
.ticker-track { display: flex; width: max-content; animation: ticker-scroll 38s linear infinite; }
.ticker-cell {
width: 290px;
flex: none;
border: 1px solid transparent;
border-right-color: rgb(255 255 255 / 8%);
padding: 4px 24px;
}
.qr-overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 24px;
background: rgb(2 6 13 / 88%);
backdrop-filter: blur(18px);
}
.qr-dialog {
position: relative;
display: grid;
width: min(820px, calc(100vw - 48px));
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 30px;
border: 1px solid rgb(196 181 253 / 24%);
border-radius: 28px;
padding: 26px;
background:
radial-gradient(circle at 12% 16%, rgb(34 211 238 / 10%), transparent 38%),
linear-gradient(145deg, #111a2a, #090f1a);
box-shadow: 0 36px 120px rgb(0 0 0 / 60%), inset 0 1px 0 rgb(255 255 255 / 7%);
}
.qr-code-frame {
display: grid;
place-items: center;
border-radius: 20px;
padding: 17px;
background: #fff;
box-shadow: 0 18px 55px rgb(34 211 238 / 14%);
}
.qr-code-frame svg { display: block; width: 264px; height: 264px; }
.qr-copy { min-width: 0; padding-right: 8px; }
.qr-source {
display: flex;
align-items: center;
gap: 8px;
color: #c4b5fd;
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.qr-source span { color: #475569; }
.qr-copy h2 {
margin: 12px 0 10px;
color: #fff;
font-size: 1.65rem;
font-weight: 750;
line-height: 1.16;
letter-spacing: -0.035em;
}
.qr-copy p { margin: 0; color: #94a3b8; font-size: 0.92rem; line-height: 1.55; }
.qr-close {
position: absolute;
top: 14px;
right: 14px;
display: flex;
align-items: center;
gap: 6px;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 999px;
padding: 7px 11px;
color: #cbd5e1;
background: rgb(255 255 255 / 5%);
font-size: 0.7rem;
font-weight: 800;
}
.qr-close:focus-visible {
outline: none;
border-color: rgb(103 232 249 / 70%);
box-shadow: 0 0 0 4px rgb(34 211 238 / 14%);
color: #fff;
}
@keyframes ticker-scroll { to { transform: translateX(-50%); } }
@keyframes pulse-ring { 70% { box-shadow: 0 0 0 8px rgb(52 211 153 / 0%); } 100% { box-shadow: 0 0 0 0 rgb(52 211 153 / 0%); } }
@keyframes ambient-drift { from { transform: translate3d(0, -2%, 0) scale(0.96); } to { transform: translate3d(5%, 5%, 0) scale(1.07); } }
@media (max-width: 1280px) {
.dashboard-shell { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: 60px 86px minmax(0, 1fr) 56px; }
.dashboard-header { min-height: 60px; padding: 7px 14px; }
.brand-group { gap: 11px; }
.brand-mark { width: 35px; height: 35px; padding: 7px; border-radius: 11px; }
.weather-strip { min-height: 0; margin: 5px 11px 0; gap: 5px; border-radius: 14px; padding: 5px; }
.weather-summary { border-radius: 10px; padding: 5px 8px; }
.weather-location { gap: 3px; font-size: 0.51rem; }
.weather-location svg { width: 10px; height: 10px; }
.weather-status { font-size: 0.45rem; }
.weather-now { gap: 6px; margin-top: 4px; }
.weather-now-icon { width: 27px; height: 27px; }
.weather-now-temp { gap: 4px; }
.weather-now-temp span { font-size: 1.15rem; }
.weather-now-temp small { font-size: 0.66rem; }
.weather-now p { margin-top: 2px; font-size: 0.52rem; }
.forecast-grid { gap: 4px; }
.forecast-day { border-radius: 9px; padding: 3px 5px; }
.forecast-day-heading span { font-size: 0.48rem; }
.forecast-day-heading small { font-size: 0.44rem; }
.forecast-condition { gap: 4px; margin-top: 2px; }
.forecast-icon { width: 17px; height: 17px; }
.forecast-condition > span { gap: 3px; }
.forecast-condition strong { font-size: 0.76rem; }
.forecast-condition small { font-size: 0.53rem; }
.forecast-rain { margin-top: 1px; font-size: 0.4rem; }
.sun-times { gap: 2px; margin-top: 2px; font-size: 0.43rem; }
.sun-times svg { width: 8px; height: 8px; }
.dashboard-grid { grid-template-columns: minmax(0, 1.6fr) minmax(310px, 0.9fr); gap: 8px; padding: 7px 11px; }
.panel { padding: 10px; border-radius: 16px; }
.panel-heading { margin-bottom: 7px; }
.panel-heading h2 { font-size: 0.96rem; }
.source-pill { gap: 5px; padding: 4px 8px; font-size: 0.57rem; }
.calendar-heading-actions { gap: 6px; }
.calendar-view-tabs button { padding: 4px 7px; font-size: 0.51rem; }
.calendar-period-bar { min-height: 25px; gap: 4px; margin-bottom: 5px; }
.calendar-period-bar p { min-width: 120px; font-size: 0.59rem; }
.calendar-period-bar button { width: 25px; height: 23px; border-radius: 6px; }
.calendar-period-bar button svg { width: 13px; height: 13px; }
.calendar-period-bar .calendar-today-button { padding-inline: 7px; font-size: 0.48rem; }
.next-event-card { min-height: 66px; padding: 7px 11px; border-radius: 13px; }
.next-event-title { margin-top: 3px; font-size: 1.22rem; }
.next-event-meta { margin-top: 1px; font-size: 0.73rem; }
.next-event-countdown { padding-left: 12px; }
.next-event-countdown > p:first-child { font-size: 1.45rem; }
.next-event-countdown > p:last-child { margin-top: 0; font-size: 0.56rem; }
.eyebrow { font-size: 0.55rem; }
.event-list { margin-top: 7px; }
.event-list-heading { margin-bottom: 4px; }
.event-list-heading > p:last-child { font-size: 0.62rem; }
.event-list-grid { gap: 4px; }
.calendar-row { min-height: 43px; gap: 7px; border-radius: 10px; padding: 3px 7px; }
.calendar-date { width: 53px; padding-right: 7px; }
.calendar-day { font-size: 0.51rem; }
.calendar-date-number { margin-top: 0; font-size: 1.12rem; line-height: 1.05; }
.calendar-accent { width: 3px; height: 25px; }
.calendar-event-title { font-size: 0.83rem; }
.calendar-event-time { font-size: 0.69rem; }
.calendar-detail { gap: 4px; margin-top: 0; font-size: 0.62rem; }
.calendar-dot { width: 5px; height: 5px; }
.week-grid { gap: 4px; }
.week-day { border-radius: 9px; padding: 5px; }
.week-day-heading { padding-bottom: 4px; }
.week-day-heading span { font-size: 0.47rem; }
.week-day-heading strong { font-size: 0.72rem; }
.week-events { gap: 4px; margin-top: 5px; }
.week-event { border-radius: 6px; padding: 4px; }
.week-event strong { font-size: 0.55rem; }
.week-event span { margin-top: 1px; font-size: 0.47rem; }
.calendar-more { font-size: 0.47rem; }
.month-grid { gap: 2px; }
.month-weekday { padding: 0 3px 2px; font-size: 0.45rem; }
.month-cell { border-radius: 6px; padding: 3px 4px; }
.month-date { font-size: 0.5rem; }
.month-events { gap: 1px; margin-top: 2px; }
.month-event { gap: 3px; font-size: 0.44rem; }
.month-event span { width: 4px; height: 4px; }
.headline-row { gap: 8px; padding: 5px 6px; border-radius: 9px; }
.headline-row > div:first-child { width: 7px; height: 7px; }
.headline-row h3 { margin-top: 3px; font-size: 0.79rem; line-height: 1.2; }
.headline-row:first-child h3 { font-size: 0.87rem; }
.headline-qr-cue { padding: 1px 5px; font-size: 0.48rem; }
.phone-handoff { margin-top: 6px; padding: 7px 10px; border-radius: 12px; }
.phone-handoff p:first-child { font-size: 0.76rem; }
.phone-handoff p:last-child { font-size: 0.64rem; }
.dashboard-footer { overflow: hidden; padding-block: 2px; }
.footer-meta { min-height: 14px; margin-bottom: 0; padding-inline: 14px; line-height: 1; }
.ticker-cell { display: flex; width: 224px; height: 36px; flex-direction: column; justify-content: center; padding: 0 16px; }
.ticker-cell > div:first-child { min-height: 11px; line-height: 1; }
.ticker-cell > div:nth-child(2) { min-height: 18px; margin-top: 1px; line-height: 1; }
.quote-symbol { font-size: 0.63rem; line-height: 1; }
.quote-name { font-size: 0.58rem; line-height: 1; }
.quote-value { font-size: 0.96rem; line-height: 1; }
.quote-move { font-size: 0.68rem; line-height: 1; }
.quote-move svg { width: 12px; height: 12px; }
.qr-dialog { width: min(720px, calc(100vw - 28px)); gap: 20px; border-radius: 20px; padding: 18px; }
.qr-code-frame { border-radius: 14px; padding: 12px; }
.qr-code-frame svg { width: 220px; height: 220px; }
.qr-copy h2 { margin-block: 8px; font-size: 1.22rem; }
.qr-copy p { font-size: 0.72rem; line-height: 1.42; }
.qr-source { gap: 6px; font-size: 0.56rem; }
.qr-close { top: 9px; right: 9px; padding: 5px 8px; font-size: 0.62rem; }
}
@media (max-width: 700px) {
body { overflow: auto; }
.dashboard-shell { display: flex; height: auto; min-height: 100vh; overflow: visible; }
.dashboard-shell > section { grid-template-columns: 1fr; }
.weather-strip { min-height: 208px; margin: 10px; }
.weather-summary { min-height: 70px; }
.forecast-grid { grid-template-columns: repeat(7, minmax(76px, 1fr)); overflow-x: auto; }
.panel { min-height: 560px; }
.qr-dialog { grid-template-columns: 1fr; text-align: center; }
.qr-code-frame { width: max-content; margin: 0 auto; }
.qr-copy { padding: 0; }
.qr-source { justify-content: center; }
}
@media (prefers-reduced-motion: reduce) {
.ticker-track,
.pulse-dot,
.ambient { animation: none; }
}
@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "Hobbs' Family Organizer App",
description: "A private Hobbs family organizer for calendars, weather, markets, commodities, and AI and technology headlines.",
icons: {
icon: "/favicon.svg",
shortcut: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className="antialiased">{children}</body>
</html>
);
}
@@ -0,0 +1,880 @@
"use client";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Cloud,
CloudFog,
CloudLightning,
CloudRain,
CloudSun,
MapPin,
Minus,
Newspaper,
QrCode,
Snowflake,
Sparkles,
Sun,
Sunrise,
Sunset,
TrendingDown,
TrendingUp,
Wifi,
X,
} from "lucide-react";
import QRCode from "react-qr-code";
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
type CalendarEvent = {
id: string;
calendar: string;
title: string;
location?: string;
start: string;
end: string;
allDay: boolean;
tone: "cyan" | "amber" | "violet" | "emerald" | "rose";
};
type CalendarState = "loading" | "live" | "partial" | "stale" | "error";
type CalendarView = "agenda" | "week" | "month";
type WeatherDay = {
date: string;
code: number;
condition: string;
icon: "clear" | "partly-cloudy" | "cloudy" | "fog" | "rain" | "snow" | "storm";
high: number;
low: number;
sunrise: string;
sunset: string;
precipitationChance: number;
};
type WeatherState = "loading" | "live" | "stale" | "error";
type Headline = {
source: string;
age: string;
title: string;
tone: "cyan" | "amber" | "violet";
url?: string;
};
type Quote = {
symbol: string;
name: string;
value: string;
move: string;
direction: "up" | "down" | "flat";
seriesId?: string;
observedAt?: string;
};
type MarketState = "loading" | "live" | "partial" | "stale" | "demo";
const demoHeadlines: Headline[] = [
{ source: "OPENAI", age: "18m", title: "New tools push multimodal assistants deeper into everyday work", tone: "cyan" },
{ source: "ARS TECHNICA", age: "42m", title: "Chipmakers rethink the data-center roadmap for efficient inference", tone: "amber" },
{ source: "GOOGLE AI", age: "1h", title: "Research update explores smaller models with stronger reasoning", tone: "violet" },
{ source: "WIRED", age: "2h", title: "The next wave of personal AI devices is taking shape", tone: "cyan" },
{ source: "NVIDIA", age: "3h", title: "New accelerated-computing platform targets local AI workloads", tone: "amber" },
];
const demoQuotes: Quote[] = [
{ symbol: "S&P 500", name: "Previous close", value: "5,782.41", move: "+0.62%", direction: "up" },
{ symbol: "NASDAQ", name: "Previous close", value: "18,439.17", move: "+0.91%", direction: "up" },
{ symbol: "DOW", name: "Previous close", value: "42,114.40", move: "+0.38%", direction: "up" },
{ symbol: "WTI", name: "Crude oil", value: "$67.42", move: "-1.18%", direction: "down" },
{ symbol: "NAT GAS", name: "Henry Hub", value: "$3.12", move: "-0.54%", direction: "down" },
{ symbol: "COPPER", name: "Per pound", value: "$4.31", move: "+0.21%", direction: "up" },
];
const toneClasses = {
cyan: "bg-cyan-300",
amber: "bg-amber-300",
violet: "bg-violet-300",
emerald: "bg-emerald-300",
rose: "bg-rose-300",
};
const calendarToneColors = {
cyan: "#67e8f9",
amber: "#fcd34d",
violet: "#c4b5fd",
emerald: "#6ee7b7",
rose: "#fda4af",
};
const calendarViews: CalendarView[] = ["agenda", "week", "month"];
let organizerTimeZone = "America/Chicago";
const weatherIcons = {
clear: Sun,
"partly-cloudy": CloudSun,
cloudy: Cloud,
fog: CloudFog,
rain: CloudRain,
snow: Snowflake,
storm: CloudLightning,
};
function formatClock(date: Date | null) {
if (!date) return { time: "--:--", period: "", date: "Loading dashboard…" };
const clock = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).formatToParts(date);
return {
time: `${clock.find((part) => part.type === "hour")?.value ?? ""}:${clock.find((part) => part.type === "minute")?.value ?? ""}`,
period: clock.find((part) => part.type === "dayPeriod")?.value ?? "",
date: new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
weekday: "long",
month: "long",
day: "numeric",
}).format(date),
};
}
function organizerDateKey(date: Date) {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const value = (part: string) => parts.find((entry) => entry.type === part)?.value ?? "";
return `${value("year")}-${value("month")}-${value("day")}`;
}
function allDayDate(value: string) {
const [year, month, day] = value.split("-").map(Number);
return new Date(Date.UTC(year, month - 1, day, 12));
}
function addDateKey(value: string, days: number) {
const date = allDayDate(value);
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function addMonthsToDateKey(value: string, months: number) {
const [year, month] = value.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1 + months, 1, 12));
return date.toISOString().slice(0, 10);
}
function startOfWeekKey(value: string) {
const date = allDayDate(value);
const mondayOffset = (date.getUTCDay() + 6) % 7;
return addDateKey(value, -mondayOffset);
}
function weekDateKeys(value: string) {
const start = startOfWeekKey(value);
return Array.from({ length: 7 }, (_, index) => addDateKey(start, index));
}
function monthDateKeys(value: string) {
const first = `${value.slice(0, 7)}-01`;
const start = startOfWeekKey(first);
return Array.from({ length: 42 }, (_, index) => addDateKey(start, index));
}
function eventDateKey(event: CalendarEvent) {
return event.allDay ? event.start : organizerDateKey(new Date(event.start));
}
function eventOccursOnDate(event: CalendarEvent, dateKey: string) {
if (event.allDay) return event.start <= dateKey && event.end > dateKey;
const start = organizerDateKey(new Date(event.start));
const inclusiveEnd = organizerDateKey(new Date(Math.max(Date.parse(event.start), Date.parse(event.end) - 1)));
return start <= dateKey && inclusiveEnd >= dateKey;
}
function eventIsUpcoming(event: CalendarEvent, now: Date) {
if (event.allDay) return event.end > organizerDateKey(now);
return Date.parse(event.end) > now.getTime();
}
function formatDateKey(value: string, options: Intl.DateTimeFormatOptions) {
return new Intl.DateTimeFormat("en-US", { ...options, timeZone: "UTC" }).format(allDayDate(value));
}
function compactEventTime(event: CalendarEvent, dateKey: string) {
if (event.allDay) return "All day";
if (eventDateKey(event) !== dateKey) return "Continues";
return new Intl.DateTimeFormat("en-US", {
timeZone: organizerTimeZone,
hour: "numeric",
minute: "2-digit",
}).format(new Date(event.start));
}
function formatSunTime(value: string) {
const time = value.split("T")[1]?.slice(0, 5);
if (!time) return "--";
const [hour, minute] = time.split(":").map(Number);
const displayHour = hour % 12 || 12;
return `${displayHour}:${String(minute).padStart(2, "0")}`;
}
function WeatherGlyph({ icon, className }: { icon: WeatherDay["icon"]; className?: string }) {
const Glyph = weatherIcons[icon] ?? CloudSun;
return <Glyph className={className} aria-hidden="true" />;
}
function formatEventRow(event: CalendarEvent, now: Date) {
const today = organizerDateKey(now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const eventDate = event.allDay ? event.start : organizerDateKey(startDate);
const isToday = eventDate === today;
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
return {
day: isToday ? "TODAY" : new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short" }).format(startDate).toUpperCase(),
date: new Intl.DateTimeFormat("en-US", { timeZone, day: "2-digit" }).format(startDate),
time: event.allDay ? "ALL DAY" : new Intl.DateTimeFormat("en-US", { timeZone, hour: "numeric", minute: "2-digit" }).format(startDate),
};
}
function eventIsHappening(event: CalendarEvent, now: Date) {
if (event.allDay) {
const today = organizerDateKey(now);
return event.start <= today && event.end > today;
}
const time = now.getTime();
return Date.parse(event.start) <= time && Date.parse(event.end) > time;
}
function formatEventWhen(event: CalendarEvent, now: Date) {
const row = formatEventRow(event, now);
const startDate = event.allDay ? allDayDate(event.start) : new Date(event.start);
const timeZone = event.allDay ? "UTC" : organizerTimeZone;
const dateLabel = event.allDay && event.start === organizerDateKey(now)
? "Today"
: new Intl.DateTimeFormat("en-US", { timeZone, weekday: "short", month: "short", day: "numeric" }).format(startDate);
return `${dateLabel} · ${event.allDay ? "All day" : row.time} · ${event.calendar}`;
}
function formatCountdown(event: CalendarEvent, now: Date) {
if (eventIsHappening(event, now)) return "NOW";
if (event.allDay) {
const today = allDayDate(organizerDateKey(now)).getTime();
const start = allDayDate(event.start).getTime();
const days = Math.max(1, Math.round((start - today) / 86_400_000));
return `${days}d`;
}
const minutes = Math.max(1, Math.round((Date.parse(event.start) - now.getTime()) / 60_000));
if (minutes < 60) return `${minutes}m`;
if (minutes < 1_440) return `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
return `${Math.floor(minutes / 1_440)}d ${Math.floor((minutes % 1_440) / 60)}h`;
}
function formatObservationDate(value?: string) {
if (!value) return "";
const date = new Date(`${value}T12:00:00Z`);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date);
}
function headlineHost(value?: string) {
if (!value) return "";
try {
return new URL(value).hostname.replace(/^www\./, "");
} catch {
return "Article link";
}
}
function QuoteCell({ quote }: { quote: Quote }) {
const rising = quote.direction === "up";
const falling = quote.direction === "down";
const TrendIcon = rising ? TrendingUp : falling ? TrendingDown : Minus;
const movementColor = rising ? "text-emerald-300" : falling ? "text-rose-300" : "text-slate-400";
const observed = formatObservationDate(quote.observedAt);
return (
<div className="ticker-cell" tabIndex={0}>
<div className="flex items-center gap-3">
<span className="quote-symbol text-[0.78rem] font-black tracking-[0.12em] text-slate-100">{quote.symbol}</span>
<span className="quote-name text-[0.76rem] text-slate-500">{quote.name}{observed && ` · ${observed}`}</span>
</div>
<div className="mt-1 flex items-center gap-3">
<span className="quote-value text-[1.35rem] font-semibold tabular-nums text-white">{quote.value}</span>
<span className={`quote-move flex items-center gap-1 text-[0.9rem] font-bold tabular-nums ${movementColor}`}>
<TrendIcon className="h-4 w-4" strokeWidth={2.5} />
{quote.move}
</span>
</div>
</div>
);
}
export default function Home() {
const [now, setNow] = useState<Date | null>(null);
const [calendarEvents, setCalendarEvents] = useState<CalendarEvent[]>([]);
const [calendarState, setCalendarState] = useState<CalendarState>("loading");
const [calendarFeeds, setCalendarFeeds] = useState({ available: 0, total: 0 });
const [calendarView, setCalendarView] = useState<CalendarView>("agenda");
const [calendarAnchor, setCalendarAnchor] = useState("");
const [weatherDays, setWeatherDays] = useState<WeatherDay[]>([]);
const [weatherState, setWeatherState] = useState<WeatherState>("loading");
const [weatherLocation, setWeatherLocation] = useState("Sapulpa, OK");
const [headlines, setHeadlines] = useState<Headline[]>(demoHeadlines);
const [newsLive, setNewsLive] = useState(false);
const [quotes, setQuotes] = useState<Quote[]>(demoQuotes);
const [marketState, setMarketState] = useState<MarketState>("loading");
const [marketFeeds, setMarketFeeds] = useState({ available: 0, total: demoQuotes.length });
const [selectedHeadline, setSelectedHeadline] = useState<Headline | null>(null);
const hasCalendarData = useRef(false);
const hasWeatherData = useRef(false);
const hasMarketData = useRef(false);
const qrCloseRef = useRef<HTMLButtonElement>(null);
const [, setConfigurationVersion] = useState(0);
useEffect(() => {
let active = true;
void fetch("/api/config", { cache: "no-store" })
.then(async (response) => {
const payload = await response.json() as { timeZone?: string };
if (!response.ok || !active || !payload.timeZone) return;
new Intl.DateTimeFormat("en-US", { timeZone: payload.timeZone }).format(new Date());
organizerTimeZone = payload.timeZone;
const value = new Date();
setNow(value);
setCalendarAnchor(organizerDateKey(value));
setConfigurationVersion((current) => current + 1);
})
.catch(() => {
// Keep the built-in timezone when runtime configuration is unavailable.
});
return () => {
active = false;
};
}, []);
useEffect(() => {
const updateClock = () => {
const value = new Date();
setNow(value);
setCalendarAnchor((current) => current || organizerDateKey(value));
};
const frame = window.requestAnimationFrame(updateClock);
const timer = window.setInterval(updateClock, 15_000);
return () => {
window.cancelAnimationFrame(frame);
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshWeather = async () => {
try {
const response = await fetch("/api/weather", { cache: "no-store" });
const payload = await response.json() as { location?: string; items?: WeatherDay[] };
if (!response.ok || !payload.items?.length || !active) throw new Error("Weather unavailable");
hasWeatherData.current = true;
setWeatherDays(payload.items.slice(0, 7));
setWeatherLocation(payload.location || "Sapulpa, OK");
setWeatherState("live");
} catch {
if (!active) return;
setWeatherState(hasWeatherData.current ? "stale" : "error");
}
};
void refreshWeather();
const timer = window.setInterval(refreshWeather, 30 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
const closeQrFromHistory = () => setSelectedHeadline(null);
window.addEventListener("popstate", closeQrFromHistory);
return () => window.removeEventListener("popstate", closeQrFromHistory);
}, []);
useEffect(() => {
if (!selectedHeadline) return;
const frame = window.requestAnimationFrame(() => qrCloseRef.current?.focus());
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
window.addEventListener("keydown", closeOnEscape);
return () => {
window.cancelAnimationFrame(frame);
window.removeEventListener("keydown", closeOnEscape);
};
}, [selectedHeadline]);
useEffect(() => {
let active = true;
const refreshMarkets = async () => {
try {
const response = await fetch("/api/markets", { cache: "no-store" });
const payload = await response.json() as {
items?: Quote[];
seriesAvailable?: number;
seriesTotal?: number;
};
if (!response.ok || !payload.items?.length || !active) throw new Error("FRED unavailable");
hasMarketData.current = true;
setQuotes(payload.items);
setMarketFeeds({
available: payload.seriesAvailable ?? payload.items.length,
total: payload.seriesTotal ?? payload.items.length,
});
setMarketState(
payload.seriesAvailable === payload.seriesTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setMarketState(hasMarketData.current ? "stale" : "demo");
}
};
void refreshMarkets();
const timer = window.setInterval(refreshMarkets, 15 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshCalendar = async () => {
try {
const response = await fetch("/api/calendar", { cache: "no-store" });
const payload = await response.json() as {
items?: CalendarEvent[];
calendarsAvailable?: number;
calendarsTotal?: number;
};
if (!response.ok || !payload.items || !active) throw new Error("Calendar unavailable");
hasCalendarData.current = true;
setCalendarEvents(payload.items);
setCalendarFeeds({
available: payload.calendarsAvailable ?? 0,
total: payload.calendarsTotal ?? 0,
});
setCalendarState(
payload.calendarsAvailable === payload.calendarsTotal ? "live" : "partial",
);
} catch {
if (!active) return;
setCalendarState(hasCalendarData.current ? "stale" : "error");
}
};
void refreshCalendar();
const timer = window.setInterval(refreshCalendar, 5 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
useEffect(() => {
let active = true;
const refreshHeadlines = async () => {
try {
const response = await fetch("/api/headlines", { cache: "no-store" });
const payload = await response.json() as {
items?: Array<{ source: string; title: string; url: string; publishedAt: string | null }>;
};
if (!response.ok || !payload.items?.length || !active) return;
const tones: Headline["tone"][] = ["cyan", "amber", "violet"];
setHeadlines(payload.items.slice(0, 5).map((item, index) => {
const published = item.publishedAt ? Date.parse(item.publishedAt) : Number.NaN;
const minutes = Number.isNaN(published) ? null : Math.max(1, Math.round((Date.now() - published) / 60_000));
const age = minutes === null ? "new" : minutes < 60 ? `${minutes}m` : minutes < 1_440 ? `${Math.floor(minutes / 60)}h` : `${Math.floor(minutes / 1_440)}d`;
return { ...item, age, tone: tones[index % tones.length] };
}));
setNewsLive(true);
} catch {
// The demo headlines remain visible whenever live RSS is unavailable.
}
};
void refreshHeadlines();
const timer = window.setInterval(refreshHeadlines, 10 * 60_000);
return () => {
active = false;
window.clearInterval(timer);
};
}, []);
const clock = useMemo(() => formatClock(now), [now]);
const calendarNow = useMemo(() => now ?? new Date(), [now]);
const todayKey = now ? organizerDateKey(now) : "";
const activeAnchor = calendarAnchor || todayKey || "2000-01-01";
const upcomingEvents = useMemo(
() => calendarEvents.filter((event) => eventIsUpcoming(event, calendarNow)),
[calendarEvents, calendarNow],
);
const nextEvent = upcomingEvents[0];
const weekKeys = useMemo(() => weekDateKeys(activeAnchor), [activeAnchor]);
const monthKeys = useMemo(() => monthDateKeys(activeAnchor), [activeAnchor]);
const visibleDateKeys = useMemo(
() => calendarView === "week" ? weekKeys : calendarView === "month" ? monthKeys : [],
[calendarView, monthKeys, weekKeys],
);
const eventsByDate = useMemo(() => {
const result = new Map<string, CalendarEvent[]>();
for (const dateKey of visibleDateKeys) {
result.set(dateKey, calendarEvents.filter((event) => eventOccursOnDate(event, dateKey)));
}
return result;
}, [calendarEvents, visibleDateKeys]);
const currentWeather = weatherDays[0];
const weatherStatus = weatherState === "loading"
? "Updating"
: weatherState === "live"
? "Live forecast"
: weatherState === "stale"
? "Last forecast"
: "Retrying";
const calendarStatus = calendarState === "loading"
? "Syncing"
: calendarState === "live"
? `${calendarFeeds.total} calendars live`
: calendarState === "partial"
? `${calendarFeeds.available}/${calendarFeeds.total} calendars`
: calendarState === "stale"
? "Last update"
: "Retrying";
const marketStatus = marketState === "loading"
? "FRED connecting"
: marketState === "live"
? `FRED live · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "partial"
? `FRED partial · ${marketFeeds.available}/${marketFeeds.total}`
: marketState === "stale"
? "FRED last update"
: "Demo data · FRED unavailable";
const periodLabel = calendarView === "week"
? `${formatDateKey(weekKeys[0], { month: "short", day: "numeric" })} – ${formatDateKey(weekKeys[6], { month: "short", day: "numeric" })}`
: formatDateKey(`${activeAnchor.slice(0, 7)}-01`, { month: "long", year: "numeric" });
const moveCalendarPeriod = (direction: -1 | 1) => {
setCalendarAnchor((current) => {
const base = current || todayKey || activeAnchor;
return calendarView === "week" ? addDateKey(base, direction * 7) : addMonthsToDateKey(base, direction);
});
};
const handleCalendarTabKey = (event: ReactKeyboardEvent<HTMLButtonElement>, view: CalendarView) => {
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
event.preventDefault();
const currentIndex = calendarViews.indexOf(view);
const offset = event.key === "ArrowRight" ? 1 : -1;
const nextView = calendarViews[(currentIndex + offset + calendarViews.length) % calendarViews.length];
setCalendarView(nextView);
window.requestAnimationFrame(() => document.getElementById(`calendar-tab-${nextView}`)?.focus());
};
const showHeadlineQr = (headline: Headline) => {
if (!headline.url) return;
const current = window.history.state;
const base = current && typeof current === "object" ? current : {};
window.history.pushState({ ...base, daylineQr: true }, "");
setSelectedHeadline(headline);
};
const closeHeadlineQr = () => {
const state = window.history.state as { daylineQr?: boolean } | null;
if (state?.daylineQr) window.history.back();
else setSelectedHeadline(null);
};
return (
<main className="dashboard-shell">
<div className="ambient ambient-one" />
<div className="ambient ambient-two" />
<header className="dashboard-header relative z-10 flex min-h-[92px] items-center justify-between border-b border-white/8 px-8 py-4 2xl:px-12">
<div className="brand-group flex items-center gap-5">
<div className="brand-mark" aria-hidden="true"><span /><span /><span /></div>
<div>
<div className="flex items-baseline gap-3">
<h1 className="whitespace-nowrap text-[1.45rem] font-black tracking-[-0.03em] text-white">HOBBS&apos; FAMILY ORGANIZER</h1>
<span className="rounded-full border border-cyan-300/25 bg-cyan-300/8 px-2.5 py-1 text-[0.62rem] font-black tracking-[0.18em] text-cyan-200">APP</span>
</div>
<p className="mt-1 text-[0.72rem] font-semibold tracking-[0.14em] text-slate-500">YOUR FAMILY, AT A GLANCE</p>
</div>
</div>
<div className="flex items-center gap-8">
<div className="text-right">
<div className="flex items-baseline justify-end gap-2">
<span className="text-[2.65rem] font-extralight leading-none tracking-[-0.055em] text-white tabular-nums">{clock.time}</span>
<span className="text-sm font-black tracking-[0.14em] text-cyan-200">{clock.period}</span>
</div>
<p className="mt-1 text-sm font-semibold text-slate-400">{clock.date}</p>
</div>
</div>
</header>
<section className="weather-strip relative z-10" aria-labelledby="weather-title">
<div className="weather-summary">
<div className="weather-summary-top">
<div className="weather-location"><MapPin className="h-3.5 w-3.5" /><span id="weather-title">{weatherLocation}</span></div>
<span className={`weather-status weather-${weatherState}`}>{weatherStatus}</span>
</div>
{currentWeather ? (
<div className="weather-now">
<WeatherGlyph icon={currentWeather.icon} className="weather-now-icon" />
<div className="min-w-0">
<div className="weather-now-temp"><span>{Math.round(currentWeather.high)}°</span><small>{Math.round(currentWeather.low)}°</small></div>
<p>{currentWeather.condition}</p>
</div>
</div>
) : (
<div className="weather-now weather-pending"><CloudSun className="weather-now-icon" /><div><div className="weather-now-temp"><span>--°</span></div><p>{weatherState === "error" ? "Forecast unavailable" : "Loading forecast…"}</p></div></div>
)}
</div>
<div className="forecast-grid" aria-label="Seven day weather forecast">
{weatherDays.length > 0 ? weatherDays.map((day) => (
<article className={`forecast-day ${day.date === todayKey ? "forecast-today" : ""}`} key={day.date}>
<div className="forecast-day-heading"><span>{day.date === todayKey ? "TODAY" : formatDateKey(day.date, { weekday: "short" }).toUpperCase()}</span><small>{formatDateKey(day.date, { month: "numeric", day: "numeric" })}</small></div>
<div className="forecast-condition"><WeatherGlyph icon={day.icon} className="forecast-icon" /><span><strong>{Math.round(day.high)}°</strong><small>{Math.round(day.low)}°</small></span></div>
<p className="forecast-rain">RAIN {Math.round(day.precipitationChance)}%</p>
<div className="sun-times" aria-label={`Sunrise ${formatSunTime(day.sunrise)}, sunset ${formatSunTime(day.sunset)}`}>
<span><Sunrise />{formatSunTime(day.sunrise)}</span><span><Sunset />{formatSunTime(day.sunset)}</span>
</div>
</article>
)) : Array.from({ length: 7 }, (_, index) => <div className="forecast-day forecast-placeholder" key={index}><span /><span /><span /></div>)}
</div>
</section>
<section className="dashboard-grid relative z-10 grid min-h-0 flex-1 grid-cols-[minmax(0,1.48fr)_minmax(420px,0.92fr)] gap-5 px-6 py-5 2xl:gap-7 2xl:px-10">
<section className="panel flex min-h-0 flex-col" aria-labelledby="calendar-title">
<div className="panel-heading calendar-heading">
<div className="flex items-center gap-3"><CalendarDays className="h-6 w-6 text-cyan-200" /><h2 id="calendar-title">Calendar</h2></div>
<div className="calendar-heading-actions">
<div className="calendar-view-tabs" role="tablist" aria-label="Calendar view">
{calendarViews.map((view) => (
<button
aria-controls={`calendar-panel-${view}`}
aria-selected={calendarView === view}
className={calendarView === view ? "is-active" : ""}
id={`calendar-tab-${view}`}
key={view}
onClick={() => setCalendarView(view)}
onKeyDown={(event) => handleCalendarTabKey(event, view)}
role="tab"
type="button"
>
{view}
</button>
))}
</div>
<div className={`source-pill source-${calendarState}`}><Wifi className="h-3.5 w-3.5" />{calendarStatus}</div>
</div>
</div>
{calendarView === "agenda" && (
<div className="calendar-view-content" id="calendar-panel-agenda" role="tabpanel" aria-labelledby="calendar-tab-agenda">
<div className="next-event-card">
{nextEvent ? (
<>
<div className="min-w-0">
<p className="eyebrow text-cyan-200">{eventIsHappening(nextEvent, calendarNow) ? "HAPPENING NOW" : "NEXT EVENT"}</p>
<h3 className="next-event-title mt-2 truncate text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{nextEvent.title}</h3>
<p className="next-event-meta mt-1 truncate text-base text-slate-400">{formatEventWhen(nextEvent, calendarNow)}</p>
</div>
<div className="next-event-countdown shrink-0 pl-6 text-right"><p className="text-[2.2rem] font-extralight tracking-[-0.05em] text-cyan-100">{formatCountdown(nextEvent, calendarNow)}</p><p className="mt-1 text-xs font-bold tracking-[0.12em] text-slate-500">{eventIsHappening(nextEvent, calendarNow) ? "IN PROGRESS" : "UNTIL START"}</p></div>
</>
) : (
<div>
<p className="eyebrow text-cyan-200">{calendarState === "loading" ? "SYNCING" : calendarState === "error" ? "RETRYING" : "CALENDAR CLEAR"}</p>
<h3 className="next-event-title mt-2 text-[1.9rem] font-semibold tracking-[-0.035em] text-white">{calendarState === "loading" ? "Loading calendars…" : calendarState === "error" ? "Calendar temporarily unavailable" : "No upcoming events"}</h3>
<p className="next-event-meta mt-1 text-base text-slate-400">{calendarState === "error" ? "The organizer will retry automatically" : "Looking ahead 60 days"}</p>
</div>
)}
</div>
<div className="event-list mt-4 min-h-0 flex-1 overflow-hidden">
<div className="event-list-heading mb-3 flex items-center justify-between px-1"><p className="eyebrow">UP NEXT</p><p className="text-xs font-semibold text-slate-500">60-day view</p></div>
<div className="event-list-grid grid gap-2">
{upcomingEvents.slice(1, 9).map((event) => {
const row = formatEventRow(event, calendarNow);
return (
<article className="calendar-row" key={event.id} tabIndex={0} aria-label={`${event.title}, ${formatEventWhen(event, calendarNow)}`}>
<div className="calendar-date w-[76px] shrink-0 border-r border-white/8 pr-4 text-center"><p className="calendar-day text-[0.66rem] font-black tracking-[0.16em] text-slate-500">{row.day}</p><p className="calendar-date-number mt-0.5 text-2xl font-light text-slate-200 tabular-nums">{row.date}</p></div>
<div className="calendar-accent" style={{ backgroundColor: calendarToneColors[event.tone] }} aria-hidden="true" />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-4"><h3 className="calendar-event-title truncate text-[1.03rem] font-bold text-slate-100">{event.title}</h3><span className="calendar-event-time shrink-0 text-sm font-bold text-slate-300 tabular-nums">{row.time}</span></div>
<p className="calendar-detail"><span className="calendar-dot" style={{ backgroundColor: calendarToneColors[event.tone] }} /><span>{event.calendar}</span>{event.location && <><span>·</span><span className="truncate">{event.location}</span></>}</p>
</div>
</article>
);
})}
{!upcomingEvents.length && calendarState !== "loading" && <div className="calendar-empty"><div><CalendarDays className="mx-auto mb-3 h-7 w-7 text-slate-600" /><p>{calendarState === "error" ? "Calendar feed unavailable" : "Nothing scheduled"}</p></div></div>}
</div>
</div>
</div>
)}
{calendarView !== "agenda" && (
<div className="calendar-view-content" id={`calendar-panel-${calendarView}`} role="tabpanel" aria-labelledby={`calendar-tab-${calendarView}`}>
<div className="calendar-period-bar">
<button onClick={() => moveCalendarPeriod(-1)} type="button" aria-label={`Previous ${calendarView}`}><ChevronLeft /></button>
<p>{periodLabel}</p>
<button className="calendar-today-button" onClick={() => setCalendarAnchor(todayKey || activeAnchor)} type="button">Today</button>
<button onClick={() => moveCalendarPeriod(1)} type="button" aria-label={`Next ${calendarView}`}><ChevronRight /></button>
</div>
{calendarView === "week" && (
<div className="week-grid" role="grid" aria-label={periodLabel}>
{weekKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
return (
<section className={`week-day ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<div className="week-day-heading"><span>{formatDateKey(dateKey, { weekday: "short" }).toUpperCase()}</span><strong>{formatDateKey(dateKey, { day: "numeric" })}</strong></div>
<div className="week-events">
{dayEvents.slice(0, 3).map((event) => (
<article className="week-event" key={`${dateKey}-${event.id}`} tabIndex={0} title={`${event.title} · ${compactEventTime(event, dateKey)}`} style={{ borderLeftColor: calendarToneColors[event.tone] }}>
<strong>{event.title}</strong><span>{compactEventTime(event, dateKey)}</span>
</article>
))}
{!dayEvents.length && <span className="calendar-no-events">—</span>}
{dayEvents.length > 3 && <span className="calendar-more">+{dayEvents.length - 3} more</span>}
</div>
</section>
);
})}
</div>
)}
{calendarView === "month" && (
<div className="month-grid" role="grid" aria-label={periodLabel}>
{weekDateKeys("2024-01-01").map((dateKey) => <div className="month-weekday" key={dateKey} role="columnheader">{formatDateKey(dateKey, { weekday: "short" }).slice(0, 2).toUpperCase()}</div>)}
{monthKeys.map((dateKey) => {
const dayEvents = eventsByDate.get(dateKey) ?? [];
const outsideMonth = dateKey.slice(0, 7) !== activeAnchor.slice(0, 7);
return (
<section className={`month-cell ${outsideMonth ? "is-outside" : ""} ${dateKey === todayKey ? "is-today" : ""}`} key={dateKey} role="gridcell">
<span className="month-date">{formatDateKey(dateKey, { day: "numeric" })}</span>
<div className="month-events">
{dayEvents.slice(0, 2).map((event) => (
<div className="month-event" key={`${dateKey}-${event.id}`} title={event.title}><span style={{ backgroundColor: calendarToneColors[event.tone] }} />{event.title}</div>
))}
{dayEvents.length > 2 && <span className="calendar-more">+{dayEvents.length - 2}</span>}
</div>
</section>
);
})}
</div>
)}
</div>
)}
</section>
<aside className="panel flex min-h-0 flex-col" aria-labelledby="headlines-title">
<div className="panel-heading">
<div className="flex items-center gap-3"><Newspaper className="h-6 w-6 text-violet-200" /><h2 id="headlines-title">AI + Tech</h2></div>
<div className="source-pill"><Wifi className="h-3.5 w-3.5" />{newsLive ? "RSS live" : "RSS demo"}</div>
</div>
<div className="min-h-0 flex-1 divide-y divide-white/7 overflow-hidden">
{headlines.map((headline, index) => (
<button
className="headline-row w-full text-left"
disabled={!headline.url}
key={headline.title}
onClick={() => showHeadlineQr(headline)}
type="button"
aria-label={headline.url ? `${headline.title}. Show QR code for this article.` : headline.title}
>
<div className={`mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full ${toneClasses[headline.tone]}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2 text-[0.65rem] font-black tracking-[0.12em]">
<span className="flex items-center gap-2"><span className="text-slate-400">{headline.source}</span><span className="text-slate-700">•</span><span className="text-slate-600">{headline.age}</span></span>
{headline.url && <span className="headline-qr-cue"><QrCode className="h-3 w-3" />SCAN</span>}
</div>
<h3 className={`mt-1.5 font-semibold leading-[1.28] text-slate-100 ${index === 0 ? "text-[1.12rem]" : "text-[1rem]"}`}>{headline.title}</h3>
</div>
</button>
))}
</div>
<div className="phone-handoff mt-3 flex items-center justify-between rounded-2xl border border-violet-300/12 bg-violet-300/[0.045] px-4 py-3">
<div className="flex items-center gap-3"><Sparkles className="h-5 w-5 text-violet-200" /><div><p className="text-sm font-bold text-slate-200">Phone handoff</p><p className="text-xs text-slate-500">Select a live headline, then scan</p></div></div>
<span className="text-xs font-black tracking-[0.12em] text-violet-200">READY</span>
</div>
</aside>
</section>
<footer className="dashboard-footer relative z-10 border-t border-white/8 bg-[#050911]/92 py-3 backdrop-blur-xl">
<div className="footer-meta mb-2 flex items-center justify-between px-8 2xl:px-12">
<div className="flex items-center gap-2"><span className={`pulse-dot market-pulse-${marketState}`} /><span className="text-[0.66rem] font-black tracking-[0.16em] text-slate-400">MARKETS + COMMODITIES</span><span className="rounded-full border border-white/8 px-2 py-0.5 text-[0.6rem] font-black tracking-[0.12em] text-slate-600">LATEST FRED OBSERVATION</span></div>
<span className="text-[0.68rem] font-semibold text-slate-600">{marketStatus}</span>
</div>
<div className="ticker-window" aria-label="Market ticker">
<div className="ticker-track">
<div className="flex shrink-0">{quotes.map((quote) => <QuoteCell key={`a-${quote.symbol}`} quote={quote} />)}</div>
<div className="flex shrink-0" aria-hidden="true">{quotes.map((quote) => <QuoteCell key={`b-${quote.symbol}`} quote={quote} />)}</div>
</div>
</div>
</footer>
{selectedHeadline?.url && (
<div className="qr-overlay" role="presentation" onClick={closeHeadlineQr}>
<section
className="qr-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="qr-headline-title"
onClick={(event) => event.stopPropagation()}
>
<button className="qr-close" onClick={closeHeadlineQr} ref={qrCloseRef} type="button">
<X className="h-4 w-4" />Close
</button>
<div className="qr-code-frame" aria-label={`QR code for ${selectedHeadline.title}`}>
<QRCode
bgColor="#ffffff"
fgColor="#071018"
level="M"
size={264}
value={selectedHeadline.url}
viewBox="0 0 256 256"
/>
</div>
<div className="qr-copy">
<div className="qr-source"><QrCode className="h-4 w-4" />{selectedHeadline.source}<span>•</span>{headlineHost(selectedHeadline.url)}</div>
<h2 id="qr-headline-title">{selectedHeadline.title}</h2>
<p>Open your phone camera and point it at the code. Press Back on the Shield remote when you are finished.</p>
</div>
</section>
</div>
)}
</main>
);
}
@@ -0,0 +1,58 @@
name: hobbs-family-organizer
services:
organizer:
image: hobbs-family-organizer:1.0.0
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
init: true
environment:
NODE_ENV: production
HOSTNAME: 0.0.0.0
PORT: "3000"
TZ: ${ORGANIZER_TIMEZONE:-America/Chicago}
ORGANIZER_TIMEZONE: ${ORGANIZER_TIMEZONE:-America/Chicago}
WEATHER_LOCATION: ${WEATHER_LOCATION:-Sapulpa, OK}
WEATHER_LATITUDE: ${WEATHER_LATITUDE:-35.9987}
WEATHER_LONGITUDE: ${WEATHER_LONGITUDE:--96.11417}
NWS_FORECAST_URL: ${NWS_FORECAST_URL:-https://api.weather.gov/gridpoints/TSA/37,98/forecast}
WEATHER_USER_AGENT: ${WEATHER_USER_AGENT:-Hobbs-Family-Organizer/1.0 (self-hosted)}
CALENDAR_ICAL_URLS_FILE: /run/secrets/calendar_ical_urls
ORGANIZER_ACCESS_TOKEN_FILE: /run/secrets/organizer_access_token
secrets:
- calendar_ical_urls
- organizer_access_token
ports:
- "${HOBBS_BIND_ADDRESS:-0.0.0.0}:${HOBBS_HTTP_PORT:-8080}:3000"
read_only: true
tmpfs:
- /tmp:size=64m,mode=1777
- /app/.next-build/cache:size=128m,uid=10001,gid=10001,mode=0750
cap_drop:
- ALL
cap_add:
- SETGID
- SETUID
security_opt:
- no-new-privileges:true
pids_limit: 128
stop_grace_period: 20s
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
secrets:
calendar_ical_urls:
file: ./secrets/calendar_ical_urls.json
organizer_access_token:
file: ./secrets/organizer_access_token.txt
@@ -0,0 +1,106 @@
import { access, readFile } from "node:fs/promises";
import { constants } from "node:fs";
import { spawn } from "node:child_process";
import process from "node:process";
async function readable(path) {
try {
await access(path, constants.R_OK);
return true;
} catch {
return false;
}
}
async function settingFromFile(environmentName, fileEnvironmentName, defaultFile, required = false) {
const direct = process.env[environmentName];
if (direct !== undefined) return direct.trim();
const configuredFile = process.env[fileEnvironmentName] || defaultFile;
if (await readable(configuredFile)) return (await readFile(configuredFile, "utf8")).trim();
if (required) throw new Error(`${environmentName} is required; create ${configuredFile}`);
return "";
}
function validateCalendarConfiguration(raw) {
let values;
try {
values = JSON.parse(raw);
} catch {
throw new Error("Calendar secret must be a JSON array");
}
if (!Array.isArray(values) || values.length < 1 || values.length > 10) {
throw new Error("Calendar secret must contain between 1 and 10 URLs");
}
const normalized = 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("Every calendar URL must be an HTTPS calendar.google.com basic.ics URL");
}
return url.toString();
});
return JSON.stringify([...new Set(normalized)]);
}
async function main() {
const calendarSecret = await settingFromFile(
"CALENDAR_ICAL_URLS",
"CALENDAR_ICAL_URLS_FILE",
"/run/secrets/calendar_ical_urls",
true,
);
const accessToken = await settingFromFile(
"ORGANIZER_ACCESS_TOKEN",
"ORGANIZER_ACCESS_TOKEN_FILE",
"/run/secrets/organizer_access_token",
);
if (accessToken && !/^[A-Za-z0-9._~-]{32,512}$/.test(accessToken)) {
throw new Error("ORGANIZER_ACCESS_TOKEN must be empty or 32 to 512 URL-safe characters");
}
const organizerTimeZone = process.env.ORGANIZER_TIMEZONE || "America/Chicago";
try {
new Intl.DateTimeFormat("en-US", { timeZone: organizerTimeZone }).format(new Date());
} catch {
throw new Error(`ORGANIZER_TIMEZONE is not a valid IANA timezone: ${organizerTimeZone}`);
}
const environment = {
...process.env,
CALENDAR_ICAL_URLS: validateCalendarConfiguration(calendarSecret),
ORGANIZER_ACCESS_TOKEN: accessToken,
ORGANIZER_TIMEZONE: organizerTimeZone,
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
PORT: process.env.PORT || "3000",
};
const childOptions = {
cwd: process.cwd(),
env: environment,
stdio: "inherit",
};
if (process.platform !== "win32" && typeof process.getuid === "function" && process.getuid() === 0) {
childOptions.uid = 10001;
childOptions.gid = 10001;
}
const child = spawn(
process.execPath,
["node_modules/next/dist/bin/next", "start", "-H", environment.HOSTNAME, "-p", environment.PORT],
childOptions,
);
const forwardSignal = (signal) => {
if (!child.killed) child.kill(signal);
};
process.on("SIGINT", () => forwardSignal("SIGINT"));
process.on("SIGTERM", () => forwardSignal("SIGTERM"));
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
}
main().catch((error) => {
console.error(`[organizer] startup failed: ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
@@ -0,0 +1,37 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
globalIgnores([
"dist/**",
".vinext/**",
".wrangler/**",
".sites-runtime/**",
".openai/**",
"db/**",
"scripts/**",
"worker/**",
"cloudflare-env.d.ts",
"components/**",
"hooks/**",
"lib/**",
"vite.config.ts",
"outputs/**",
"work/**",
]),
{
files: ["components/ui/**/*.{ts,tsx}", "hooks/use-mobile.ts"],
rules: {
// These files are vendored verbatim from shadcn@4.17.0. Keep the
// registry source intact while applying the stricter rules to Site code.
"@typescript-eslint/no-unused-vars": "off",
"react-hooks/purity": "off",
"react-hooks/set-state-in-effect": "off",
},
},
]);
export default eslintConfig;
@@ -0,0 +1,7 @@
# Trusted-LAN example. Caddy obtains an internal certificate and proxies to the
# host-published organizer port. Install Caddy's root CA on each Shield.
organizer.home.arpa {
tls internal
encode zstd gzip
reverse_proxy 127.0.0.1:8080
}
@@ -0,0 +1,18 @@
# Place this server block inside nginx's http context. Replace the certificate
# paths with your internal or publicly trusted certificate.
server {
listen 443 ssl;
server_name organizer.home.arpa;
ssl_certificate /etc/nginx/tls/organizer.crt;
ssl_certificate_key /etc/nginx/tls/organizer.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_read_timeout 60s;
}
}
@@ -0,0 +1,24 @@
/** @type {import("next").NextConfig} */
const nextConfig = {
distDir: ".next-build",
poweredByHeader: false,
turbopack: {
root: process.cwd(),
},
async headers() {
return [
{
source: "/:path*",
headers: [
{ key: "Content-Security-Policy", value: "default-src 'self'; connect-src 'self'; font-src 'self'; frame-ancestors 'none'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" },
{ key: "Permissions-Policy", value: "camera=(), geolocation=(), microphone=()" },
{ key: "Referrer-Policy", value: "no-referrer" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "DENY" },
],
},
];
},
};
export default nextConfig;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,34 @@
{
"name": "hobbs-family-organizer",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=22.13.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint app next.config.mjs proxy.ts --ignore-pattern .next --ignore-pattern .next-build"
},
"dependencies": {
"ical.js": "^2.2.1",
"lucide-react": "^1.31.0",
"next": "16.3.4",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-qr-code": "2.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "4.2.1",
"@types/node": "22.19.19",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"eslint": "9.39.4",
"eslint-config-next": "16.3.4",
"tailwindcss": "4.2.1",
"tw-animate-css": "^1.4.0",
"typescript": "5.9.3"
},
"type": "module"
}
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,35 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
function tokenMatches(actual: string, expected: string) {
const actualValue = createHash("sha256").update(actual).digest();
const expectedValue = createHash("sha256").update(expected).digest();
return timingSafeEqual(actualValue, expectedValue);
}
export function proxy(request: NextRequest) {
if (request.nextUrl.pathname === "/api/health") return NextResponse.next();
const configuredToken = process.env.ORGANIZER_ACCESS_TOKEN?.trim();
if (!configuredToken) return NextResponse.next();
const authorization = request.headers.get("authorization") || "";
const suppliedToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
if (tokenMatches(suppliedToken, configuredToken)) return NextResponse.next();
return NextResponse.json(
{ error: "Unauthorized" },
{
status: 401,
headers: {
"Cache-Control": "no-store",
"WWW-Authenticate": "Bearer realm=\"Hobbs Family Organizer\"",
},
},
);
}
export const config = {
matcher: ["/((?!api/health).*)"],
};
@@ -0,0 +1,6 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 19.2727C22 20.779 20.779 22 19.2727 22H14.7273C13.221 22 12 20.779 12 19.2727V12H19.2727C20.779 12 22 13.221 22 14.7273V19.2727Z" fill="#68C4FF"/>
<path d="M20 2C21.1046 2 22 2.89543 22 4V7C22 8.10457 21.1046 9 20 9H17C15.8954 9 15 8.10457 15 7V4C15 2.89543 15.8954 2 17 2H20Z" fill="#0C79D8"/>
<path d="M7 15C8.10457 15 9 15.8954 9 17V20C9 21.1046 8.10457 22 7 22H4C2.89543 22 2 21.1046 2 20V17C2 15.8954 2.89543 15 4 15H7Z" fill="#0C79D8"/>
<path d="M12 12H4.72727C3.22104 12 2 10.779 2 9.27273V4.72727C2 3.22104 3.22104 2 4.72727 2H9.27273C10.779 2 12 3.22104 12 4.72727V12Z" fill="#2E9EFF"/>
</svg>

After

Width:  |  Height:  |  Size: 718 B

@@ -0,0 +1,76 @@
[CmdletBinding()]
param(
[string]$DestinationDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
if (-not $DestinationDirectory) {
$DestinationDirectory = Join-Path $ProjectRoot "backups"
}
$DestinationDirectory = [IO.Path]::GetFullPath($DestinationDirectory)
$Sources = @{
Environment = Join-Path $ProjectRoot ".env"
Calendars = Join-Path $ProjectRoot "secrets/calendar_ical_urls.json"
Token = Join-Path $ProjectRoot "secrets/organizer_access_token.txt"
}
foreach ($source in $Sources.GetEnumerator()) {
if (-not (Test-Path -LiteralPath $source.Value -PathType Leaf)) {
throw "Required configuration is missing. Run scripts/configure.ps1 before creating a backup."
}
}
New-Item -ItemType Directory -Path $DestinationDirectory -Force | Out-Null
$timestamp = Get-Date -Format "yyyyMMdd-HHmmss"
$suffix = [Guid]::NewGuid().ToString("N").Substring(0, 8)
$archivePath = Join-Path $DestinationDirectory "hobbs-organizer-config-$timestamp-$suffix.zip"
$stagingPath = Join-Path $DestinationDirectory (".backup-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$destinationPrefix = $DestinationDirectory.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($destinationPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a staging directory outside the selected backup directory."
}
try {
New-Item -ItemType Directory -Path (Join-Path $stagingPath "config") -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stagingPath "secrets") -Force | Out-Null
Copy-Item -LiteralPath $Sources.Environment -Destination (Join-Path $stagingPath "config/environment.env")
Copy-Item -LiteralPath $Sources.Calendars -Destination (Join-Path $stagingPath "secrets/calendar_ical_urls.json")
Copy-Item -LiteralPath $Sources.Token -Destination (Join-Path $stagingPath "secrets/organizer_access_token.txt")
$manifest = @(
"format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1",
"created_utc=$((Get-Date).ToUniversalTime().ToString('o'))",
"contains_secrets=true"
) -join [Environment]::NewLine
[IO.File]::WriteAllText(
(Join-Path $stagingPath "manifest.txt"),
$manifest + [Environment]::NewLine,
(New-Object System.Text.UTF8Encoding($false))
)
Compress-Archive -Path (Join-Path $stagingPath "*") -DestinationPath $archivePath -CompressionLevel Optimal
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $archivePath "/inheritance:r" "/grant:r" "${userName}:(F)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
else {
& chmod 600 -- $archivePath
if ($LASTEXITCODE -ne 0) { throw "Could not restrict backup archive permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Created a protected configuration backup at: $archivePath"
Write-Host "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
DESTINATION_DIRECTORY="${1:-$PROJECT_ROOT/backups}"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
CALENDAR_FILE="$PROJECT_ROOT/secrets/calendar_ical_urls.json"
TOKEN_FILE="$PROJECT_ROOT/secrets/organizer_access_token.txt"
for required_file in "$ENVIRONMENT_FILE" "$CALENDAR_FILE" "$TOKEN_FILE"; do
if [[ ! -f "$required_file" ]]; then
printf '%s\n' "Required configuration is missing. Run scripts/configure.sh before creating a backup." >&2
exit 1
fi
done
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to create a backup." >&2; exit 1; }
mkdir -p -- "$DESTINATION_DIRECTORY"
DESTINATION_DIRECTORY="$(cd -- "$DESTINATION_DIRECTORY" && pwd -P)"
chmod 700 -- "$DESTINATION_DIRECTORY"
timestamp="$(date -u +%Y%m%d-%H%M%S)"
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp.tar.gz"
archive_counter=1
while [[ -e "$archive_path" ]]; do
archive_path="$DESTINATION_DIRECTORY/hobbs-organizer-config-$timestamp-$archive_counter.tar.gz"
((archive_counter += 1))
done
staging_path="$(mktemp -d "$DESTINATION_DIRECTORY/.backup-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
mkdir -p -- "$staging_path/config" "$staging_path/secrets"
cp -- "$ENVIRONMENT_FILE" "$staging_path/config/environment.env"
cp -- "$CALENDAR_FILE" "$staging_path/secrets/calendar_ical_urls.json"
cp -- "$TOKEN_FILE" "$staging_path/secrets/organizer_access_token.txt"
chmod 600 -- "$staging_path/config/environment.env" "$staging_path/secrets/calendar_ical_urls.json" "$staging_path/secrets/organizer_access_token.txt"
{
printf '%s\n' 'format=HOBBS_ORGANIZER_CONFIG_BACKUP_V1'
printf 'created_utc=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
printf '%s\n' 'contains_secrets=true'
} >"$staging_path/manifest.txt"
(
cd -- "$staging_path"
tar -czf "$archive_path" config secrets manifest.txt
)
chmod 600 -- "$archive_path"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf 'Created a protected configuration backup at: %s\n' "$archive_path"
printf '%s\n' "The archive contains secrets. Store it in an encrypted or access-controlled backup system."
@@ -0,0 +1,194 @@
[CmdletBinding()]
param(
[ValidateSet("trusted-lan", "bearer")]
[string]$Mode,
[switch]$RotateToken
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$SecretsDirectory = Join-Path $ProjectRoot "secrets"
$CalendarFile = Join-Path $SecretsDirectory "calendar_ical_urls.json"
$TokenFile = Join-Path $SecretsDirectory "organizer_access_token.txt"
$EnvironmentFile = Join-Path $ProjectRoot ".env"
$EnvironmentExample = Join-Path $ProjectRoot ".env.example"
$Utf8NoBom = New-Object System.Text.UTF8Encoding($false)
function Test-IsWindows {
if ($PSVersionTable.PSEdition -eq "Desktop") { return $true }
return [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform(
[Runtime.InteropServices.OSPlatform]::Windows
)
}
function Read-HiddenText {
param([Parameter(Mandatory)][string]$Prompt)
$secureValue = Read-Host $Prompt -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureValue)
try {
return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
}
finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
function Read-YesNo {
param(
[Parameter(Mandatory)][string]$Prompt,
[bool]$Default = $false
)
$answer = (Read-Host $Prompt).Trim().ToLowerInvariant()
if (-not $answer) { return $Default }
return $answer -in @("y", "yes")
}
function Test-PrivateGoogleCalendarUrl {
param([Parameter(Mandatory)][string]$Value)
$uri = $null
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) { return $false }
if ($uri.Scheme -ne "https" -or $uri.Host -ne "calendar.google.com") { return $false }
if (-not $uri.IsDefaultPort -or $uri.UserInfo -or $uri.Fragment) { return $false }
return $uri.AbsolutePath -match "^/calendar/ical/[^/]+/(?:private-[^/]+|public)/basic[.]ics$"
}
function Protect-SecretFile {
param([Parameter(Mandatory)][string]$Path)
if (Test-IsWindows) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
& icacls.exe $Path "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
else {
& chmod 600 -- $Path
if ($LASTEXITCODE -ne 0) {
throw "Could not restrict permissions on a secret file."
}
}
}
function Write-SecretFile {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][AllowEmptyString()][string]$Content
)
$directory = Split-Path -Parent $Path
$temporaryPath = Join-Path $directory (".configure-" + [Guid]::NewGuid().ToString("N") + ".tmp")
try {
[IO.File]::WriteAllText($temporaryPath, $Content, $Utf8NoBom)
Protect-SecretFile -Path $temporaryPath
Move-Item -LiteralPath $temporaryPath -Destination $Path -Force
Protect-SecretFile -Path $Path
}
finally {
if (Test-Path -LiteralPath $temporaryPath) {
Remove-Item -LiteralPath $temporaryPath -Force
}
}
}
function New-AccessToken {
$bytes = New-Object byte[] 48
$generator = [Security.Cryptography.RandomNumberGenerator]::Create()
try {
$generator.GetBytes($bytes)
}
finally {
$generator.Dispose()
}
return [Convert]::ToBase64String($bytes).TrimEnd("=").Replace("+", "-").Replace("/", "_")
}
if (-not (Test-Path -LiteralPath $EnvironmentExample -PathType Leaf)) {
throw "Missing .env.example. Run this script from an intact release bundle."
}
New-Item -ItemType Directory -Path $SecretsDirectory -Force | Out-Null
if (-not (Test-IsWindows)) {
& chmod 700 -- $SecretsDirectory
if ($LASTEXITCODE -ne 0) { throw "Could not restrict the secrets directory permissions." }
}
if (-not (Test-Path -LiteralPath $EnvironmentFile)) {
Copy-Item -LiteralPath $EnvironmentExample -Destination $EnvironmentFile
Write-Host "Created .env from .env.example."
}
else {
Write-Host "Preserved the existing .env file."
}
$writeCalendars = -not (Test-Path -LiteralPath $CalendarFile -PathType Leaf)
if (-not $writeCalendars) {
$writeCalendars = Read-YesNo "A calendar secret already exists. Replace it? [y/N]"
}
if ($writeCalendars) {
$calendarUrls = [Collections.Generic.List[string]]::new()
Write-Host "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while ($true) {
$calendarUrl = Read-HiddenText ("Calendar URL #{0}" -f ($calendarUrls.Count + 1))
if ([string]::IsNullOrWhiteSpace($calendarUrl)) { break }
if (-not (Test-PrivateGoogleCalendarUrl -Value $calendarUrl.Trim())) {
Write-Warning "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved."
continue
}
$calendarUrls.Add($calendarUrl.Trim())
}
if ($calendarUrls.Count -eq 0) {
throw "At least one calendar URL is required when creating the calendar secret."
}
$calendarJson = ConvertTo-Json -InputObject @($calendarUrls.ToArray()) -Compress
Write-SecretFile -Path $CalendarFile -Content ($calendarJson + [Environment]::NewLine)
Write-Host ("Saved {0} calendar source(s) without displaying their URLs." -f $calendarUrls.Count)
}
else {
Protect-SecretFile -Path $CalendarFile
Write-Host "Preserved the existing calendar secret."
}
$hasExistingToken = (Test-Path -LiteralPath $TokenFile -PathType Leaf) -and ((Get-Item -LiteralPath $TokenFile).Length -gt 0)
if (-not $Mode) {
$defaultMode = if ($hasExistingToken) { "bearer" } else { "trusted-lan" }
$selectedMode = (Read-Host "Access mode [trusted-lan/bearer] (default: $defaultMode)").Trim().ToLowerInvariant()
$Mode = if ($selectedMode) { $selectedMode } else { $defaultMode }
if ($Mode -notin @("trusted-lan", "bearer")) {
throw "Access mode must be trusted-lan or bearer."
}
}
if ($Mode -eq "trusted-lan") {
if ($hasExistingToken -and -not (Read-YesNo "This will erase the current access token and disable bearer authentication. Continue? [y/N]")) {
throw "Configuration cancelled before changing the access mode."
}
Write-SecretFile -Path $TokenFile -Content ""
Write-Host "Configured trusted-LAN mode with an empty token file."
}
elseif ($hasExistingToken -and -not $RotateToken) {
Protect-SecretFile -Path $TokenFile
Write-Host "Preserved the existing bearer token. Use -RotateToken to replace it."
}
else {
$accessToken = New-AccessToken
try {
Write-SecretFile -Path $TokenFile -Content ($accessToken + [Environment]::NewLine)
}
finally {
$accessToken = $null
}
Write-Host "Generated and stored a new bearer token. The token was not displayed."
}
Write-Host "Configuration is ready. Review .env, then run: docker compose up -d --build"
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
SECRETS_DIRECTORY="$PROJECT_ROOT/secrets"
CALENDAR_FILE="$SECRETS_DIRECTORY/calendar_ical_urls.json"
TOKEN_FILE="$SECRETS_DIRECTORY/organizer_access_token.txt"
ENVIRONMENT_FILE="$PROJECT_ROOT/.env"
ENVIRONMENT_EXAMPLE="$PROJECT_ROOT/.env.example"
MODE=""
ROTATE_TOKEN=0
usage() {
printf '%s\n' "Usage: $0 [--mode trusted-lan|bearer] [--rotate-token]" >&2
}
while (($#)); do
case "$1" in
--mode)
(($# >= 2)) || { usage; exit 2; }
MODE="$2"
shift 2
;;
--mode=*)
MODE="${1#*=}"
shift
;;
--rotate-token)
ROTATE_TOKEN=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
done
if [[ -n "$MODE" && "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
if [[ ! -f "$ENVIRONMENT_EXAMPLE" ]]; then
printf '%s\n' "Missing .env.example. Run this script from an intact release bundle." >&2
exit 1
fi
mkdir -p -- "$SECRETS_DIRECTORY"
chmod 700 -- "$SECRETS_DIRECTORY"
if [[ ! -e "$ENVIRONMENT_FILE" ]]; then
cp -- "$ENVIRONMENT_EXAMPLE" "$ENVIRONMENT_FILE"
chmod 600 -- "$ENVIRONMENT_FILE"
printf '%s\n' "Created .env from .env.example."
else
printf '%s\n' "Preserved the existing .env file."
fi
replace_calendars=1
if [[ -f "$CALENDAR_FILE" ]]; then
read -r -p "A calendar secret already exists. Replace it? [y/N] " calendar_answer
case "${calendar_answer,,}" in
y|yes) replace_calendars=1 ;;
*) replace_calendars=0 ;;
esac
fi
if ((replace_calendars)); then
calendar_pattern='^https://calendar[.]google[.]com/calendar/ical/[A-Za-z0-9._~%+@-]+/(private-[A-Za-z0-9._~%+@-]+|public)/basic[.]ics$'
calendar_urls=()
printf '%s\n' "Enter private Google Calendar iCal URLs. Input is hidden; submit a blank entry when finished."
while true; do
printf 'Calendar URL #%d: ' "$(( ${#calendar_urls[@]} + 1 ))" >&2
IFS= read -r -s calendar_url
printf '\n' >&2
[[ -n "$calendar_url" ]] || break
if [[ ! "$calendar_url" =~ $calendar_pattern ]]; then
printf '%s\n' "That entry is not a supported private Google Calendar basic.ics HTTPS URL; it was not saved." >&2
unset calendar_url
continue
fi
calendar_urls+=("$calendar_url")
unset calendar_url
done
if ((${#calendar_urls[@]} == 0)); then
printf '%s\n' "At least one calendar URL is required when creating the calendar secret." >&2
exit 1
fi
calendar_temporary="$(mktemp "$SECRETS_DIRECTORY/.calendar-XXXXXXXX")"
cleanup_calendar() {
[[ -n "${calendar_temporary:-}" && -e "$calendar_temporary" ]] && rm -f -- "$calendar_temporary"
}
trap cleanup_calendar EXIT HUP INT TERM
chmod 600 -- "$calendar_temporary"
{
printf '['
for calendar_index in "${!calendar_urls[@]}"; do
((calendar_index == 0)) || printf ','
printf '"%s"' "${calendar_urls[$calendar_index]}"
done
printf ']\n'
} >"$calendar_temporary"
mv -f -- "$calendar_temporary" "$CALENDAR_FILE"
calendar_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$CALENDAR_FILE"
printf 'Saved %d calendar source(s) without displaying their URLs.\n' "${#calendar_urls[@]}"
unset calendar_urls
else
chmod 600 -- "$CALENDAR_FILE"
printf '%s\n' "Preserved the existing calendar secret."
fi
has_existing_token=0
[[ -s "$TOKEN_FILE" ]] && has_existing_token=1
if [[ -z "$MODE" ]]; then
default_mode="trusted-lan"
((has_existing_token)) && default_mode="bearer"
read -r -p "Access mode [trusted-lan/bearer] (default: $default_mode) " selected_mode
MODE="${selected_mode:-$default_mode}"
if [[ "$MODE" != "trusted-lan" && "$MODE" != "bearer" ]]; then
printf '%s\n' "Access mode must be trusted-lan or bearer." >&2
exit 2
fi
fi
if [[ "$MODE" == "trusted-lan" ]]; then
if ((ROTATE_TOKEN)); then
printf '%s\n' "--rotate-token can only be used with bearer mode." >&2
exit 2
fi
if ((has_existing_token)); then
read -r -p "This will erase the current access token and disable bearer authentication. Continue? [y/N] " erase_answer
case "${erase_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Configuration cancelled before changing the access mode." >&2; exit 1 ;;
esac
fi
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
chmod 600 -- "$token_temporary"
: >"$token_temporary"
mv -f -- "$token_temporary" "$TOKEN_FILE"
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Configured trusted-LAN mode with an empty token file."
elif ((has_existing_token)) && ((ROTATE_TOKEN == 0)); then
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Preserved the existing bearer token. Use --rotate-token to replace it."
else
token_temporary="$(mktemp "$SECRETS_DIRECTORY/.token-XXXXXXXX")"
cleanup_token() {
[[ -n "${token_temporary:-}" && -e "$token_temporary" ]] && rm -f -- "$token_temporary"
}
trap cleanup_token EXIT HUP INT TERM
chmod 600 -- "$token_temporary"
if command -v openssl >/dev/null 2>&1; then
openssl rand -base64 48 2>/dev/null | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
elif command -v base64 >/dev/null 2>&1 && [[ -r /dev/urandom ]]; then
dd if=/dev/urandom bs=48 count=1 2>/dev/null | base64 | tr -d '\r\n=' | tr '+/' '-_' >"$token_temporary"
else
printf '%s\n' "Bearer mode requires openssl, or base64 with /dev/urandom." >&2
exit 1
fi
[[ -s "$token_temporary" ]] || { printf '%s\n' "Secure token generation failed." >&2; exit 1; }
mv -f -- "$token_temporary" "$TOKEN_FILE"
token_temporary=""
trap - EXIT HUP INT TERM
chmod 600 -- "$TOKEN_FILE"
printf '%s\n' "Generated and stored a new bearer token. The token was not displayed."
fi
printf '%s\n' "Configuration is ready. Review .env, then run: docker compose up -d --build"
@@ -0,0 +1,97 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Archive,
[switch]$Force
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$ProjectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot ".."))
$Archive = [IO.Path]::GetFullPath($Archive)
if (-not (Test-Path -LiteralPath $Archive -PathType Leaf)) {
throw "The backup archive does not exist."
}
if (-not $Force) {
$answer = (Read-Host "Restore will overwrite the current .env and runtime secrets. Continue? [y/N]").Trim().ToLowerInvariant()
if ($answer -notin @("y", "yes")) {
throw "Restore cancelled."
}
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$zip = [IO.Compression.ZipFile]::OpenRead($Archive)
try {
$allowedEntries = @(
"config/environment.env",
"secrets/calendar_ical_urls.json",
"secrets/organizer_access_token.txt",
"manifest.txt"
)
$totalBytes = 0L
foreach ($entry in $zip.Entries) {
$entryName = $entry.FullName.Replace("\", "/").TrimEnd("/")
if (-not $entryName) { continue }
if ($entryName -notin $allowedEntries -and $entryName -notin @("config", "secrets")) {
throw "The archive contains an unexpected path and will not be restored."
}
$totalBytes += $entry.Length
}
if ($totalBytes -gt 10MB) {
throw "The configuration archive is unexpectedly large."
}
}
finally {
$zip.Dispose()
}
$stagingPath = Join-Path $ProjectRoot (".restore-staging-" + [Guid]::NewGuid().ToString("N"))
$stagingFullPath = [IO.Path]::GetFullPath($stagingPath)
$projectPrefix = $ProjectRoot.TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar) + [IO.Path]::DirectorySeparatorChar
if (-not $stagingFullPath.StartsWith($projectPrefix, [StringComparison]::OrdinalIgnoreCase)) {
throw "Refusing to create a restore staging directory outside the application directory."
}
try {
Expand-Archive -LiteralPath $Archive -DestinationPath $stagingPath
$restoredEnvironment = Join-Path $stagingPath "config/environment.env"
$restoredCalendars = Join-Path $stagingPath "secrets/calendar_ical_urls.json"
$restoredToken = Join-Path $stagingPath "secrets/organizer_access_token.txt"
foreach ($required in @($restoredEnvironment, $restoredCalendars, $restoredToken)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "The archive is missing required configuration files."
}
}
$secretsDirectory = Join-Path $ProjectRoot "secrets"
New-Item -ItemType Directory -Path $secretsDirectory -Force | Out-Null
Copy-Item -LiteralPath $restoredEnvironment -Destination (Join-Path $ProjectRoot ".env") -Force
Copy-Item -LiteralPath $restoredCalendars -Destination (Join-Path $secretsDirectory "calendar_ical_urls.json") -Force
Copy-Item -LiteralPath $restoredToken -Destination (Join-Path $secretsDirectory "organizer_access_token.txt") -Force
$secretFiles = @(
(Join-Path $secretsDirectory "calendar_ical_urls.json"),
(Join-Path $secretsDirectory "organizer_access_token.txt")
)
if ($PSVersionTable.PSEdition -eq "Desktop" -or [Runtime.InteropServices.RuntimeInformation]::IsOSPlatform([Runtime.InteropServices.OSPlatform]::Windows)) {
$userName = [Security.Principal.WindowsIdentity]::GetCurrent().Name
foreach ($secretFile in $secretFiles) {
& icacls.exe $secretFile "/inheritance:r" "/grant:r" "${userName}:(R,W)" | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
else {
& chmod 600 -- @secretFiles
if ($LASTEXITCODE -ne 0) { throw "Could not restrict restored secret permissions." }
}
}
finally {
if (Test-Path -LiteralPath $stagingFullPath) {
Remove-Item -LiteralPath $stagingFullPath -Recurse -Force
}
}
Write-Host "Configuration restored. Restart with: docker compose up -d --build"
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077
SCRIPT_DIRECTORY="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
PROJECT_ROOT="$(cd -- "$SCRIPT_DIRECTORY/.." && pwd -P)"
FORCE=0
usage() {
printf '%s\n' "Usage: $0 [--force] BACKUP.tar.gz" >&2
}
if [[ "${1:-}" == "--force" ]]; then
FORCE=1
shift
fi
[[ $# -eq 1 ]] || { usage; exit 2; }
archive_argument="$1"
archive_directory="$(cd -- "$(dirname -- "$archive_argument")" && pwd -P)"
archive_path="$archive_directory/$(basename -- "$archive_argument")"
[[ -f "$archive_path" ]] || { printf '%s\n' "The backup archive does not exist." >&2; exit 1; }
command -v tar >/dev/null 2>&1 || { printf '%s\n' "tar is required to restore a backup." >&2; exit 1; }
if ((FORCE == 0)); then
read -r -p "Restore will overwrite the current .env and runtime secrets. Continue? [y/N] " restore_answer
case "${restore_answer,,}" in
y|yes) ;;
*) printf '%s\n' "Restore cancelled." >&2; exit 1 ;;
esac
fi
while IFS= read -r archive_entry; do
normalized_entry="${archive_entry%/}"
case "$normalized_entry" in
config|config/environment.env|secrets|secrets/calendar_ical_urls.json|secrets/organizer_access_token.txt|manifest.txt) ;;
*) printf '%s\n' "The archive contains an unexpected path and will not be restored." >&2; exit 1 ;;
esac
done < <(tar -tzf "$archive_path")
staging_path="$(mktemp -d "$PROJECT_ROOT/.restore-staging-XXXXXXXX")"
cleanup_staging() {
[[ -n "${staging_path:-}" && -d "$staging_path" ]] && rm -rf -- "$staging_path"
}
trap cleanup_staging EXIT HUP INT TERM
tar -xzf "$archive_path" -C "$staging_path"
for required_file in \
"$staging_path/config/environment.env" \
"$staging_path/secrets/calendar_ical_urls.json" \
"$staging_path/secrets/organizer_access_token.txt"; do
[[ -f "$required_file" && ! -L "$required_file" ]] || {
printf '%s\n' "The archive is missing a required regular file." >&2
exit 1
}
done
mkdir -p -- "$PROJECT_ROOT/secrets"
cp -- "$staging_path/config/environment.env" "$PROJECT_ROOT/.env"
cp -- "$staging_path/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/calendar_ical_urls.json"
cp -- "$staging_path/secrets/organizer_access_token.txt" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
chmod 600 -- "$PROJECT_ROOT/.env" "$PROJECT_ROOT/secrets/calendar_ical_urls.json" "$PROJECT_ROOT/secrets/organizer_access_token.txt"
rm -rf -- "$staging_path"
staging_path=""
trap - EXIT HUP INT TERM
printf '%s\n' "Configuration restored. Restart with: docker compose up -d --build"
@@ -0,0 +1,4 @@
# Runtime secrets are created by scripts/configure.* and must never be committed.
*
!.gitignore
!calendar_ical_urls.example.json
@@ -0,0 +1,3 @@
[
"https://calendar.google.com/calendar/ical/your-calendar-id%40group.calendar.google.com/private-REPLACE_WITH_YOUR_PRIVATE_KEY/basic.ics"
]
@@ -0,0 +1,39 @@
# Hobbs' Family Organizer App for NVIDIA Shield
Android TV wrapper for the private Hobbs family organizer dashboard. Secrets are never stored in source or the APK; configuration is provisioned into app-private files over ADB.
## Dashboard configuration
The wrapper supports both the hosted Site and a self-hosted server:
- No `files/base_url`: use `https://dayline-tv-jeremy.tautfoal1.chatgpt.site/` with the legacy `files/service_token` and `OAI-Sites-Authorization` header.
- `files/base_url` present: use that exact origin. An optional `files/access_token` is sent as `Authorization: Bearer ...`.
- HTTP additionally requires `files/allow_insecure_http` and is restricted to private LAN IPs or `.local`, `.lan`, and `.home.arpa` names.
Every navigation, resource request, and redirect is restricted to the configured scheme, host, and effective port. TLS errors are always rejected. The network policy trusts system certificates and user-installed CAs so an internal CA can be used without disabling certificate validation.
Use `scripts/configure-shield.ps1` to write local settings. The script streams tokens through ADB standard input so they never appear in command arguments or shell history:
```powershell
.\scripts\configure-shield.ps1 `
-Device 192.168.86.179:5555 `
-BaseUrl https://organizer.home.arpa/ `
-PromptForToken `
-AdbPath C:\path\to\adb.exe
```
For an unauthenticated HTTP service on a trusted LAN, omit `-PromptForToken` and add `-AllowInsecureHttp`. Run `scripts/restore-hosted-site.ps1` to remove only the local settings and return to the preserved hosted token.
The daily 6:30 AM wake-and-launch schedule is enabled per device by creating the app-private `files/daily_wake_0630_enabled` flag. When enabled, the alarm is restored after app updates, device restarts, and wall-clock or timezone changes. It can wake a sleeping Shield, but not one that is fully powered off or disconnected from power. HDMI-CEC power control must be enabled on the Shield and television for the TV to wake with it.
## Included APK identity
- File: `Hobbs-Family-Organizer-Local-v1.4.apk`
- Package: `com.dayline.tv`
- Version: `1.4` (`versionCode 5`)
- APK SHA-256: `A52B91E3ED169CAD985E6F70644BD13F6DD88FFFAAA8D9F1CA47928418495412`
- Signing certificate SHA-256: `27a386f6529b25831ed4139859d308a0ea4351b16dfc92a5ffd159a4227ccafe`
The signing certificate matches the installed v1.3 build, so `adb install -r` performs an in-place upgrade. The private signing key is not included.
This internal wrapper is a debuggable build because its provisioning and rollback scripts use Android's `run-as` command to write app-private configuration without exposing tokens in ADB arguments. Disable network debugging on the Shield after provisioning and re-enable it only for controlled maintenance.
@@ -0,0 +1,227 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Device,
[Parameter(Mandatory = $true)]
[string]$BaseUrl,
[string]$AdbPath = "adb",
[switch]$AllowInsecureHttp,
[switch]$PromptForToken,
[string]$TokenFile,
[switch]$NoLaunch
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if ($PromptForToken -and $PSBoundParameters.ContainsKey("TokenFile")) {
throw "Use either -PromptForToken or -TokenFile, not both."
}
function Resolve-AdbExecutable {
param([string]$RequestedPath)
if (Test-Path -LiteralPath $RequestedPath -PathType Leaf) {
return (Resolve-Path -LiteralPath $RequestedPath).Path
}
$command = Get-Command -Name $RequestedPath -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
}
return $command.Source
}
function Invoke-Adb {
param(
[Parameter(Mandatory = $true)]
[string[]]$Arguments,
[AllowNull()]
[string]$StandardInput = $null
)
$startInfo = [System.Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $script:ResolvedAdbPath
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardInput = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) {
[void]$startInfo.ArgumentList.Add($argument)
}
$process = [System.Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) {
throw "ADB could not be started."
}
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if ($null -ne $StandardInput) {
$process.StandardInput.Write($StandardInput)
}
$process.StandardInput.Close()
$process.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
if ($process.ExitCode -ne 0) {
$detail = $stderr.Trim()
if ([string]::IsNullOrWhiteSpace($detail)) {
$detail = $stdout.Trim()
}
throw "ADB failed with exit code $($process.ExitCode): $detail"
}
return $stdout.Trim()
}
function Set-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("base_url", "access_token", "allow_insecure_http")]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Value
)
$remoteCommand = "run-as com.dayline.tv sh -c 'umask 077; cat > files/.$Name.new && chmod 600 files/.$Name.new && mv files/.$Name.new files/$Name'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand) -StandardInput $Value)
}
function Remove-AppPrivateFile {
param(
[Parameter(Mandatory = $true)]
[ValidateSet("access_token", "allow_insecure_http")]
[string]$Name
)
$remoteCommand = "run-as com.dayline.tv sh -c 'rm -f files/$Name files/.$Name.new'"
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", $remoteCommand))
}
function Test-PrivateLanHost {
param([string]$HostName)
$normalized = $HostName.Trim().Trim("[", "]").ToLowerInvariant()
if ($normalized -eq "localhost" -or
$normalized.EndsWith(".local") -or
$normalized.EndsWith(".lan") -or
$normalized.EndsWith(".home.arpa")) {
return $true
}
$address = $null
if (-not [System.Net.IPAddress]::TryParse($normalized, [ref]$address)) {
return $false
}
$bytes = $address.GetAddressBytes()
if ($address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
return $bytes[0] -eq 10 -or
$bytes[0] -eq 127 -or
($bytes[0] -eq 172 -and $bytes[1] -ge 16 -and $bytes[1] -le 31) -or
($bytes[0] -eq 192 -and $bytes[1] -eq 168) -or
($bytes[0] -eq 169 -and $bytes[1] -eq 254)
}
if ($address.Equals([System.Net.IPAddress]::IPv6Loopback)) {
return $true
}
return (($bytes[0] -band 0xFE) -eq 0xFC) -or
($bytes[0] -eq 0xFE -and ($bytes[1] -band 0xC0) -eq 0x80)
}
$script:ResolvedAdbPath = Resolve-AdbExecutable -RequestedPath $AdbPath
$dashboardUri = $null
if (-not [Uri]::TryCreate($BaseUrl.Trim(), [UriKind]::Absolute, [ref]$dashboardUri)) {
throw "BaseUrl is not a valid absolute URL."
}
if ($dashboardUri.Scheme -notin @("http", "https")) {
throw "BaseUrl must use http or https."
}
if (-not [string]::IsNullOrEmpty($dashboardUri.UserInfo) -or
-not [string]::IsNullOrEmpty($dashboardUri.Query) -or
-not [string]::IsNullOrEmpty($dashboardUri.Fragment)) {
throw "BaseUrl cannot contain credentials, a query, or a fragment."
}
if ($dashboardUri.AbsolutePath -notin @("", "/")) {
throw "The organizer must be served from the URL root path."
}
if ($dashboardUri.Scheme -eq "http") {
if (-not $AllowInsecureHttp) {
throw "HTTP requires the explicit -AllowInsecureHttp switch. HTTPS is recommended."
}
if (-not (Test-PrivateLanHost -HostName $dashboardUri.Host)) {
throw "HTTP is restricted to private LAN IP addresses or .local, .lan, and .home.arpa hosts."
}
}
$normalizedBaseUrl = $dashboardUri.GetLeftPart([UriPartial]::Authority) + "/"
$plainToken = $null
try {
if ($PSBoundParameters.ContainsKey("TokenFile")) {
if (-not (Test-Path -LiteralPath $TokenFile -PathType Leaf)) {
throw "TokenFile was not found."
}
$plainToken = (Get-Content -LiteralPath $TokenFile -Raw).Trim()
} elseif ($PromptForToken) {
$secureToken = Read-Host "Local bearer token" -AsSecureString
$pointer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureToken)
try {
$plainToken = [Runtime.InteropServices.Marshal]::PtrToStringBSTR($pointer)
} finally {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($pointer)
}
}
if ($null -ne $plainToken -and $plainToken -notmatch "^[A-Za-z0-9._~-]{32,512}$") {
throw "The bearer token must contain 32 to 512 URL-safe characters."
}
[void](Invoke-Adb -Arguments @("connect", $Device))
$state = Invoke-Adb -Arguments @("-s", $Device, "get-state")
if ($state -ne "device") {
throw "The Shield is not connected and authorized."
}
$packagePath = Invoke-Adb -Arguments @("-s", $Device, "shell", "pm path com.dayline.tv")
if (-not $packagePath.StartsWith("package:")) {
throw "Hobbs' Family Organizer App is not installed on the selected Shield."
}
Set-AppPrivateFile -Name "base_url" -Value $normalizedBaseUrl
if ($null -ne $plainToken) {
Set-AppPrivateFile -Name "access_token" -Value $plainToken
} else {
Remove-AppPrivateFile -Name "access_token"
}
if ($dashboardUri.Scheme -eq "http") {
Set-AppPrivateFile -Name "allow_insecure_http" -Value "enabled"
} else {
Remove-AppPrivateFile -Name "allow_insecure_http"
}
if (-not $NoLaunch) {
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am force-stop com.dayline.tv"))
[void](Invoke-Adb -Arguments @("-s", $Device, "shell", "am start -W -n com.dayline.tv/.MainActivity"))
}
Write-Host "Configured Hobbs' Family Organizer App on $Device"
Write-Host "Dashboard: $normalizedBaseUrl"
Write-Host "Bearer token: $(if ($null -eq $plainToken) { 'not configured' } else { 'configured' })"
Write-Host "Insecure HTTP: $(if ($dashboardUri.Scheme -eq 'http') { 'enabled for this private LAN origin' } else { 'disabled' })"
Write-Host "The legacy hosted service_token was preserved for rollback."
} finally {
$plainToken = $null
}
@@ -0,0 +1,37 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Device,
[string]$AdbPath = "adb"
)
$ErrorActionPreference = "Stop"
if (Test-Path -LiteralPath $AdbPath -PathType Leaf) {
$resolvedAdb = (Resolve-Path -LiteralPath $AdbPath).Path
} else {
$command = Get-Command -Name $AdbPath -CommandType Application -ErrorAction SilentlyContinue
if ($null -eq $command) {
throw "ADB was not found. Install Android platform-tools or pass -AdbPath with the full path to adb.exe."
}
$resolvedAdb = $command.Source
}
& $resolvedAdb connect $Device | Out-Null
if ((& $resolvedAdb -s $Device get-state) -ne "device") {
throw "The Shield is not connected and authorized."
}
& $resolvedAdb -s $Device shell "run-as com.dayline.tv sh -c 'rm -f files/base_url files/.base_url.new files/access_token files/.access_token.new files/allow_insecure_http files/.allow_insecure_http.new'"
if ($LASTEXITCODE -ne 0) {
throw "The local configuration could not be removed."
}
& $resolvedAdb -s $Device shell am force-stop com.dayline.tv
& $resolvedAdb -s $Device shell am start -W -n com.dayline.tv/.MainActivity
if ($LASTEXITCODE -ne 0) {
throw "The organizer could not be relaunched."
}
Write-Host "Restored the hosted Site configuration on $Device. The legacy service_token was not changed."
@@ -0,0 +1,6 @@
.gradle/
.idea/
build/
app/build/
local.properties
*.iml
@@ -0,0 +1,13 @@
# Building the Android wrapper
The source targets Android SDK 35, Java 17, Android Gradle Plugin 8.7.3, and Gradle 8.9.
From this directory, with the Android SDK and Gradle installed:
```text
gradle testDebugUnitTest lintDebug assembleDebug
```
The debug APK is written to `app/build/outputs/apk/debug/app-debug.apk`.
Android requires the same signing certificate for upgrades. The release bundle intentionally excludes the private key used for the prebuilt APK. Use the included signed APK for existing Shields unless you have retained the original key separately.
@@ -0,0 +1,37 @@
plugins {
id "com.android.application"
}
android {
namespace "com.dayline.tv"
compileSdk 35
defaultConfig {
applicationId "com.dayline.tv"
minSdk 26
targetSdk 35
versionCode 5
versionName "1.4"
testInstrumentationRunner "android.test.InstrumentationTestRunner"
}
buildTypes {
debug {
debuggable true
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
}
dependencies {
testImplementation "junit:junit:4.13.2"
}
@@ -0,0 +1 @@
# Hobbs' Family Organizer App currently has no release-only shrinking rules.
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:allowBackup="false"
android:banner="@drawable/tv_banner"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="false"
android:hardwareAccelerated="true"
android:icon="@drawable/ic_dayline"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/Theme.DaylineTV"
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|smallestScreenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<receiver
android:name=".ScheduleReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
<action android:name="android.intent.action.TIME_SET" />
<action android:name="android.intent.action.TIMEZONE_CHANGED" />
</intent-filter>
</receiver>
</application>
</manifest>
@@ -0,0 +1,245 @@
package com.dayline.tv;
import android.content.Context;
import android.net.Uri;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.IDN;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.Locale;
import java.util.regex.Pattern;
final class DashboardConfig {
private static final String LEGACY_SITE_URL = "https://dayline-tv-jeremy.tautfoal1.chatgpt.site/";
private static final String LEGACY_AUTH_HEADER = "OAI-Sites-Authorization";
private static final String LOCAL_AUTH_HEADER = "Authorization";
private static final String BASE_URL_FILE = "base_url";
private static final String ACCESS_TOKEN_FILE = "access_token";
private static final String LEGACY_TOKEN_FILE = "service_token";
private static final String ALLOW_HTTP_FILE = "allow_insecure_http";
private static final int MAX_URL_BYTES = 2048;
private static final int MAX_TOKEN_BYTES = 1024;
private static final Pattern TOKEN_PATTERN = Pattern.compile("[A-Za-z0-9._~-]{32,512}");
private final URI baseOrigin;
private final String baseUrl;
private final String authHeader;
private final String token;
private DashboardConfig(URI baseOrigin, String baseUrl, String authHeader, String token) {
this.baseOrigin = baseOrigin;
this.baseUrl = baseUrl;
this.authHeader = authHeader;
this.token = token;
}
static DashboardConfig load(Context context) throws ConfigException {
String configuredUrl = readOptionalText(context, BASE_URL_FILE, MAX_URL_BYTES);
boolean hostedFallback = configuredUrl == null;
String rawUrl = hostedFallback ? LEGACY_SITE_URL : configuredUrl;
URI origin = parseBaseUrl(rawUrl, context.getFileStreamPath(ALLOW_HTTP_FILE).isFile());
String token;
String authHeader;
if (hostedFallback) {
token = readOptionalText(context, LEGACY_TOKEN_FILE, MAX_TOKEN_BYTES);
if (!isValidToken(token)) {
throw new ConfigException(
"The hosted dashboard access token is missing. Provision files/service_token over ADB or configure files/base_url for a local server."
);
}
authHeader = LEGACY_AUTH_HEADER;
} else {
token = readOptionalText(context, ACCESS_TOKEN_FILE, MAX_TOKEN_BYTES);
if (token != null && !isValidToken(token)) {
throw new ConfigException("The local access token must be 32 to 512 URL-safe characters.");
}
authHeader = token == null ? null : LOCAL_AUTH_HEADER;
}
String normalizedUrl = normalizedRootUrl(origin);
try {
return new DashboardConfig(new URI(normalizedUrl), normalizedUrl, authHeader, token);
} catch (URISyntaxException error) {
throw new ConfigException("The dashboard URL could not be normalized.");
}
}
String baseUrl() {
return baseUrl;
}
boolean hasToken() {
return token != null;
}
String authHeader() {
return authHeader;
}
String authorizationValue() {
return token == null ? null : "Bearer " + token;
}
boolean isAllowed(Uri uri) {
if (uri == null) return false;
try {
return sameOrigin(baseOrigin, new URI(uri.toString()));
} catch (URISyntaxException error) {
return false;
}
}
private static URI parseBaseUrl(String value, boolean allowHttp) throws ConfigException {
URI uri;
try {
uri = new URI(value.trim());
} catch (URISyntaxException error) {
throw new ConfigException("The configured dashboard URL is invalid.");
}
String scheme = lower(uri.getScheme());
String host = normalizeHost(uri.getHost());
if (scheme == null || host == null || host.isBlank()) {
throw new ConfigException("The dashboard URL must include an http or https host.");
}
if (!scheme.equals("https") && !scheme.equals("http")) {
throw new ConfigException("The dashboard URL must use http or https.");
}
if (uri.getUserInfo() != null || uri.getRawQuery() != null || uri.getRawFragment() != null) {
throw new ConfigException("The dashboard URL cannot contain credentials, a query, or a fragment.");
}
String path = uri.getRawPath();
if (path != null && !path.isEmpty() && !path.equals("/")) {
throw new ConfigException("The dashboard must be served from the URL root path.");
}
if (uri.getPort() == 0 || uri.getPort() < -1 || uri.getPort() > 65535) {
throw new ConfigException("The dashboard URL contains an invalid port.");
}
if (scheme.equals("http") && (!allowHttp || !isPrivateLanHost(host))) {
throw new ConfigException(
"HTTP requires files/allow_insecure_http and a private LAN address or .local, .lan, or .home.arpa host."
);
}
return uri;
}
static boolean sameOrigin(URI expected, URI candidate) {
if (expected == null || candidate == null || candidate.getUserInfo() != null) return false;
String expectedScheme = lower(expected.getScheme());
String candidateScheme = lower(candidate.getScheme());
if (!"http".equals(candidateScheme) && !"https".equals(candidateScheme)) return false;
return expectedScheme != null
&& expectedScheme.equals(candidateScheme)
&& normalizeHost(expected.getHost()).equals(normalizeHost(candidate.getHost()))
&& effectivePort(expected) == effectivePort(candidate);
}
static boolean isPrivateLanHost(String value) {
String host = normalizeHost(value);
if (host == null || host.isBlank()) return false;
if (host.equals("localhost") || host.endsWith(".local") || host.endsWith(".lan") || host.endsWith(".home.arpa")) {
return true;
}
if (isPrivateIpv4(host)) return true;
String ipv6 = host.toLowerCase(Locale.ROOT);
if (ipv6.startsWith("[") && ipv6.endsWith("]")) ipv6 = ipv6.substring(1, ipv6.length() - 1);
return ipv6.equals("::1")
|| ipv6.startsWith("fc")
|| ipv6.startsWith("fd")
|| ipv6.startsWith("fe8")
|| ipv6.startsWith("fe9")
|| ipv6.startsWith("fea")
|| ipv6.startsWith("feb");
}
private static boolean isPrivateIpv4(String host) {
String[] parts = host.split("\\.", -1);
if (parts.length != 4) return false;
int[] octets = new int[4];
for (int index = 0; index < parts.length; index++) {
if (parts[index].isEmpty() || parts[index].length() > 3) return false;
try {
octets[index] = Integer.parseInt(parts[index]);
} catch (NumberFormatException error) {
return false;
}
if (octets[index] < 0 || octets[index] > 255) return false;
}
return octets[0] == 10
|| octets[0] == 127
|| octets[0] == 192 && octets[1] == 168
|| octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31
|| octets[0] == 169 && octets[1] == 254;
}
private static int effectivePort(URI uri) {
if (uri.getPort() != -1) return uri.getPort();
return "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80;
}
private static String normalizedRootUrl(URI uri) throws ConfigException {
String scheme = lower(uri.getScheme());
String host = normalizeHost(uri.getHost());
try {
return new URI(scheme, null, host, uri.getPort(), "/", null, null).toString();
} catch (URISyntaxException error) {
throw new ConfigException("The dashboard URL could not be normalized.");
}
}
private static String normalizeHost(String host) {
if (host == null) return null;
String normalized = host.trim().toLowerCase(Locale.ROOT);
if (normalized.startsWith("[") && normalized.endsWith("]")) {
return normalized.substring(1, normalized.length() - 1);
}
if (normalized.contains(":")) return normalized;
try {
return IDN.toASCII(normalized, IDN.USE_STD3_ASCII_RULES).toLowerCase(Locale.ROOT);
} catch (IllegalArgumentException error) {
return "";
}
}
private static String lower(String value) {
return value == null ? null : value.toLowerCase(Locale.ROOT);
}
private static boolean isValidToken(String value) {
return value != null && TOKEN_PATTERN.matcher(value).matches();
}
private static String readOptionalText(Context context, String name, int maxBytes) throws ConfigException {
File file = context.getFileStreamPath(name);
if (!file.isFile()) return null;
if (file.length() == 0) return null;
if (file.length() > maxBytes) throw new ConfigException("Configuration file " + name + " is too large.");
try (InputStream input = new FileInputStream(file)) {
byte[] bytes = new byte[(int) file.length()];
int offset = 0;
while (offset < bytes.length) {
int read = input.read(bytes, offset, bytes.length - offset);
if (read < 0) break;
offset += read;
}
String value = new String(bytes, 0, offset, StandardCharsets.UTF_8).trim();
return value.isEmpty() ? null : value;
} catch (IOException error) {
throw new ConfigException("Configuration file " + name + " could not be read.");
}
}
static final class ConfigException extends Exception {
ConfigException(String message) {
super(message);
}
}
}
@@ -0,0 +1,80 @@
package com.dayline.tv;
import android.app.AlarmManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import android.util.Log;
import java.util.Calendar;
final class DaylineScheduler {
static final String ACTION_DAILY_WAKE = "com.dayline.tv.action.DAILY_WAKE";
static final int WAKE_HOUR = 6;
static final int WAKE_MINUTE = 30;
private static final String TAG = "DaylineScheduler";
private static final int REQUEST_CODE = 630;
private static final String ENABLED_FLAG_FILE = "daily_wake_0630_enabled";
private DaylineScheduler() {
}
static void scheduleNext(Context context) {
Context appContext = context.getApplicationContext();
AlarmManager alarmManager = (AlarmManager) appContext.getSystemService(Context.ALARM_SERVICE);
if (alarmManager == null) {
Log.e(TAG, "AlarmManager is unavailable; daily launch was not scheduled");
return;
}
PendingIntent launchIntent = launchPendingIntent(appContext);
if (!appContext.getFileStreamPath(ENABLED_FLAG_FILE).isFile()) {
alarmManager.cancel(launchIntent);
Log.i(TAG, "Daily launch is disabled on this device");
return;
}
long triggerAtMillis = nextWakeMillis(System.currentTimeMillis());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !alarmManager.canScheduleExactAlarms()) {
alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
Log.w(TAG, "Exact-alarm access is unavailable; scheduled an approximate 6:30 AM launch");
} else {
alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, launchIntent);
Log.i(TAG, "Scheduled daily launch for " + triggerAtMillis);
}
}
static long nextWakeMillis(long nowMillis) {
Calendar next = Calendar.getInstance();
next.setTimeInMillis(nowMillis);
next.set(Calendar.HOUR_OF_DAY, WAKE_HOUR);
next.set(Calendar.MINUTE, WAKE_MINUTE);
next.set(Calendar.SECOND, 0);
next.set(Calendar.MILLISECOND, 0);
if (next.getTimeInMillis() <= nowMillis) {
next.add(Calendar.DAY_OF_YEAR, 1);
}
return next.getTimeInMillis();
}
private static PendingIntent launchPendingIntent(Context context) {
Intent intent = new Intent(context, MainActivity.class)
.setAction(ACTION_DAILY_WAKE)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK
| Intent.FLAG_ACTIVITY_CLEAR_TOP
| Intent.FLAG_ACTIVITY_SINGLE_TOP
);
return PendingIntent.getActivity(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
}
@@ -0,0 +1,399 @@
package com.dayline.tv;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.graphics.Color;
import android.net.Uri;
import android.net.http.SslError;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.view.KeyEvent;
import android.view.View;
import android.view.WindowManager;
import android.webkit.CookieManager;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebResourceResponse;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
public final class MainActivity extends Activity {
private static final int MAX_REDIRECTS = 5;
private WebView webView;
private volatile DashboardConfig dashboardConfig;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
configureWakeBehavior();
DaylineScheduler.scheduleNext(this);
enterImmersiveMode();
configureWebView();
loadDashboard();
}
@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
configureWakeBehavior();
DaylineScheduler.scheduleNext(this);
}
@SuppressWarnings("deprecation")
private void configureWakeBehavior() {
getWindow().addFlags(
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED
| WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
setTurnScreenOn(true);
setShowWhenLocked(true);
}
}
@SuppressLint("SetJavaScriptEnabled")
private void configureWebView() {
WebView.setWebContentsDebuggingEnabled(false);
webView = new WebView(this);
webView.setBackgroundColor(Color.rgb(7, 11, 18));
webView.setFocusable(true);
webView.setFocusableInTouchMode(true);
WebSettings settings = webView.getSettings();
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
settings.setJavaScriptCanOpenWindowsAutomatically(false);
settings.setSupportMultipleWindows(false);
settings.setBuiltInZoomControls(false);
settings.setDisplayZoomControls(false);
settings.setSupportZoom(false);
settings.setUseWideViewPort(true);
settings.setLoadWithOverviewMode(true);
settings.setLayoutAlgorithm(WebSettings.LayoutAlgorithm.NORMAL);
settings.setMediaPlaybackRequiresUserGesture(false);
settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
settings.setSafeBrowsingEnabled(true);
settings.setUserAgentString(settings.getUserAgentString() + " HobbsOrganizer/1.4");
webView.setInitialScale(0);
CookieManager cookies = CookieManager.getInstance();
cookies.setAcceptCookie(false);
cookies.setAcceptThirdPartyCookies(webView, false);
webView.setWebChromeClient(new WebChromeClient());
webView.setWebViewClient(new DaylineWebViewClient());
setContentView(webView);
webView.requestFocus(View.FOCUS_DOWN);
}
private void loadDashboard() {
try {
dashboardConfig = DashboardConfig.load(this);
} catch (DashboardConfig.ConfigException error) {
showLocalMessage(
"Setup required",
error.getMessage()
);
return;
}
Map<String, String> headers = new LinkedHashMap<>();
if (dashboardConfig.hasToken()) {
headers.put(dashboardConfig.authHeader(), dashboardConfig.authorizationValue());
}
webView.loadUrl(dashboardConfig.baseUrl(), headers);
}
private void showLocalMessage(String title, String detail) {
String html = "<!doctype html><html><head><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">"
+ "<style>html,body{height:100%;margin:0;background:#070b12;color:#f8fafc;font-family:sans-serif}"
+ "body{display:grid;place-items:center}.card{max-width:760px;border:1px solid #ffffff1a;border-radius:28px;padding:52px;"
+ "background:#0c121d;text-align:center;box-shadow:0 30px 90px #0006}h1{font-size:44px;margin:0 0 16px;color:#a5f3fc}"
+ "p{font-size:24px;line-height:1.5;color:#94a3b8;margin:0}</style></head><body><div class=\"card\"><h1>"
+ escapeHtml(title) + "</h1><p>" + escapeHtml(detail) + "</p></div></body></html>";
webView.loadDataWithBaseURL("https://dayline.local/", html, "text/html", "UTF-8", null);
}
private static String escapeHtml(String value) {
return value.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;");
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (hasFocus) enterImmersiveMode();
}
@SuppressWarnings("deprecation")
private void enterImmersiveMode() {
getWindow().getDecorView().setSystemUiVisibility(
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
| View.SYSTEM_UI_FLAG_FULLSCREEN
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_LAYOUT_STABLE
);
}
@Override
public boolean onKeyDown(int keyCode, KeyEvent event) {
if (keyCode == KeyEvent.KEYCODE_BACK && webView != null && webView.canGoBack()) {
webView.goBack();
return true;
}
return super.onKeyDown(keyCode, event);
}
@Override
protected void onDestroy() {
if (webView != null) {
webView.stopLoading();
webView.setWebChromeClient(null);
webView.setWebViewClient(null);
webView.destroy();
webView = null;
}
dashboardConfig = null;
super.onDestroy();
}
private final class DaylineWebViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
DashboardConfig config = dashboardConfig;
return config == null || !config.isAllowed(request.getUrl());
}
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
DashboardConfig config = dashboardConfig;
return config == null || !config.isAllowed(Uri.parse(url));
}
@Override
public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
DashboardConfig config = dashboardConfig;
if (config == null) return localError(503, "Unavailable", "Dashboard configuration is unavailable.");
if (!config.isAllowed(request.getUrl())) return localError(403, "Forbidden", "Blocked cross-origin request.");
if (!"GET".equalsIgnoreCase(request.getMethod())) return null;
return fetchRequest(request.getUrl(), request.getRequestHeaders(), config, request.isForMainFrame(), 0);
}
@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
handler.cancel();
}
@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
showLocalMessage("The organizer is offline", "Check the Shield network connection. The dashboard will be available after relaunching the app.");
}
}
}
private WebResourceResponse fetchRequest(
Uri uri,
Map<String, String> requestHeaders,
DashboardConfig config,
boolean mainFrame,
int redirectCount
) {
if (!config.isAllowed(uri)) return localError(403, "Forbidden", "Blocked cross-origin request.");
if (redirectCount > MAX_REDIRECTS) return localError(508, "Loop Detected", "Too many redirects.");
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) new URL(uri.toString()).openConnection();
connection.setInstanceFollowRedirects(false);
connection.setConnectTimeout(10_000);
connection.setReadTimeout(20_000);
connection.setRequestMethod("GET");
if (config.hasToken()) {
connection.setRequestProperty(config.authHeader(), config.authorizationValue());
}
connection.setRequestProperty("Accept-Encoding", "identity");
copyRequestHeader(requestHeaders, connection, "Accept");
copyRequestHeader(requestHeaders, connection, "Accept-Language");
copyRequestHeader(requestHeaders, connection, "User-Agent");
int status = connection.getResponseCode();
if (status >= 300 && status < 400) {
String location = connection.getHeaderField("Location");
if (location == null || location.isBlank()) {
connection.disconnect();
return localError(502, "Bad Gateway", "Invalid redirect from the organizer.");
}
Uri redirect = Uri.parse(new URL(new URL(uri.toString()), location).toString());
connection.disconnect();
if (!config.isAllowed(redirect)) {
return localError(401, "Unauthorized", "Organizer access needs to be provisioned again.");
}
return fetchRequest(redirect, requestHeaders, config, mainFrame, redirectCount + 1);
}
String contentType = connection.getContentType();
String mimeType = mimeType(contentType, uri);
String encoding = charset(contentType, mimeType);
InputStream raw = status >= 400 ? connection.getErrorStream() : connection.getInputStream();
if (raw == null) raw = new ByteArrayInputStream(new byte[0]);
Map<String, String> responseHeaders = responseHeaders(connection.getHeaderFields());
String reason = connection.getResponseMessage();
if (reason == null || reason.isBlank()) reason = status >= 400 ? "Request Failed" : "OK";
if (mainFrame && "text/html".equalsIgnoreCase(mimeType)) {
byte[] original = readLimited(raw, 2 * 1024 * 1024);
raw.close();
connection.disconnect();
String html = new String(original, StandardCharsets.UTF_8);
String viewport = "<meta name=\"viewport\" content=\"width=1920, initial-scale=1, user-scalable=no\">";
String rewritten = html.replaceFirst(
"(?i)<meta[^>]+name\\s*=\\s*[\\\"']viewport[\\\"'][^>]*>",
viewport
);
if (rewritten.equals(html)) {
rewritten = html.replaceFirst("(?i)<head[^>]*>", "$0" + viewport);
}
return new WebResourceResponse(
mimeType,
"UTF-8",
status,
reason,
responseHeaders,
new ByteArrayInputStream(rewritten.getBytes(StandardCharsets.UTF_8))
);
}
InputStream body = new DisconnectingInputStream(raw, connection);
return new WebResourceResponse(mimeType, encoding, status, reason, responseHeaders, body);
} catch (IOException error) {
if (connection != null) connection.disconnect();
return localError(502, "Bad Gateway", "The organizer could not be reached.");
}
}
private static void copyRequestHeader(Map<String, String> source, HttpURLConnection connection, String name) {
if (source == null) return;
for (Map.Entry<String, String> entry : source.entrySet()) {
if (name.equalsIgnoreCase(entry.getKey()) && entry.getValue() != null) {
connection.setRequestProperty(name, entry.getValue());
return;
}
}
}
private static Map<String, String> responseHeaders(Map<String, List<String>> source) {
if (source == null) return Collections.emptyMap();
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> entry : source.entrySet()) {
String name = entry.getKey();
if (name == null || entry.getValue() == null) continue;
String lower = name.toLowerCase(Locale.ROOT);
if (lower.equals("content-length") || lower.equals("content-encoding")
|| lower.equals("transfer-encoding") || lower.equals("connection")
|| lower.equals("set-cookie")) continue;
result.put(name, String.join(", ", entry.getValue()));
}
return result;
}
private static String mimeType(String contentType, Uri uri) {
if (contentType != null && !contentType.isBlank()) {
return contentType.split(";", 2)[0].trim();
}
String path = uri.getPath() == null ? "" : uri.getPath().toLowerCase(Locale.ROOT);
if (path.endsWith(".js") || path.endsWith(".mjs")) return "application/javascript";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".json")) return "application/json";
if (path.endsWith(".svg")) return "image/svg+xml";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
if (path.endsWith(".webp")) return "image/webp";
if (path.endsWith(".woff2")) return "font/woff2";
return "text/html";
}
private static String charset(String contentType, String mimeType) {
if (contentType != null) {
for (String part : contentType.split(";")) {
String trimmed = part.trim();
if (trimmed.toLowerCase(Locale.ROOT).startsWith("charset=")) {
return trimmed.substring(8).replace("\"", "");
}
}
}
return mimeType.startsWith("text/") || mimeType.contains("javascript") || mimeType.contains("json")
? "UTF-8"
: null;
}
private static byte[] readLimited(InputStream input, int limit) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
byte[] buffer = new byte[16 * 1024];
int total = 0;
int read;
while ((read = input.read(buffer)) >= 0) {
total += read;
if (total > limit) throw new IOException("Response body too large");
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private static WebResourceResponse localError(int status, String reason, String message) {
byte[] body = message.getBytes(StandardCharsets.UTF_8);
return new WebResourceResponse(
"text/plain",
"UTF-8",
status,
reason,
Collections.singletonMap("Cache-Control", "no-store"),
new ByteArrayInputStream(body)
);
}
private static final class DisconnectingInputStream extends FilterInputStream {
private final HttpURLConnection connection;
DisconnectingInputStream(InputStream input, HttpURLConnection connection) {
super(input);
this.connection = connection;
}
@Override
public void close() throws IOException {
try {
super.close();
} finally {
connection.disconnect();
}
}
}
}
@@ -0,0 +1,19 @@
package com.dayline.tv;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
public final class ScheduleReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent == null ? null : intent.getAction();
if (!Intent.ACTION_BOOT_COMPLETED.equals(action)
&& !Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)
&& !Intent.ACTION_TIME_CHANGED.equals(action)
&& !Intent.ACTION_TIMEZONE_CHANGED.equals(action)) {
return;
}
DaylineScheduler.scheduleNext(context);
}
}
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:fillColor="#08111E" android:pathData="M8,0 L100,0 Q108,0 108,8 L108,100 Q108,108 100,108 L8,108 Q0,108 0,100 L0,8 Q0,0 8,0" />
<path android:fillColor="#22D3EE" android:pathData="M24,62 L36,62 L36,82 L24,82 Z" />
<path android:fillColor="#A5F3FC" android:pathData="M48,26 L60,26 L60,82 L48,82 Z" />
<path android:fillColor="#67E8F9" android:pathData="M72,43 L84,43 L84,82 L72,82 Z" />
</vector>
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#07101D" android:pathData="M0,0 L320,0 L320,180 L0,180 Z" />
<path android:fillColor="#0E2233" android:pathData="M0,0 L150,0 L62,180 L0,180 Z" />
<path android:fillColor="#22D3EE" android:pathData="M54,97 L70,97 L70,132 L54,132 Z" />
<path android:fillColor="#A5F3FC" android:pathData="M82,53 L98,53 L98,132 L82,132 Z" />
<path android:fillColor="#67E8F9" android:pathData="M110,74 L126,74 L126,132 L110,132 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M150,64 L160,64 L160,86 L180,86 L180,64 L190,64 L190,120 L180,120 L180,97 L160,97 L160,120 L150,120 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M203,64 L238,64 L238,75 L213,75 L213,87 L234,87 L234,98 L213,98 L213,120 L203,120 Z" />
<path android:fillColor="#E2E8F0" android:pathData="M250,64 L280,64 Q290,64 290,74 L290,110 Q290,120 280,120 L250,120 Q240,120 240,110 L240,74 Q240,64 250,64 Z" />
<path android:fillColor="#07101D" android:pathData="M254,75 L276,75 Q280,75 280,79 L280,105 Q280,109 276,109 L254,109 Q250,109 250,105 L250,79 Q250,75 254,75 Z" />
<path android:fillColor="#67E8F9" android:pathData="M150,132 L290,132 L290,137 L150,137 Z" />
</vector>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hobbs\' Family Organizer App</string>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.DaylineTV" parent="android:style/Theme.Material.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowActionModeOverlay">true</item>
<item name="android:windowFullscreen">true</item>
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">#070B12</item>
<item name="android:colorAccent">#67E8F9</item>
<item name="android:navigationBarColor">#070B12</item>
<item name="android:statusBarColor">#070B12</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
@@ -0,0 +1,42 @@
package com.dayline.tv;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
import java.net.URI;
public final class DashboardConfigTest {
@Test
public void acceptsPrivateLanHosts() {
assertTrue(DashboardConfig.isPrivateLanHost("10.0.0.10"));
assertTrue(DashboardConfig.isPrivateLanHost("172.16.0.1"));
assertTrue(DashboardConfig.isPrivateLanHost("172.31.255.254"));
assertTrue(DashboardConfig.isPrivateLanHost("192.168.86.50"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.home.arpa"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.local"));
assertTrue(DashboardConfig.isPrivateLanHost("organizer.lan"));
assertTrue(DashboardConfig.isPrivateLanHost("fd12:3456::10"));
}
@Test
public void rejectsPublicOrDeceptiveHosts() {
assertFalse(DashboardConfig.isPrivateLanHost("8.8.8.8"));
assertFalse(DashboardConfig.isPrivateLanHost("172.32.0.1"));
assertFalse(DashboardConfig.isPrivateLanHost("192.168.86.999"));
assertFalse(DashboardConfig.isPrivateLanHost("organizer.lan.example.com"));
assertFalse(DashboardConfig.isPrivateLanHost("example.com"));
}
@Test
public void sameOriginRequiresMatchingSchemeHostAndEffectivePort() throws Exception {
URI expected = new URI("https://organizer.home.arpa/");
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://ORGANIZER.home.arpa/api/weather")));
assertTrue(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:443/assets/app.js")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("http://organizer.home.arpa/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://organizer.home.arpa:8443/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://other.home.arpa/")));
assertFalse(DashboardConfig.sameOrigin(expected, new URI("https://user@organizer.home.arpa/")));
}
}
@@ -0,0 +1,3 @@
plugins {
id "com.android.application" version "8.7.3" apply false
}
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=false
android.nonTransitiveRClass=true
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HobbsFamilyOrganizer"
include(":app")
@@ -0,0 +1,59 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
},
"types": [
"node"
]
},
"include": [
"next-env.d.ts",
"app/**/*.ts",
"app/**/*.tsx",
"proxy.ts",
".next-build/types/**/*.ts",
".next-build/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"examples",
"dist",
".vinext",
".wrangler",
".sites-runtime",
"outputs",
"work",
".openai",
"db",
"scripts",
"worker",
"cloudflare-env.d.ts",
"vite.config.ts",
"next.config.ts"
]
}
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 shadcn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,629 @@
@theme inline {
@keyframes accordion-down {
from {
height: 0;
}
to {
height: var(
--radix-accordion-content-height,
var(--accordion-panel-height, auto)
);
}
}
@keyframes accordion-up {
from {
height: var(
--radix-accordion-content-height,
var(--accordion-panel-height, auto)
);
}
to {
height: 0;
}
}
}
/* Custom variants */
@custom-variant data-open {
&:where([data-state="open"]),
&:where([data-open]:not([data-open="false"])) {
@slot;
}
}
@custom-variant data-closed {
&:where([data-state="closed"]),
&:where([data-closed]:not([data-closed="false"])) {
@slot;
}
}
@custom-variant data-checked {
&:where([data-state="checked"]),
&:where([data-checked]:not([data-checked="false"])) {
@slot;
}
}
@custom-variant data-unchecked {
&:where([data-state="unchecked"]),
&:where([data-unchecked]:not([data-unchecked="false"])) {
@slot;
}
}
@custom-variant data-selected {
&:where([data-selected="true"]) {
@slot;
}
}
@custom-variant data-disabled {
&:where([data-disabled="true"]),
&:where([data-disabled]:not([data-disabled="false"])) {
@slot;
}
}
@custom-variant data-active {
&:where([data-state="active"]),
&:where([data-active]:not([data-active="false"])) {
@slot;
}
}
@custom-variant data-horizontal {
&:where([data-orientation="horizontal"]) {
@slot;
}
}
@custom-variant data-vertical {
&:where([data-orientation="vertical"]) {
@slot;
}
}
@utility no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
/* scroll-fade */
@property --scroll-fade-t {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-b {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-s {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-e {
syntax: "<length-percentage>";
inherits: false;
initial-value: 0px;
}
@property --scroll-fade-mask {
syntax: "*";
inherits: false;
}
@theme inline {
@keyframes scroll-fade-reveal-t {
from {
--scroll-fade-t: 0px;
}
to {
--scroll-fade-t: var(--_scroll-fade-size-t, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
}
}
@keyframes scroll-fade-reveal-b {
from {
--scroll-fade-b: var(--_scroll-fade-size-b, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
}
to {
--scroll-fade-b: 0px;
}
}
@keyframes scroll-fade-reveal-s {
from {
--scroll-fade-s: 0px;
}
to {
--scroll-fade-s: var(--_scroll-fade-size-s, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
}
}
@keyframes scroll-fade-reveal-e {
from {
--scroll-fade-e: var(--_scroll-fade-size-e, var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10))));
}
to {
--scroll-fade-e: 0px;
}
}
}
@utility scroll-fade {
--_scroll-fade-size-t: var(
--scroll-fade-t-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--_scroll-fade-size-b: var(
--scroll-fade-b-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-block: linear-gradient(
to bottom,
transparent 0,
#000 var(--scroll-fade-t, 0px),
#000 calc(100% - var(--scroll-fade-b, 0px)),
transparent 100%
);
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation:
scroll-fade-reveal-t 1ms ease-in-out,
scroll-fade-reveal-b 1ms ease-in-out;
animation-timeline: scroll(self y), scroll(self y);
animation-range:
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-t: var(--_scroll-fade-size-t);
--scroll-fade-b: var(--_scroll-fade-size-b);
}
}
@utility scroll-fade-y {
--_scroll-fade-size-t: var(
--scroll-fade-t-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--_scroll-fade-size-b: var(
--scroll-fade-b-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-block: linear-gradient(
to bottom,
transparent 0,
#000 var(--scroll-fade-t, 0px),
#000 calc(100% - var(--scroll-fade-b, 0px)),
transparent 100%
);
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
mask-image: var(--scroll-fade-mask, var(--scroll-fade-block));
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation:
scroll-fade-reveal-t 1ms ease-in-out,
scroll-fade-reveal-b 1ms ease-in-out;
animation-timeline: scroll(self y), scroll(self y);
animation-range:
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-t: var(--_scroll-fade-size-t);
--scroll-fade-b: var(--_scroll-fade-size-b);
}
}
@utility scroll-fade-x {
--_scroll-fade-size-s: var(
--scroll-fade-s-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--_scroll-fade-size-e: var(
--scroll-fade-e-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-inline: linear-gradient(
to right,
transparent 0,
#000 var(--scroll-fade-s, 0px),
#000 calc(100% - var(--scroll-fade-e, 0px)),
transparent 100%
);
&:where([dir="rtl"], [dir="rtl"] *) {
--scroll-fade-inline: linear-gradient(
to left,
transparent 0,
#000 var(--scroll-fade-s, 0px),
#000 calc(100% - var(--scroll-fade-e, 0px)),
transparent 100%
);
}
-webkit-mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
mask-image: var(--scroll-fade-mask, var(--scroll-fade-inline));
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation:
scroll-fade-reveal-s 1ms ease-in-out,
scroll-fade-reveal-e 1ms ease-in-out;
animation-timeline: scroll(self inline), scroll(self inline);
animation-range:
0 var(--scroll-fade-reveal, calc(var(--spacing) * 24)),
calc(100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))) 100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-s: var(--_scroll-fade-size-s);
--scroll-fade-e: var(--_scroll-fade-size-e);
}
}
@utility scroll-fade-t {
--_scroll-fade-size-t: var(
--scroll-fade-t-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to bottom,
transparent 0,
#000 var(--scroll-fade-t, 0px),
#000 100%
);
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-t 1ms ease-in-out;
animation-timeline: scroll(self y);
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-t: var(--_scroll-fade-size-t);
}
}
@utility scroll-fade-b {
--_scroll-fade-size-b: var(
--scroll-fade-b-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to bottom,
#000 0,
#000 calc(100% - var(--scroll-fade-b, 0px)),
transparent 100%
);
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-b 1ms ease-in-out;
animation-timeline: scroll(self y);
animation-range: calc(
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
)
100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-b: var(--_scroll-fade-size-b);
}
}
@utility scroll-fade-l {
--_scroll-fade-size-s: var(
--scroll-fade-s-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to right,
transparent 0,
#000 var(--scroll-fade-s, 0px),
#000 100%
);
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-s 1ms ease-in-out;
animation-timeline: scroll(self x);
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-s: var(--_scroll-fade-size-s);
}
}
@utility scroll-fade-r {
--_scroll-fade-size-e: var(
--scroll-fade-e-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to right,
#000 0,
#000 calc(100% - var(--scroll-fade-e, 0px)),
transparent 100%
);
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-e 1ms ease-in-out;
animation-timeline: scroll(self x);
animation-range: calc(
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
)
100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-e: var(--_scroll-fade-size-e);
}
}
@utility scroll-fade-s {
--_scroll-fade-size-s: var(
--scroll-fade-s-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to right,
transparent 0,
#000 var(--scroll-fade-s, 0px),
#000 100%
);
&:where([dir="rtl"], [dir="rtl"] *) {
--scroll-fade-mask: linear-gradient(
to left,
transparent 0,
#000 var(--scroll-fade-s, 0px),
#000 100%
);
}
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-s 1ms ease-in-out;
animation-timeline: scroll(self inline);
animation-range: 0 var(--scroll-fade-reveal, calc(var(--spacing) * 24));
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-s: var(--_scroll-fade-size-s);
}
}
@utility scroll-fade-e {
--_scroll-fade-size-e: var(
--scroll-fade-e-size,
var(--scroll-fade-size, min(12%, calc(var(--spacing) * 10)))
);
--scroll-fade-mask: linear-gradient(
to right,
#000 0,
#000 calc(100% - var(--scroll-fade-e, 0px)),
transparent 100%
);
&:where([dir="rtl"], [dir="rtl"] *) {
--scroll-fade-mask: linear-gradient(
to left,
#000 0,
#000 calc(100% - var(--scroll-fade-e, 0px)),
transparent 100%
);
}
-webkit-mask-image: var(--scroll-fade-mask);
mask-image: var(--scroll-fade-mask);
-webkit-mask-composite: source-in;
mask-composite: intersect;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
@supports (animation-timeline: scroll()) {
animation: scroll-fade-reveal-e 1ms ease-in-out;
animation-timeline: scroll(self inline);
animation-range: calc(
100% - var(--scroll-fade-reveal, calc(var(--spacing) * 24))
)
100%;
animation-fill-mode: both;
}
@supports not (animation-timeline: scroll()) {
--scroll-fade-e: var(--_scroll-fade-size-e);
}
}
@utility scroll-fade-* {
--scroll-fade-size: calc(var(--spacing) * --value(integer));
--scroll-fade-size: --value([length], [percentage]);
}
@utility scroll-fade-t-* {
--scroll-fade-t-size: calc(var(--spacing) * --value(integer));
--scroll-fade-t-size: --value([length], [percentage]);
}
@utility scroll-fade-b-* {
--scroll-fade-b-size: calc(var(--spacing) * --value(integer));
--scroll-fade-b-size: --value([length], [percentage]);
}
@utility scroll-fade-s-* {
--scroll-fade-s-size: calc(var(--spacing) * --value(integer));
--scroll-fade-s-size: --value([length], [percentage]);
}
@utility scroll-fade-e-* {
--scroll-fade-e-size: calc(var(--spacing) * --value(integer));
--scroll-fade-e-size: --value([length], [percentage]);
}
@utility scroll-fade-none {
--scroll-fade-mask: none;
}
/* shimmer */
@property --shimmer-angle {
syntax: "<angle>";
inherits: true;
initial-value: 20deg;
}
@property --shimmer-image {
syntax: "*";
inherits: false;
}
@property --shimmer-text-fill {
syntax: "*";
inherits: false;
}
@theme inline {
@keyframes tw-shimmer {
from {
background-position: 100% 0;
}
to {
background-position: 0 0;
}
}
}
@utility shimmer {
--_spread: var(--shimmer-spread, calc(3ch + 40px));
--_base: currentColor;
--_highlight: var(
--shimmer-color,
oklch(from currentColor l c h / calc(alpha* 0.2))
);
background-image: var(
--shimmer-image,
linear-gradient(
calc(90deg + var(--shimmer-angle)),
var(--_base) calc(50% - var(--_spread)),
color-mix(in oklch, var(--_highlight), var(--_base) 50%)
calc(50% - var(--_spread) * 0.5),
var(--_highlight) 50%,
color-mix(in oklch, var(--_highlight), var(--_base) 50%)
calc(50% + var(--_spread) * 0.5),
var(--_base) calc(50% + var(--_spread))
)
);
background-repeat: no-repeat;
background-size: calc(200% + var(--_spread) * 2) 100%;
background-position: 0 0;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: var(--shimmer-text-fill, transparent);
animation: tw-shimmer var(--shimmer-duration, 2s) linear infinite;
@variant dark {
--_highlight: var(
--shimmer-color,
oklch(from currentColor max(0.8, calc(l + 0.4)) c h / calc(alpha + 0.4))
);
}
&:where([dir="rtl"], [dir="rtl"] *) {
animation-direction: reverse;
}
}
@utility shimmer-once {
animation-iteration-count: 1;
}
@utility shimmer-reverse {
animation-direction: reverse;
}
@utility shimmer-none {
--shimmer-image: none;
--shimmer-text-fill: currentColor;
}
@utility shimmer-color-* {
--shimmer-color: --value(--color, [color]);
--shimmer-color: color-mix(
in oklch,
--value(--color, [color]) calc(--modifier(integer) * 1%),
transparent
);
}
@utility shimmer-duration-* {
--shimmer-duration: calc(--value(integer) * 1ms);
}
@utility shimmer-spread-* {
--shimmer-spread: calc(var(--spacing) * --value(integer));
--shimmer-spread: --value([length], [percentage]);
}
@utility shimmer-angle-* {
--shimmer-angle: calc(--value(integer) * 1deg);
}
@media (prefers-reduced-motion: reduce) {
.shimmer {
animation: none;
background-image: none;
-webkit-text-fill-color: currentColor;
}
}