commit 3e5ca50b936327ba048a25fe90ed7396e3d60cc5 Author: Nicholas Ward Date: Wed Oct 7 08:27:00 2026 -0500 Add read-only household dashboard with themes and container validation diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6dfd88c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +__pycache__ +tests +*.pyc +.env +*.env + +.git +.gitea +.venv +node_modules +config.example.json diff --git a/.gitea/workflows/validate.yml b/.gitea/workflows/validate.yml new file mode 100644 index 0000000..cd73d90 --- /dev/null +++ b/.gitea/workflows/validate.yml @@ -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 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bdab85c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +__pycache__/ +*.py[cod] +.venv/ +.env +*.env +node_modules/ +coverage/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3803e49 --- /dev/null +++ b/Dockerfile @@ -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()"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d53aa80 --- /dev/null +++ b/README.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..9a80e21 --- /dev/null +++ b/app.py @@ -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) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c82659c --- /dev/null +++ b/compose.yaml @@ -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 diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..79a4023 --- /dev/null +++ b/config.example.json @@ -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" +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a05484b --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{"name":"household-dashboard","private":true,"type":"module","scripts":{"test":"node tests/layout.mjs"}} diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..2adafe9 --- /dev/null +++ b/requirements.lock @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f78f64d --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +caldav==2.1.0 +requests==2.32.5 +websocket-client==1.8.0 +gunicorn==26.2.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..c4dae0a --- /dev/null +++ b/static/app.js @@ -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(enda.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;iavailable){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{ + 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)1||block.event.title.length>30)dense.push(block.event); + } + if(key===today){const current=wallHour(new Date(),key);if(current>=first&¤t[`${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=''; + const cloud=''; + const moon=''; + let shape=condition==='sunny'?sun:condition==='clear-night'?moon:cloud; + if(condition==='partlycloudy')shape=''+sun+''+cloud; + if(['rainy','pouring','lightning-rainy','snowy-rainy'].includes(condition))shape+=''; + if(['lightning','lightning-rainy'].includes(condition))shape+=''; + if(['snowy','snowy-rainy','hail'].includes(condition))shape+=''; + if(condition==='fog')shape+=''; + if(['windy','windy-variant'].includes(condition))shape=''; + 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();}); +} diff --git a/static/favicon.svg b/static/favicon.svg new file mode 100644 index 0000000..3c3c71b --- /dev/null +++ b/static/favicon.svg @@ -0,0 +1 @@ + diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..7f48d13 --- /dev/null +++ b/static/index.html @@ -0,0 +1,5 @@ + + +Household +
Household

Loading household calendar…

+ diff --git a/static/skylight.css b/static/skylight.css new file mode 100644 index 0000000..722ddf1 --- /dev/null +++ b/static/skylight.css @@ -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}} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..6b84acd --- /dev/null +++ b/static/style.css @@ -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)} diff --git a/tests/layout.mjs b/tests/layout.mjs new file mode 100644 index 0000000..b612871 --- /dev/null +++ b/tests/layout.mjs @@ -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('