Add read-only household dashboard with themes and container validation
Validate Dashboard / validate (push) Successful in 27s

This commit is contained in:
nicholas committed 2026-10-07 08:27:00 -05:00
commit 3e5ca50b93
18 files changed
+1085

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
__pycache__
tests
*.pyc
.env
*.env
.git
.gitea
.venv
node_modules
config.example.json
+29
View File
@@ -0,0 +1,29 @@
name: Validate Dashboard
on:
push:
branches: [master]
pull_request:
workflow_dispatch:
jobs:
validate:
runs-on: shared-ci
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.13'
- uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install Python dependencies
run: |
python -m venv .venv
.venv/bin/pip install -r requirements.lock
- name: Test backend and read-only routes
run: .venv/bin/python -m unittest discover -s tests
- name: Test calendar layout and themes
run: npm test
- name: Validate container configuration
run: docker compose config --quiet
- name: Build container
run: docker build -t household-dashboard:ci .
+7
View File
@@ -0,0 +1,7 @@
__pycache__/
*.py[cod]
.venv/
.env
*.env
node_modules/
coverage/
+10
View File
@@ -0,0 +1,10 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt requirements.lock .
RUN pip install --no-cache-dir -r requirements.lock
COPY app.py .
COPY static ./static
USER 65534:65534
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "--timeout", "60", "--graceful-timeout", "20", "--worker-tmp-dir", "/tmp", "app:create_app()"]
+104
View File
@@ -0,0 +1,104 @@
# Household dashboard
Read-only household display at `https://dashboard.uuard.com`, available on the
LAN/VPN through Traefik's `allow-local` middleware. Home Assistant and its
household dashboard remain available at their original URL. The wall display now
opens this dashboard on startup.
The plain HTML/JavaScript page shows the same seven-day rolling schedule (today plus six days), enlarged
12-hour clock, 12 hourly weather slots, and the Next task list.
It has no buttons, links, forms, checkboxes, or editing actions. Calendar events
include recurrence expansion, all-day and multi-day events, locations, overlapping
events, past-event dimming, and a current-time line in America/Chicago.
A small Python server reads the five calendars and four task lists directly from
Stalwart using the existing read-only `dashboard@uuard.com` account. Weather uses
Home Assistant's existing `weather.forecast_home` entity via a read-only forecast
subscription. Each calendar, task list, and weather feed refreshes independently every five
minutes, with bounded requests and backoff after errors. The display polls once a
minute, retains the last successful data, and shows which sources failed or became
stale. Crowded lists and calendar details rotate automatically every 20 seconds. Credentials never
reach the browser. Only fixed static files and GET snapshot/health routes are served.
## Themes
Skylight is the default theme, based on the official Skylight Home/Schedule
references: light panels, pale blue rail, serif date/time headings, pastel events,
and orange current-day/time markers. Read-only calendar, weather and Next tasks
remain visible. Its styling lives in `static/skylight.css`; the original Home
Assistant styling remains in `static/style.css`.
Use `https://dashboard.uuard.com/?theme=homeassistant` for the original dark theme,
or `?theme=skylight` for the default. Unknown themes fall back to Skylight. Theme
selection uses the URL so the wall display remains free of interactive controls.
Both themes show only Next; the backend retains the existing source integration.
Reference: https://skylight.zendesk.com/hc/en-us/articles/49738702477723-The-Home-Screen
## Run on ward-vm-apps-02
The existing Ansible SOPS secret-rendering flow provisions
`/etc/home-ops/household-dashboard.json` before Compose starts. Its encrypted
source is the matching `secret_files` entry in `secrets/sops/production.sops.yml`.
The file is owned by root, readable by the container's group, and mounted read-only.
Once the secret has been rendered:
```sh
docker compose up -d --build
```
The file contains the existing read-only Stalwart dashboard account's credentials
and a dedicated Home Assistant long-lived access token. The token belongs to
`Household dashboard service`, a local-only account in `system-read-only`, with
no interactive login credentials. It has read permission and no entity control
permission; it is independent of the original dashboard user's browser sessions.
The frontend receives household display data only, never passwords or tokens.
No HA configuration changes or restart are required. The app runs as UID 65534 and
exposes port 8124. Traefik applies TLS and local-network restrictions and injects
a shared proxy credential; direct requests for household data are rejected. The
proxy credential is also stored in SOPS and rendered into the edge stack environment.
Credentials must be rendered on both hosts before recreating their services.
## Deployment ownership
The dashboard app, apps-02 Compose inventory entry, edge DNS record, Traefik route,
proxy environment variable, encrypted credentials, and wall-display startup URL
must all be included in the deployed revision. Ansible intentionally stashes host
checkout changes, including untracked files, before checking out that revision;
manual host edits are temporary and will disappear on the next deployment.
This repository owns the standalone application. Production infrastructure and
SOPS credentials live in `nicholas/home-ops`; its current deployment builds the
app copy at `stacks/apps/automation/household-dashboard`. Creating this repository
does not yet change that deployment to consume this repository. Keep that app
copy synchronized until deployment ownership is migrated.
The validation Action runs tests, validates Compose, and builds the image. It has
no production credentials and does not automatically deploy. Infrastructure
changes require deploying both apps-02 and edge-01 through home-ops.
The host inventory includes this Compose project. Routine source updates require
`docker compose up -d --build` to rebuild the application image.
Gunicorn serves the app with one worker and four request threads. The container
has a read-only filesystem and resource limits; runtime dependencies are pinned
in `requirements.lock`. `/health/live` checks the process only, while authenticated
`/health` reports readiness based on required source freshness. Upstream failures
do not make process liveness fail. The cache is in memory.
The browser bounds fetch time, backs off on failure, refreshes at midnight, and
reloads automatically when the application version changes.
## Local development and checks
```sh
python3 -m venv .venv
.venv/bin/pip install -r requirements.lock
.venv/bin/python -m unittest discover -s tests
npm test
```
Use the example in `config.example.json` to understand the server configuration.
Supply actual credentials through the existing SOPS-managed file; never commit
credential files or use frontend environment variables for secrets.
+322
View File
@@ -0,0 +1,322 @@
"""Read-only household dashboard. Credentials stay behind the trusted edge proxy."""
import copy
import hashlib
import hmac
import json
import logging
import math
import os
import random
import threading
import time
from contextlib import closing
from datetime import date, datetime, timedelta
from pathlib import Path
from urllib.parse import urlparse
from zoneinfo import ZoneInfo
import caldav
import requests
import websocket
from requests.auth import HTTPBasicAuth
ZONE = ZoneInfo("America/Chicago")
STATIC = Path(__file__).parent / "static"
REFRESH_SECONDS = 300
STALE_SECONDS = 900
CALENDARS = [("Personal Commitments", "#FF6B6B"), ("Work", "#4ECDC4"),
("Focus", "#45B7D1"), ("Meal Plan", "#FFA07A"), ("Exercise", "#98D8C8")]
TASK_LISTS = ["Inbox", "Next", "Waiting", "Someday"]
SOURCES = {**{f"calendar:{n}": n for n, _ in CALENDARS},
**{f"tasks:{n}": n for n in TASK_LISTS},
"weather:hourly": "Hourly weather", "weather:daily": "Daily weather"}
REQUIRED = set(SOURCES) - {"weather:daily"}
FILES = {"/": ("index.html", "text/html"), "/app.js": ("app.js", "text/javascript"),
"/style.css": ("style.css", "text/css"), "/skylight.css": ("skylight.css", "text/css"), "/favicon.svg": ("favicon.svg", "image/svg+xml")}
VERSION = hashlib.sha256(Path(__file__).read_bytes() + b"".join((STATIC / f).read_bytes() for f, _ in FILES.values())).hexdigest()[:12]
def local_iso(value):
if isinstance(value, datetime):
return (value.replace(tzinfo=ZONE) if value.tzinfo is None else value.astimezone(ZONE)).isoformat()
return value.isoformat()
def serialize_event(component, name, color):
if str(component.get("STATUS", "")).upper() == "CANCELLED":
return None
start = component.decoded("DTSTART")
if not isinstance(start, date):
raise ValueError("Invalid event date")
all_day = not isinstance(start, datetime)
duration = timedelta(days=1) if all_day else timedelta(0)
end = component.decoded("DTEND") if "DTEND" in component else start + component.decoded("DURATION", duration)
if isinstance(end, datetime) != isinstance(start, datetime):
raise ValueError("Invalid event interval")
if all_day:
before, equal = end < start, end == start
else:
start_epoch = (start.replace(tzinfo=ZONE) if start.tzinfo is None else start).timestamp()
end_epoch = (end.replace(tzinfo=ZONE) if end.tzinfo is None else end).timestamp()
before, equal = end_epoch < start_epoch, end_epoch == start_epoch
if before:
raise ValueError("Invalid event interval")
return {"id": str(component.get("UID", "")), "title": str(component.get("SUMMARY", "Untitled"))[:500],
"start": local_iso(start), "end": local_iso(end), "allDay": all_day, "point": not all_day and equal,
"location": str(component.get("LOCATION", ""))[:500], "calendar": name, "color": color}
def serialize_task(component):
if str(component.get("STATUS", "")).upper() in ("COMPLETED", "CANCELLED") or int(component.get("PERCENT-COMPLETE", 0)) == 100:
return None
return {"id": str(component.get("UID", "")), "title": str(component.get("SUMMARY", "Untitled"))[:500],
"description": str(component.get("DESCRIPTION", ""))[:2000],
"due": local_iso(component.decoded("DUE")) if "DUE" in component else None}
class Store:
def __init__(self):
self.lock = threading.Lock()
self.records = {key: {"data": None, "updated": None, "error": None, "invalid": 0} for key in SOURCES}
def publish(self, key, data, invalid=0, now=None):
with self.lock:
self.records[key] = {"data": copy.deepcopy(data), "updated": time.time() if now is None else now,
"error": None, "invalid": invalid}
def fail(self, key, error):
with self.lock:
self.records[key]["error"] = error
def snapshot(self, now=None):
now = time.time() if now is None else now
with self.lock:
records = copy.deepcopy(self.records)
events, tasks, sources, updated = [], {n: [] for n in TASK_LISTS}, {}, {}
for key, record in records.items():
stamp = record["updated"]
age = max(0, int(now - stamp)) if stamp is not None else None
state = "loading" if stamp is None else "fresh"
if record["error"]:
state = "unavailable" if stamp is None else "error"
if age is not None and age > STALE_SECONDS:
state = "stale"
if state == "fresh" and record["invalid"]:
state = "degraded"
sources[key] = {"label": SOURCES[key], "updated": datetime.fromtimestamp(stamp, ZONE).isoformat() if stamp is not None else None,
"ageSeconds": age, "status": state, "invalidItems": record["invalid"]}
if stamp is not None:
updated[key] = sources[key]["updated"]
if key.startswith("calendar:"):
events.extend(record["data"] or [])
elif key.startswith("tasks:"):
tasks[key.split(":", 1)[1]] = record["data"] or []
hourly = records["weather:hourly"]["data"]
weather = copy.deepcopy(hourly) if hourly is not None else None
if weather is not None:
weather["daily"] = records["weather:daily"]["data"] or []
# Keep the old display compatible during rollout; current clients use per-source metadata.
calendar_updates = [v for k, v in updated.items() if k.startswith(("calendar:", "tasks:"))]
if calendar_updates:
updated["calendar"] = min(calendar_updates)
if "weather:hourly" in updated:
updated["weather"] = updated["weather:hourly"]
return {"version": VERSION, "timezone": str(ZONE), "generatedAt": datetime.fromtimestamp(now, ZONE).isoformat(),
"staleAfterSeconds": STALE_SECONDS, "events": sorted(events, key=lambda e: (e["start"], e["title"])),
"tasks": tasks, "weather": weather, "updated": updated, "sources": sources,
"errors": [k for k, s in sources.items() if s["status"] != "fresh"]}
class DavReader:
def __init__(self, config):
self.config = config
self.discovery_lock = threading.Lock()
self.urls = {}
self.discovered = 0
def client(self):
c = self.config
return caldav.DAVClient(url=c["dav_url"], auth=HTTPBasicAuth(c["dav_user"], c["dav_password"]), timeout=15)
def collection_url(self, name):
with self.discovery_lock:
if name not in self.urls or time.monotonic() - self.discovered > 1800:
self.discovered = time.monotonic()
try:
with self.client() as client:
urls = {calendar.name: str(calendar.url) for calendar in client.principal().calendars()}
self.urls = urls
except Exception:
if name not in self.urls:
raise
logging.warning("DAV discovery unavailable; retaining known collection URLs")
if name not in self.urls:
raise ValueError("Collection unavailable")
return self.urls[name]
def fetch(self, name, color=None):
url = self.collection_url(name)
today = datetime.now(ZONE).replace(hour=0, minute=0, second=0, microsecond=0)
with self.client() as client:
calendar = caldav.Calendar(client=client, url=url)
if color:
# One extra day covers midnight between five-minute polls.
results = calendar.search(start=today, end=today + timedelta(days=8), event=True, expand=True)
else:
results = calendar.search(todo=True, include_completed=True)
items, invalid = [], 0
for result in results:
try:
item = serialize_event(result.icalendar_component, name, color) if color else serialize_task(result.icalendar_component)
if item is not None:
items.append(item)
except (ValueError, TypeError, KeyError, AttributeError, OverflowError):
invalid += 1
return items, invalid
def receive(ws, deadline):
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError("Forecast deadline exceeded")
ws.settimeout(min(15, remaining))
message = json.loads(ws.recv())
if not isinstance(message, dict):
raise ValueError("Invalid weather message")
return message
def fetch_weather(config, kind="hourly"):
base, token = config["ha_url"].rstrip("/"), config["ha_access_token"]
attrs, condition = {}, None
if kind == "hourly":
with requests.get(base + "/api/states/weather.forecast_home", headers={"Authorization": "Bearer " + token}, timeout=(5, 15)) as response:
response.raise_for_status()
state = response.json()
attrs, condition = state["attributes"], state["state"]
deadline = time.monotonic() + 30
with closing(websocket.create_connection(base.replace("http://", "ws://").replace("https://", "wss://") + "/api/websocket", timeout=10)) as ws:
if receive(ws, deadline).get("type") != "auth_required":
raise ValueError("Weather handshake failed")
ws.send(json.dumps({"type": "auth", "access_token": token}))
if receive(ws, deadline).get("type") != "auth_ok":
raise ValueError("Weather authentication failed")
ws.send(json.dumps({"id": 1, "type": "weather/subscribe_forecast", "entity_id": "weather.forecast_home", "forecast_type": kind}))
while True:
message = receive(ws, deadline)
if message.get("id") != 1:
continue
if message.get("type") == "result" and not message.get("success"):
raise ValueError("Weather forecast unavailable")
if message.get("type") == "event":
forecast = message["event"]["forecast"]
break
if not isinstance(forecast, list) or not forecast:
raise ValueError("Empty forecast")
valid, invalid = [], 0
for item in forecast:
try:
if not math.isfinite(float(item["temperature"])):
raise ValueError("Invalid temperature")
stamp = datetime.fromisoformat(item["datetime"])
if stamp.tzinfo is None:
raise ValueError("Forecast timezone missing")
# Expose only display fields, never arbitrary upstream attributes.
clean = {"datetime": stamp.isoformat(), "temperature": float(item["temperature"]), "condition": str(item.get("condition", "cloudy"))}
for key in ("templow", "precipitation_probability"):
if item.get(key) is not None:
value = float(item[key])
if math.isfinite(value):
clean[key] = value
valid.append(clean)
except (ValueError, TypeError, KeyError):
invalid += 1
if not valid:
raise ValueError("No usable forecast")
valid.sort(key=lambda item: datetime.fromisoformat(item["datetime"]).timestamp())
if kind == "daily":
return valid, invalid
return {"forecast": valid, "unit": attrs.get("temperature_unit", "°F"), "condition": condition}, invalid
def refresh_source(key, fetch, store, stop):
failures = 0
while not stop.is_set():
started = time.monotonic()
try:
data, invalid = fetch()
store.publish(key, data, invalid)
failures = 0
logging.info("Source refreshed: %s (%d invalid items)", key, invalid)
except Exception as exc:
# Log types only: exception bodies and upstream responses may contain secrets.
store.fail(key, type(exc).__name__)
failures += 1
logging.warning("Source refresh failed: %s (%s)", key, type(exc).__name__)
interval = REFRESH_SECONDS if not failures else min(REFRESH_SECONDS, 30 * 2 ** min(failures - 1, 4))
delay = max(1, interval - (time.monotonic() - started))
stop.wait(delay + random.uniform(0, 5))
def validate_config(config):
for key in ("dav_url", "dav_user", "dav_password", "ha_url", "ha_access_token", "proxy_token"):
if not isinstance(config.get(key), str) or not config[key].strip():
raise ValueError("Required dashboard credential missing: " + key)
for key in ("dav_url", "ha_url"):
url = urlparse(config[key])
if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password:
raise ValueError("Invalid dashboard upstream URL: " + key)
if len(config["proxy_token"]) < 32:
raise ValueError("Proxy credential too short")
class Application:
def __init__(self, config, store):
self.proxy_token, self.store = config["proxy_token"], store
self.assets = {path: ((STATIC / filename).read_bytes(), mime) for path, (filename, mime) in FILES.items()}
html, mime = self.assets["/"]
html = html.replace(b'APP_VERSION', VERSION.encode()).replace(b'"/app.js"', f'"/app.js?v={VERSION}"'.encode()).replace(b'"/style.css"', f'"/style.css?v={VERSION}"'.encode())
html = html.replace(b'"/skylight.css"', f'"/skylight.css?v={VERSION}"'.encode())
self.assets["/"] = html, mime
def __call__(self, environ, start_response):
path, method = environ.get("PATH_INFO", "/"), environ.get("REQUEST_METHOD", "GET")
code, mime, content = 200, "application/json", b""
if method not in ("GET", "HEAD"):
code, content = 405, b'{"error":"Method not allowed"}'
elif path == "/health/live":
content = b'{"ok":true}'
elif not hmac.compare_digest(environ.get("HTTP_X_DASHBOARD_KEY", "").encode(), self.proxy_token.encode()):
code, content = 403, b'{"error":"Forbidden"}'
elif path in ("/api/dashboard", "/health"):
data = self.store.snapshot()
if path == "/health":
ready = all(data["sources"][key]["status"] == "fresh" for key in REQUIRED)
code = 200 if ready else 503
data = {"ok": ready, "sources": data["sources"]}
content = json.dumps(data, allow_nan=False).encode()
elif path in self.assets:
content, mime = self.assets[path]
else:
code, content = 404, b'{"error":"Not found"}'
names = {200: "OK", 403: "Forbidden", 404: "Not Found", 405: "Method Not Allowed", 503: "Service Unavailable"}
headers = [("Content-Type", mime + "; charset=utf-8"), ("Content-Length", str(len(content))),
("Cache-Control", "no-store"), ("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "no-referrer"),
("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'")]
if code == 405:
headers.append(("Allow", "GET, HEAD"))
start_response(f"{code} {names[code]}", headers)
return [] if method == "HEAD" else [content]
def create_app():
config = json.loads(Path(os.environ.get("CONFIG_FILE", "/run/secrets/dashboard.json")).read_text())
validate_config(config)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
store, stop, dav = Store(), threading.Event(), DavReader(config)
jobs = [(f"calendar:{name}", lambda n=name, c=color: dav.fetch(n, c)) for name, color in CALENDARS]
jobs.extend((f"tasks:{name}", lambda n=name: dav.fetch(n)) for name in TASK_LISTS)
jobs.extend((f"weather:{kind}", lambda k=kind: fetch_weather(config, k)) for kind in ("hourly", "daily"))
for key, fetch in jobs:
threading.Thread(target=refresh_source, args=(key, fetch, store, stop), daemon=True, name=key).start()
return Application(config, store)
+33
View File
@@ -0,0 +1,33 @@
name: household-dashboard
services:
dashboard:
build: .
restart: unless-stopped
init: true
mem_limit: 512m
cpus: 1.0
pids_limit: 128
stop_grace_period: 25s
environment:
TZ: America/Chicago
CONFIG_FILE: /run/secrets/dashboard.json
ports:
- "8124:8080"
volumes:
- type: bind
source: /etc/home-ops/household-dashboard.json
target: /run/secrets/dashboard.json
read_only: true
bind:
create_host_path: false
read_only: true
tmpfs:
- /tmp:size=16m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health/live', timeout=5)"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
+8
View File
@@ -0,0 +1,8 @@
{
"dav_url": "https://stalwart.uuard.com/dav/cal/dashboard@uuard.com",
"dav_user": "dashboard@uuard.com",
"dav_password": "REPLACE_WITH_EXISTING_READ_ONLY_DAV_PASSWORD",
"ha_url": "http://ward-vm-apps-02.hosts.uuard.com:8123",
"ha_access_token": "REPLACE_WITH_DEDICATED_READ_ONLY_HA_TOKEN",
"proxy_token": "REPLACE_WITH_SHARED_EDGE_TOKEN_AT_LEAST_32_CHARACTERS"
}
+1
View File
@@ -0,0 +1 @@
{"name":"household-dashboard","private":true,"type":"module","scripts":{"test":"node tests/layout.mjs"}}
+16
View File
@@ -0,0 +1,16 @@
caldav==2.1.0
certifi==2026.7.22
charset-normalizer==3.5.2
click==8.5.0
gunicorn==26.2.0
icalendar==7.3.0
idna==3.20
lxml==6.1.3
python-dateutil==2.9.0.post0
recurring-ical-events==3.8.2
requests==2.32.5
six==1.17.0
tzdata==2026.5
urllib3==2.8.0
websocket-client==1.8.0
x-wr-timezone==2.0.1
+4
View File
@@ -0,0 +1,4 @@
caldav==2.1.0
requests==2.32.5
websocket-client==1.8.0
gunicorn==26.2.0
+240
View File
@@ -0,0 +1,240 @@
const ZONE = 'America/Chicago';
const TASKS = ['Next'];
export function resolveTheme(value) {return value==='homeassistant'?'homeassistant':'skylight';}
let theme='skylight';
function eventColor(color) {
if(theme==='homeassistant')return color;
const hex=color.replace('#','');
return '#'+[0,2,4].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*.28+255*.72).toString(16).padStart(2,'0')).join('');
}
const dayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,year:'numeric',month:'2-digit',day:'2-digit'});
const hourFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'2-digit',minute:'2-digit',hourCycle:'h23'});
const clockFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'numeric',minute:'2-digit',hour12:true});
const weekdayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,weekday:'short'});
export function dayKey(value) {
const p=dayFormatter.formatToParts(new Date(value));
return ['year','month','day'].map(k=>p.find(v=>v.type===k).value).join('-');
}
export function addDays(key,count) {
const date=new Date(`${key}T12:00:00Z`);date.setUTCDate(date.getUTCDate()+count);
return date.toISOString().slice(0,10);
}
export function wallHour(value,key) {
const p=hourFormatter.formatToParts(new Date(value));
const offset=(Date.parse(`${dayKey(value)}T12:00:00Z`)-Date.parse(`${key}T12:00:00Z`))/86400000;
return offset*24+Number(p.find(v=>v.type==='hour').value)+Number(p.find(v=>v.type==='minute').value)/60;
}
export function layoutEvents(events,key,minimumDuration=.25) {
const blocks=[];
for(const event of events) {
if(event.allDay)continue;
try {
let start=wallHour(event.start,key),end=wallHour(event.end,key);
const elapsed=(Date.parse(event.end)-Date.parse(event.start))/3600000;
if(elapsed>0&&end<=start)end=start+elapsed; // The repeated hour at fall DST transition.
if(start>=24||end<0||(end===0&&start<0)||(event.point&&dayKey(event.start)!==key))continue;
start=Math.max(0,start);end=Math.min(24,end);
if(end<start||(!event.point&&end===start))continue;
blocks.push({event,start,end,displayEnd:Math.min(24,Math.max(end,start+minimumDuration))});
} catch { /* A malformed item must not blank the entire schedule. */ }
}
blocks.sort((a,b)=>a.start-b.start||b.displayEnd-a.displayEnd);
let group=[],end=-1;
function finish() {
const columns=[];
for(const block of group) {
let col=columns.findIndex(lastEnd=>lastEnd<=block.start);
if(col===-1)col=columns.length;
columns[col]=block.displayEnd;block.column=col;
}
for(const block of group)block.columns=columns.length;
}
for(const block of blocks) {
if(block.start>=end){finish();group=[];end=-1;}
group.push(block);end=Math.max(end,block.displayEnd);
}
finish();return blocks;
}
export function paginateHeights(heights,available) {
const pages=[];let page=[],used=0;
for(let i=0;i<heights.length;i++) {
if(page.length&&used+heights[i]>available){pages.push(page);page=[];used=0;}
page.push(i);used+=heights[i];
}
if(page.length)pages.push(page);
return pages;
}
export function sourceState(source,now,staleAfter=900) {
if(!source?.updated)return source?.status||'loading';
if(now-Date.parse(source.updated)>staleAfter*1000)return 'stale';
return source.status;
}
const formatTime=value=>clockFormatter.format(new Date(value)).replace(':00','');
const el=(tag,className,text)=>{const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=text;return node;};
let data,failed=false,inFlight=false,retryCount=0,pollTimer,rotation=0,lastReceived=null,serverOffset=0,lastDay;
const taskPages=new Map();
function now(){return Date.now()+serverOffset;}
function tickClock() {
const p=clockFormatter.formatToParts(new Date());
document.querySelector('#clock').textContent=p.filter(v=>['hour','minute','literal'].includes(v.type)).map(v=>v.value).join('').trim();
document.querySelector('#period').textContent=p.find(v=>v.type==='dayPeriod').value;
const dateNode=document.querySelector('#today-date');
if(dateNode)dateNode.textContent=new Intl.DateTimeFormat('en-US',{timeZone:ZONE,weekday:'short',month:'short',day:'numeric'}).format(new Date());
const day=dayKey(new Date());
if(lastDay&&lastDay!==day){renderCalendar();refresh();}
lastDay=day;
}
function eventTime(event) {return event.point?formatTime(event.start):`${formatTime(event.start)}–${formatTime(event.end)}`;}
function contrast(color) {
const hex=color.replace('#','');const rgb=[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16));
const linear=rgb.map(value=>value/255<=.04045?value/255/12.92:((value/255+.055)/1.055)**2.4);
return linear[0]*.2126+linear[1]*.7152+linear[2]*.0722>.179?'#151515':'#fff';
}
function renderCalendar() {
const container=document.querySelector('#calendar');
const loaded=data&&Object.entries(data.sources).some(([key,s])=>key.startsWith('calendar:')&&s.updated);
if(!loaded){container.replaceChildren(el('p','loading',data?'Calendar temporarily unavailable':'Loading household calendar…'));return;}
const today=dayKey(new Date()),days=Array.from({length:7},(_,i)=>addDays(today,i));
const raw=days.map(key=>layoutEvents(data.events,key,0)).flat();
const first=Math.min(8,...raw.map(e=>Math.floor(e.start)));
const last=Math.min(24,Math.max(21,...raw.map(e=>Math.floor(e.end)+1)));
const head=el('div','schedule-head');head.append(el('div'));
for(const key of days) {
const date=new Date(`${key}T12:00:00Z`),heading=el('div',`day-heading${key===today?' today':''}`);
heading.append(el('span','day-name',weekdayFormatter.format(date)));
const summary=el('div','day-summary');summary.append(el('strong','',String(date.getUTCDate())));
const daily=data.weather?.daily?.find(item=>dayKey(item.datetime)===key);
if(daily){const weather=el('span','day-weather');weather.append(weatherIcon(daily.condition),el('span','',`${Math.round(daily.temperature)}°\n${daily.templow!=null?Math.round(daily.templow)+'°':''}`));summary.append(weather);}
heading.append(summary);head.append(heading);
}
const allDay=el('div','all-day');allDay.append(el('div','all-day-label','All day'));
const dayEvents=days.map(key=>data.events.filter(e=>e.allDay&&e.start<=key&&e.end>key));
const bubbleHeight=window.innerWidth>=2400?34:24;
const cap=Math.max(1,Math.floor((container.clientHeight*.18-18)/bubbleHeight));
const maximum=Math.max(1,...dayEvents.map(events=>Math.min(cap,events.length)));
allDay.style.height=`${maximum*bubbleHeight+18}px`;
const dense=[];
dayEvents.forEach(events=>{
const cell=el('div','all-day-cell'),pages=Math.max(1,Math.ceil(events.length/cap)),offset=(rotation%pages)*cap;
for(const event of events.slice(offset,offset+cap)) {
const node=el('div','all-day-event',event.title);node.style.backgroundColor=eventColor(event.color);node.style.setProperty('--event-text',contrast(eventColor(event.color)));cell.append(node);
}
if(pages>1){cell.append(el('div','all-day-more',`${offset+1}–${Math.min(offset+cap,events.length)} of ${events.length}`));dense.push(...events);}
allDay.append(cell);
});
const body=el('div','schedule-body'),detail=el('div','calendar-detail');
container.replaceChildren(head,allDay,body,detail);
const hourHeight=Math.max(1,body.clientHeight/(last-first));body.style.setProperty('--hour-height',`${hourHeight}px`);
const hours=el('div','hours');
for(let h=first;h<last;h++){const label=el('span','hour-label',`${h%12||12} ${h<12?'AM':'PM'}`);label.style.top=`${(h-first)*hourHeight}px`;hours.append(label);}
body.append(hours);
days.forEach(key=>{
const column=el('div','day-column');
for(const block of layoutEvents(data.events,key,26/hourHeight)) {
const start=Math.max(first,block.start),end=Math.min(last,block.displayEnd);if(end<=start)continue;
const height=(end-start)*hourHeight-2,compact=height<48;
const node=el('div',`event${Date.parse(block.event.end)<now()?' past':''}${compact?' compact':''}`);
Object.assign(node.style,{backgroundColor:eventColor(block.event.color),top:`${(start-first)*hourHeight}px`,height:`${Math.max(18,height)}px`,left:`calc(4px + (100% - 8px) * ${block.column/block.columns})`,width:`calc((100% - 8px) / ${block.columns} - 2px)`});
node.style.setProperty('--event-text',contrast(eventColor(block.event.color)));
node.append(el('div','event-title',block.event.title),el('div','event-time',eventTime(block.event)));
if(block.event.location)node.append(el('div','event-location',block.event.location));
column.append(node);
if(compact||block.columns>1||block.event.title.length>30)dense.push(block.event);
}
if(key===today){const current=wallHour(new Date(),key);if(current>=first&&current<last){const line=el('div','now-line');line.style.top=`${(current-first)*hourHeight}px`;column.append(line);}}
body.append(column);
});
const unique=[...new Map(dense.map(e=>[`${e.id}:${e.start}`,e])).values()];
if(unique.length){const event=unique[rotation%unique.length];detail.textContent=`${weekdayFormatter.format(new Date(event.allDay?event.start+'T12:00:00Z':event.start))} · ${event.allDay?'All day':eventTime(event)} · ${event.title}${event.location?' · '+event.location:''}`;}
}
function weatherIcon(condition) {
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('viewBox','0 0 32 32');svg.setAttribute('class','weather-icon');
svg.setAttribute('role','img');svg.setAttribute('aria-label',condition||'Weather');
const sun='<circle cx="16" cy="16" r="6" fill="#ffce54"/><path d="M16 2v4m0 20v4M2 16h4m20 0h4M6 6l3 3m14 14 3 3M6 26l3-3M23 9l3-3" stroke="#ffce54" stroke-width="2" stroke-linecap="round"/>';
const cloud='<path d="M8 23h17a5 5 0 0 0 0-10h-1a8 8 0 0 0-15-2 6 6 0 0 0-1 12Z" fill="#c9d2dc"/>';
const moon='<path d="M25 22A12 12 0 0 1 10 6a12 12 0 1 0 15 16Z" fill="#fff59d"/>';
let shape=condition==='sunny'?sun:condition==='clear-night'?moon:cloud;
if(condition==='partlycloudy')shape='<g transform="translate(-2 -4) scale(.85)">'+sun+'</g>'+cloud;
if(['rainy','pouring','lightning-rainy','snowy-rainy'].includes(condition))shape+='<path d="m10 26-2 4m10-4-2 4m10-4-2 4" stroke="#55b8ff" stroke-width="2" stroke-linecap="round"/>';
if(['lightning','lightning-rainy'].includes(condition))shape+='<path d="m18 17-6 9h5l-2 6 8-11h-6Z" fill="#ffce54"/>';
if(['snowy','snowy-rainy','hail'].includes(condition))shape+='<path d="M10 25v6m-3-3h6m10-3v6m-3-3h6" stroke="#c4e8ff" stroke-width="2"/>';
if(condition==='fog')shape+='<path d="M3 26h26M6 30h20" stroke="#b0bec5" stroke-width="2"/>';
if(['windy','windy-variant'].includes(condition))shape='<path d="M2 12h21a4 4 0 1 0-4-4M2 18h25a4 4 0 1 1-4 4M2 24h12" fill="none" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>';
svg.innerHTML=shape;return svg;
}
function renderWeather() {
const container=document.querySelector('#weather');
if(!data?.weather){container.replaceChildren(el('p','loading',data?'Weather temporarily unavailable':'Loading weather…'));return;}
const cutoff=Math.floor(now()/3600000)*3600000;
const items=data.weather.forecast.filter(item=>Date.parse(item.datetime)>=cutoff).slice(0,12);
if(!items.length){container.replaceChildren(el('p','loading','Weather forecast is out of date'));return;}
const forecast=el('div','forecast');
for(const item of items){const slot=el('div','forecast-slot');slot.append(el('span','',formatTime(item.datetime)),weatherIcon(item.condition),el('span','temperature',`${Math.round(item.temperature)}°`),el('span','rain',item.precipitation_probability!=null?`${Math.round(item.precipitation_probability)}%`:''));forecast.append(slot);}
container.replaceChildren(forecast);
}
function showTaskPages() {
for(const entry of taskPages.values()) {
const page=rotation%entry.pages.length,indices=entry.pages[page];
const list=el('ul');for(const index of indices)list.append(entry.rows[index]);entry.window.replaceChildren(list);
entry.label.textContent=entry.pages.length===1?`${entry.rows.length} ${entry.rows.length===1?'task':'tasks'}`:`${indices[0]+1}–${indices.at(-1)+1} of ${entry.rows.length} · ${page+1}/${entry.pages.length}`;
}
}
function renderTasks() {
taskPages.clear();const grid=document.querySelector('#tasks');grid.replaceChildren();
for(const name of TASKS) {
const card=el('section','card task-list');card.append(el('h2','',name));
const window=el('div','task-window'),label=el('div','task-count');card.append(window,label);grid.append(card);
if(!data?.sources[`tasks:${name}`]?.updated){window.append(el('p','empty',data?'Temporarily unavailable':'Loading…'));continue;}
const tasks=data.tasks[name];
if(!tasks.length){window.append(el('p','empty','No tasks'));continue;}
const list=el('ul'),rows=[];
for(const task of tasks) {
const row=el('li'),text=el('span','task-text');text.append(el('span','task-title',task.title));
if(task.description)text.append(el('small','task-description',task.description));
row.append(el('span','task-dot'),text);list.append(row);rows.push(row);
}
window.append(list);
const heights=rows.map(row=>row.getBoundingClientRect().height);
taskPages.set(name,{window,label,rows,pages:paginateHeights(heights,Math.max(1,window.clientHeight-2))});
}
showTaskPages();
}
function status() {
const node=document.querySelector('#status');
if(!data){node.textContent=failed?'Unable to reach household data · retrying':'Connecting to household data…';node.classList.toggle('warning',failed);return;}
const problems=Object.entries(data.sources).filter(([key,source])=>(!key.startsWith('tasks:')||TASKS.includes(key.slice(6)))&&sourceState(source,now(),data.staleAfterSeconds)!=='fresh');
const updated=Object.values(data.sources).map(s=>s.updated).filter(Boolean).sort();
let text=updated.length?`Updated ${formatTime(updated[0])}`:'Waiting for household data';
if(failed)text=`Connection lost · last received ${lastReceived?formatTime(lastReceived):'unknown'}`;
else if(problems.length){const [key,source]=problems[rotation%problems.length];const state=sourceState(source,now(),data.staleAfterSeconds);const reason=state==='degraded'?'some items unavailable':source.updated?'showing older data':'temporarily unavailable';text=`${source.label}: ${reason}${source.updated?' · last updated '+formatTime(source.updated):''}${problems.length>1?' · '+problems.length+' sources affected':''}`;}
node.textContent=text;node.classList.toggle('warning',failed||problems.length>0);
document.querySelector('.weather').classList.toggle('source-stale',sourceState(data.sources['weather:hourly'],now(),data.staleAfterSeconds)!=='fresh');
for(const card of document.querySelectorAll('.task-list')){const name=card.querySelector('h2').textContent;card.classList.toggle('source-stale',sourceState(data.sources[`tasks:${name}`],now(),data.staleAfterSeconds)!=='fresh');}
}
function validSnapshot(value) {
return value&&typeof value.version==='string'&&Array.isArray(value.events)&&value.tasks&&TASKS.every(name=>Array.isArray(value.tasks[name]))&&value.sources&&Number.isFinite(Date.parse(value.generatedAt))&&Number.isFinite(value.staleAfterSeconds);
}
async function refresh() {
if(inFlight)return;
inFlight=true;clearTimeout(pollTimer);
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),10000);
try {
const response=await fetch('/api/dashboard',{cache:'no-store',signal:controller.signal});if(!response.ok)throw new Error('Unavailable');
const next=await response.json();if(!validSnapshot(next))throw new Error('Invalid snapshot');
const version=document.querySelector('meta[name="app-version"]')?.content;
if(version&&version!=='APP_VERSION'&&next.version!==version){location.reload();return;}
data=next;lastReceived=Date.now();serverOffset=Date.parse(next.generatedAt)-Date.now();failed=false;retryCount=0;
renderCalendar();renderWeather();renderTasks();
} catch {failed=true;retryCount++;}
finally {clearTimeout(timeout);inFlight=false;status();pollTimer=setTimeout(refresh,failed?Math.min(60000,15000*2**Math.min(retryCount-1,2)):60000);}
}
if(typeof document!=='undefined') {
theme=resolveTheme(new URLSearchParams(location.search).get('theme'));
document.documentElement.dataset.theme=theme;
tickClock();renderTasks();status();refresh();
setInterval(tickClock,1000);
setInterval(()=>{rotation++;if(data){renderCalendar();renderWeather();showTaskPages();}status();},20000);
let resizeTimer;window.addEventListener('resize',()=>{clearTimeout(resizeTimer);resizeTimer=setTimeout(()=>{if(data){renderCalendar();renderTasks();}},150);});
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="6" fill="#1c1c1c"/><path d="M6 9h20v17H6z" fill="none" stroke="#4ecdc4" stroke-width="2"/><path d="M6 14h20M11 6v6M21 6v6" stroke="#4ecdc4" stroke-width="2"/><path d="m11 20 3 3 7-6" fill="none" stroke="#fff" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 327 B

+5
View File
@@ -0,0 +1,5 @@
<!doctype html>
<html lang="en" data-theme="skylight">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Household</title><meta name="app-version" content="APP_VERSION"><link rel="icon" href="/favicon.svg"><link rel="stylesheet" href="/style.css"><link rel="stylesheet" href="/skylight.css"><script type="module" src="/app.js"></script></head>
<body><header class="theme-header"><span id="today-date"></span><span class="theme-title">Household</span></header><div class="theme-rail" aria-hidden="true"><div class="rail-home">⌂<small>Home</small></div><div>▦<small>Calendar</small></div><div>✓<small>Tasks</small></div></div><main class="dashboard"><section class="card calendar" aria-label="Household calendar"><div id="calendar"><p class="loading">Loading household calendar…</p></div></section><aside><section class="card clock-card" aria-label="Current time"><time id="clock"></time><span id="period"></span></section><section class="card weather" aria-label="Hourly weather"><h2>Hourly weather</h2><div id="weather"><p class="loading">Loading weather…</p></div></section><section class="tasks" aria-label="Tasks"><h2>Tasks</h2><div id="tasks" class="task-grid"></div></section></aside></main><div id="status" role="status" aria-live="polite"></div></body>
</html>
+47
View File
@@ -0,0 +1,47 @@
/* Skylight Home / Schedule reference. All overrides are isolated to this theme. */
html[data-theme=skylight]{color-scheme:light;color:#292929;background:#fff;font-family:"Avenir Next",Avenir,"Trebuchet MS",Arial,sans-serif;--border:#e5e7e8;--muted:#757b80;--event-font:16px;--event-small:12px}
[data-theme=skylight] body{padding:104px 24px 30px 116px}
[data-theme=skylight] .theme-header{display:flex;position:absolute;top:0;left:124px;right:24px;height:88px;align-items:center;gap:24px}
[data-theme=skylight] #today-date{font-family:Georgia,serif;font-size:44px;white-space:nowrap}
[data-theme=skylight] .theme-title{margin-left:auto;color:#757b80;font-size:18px}
[data-theme=skylight] .theme-rail{display:flex;position:absolute;top:0;bottom:0;left:0;width:92px;background:#e8f1f6;flex-direction:column;align-items:center;gap:44px;color:#747e83;font-size:30px;text-align:center;padding:20px 0}
[data-theme=skylight] .theme-rail small{display:block;font-size:15px;margin-top:4px}
[data-theme=skylight] .rail-home{color:#292929;background:white;width:92px;margin-top:-20px;padding:20px 0 18px}
[data-theme=skylight] .dashboard{grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);height:calc(100dvh - 134px);gap:24px}
[data-theme=skylight] .card{background:white;border-color:#e5e7e8;border-radius:20px}
[data-theme=skylight] aside{gap:20px}
[data-theme=skylight] .clock-card{position:absolute;top:0;left:440px;height:88px;min-height:0;border:0;border-radius:0;background:transparent;padding:0;gap:10px}
[data-theme=skylight] #clock{font-family:Georgia,serif;font-size:42px;line-height:1;font-weight:400}
[data-theme=skylight] #period{font-family:Georgia,serif;font-size:36px;opacity:1;align-self:center;margin:0}
[data-theme=skylight] h2{font-weight:600;font-size:22px}
[data-theme=skylight] .tasks>h2{display:none}
[data-theme=skylight] .task-grid{grid-template-columns:1fr;grid-template-rows:1fr}
[data-theme=skylight] .task-list h2{margin:24px}
[data-theme=skylight] .task-list li{margin:8px 0;padding:18px 16px;border:0;border-radius:12px;background:#f0f4fa;gap:14px;font-size:18px}
[data-theme=skylight] .task-list ul{padding:0 24px}
[data-theme=skylight] .task-dot{border-radius:50%;border-color:#9faabd;width:22px;height:22px;flex-basis:22px}
[data-theme=skylight] .calendar{padding:20px 16px 12px}
[data-theme=skylight] .day-heading,[data-theme=skylight] .all-day-cell,[data-theme=skylight] .day-column{background-color:white;border-left-color:#eaedef}
[data-theme=skylight] .day-heading{color:#65696e;font-size:14px}
[data-theme=skylight] .day-heading strong,[data-theme=skylight] .day-weather{color:#292929}
[data-theme=skylight] .day-heading.today strong{background:#ec8b36;color:white}
[data-theme=skylight] .day-heading strong{font-size:26px}
[data-theme=skylight] .day-column{background-image:repeating-linear-gradient(to bottom,transparent 0,transparent calc(var(--hour-height) - 1px),#eceeef calc(var(--hour-height) - 1px),#eceeef var(--hour-height))}
[data-theme=skylight] .event{border-radius:9px;padding:6px 8px}
[data-theme=skylight] .all-day-event{border-radius:6px}
[data-theme=skylight] .event-title{font-weight:600}
[data-theme=skylight] .now-line,[data-theme=skylight] .now-line:before{background:#ec8b36}
[data-theme=skylight] .calendar-detail,[data-theme=skylight] .all-day-more{color:#747b80}
[data-theme=skylight] #status{left:116px;color:#747b80}
[data-theme=skylight] #status.warning{color:#966100}
[data-theme=skylight] .source-stale{border-color:#bd933b}
@media(min-width:2400px){[data-theme=skylight] body{padding:156px 36px 38px 164px}[data-theme=skylight] .dashboard{height:calc(100dvh - 194px);gap:32px}[data-theme=skylight] .theme-rail{width:128px;font-size:44px;gap:60px}[data-theme=skylight] .rail-home{width:128px}[data-theme=skylight] .theme-rail small{font-size:22px}[data-theme=skylight] .theme-header{left:164px;height:136px}[data-theme=skylight] #today-date{font-size:64px}[data-theme=skylight] .clock-card{left:640px;height:136px}[data-theme=skylight] #clock{font-size:64px}[data-theme=skylight] #period{font-size:54px}[data-theme=skylight] h2{font-size:32px}[data-theme=skylight] .task-list li{font-size:26px;padding:24px}[data-theme=skylight] .day-heading{font-size:22px}[data-theme=skylight] .day-heading strong{font-size:36px}[data-theme=skylight]{--event-font:22px;--event-small:16px}}
@media(max-width:900px){[data-theme=skylight] body{padding:88px 12px 32px}[data-theme=skylight] .theme-rail{display:none}[data-theme=skylight] .theme-header{left:12px;height:76px}[data-theme=skylight] #today-date{font-size:24px}[data-theme=skylight] .theme-title{display:none}[data-theme=skylight] .clock-card{left:auto;right:12px;height:76px}[data-theme=skylight] #clock{font-size:28px}[data-theme=skylight] #period{font-size:22px}[data-theme=skylight] .dashboard{grid-template-columns:1fr;height:auto;gap:16px}[data-theme=skylight] .calendar{padding:12px 6px}[data-theme=skylight] .day-heading{font-size:11px}[data-theme=skylight] .day-heading strong{font-size:18px}[data-theme=skylight] #status{left:8px;background:#fffffff0}[data-theme=skylight]{--event-font:12px;--event-small:10px}}
[data-theme=skylight] .day-heading{position:relative;display:flex;align-items:flex-start;gap:6px;padding:10px 8px 34px;font-family:Georgia,serif;font-size:clamp(16px,1.35vw,38px);min-height:82px;white-space:nowrap}
[data-theme=skylight] .day-heading strong{font-family:Georgia,serif;font-size:inherit;font-weight:400;line-height:1.15}
[data-theme=skylight] .day-summary{min-height:0}
[data-theme=skylight] .day-weather{position:absolute;bottom:6px;left:8px;font-family:"Trebuchet MS",Arial,sans-serif;font-size:12px;gap:4px}
[data-theme=skylight] .day-heading.today strong{width:32px;height:32px;margin-top:-3px}
@media(min-width:2400px){[data-theme=skylight] .day-heading{min-height:114px;padding-bottom:46px}[data-theme=skylight] .day-weather{font-size:18px}[data-theme=skylight] .day-heading.today strong{width:46px;height:46px}}
@media(max-width:900px){[data-theme=skylight] .day-heading{font-size:14px;gap:2px;padding:6px 3px 34px;min-height:70px;flex-wrap:wrap}[data-theme=skylight] .day-weather{left:3px;font-size:10px}[data-theme=skylight] .day-heading.today strong{width:24px;height:24px}[data-theme=skylight] .day-summary{flex-direction:row}}
+17
View File
@@ -0,0 +1,17 @@
:root{color-scheme:dark;font-family:Roboto,Arial,sans-serif;color:#e1e1e1;background:#111;--border:#383838;--muted:#aaa;--event-font:16px;--event-small:12px}
*{box-sizing:border-box}body{margin:0;padding:8px;overflow:hidden}.dashboard{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:8px;height:calc(100dvh - 42px);min-height:0}
.card{background:#1c1c1c;border:1px solid var(--border);border-radius:12px;overflow:hidden}aside{min-width:0;min-height:0;display:flex;flex-direction:column;gap:8px}h2{font-size:20px;font-weight:400;margin:16px}
.clock-card{display:flex;align-items:center;justify-content:center;padding:16px;min-height:144px;gap:16px;flex:none}#clock{font-size:128px;line-height:.85;font-weight:500;font-variant-numeric:tabular-nums}#period{font-size:48px;opacity:.6;align-self:flex-end;margin-bottom:8px}
.weather{flex:none}.weather h2{margin-bottom:14px}.forecast{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;padding:0 12px 16px;text-align:center}.forecast-slot{display:flex;flex-direction:column;align-items:center;gap:8px;font-size:14px}.weather-icon{width:36px;height:36px;flex-shrink:0}.temperature{font-size:18px}.rain{color:var(--muted);font-size:12px;min-height:14px}
.tasks{display:flex;flex-direction:column;flex:1;min-height:0}.tasks>h2{margin:12px 0}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));grid-template-rows:minmax(0,1fr) minmax(0,1fr);gap:8px;flex:1;min-height:0}.task-list{display:flex;flex-direction:column;min-height:0}.task-list h2{margin:16px;flex:none}.task-window{flex:1;min-height:0;overflow:hidden}.task-list ul{list-style:none;padding:0 16px;margin:0}.task-list li{display:flex;gap:12px;align-items:flex-start;padding:10px 0;line-height:1.35;border-bottom:1px solid #ffffff08;font-size:16px}.task-text{min-width:0;overflow-wrap:anywhere}.task-title{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.task-dot{width:18px;height:18px;flex:0 0 18px;border:2px solid #9b9b9b;border-radius:3px;margin-top:1px}.task-description{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:12px;color:var(--muted);margin-top:4px}.task-count{flex:none;min-height:24px;padding:4px 16px 8px;color:var(--muted);font-size:12px}
.empty,.loading{color:var(--muted);font-size:14px;margin:20px 16px}.calendar{padding:16px 12px 12px;background:#30363f;min-height:0}#calendar{display:flex;flex-direction:column;height:100%;min-height:0}.schedule-head,.all-day,.schedule-body{display:grid;grid-template-columns:48px repeat(7,minmax(0,1fr))}.schedule-head{flex:none}.day-heading{text-align:center;padding:4px 0 8px;font-size:13px;color:var(--muted);background:#353c45;border-left:1px solid #46505c}.day-summary{display:flex;align-items:center;justify-content:center;gap:12px;min-height:44px}.day-heading strong{display:block;font-size:24px;font-weight:500;color:#e1e1e1}.day-heading.today strong{background:#3385ff;color:white;border-radius:50%;width:38px;height:38px;display:flex;align-items:center;justify-content:center}.day-weather{display:flex;gap:7px;align-items:center;white-space:pre-line;font-size:13px;color:#e1e1e1}.day-weather .weather-icon{width:20px;height:20px}
.all-day{flex:none;border-bottom:1px solid var(--border);min-height:30px;max-height:20%}.all-day-label{font-size:11px;color:var(--muted);padding-top:8px}.all-day-cell{background:#353c45;border-left:1px solid #46505c;padding:3px;overflow:hidden}.all-day-event{border-radius:4px;padding:4px;font-size:12px;margin-bottom:3px;color:var(--event-text,#151515);overflow-wrap:anywhere}.all-day-more{font-size:10px;color:#ccc;min-height:12px}.schedule-body{flex:1;min-height:0}.hours{position:relative}.hour-label{position:absolute;left:0;right:5px;color:var(--muted);font-size:11px;text-align:right;transform:translateY(-50%)}.day-column{position:relative;min-height:0;border-left:1px solid #46505c;background-color:#353c45;background-image:repeating-linear-gradient(to bottom,transparent 0,transparent calc(var(--hour-height) - 1px),#ffffff15 calc(var(--hour-height) - 1px),#ffffff15 var(--hour-height))}
.event{position:absolute;border-radius:5px;padding:4px 5px;color:var(--event-text,#151515);overflow:hidden;font-size:var(--event-font);line-height:1.2;overflow-wrap:anywhere}.event-title{font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.event-time,.event-location{font-size:var(--event-small);margin-top:3px}.event.compact{padding:3px 4px}.event.compact .event-title{-webkit-line-clamp:1;white-space:nowrap;text-overflow:ellipsis;display:block;overflow:hidden}.event.compact .event-time,.event.compact .event-location{display:none}.event.past{opacity:.55}.now-line{position:absolute;height:2px;background:#f44336;left:0;right:0;z-index:3}.now-line:before{content:"";position:absolute;width:8px;height:8px;border-radius:50%;background:#f44336;left:-4px;top:-3px}.calendar-detail{flex:none;min-height:22px;font-size:12px;color:#d4d8df;padding:6px 0 0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
#status{position:fixed;bottom:5px;left:16px;right:16px;color:var(--muted);font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}#status.warning{color:#ffd180}.card.source-stale{border-color:#8d7139}
@media(min-width:2400px){:root{--event-font:20px;--event-small:15px}h2{font-size:32px}.forecast-slot{font-size:22px}.temperature{font-size:28px}.task-list li{font-size:20px}.task-list h2{margin:24px}.weather-icon{width:44px;height:44px}.all-day-event{font-size:20px}.day-heading{font-size:18px}.day-heading strong{font-size:32px}.day-weather{font-size:18px}.day-weather .weather-icon{width:24px;height:24px}.task-count,#status,.calendar-detail{font-size:16px}.all-day-more{font-size:14px}}
@media(max-height:800px) and (min-width:901px){.clock-card{min-height:104px;padding:10px}#clock{font-size:96px}#period{font-size:32px}.weather h2{font-size:16px;margin:10px}.forecast-slot{gap:4px;font-size:12px}.weather-icon{width:26px;height:26px}.task-list h2{margin:10px 16px;font-size:18px}.tasks>h2{margin:8px 0}.task-list li{padding:6px 0;font-size:14px}}
@media(max-width:900px){body{overflow:auto;padding-bottom:30px}.dashboard{grid-template-columns:1fr;height:auto}.calendar{height:75dvh;min-height:480px}aside{height:90dvh;min-height:650px}.forecast{gap:2px;padding-left:4px;padding-right:4px}.forecast-slot{font-size:11px}.weather-icon{width:23px;height:23px}.temperature{font-size:14px}:root{--event-font:12px;--event-small:10px}.schedule-head,.all-day,.schedule-body{grid-template-columns:36px repeat(7,minmax(0,1fr))}.day-summary{gap:3px;flex-direction:column}.day-weather{font-size:10px}.day-weather .weather-icon{width:12px;height:12px}.day-heading strong{font-size:18px}.day-heading.today strong{height:28px;width:28px}#clock{font-size:clamp(72px,18vw,128px)}#period{font-size:32px}#status{background:#111e;padding:4px;left:4px;right:4px}}
.all-day-event{white-space:nowrap;text-overflow:ellipsis;overflow:hidden}
.theme-header,.theme-rail{display:none}
.task-grid{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
+30
View File
@@ -0,0 +1,30 @@
import assert from 'node:assert/strict';
import {dayKey, addDays, wallHour, layoutEvents} from '../static/app.js';
assert.equal(dayKey('2026-10-05T01:00:00Z'), '2026-10-04');
assert.equal(addDays('2026-10-31', 2), '2026-11-02');
assert.equal(wallHour('2026-11-02T15:00:00Z', '2026-11-02'), 9);
const event=(start,end)=>({start:`2026-10-04T${start}:00-05:00`,end:`2026-10-04T${end}:00-05:00`,allDay:false});
const blocks=layoutEvents([event('09:00','11:00'),event('10:00','12:00'),event('13:00','14:00')], '2026-10-04');
assert.deepEqual(blocks.map(b=>[b.column,b.columns]), [[0,2],[1,2],[0,1]]);
const overnight=layoutEvents([{start:'2026-10-03T23:00:00-05:00',end:'2026-10-04T02:00:00-05:00',allDay:false}], '2026-10-04');
assert.equal(overnight[0].start, 0);assert.equal(overnight[0].end, 2);
assert.equal(layoutEvents([event('09:00','10:00')], '2026-10-05').length, 0);
console.log('Overlap, midnight clipping, rolling dates and DST checks passed.');
const point=layoutEvents([{start:'2026-10-04T09:00:00-05:00',end:'2026-10-04T09:00:00-05:00',point:true,allDay:false}], '2026-10-04');
assert.equal(point.length,1);assert.equal(point[0].end,9);assert.equal(point[0].displayEnd,9.25);
assert.equal(layoutEvents([{start:'2026-10-03T09:00:00-05:00',end:'2026-10-03T09:00:00-05:00',point:true,allDay:false}], '2026-10-04').length,0);
const fold=layoutEvents([{start:'2026-11-01T01:45:00-05:00',end:'2026-11-01T01:15:00-06:00',allDay:false}], '2026-11-01');
assert.equal(fold.length,1);assert.equal(fold[0].end-fold[0].start,.5);
const {paginateHeights,sourceState}=await import('../static/app.js');
assert.deepEqual(paginateHeights([30,60,40,80],100),[[0,1],[2],[3]]);
assert.deepEqual(paginateHeights([150,20],100),[[0],[1]]);
assert.equal(sourceState({updated:'2000-01-01T00:00:00Z',status:'fresh'},Date.parse('2026-10-04T00:00:00Z')),'stale');
assert.equal(sourceState({updated:null,status:'unavailable'},Date.now()),'unavailable');
console.log('Point events, repeated DST hour, paging and stale-data checks passed.');
const {resolveTheme}=await import('../static/app.js');
assert.equal(resolveTheme('homeassistant'),'homeassistant');
assert.equal(resolveTheme('skylight'),'skylight');
assert.equal(resolveTheme(null),'skylight');
assert.equal(resolveTheme('<script>'),'skylight');
console.log('Theme selection and fallback checks passed.');
+200
View File
@@ -0,0 +1,200 @@
import importlib.util
import json
import unittest
from pathlib import Path
from unittest.mock import MagicMock, Mock, patch
from icalendar import Calendar, Event
import recurring_ical_events
spec = importlib.util.spec_from_file_location('dashboard', Path(__file__).parents[1] / 'app.py')
app = importlib.util.module_from_spec(spec)
spec.loader.exec_module(app)
TOKEN = 'test-proxy-credential-that-is-long-enough'
CONFIG = {'dav_url': 'https://dav.example/dav', 'dav_user': 'reader', 'dav_password': 'private-dav',
'ha_url': 'http://ha', 'ha_access_token': 'private-ha', 'proxy_token': TOKEN}
def request(application, path='/api/dashboard', method='GET', token=TOKEN):
result = {}
def start_response(status, headers):
result['status'] = int(status.split()[0])
result['headers'] = dict(headers)
result['body'] = b''.join(application({'PATH_INFO': path, 'REQUEST_METHOD': method, 'HTTP_X_DASHBOARD_KEY': token}, start_response))
return result
class Events(unittest.TestCase):
def event(self, text):
return Event.from_ical('BEGIN:VEVENT\r\nUID:test\r\nSUMMARY:Test\r\n' + text + '\r\nEND:VEVENT')
def test_exclusive_all_day_end(self):
e = app.serialize_event(self.event('DTSTART;VALUE=DATE:20261004\r\nDTEND;VALUE=DATE:20261006'), 'Work', '#fff')
self.assertTrue(e['allDay'])
self.assertEqual((e['start'], e['end']), ('2026-10-04', '2026-10-06'))
def test_floating_time_and_duration(self):
e = app.serialize_event(self.event('DTSTART:20261004T090000\r\nDURATION:PT30M'), 'Work', '#fff')
self.assertEqual(e['start'], '2026-10-04T09:00:00-05:00')
self.assertEqual(e['end'], '2026-10-04T09:30:00-05:00')
def test_point_event_preserves_zero_duration(self):
e = app.serialize_event(self.event('DTSTART:20261004T090000'), 'Work', '#fff')
self.assertEqual(e['start'], e['end'])
self.assertTrue(e['point'])
def test_default_all_day_duration_is_one_day(self):
e = app.serialize_event(self.event('DTSTART;VALUE=DATE:20261004'), 'Work', '#fff')
self.assertEqual(e['end'], '2026-10-05')
def test_repeated_dst_hour_is_not_a_negative_interval(self):
e = app.serialize_event(self.event('DTSTART:20261101T064500Z\r\nDTEND:20261101T071500Z'), 'Work', '#fff')
self.assertEqual(e['start'], '2026-11-01T01:45:00-05:00')
self.assertEqual(e['end'], '2026-11-01T01:15:00-06:00')
self.assertFalse(e['point'])
def test_invalid_intervals_are_rejected(self):
with self.assertRaises(ValueError):
app.serialize_event(self.event('DTSTART:20261004T090000\r\nDTEND:20261004T080000'), 'Work', '#fff')
def test_cancelled_event_hidden(self):
self.assertIsNone(app.serialize_event(self.event('STATUS:CANCELLED\r\nDTSTART:20261004T090000'), 'Work', '#fff'))
def test_recurrence_exclusion_and_moved_instance(self):
calendar = Calendar.from_ical('''BEGIN:VCALENDAR
VERSION:2.0
BEGIN:VEVENT
UID:recurring
SUMMARY:Daily
DTSTART:20261004T140000Z
DTEND:20261004T150000Z
RRULE:FREQ=DAILY;COUNT=4
EXDATE:20261005T140000Z
END:VEVENT
BEGIN:VEVENT
UID:recurring
RECURRENCE-ID:20261006T140000Z
SUMMARY:Moved
DTSTART:20261006T160000Z
DTEND:20261006T170000Z
END:VEVENT
END:VCALENDAR''')
occurrences = recurring_ical_events.of(calendar).between((2026, 10, 4), (2026, 10, 8))
events = [app.serialize_event(c, 'Work', '#fff') for c in occurrences]
self.assertEqual(len(events), 3)
self.assertEqual(next(e for e in events if e['title'] == 'Moved')['start'], '2026-10-06T11:00:00-05:00')
self.assertFalse(any(e['start'].startswith('2026-10-05') for e in events))
class Sources(unittest.TestCase):
def test_failed_collection_preserves_data_and_does_not_block_another(self):
store = app.Store()
store.publish('tasks:Inbox', [{'title': 'old'}], now=100)
store.fail('tasks:Inbox', 'TimeoutError')
store.publish('tasks:Next', [{'title': 'new'}], now=200)
data = store.snapshot(now=201)
self.assertEqual(data['tasks']['Inbox'][0]['title'], 'old')
self.assertEqual(data['tasks']['Next'][0]['title'], 'new')
self.assertEqual(data['sources']['tasks:Inbox']['status'], 'error')
self.assertEqual(data['sources']['tasks:Next']['status'], 'fresh')
def test_staleness_is_derived_without_a_worker_error(self):
store = app.Store(); store.publish('tasks:Inbox', [], now=100)
self.assertEqual(store.snapshot(now=1000)['sources']['tasks:Inbox']['status'], 'fresh')
self.assertEqual(store.snapshot(now=1001)['sources']['tasks:Inbox']['status'], 'stale')
def test_malformed_item_does_not_discard_good_collection_items(self):
reader = app.DavReader(CONFIG)
good = Mock(); good.icalendar_component = Event.from_ical('BEGIN:VEVENT\nUID:x\nDTSTART:20261004T090000\nSUMMARY:Good\nEND:VEVENT')
bad = Mock(); bad.icalendar_component = Event.from_ical('BEGIN:VEVENT\nUID:y\nSUMMARY:No date\nEND:VEVENT')
client = MagicMock(); calendar = Mock(); calendar.search.return_value = [good, bad]
with patch.object(reader, 'collection_url', return_value='https://dav.example/work'), patch.object(reader, 'client', return_value=client), patch.object(app.caldav, 'Calendar', return_value=calendar):
items, invalid = reader.fetch('Work', '#ffffff')
self.assertEqual(len(items), 1); self.assertEqual(invalid, 1)
self.assertTrue(calendar.search.call_args.kwargs['expand'])
def test_failure_retry_keeps_last_snapshot(self):
store = app.Store(); store.publish('tasks:Inbox', [{'title': 'cached'}], now=100)
stop = Mock(); stop.is_set.side_effect = [False, True]
with patch.object(app.random, 'uniform', return_value=0):
app.refresh_source('tasks:Inbox', Mock(side_effect=TimeoutError), store, stop)
self.assertEqual(store.snapshot(now=101)['tasks']['Inbox'][0]['title'], 'cached')
self.assertLessEqual(stop.wait.call_args.args[0], 30)
class Routes(unittest.TestCase):
def setUp(self):
self.store = app.Store(); self.application = app.Application(CONFIG, self.store)
def test_direct_access_is_denied_including_non_ascii_header(self):
for token in ('', 'wrong', 'é' * 32):
response = request(self.application, token=token)
self.assertEqual(response['status'], 403)
self.assertNotIn(b'events', response['body'])
def test_only_read_methods_and_fixed_routes(self):
self.assertEqual(request(self.application, method='POST')['status'], 405)
self.assertEqual(request(self.application, '/../app.py')['status'], 404)
self.assertEqual(request(self.application, '/run/secrets/dashboard.json')['status'], 404)
head = request(self.application, '/app.js', method='HEAD')
self.assertEqual(head['status'], 200); self.assertEqual(head['body'], b'')
self.assertGreater(int(head['headers']['Content-Length']), 0)
def test_snapshot_and_assets_never_contain_credentials(self):
for path in ('/api/dashboard', '/', '/app.js', '/style.css'):
body = request(self.application, path)['body']
for value in ('private-ha', 'private-dav', TOKEN):
self.assertNotIn(value.encode(), body)
def test_liveness_remains_up_and_readiness_rejects_stale_data(self):
self.assertEqual(request(self.application, '/health/live', token='')['status'], 200)
for key in app.REQUIRED:
self.store.publish(key, {'forecast': [], 'unit': '°F'} if key == 'weather:hourly' else [], now=100)
with patch.object(app.time, 'time', return_value=200):
self.assertEqual(request(self.application, '/health')['status'], 200)
with patch.object(app.time, 'time', return_value=1001):
self.assertEqual(request(self.application, '/health')['status'], 503)
self.assertEqual(request(self.application, '/health/live')['status'], 200)
def test_asset_version_in_html_matches_snapshot(self):
self.assertIn(app.VERSION.encode(), request(self.application, '/')['body'])
self.assertEqual(json.loads(request(self.application)['body'])['version'], app.VERSION)
def test_config_rejects_old_browser_session_and_missing_proxy_key(self):
app.validate_config(CONFIG)
old = dict(CONFIG); old.pop('ha_access_token'); old['ha_refresh_token'] = 'borrowed'
with self.assertRaises(ValueError): app.validate_config(old)
missing = dict(CONFIG); missing.pop('proxy_token')
with self.assertRaises(ValueError): app.validate_config(missing)
class Weather(unittest.TestCase):
def socket(self):
socket = Mock()
socket.recv.side_effect = [json.dumps({'type': 'auth_required'}), json.dumps({'type': 'auth_ok'}),
json.dumps({'id': 1, 'type': 'result', 'success': True}),
json.dumps({'id': 99, 'type': 'event', 'event': {'forecast': []}}),
json.dumps({'id': 1, 'type': 'event', 'event': {'forecast': [{'datetime': '2026-10-04T19:00:00-05:00', 'temperature': 70}]}})]
return socket
def test_hourly_is_independent_of_daily_and_uses_only_read_requests(self):
socket = self.socket(); response = MagicMock(); response.__enter__.return_value = response
response.json.return_value = {'state': 'sunny', 'attributes': {'temperature_unit': '°F'}}
with patch.object(app.requests, 'post') as post, patch.object(app.requests, 'get', return_value=response), patch.object(app.websocket, 'create_connection', return_value=socket):
result, invalid = app.fetch_weather(CONFIG, 'hourly')
post.assert_not_called(); self.assertEqual(len(result['forecast']), 1); self.assertEqual(invalid, 0)
messages = [json.loads(call.args[0]) for call in socket.send.call_args_list]
self.assertEqual([m['type'] for m in messages], ['auth', 'weather/subscribe_forecast'])
self.assertEqual(messages[-1]['forecast_type'], 'hourly')
socket.close.assert_called_once()
def test_daily_does_not_depend_on_hourly_state_endpoint(self):
with patch.object(app.requests, 'get') as get, patch.object(app.websocket, 'create_connection', return_value=self.socket()):
result, invalid = app.fetch_weather(CONFIG, 'daily')
get.assert_not_called(); self.assertEqual(len(result), 1)
def test_absolute_deadline_cannot_be_extended_by_unrelated_messages(self):
socket = Mock()
with patch.object(app.time, 'monotonic', return_value=31):
with self.assertRaises(TimeoutError): app.receive(socket, deadline=30)
socket.recv.assert_not_called()