From 4efc8dfbf3a2a09dec4c42557ec41e5555696468 Mon Sep 17 00:00:00 2001 From: Fizzlepoof Date: Thu, 14 May 2026 18:14:34 +0000 Subject: [PATCH] feat(home): import doris dashboard from workspace repo Migrates workspace-only dashboard work from commits 6f0c5cd, c851073, and d04eff3 into the canonical truenas-stacks repo. --- README.md | 2 +- home/doris-dashboard/.gitignore | 13 + home/doris-dashboard/README.md | 238 ++++++++ .../bin/cron_generate_dashboard.sh | 6 + home/doris-dashboard/bin/dashboard_server.py | 105 ++++ .../doris-dashboard/bin/generate_dashboard.py | 541 ++++++++++++++++++ home/doris-dashboard/data/calendar.json | 1 + home/doris-dashboard/data/notes.json | 12 + .../data/paperless_review.example.json | 32 ++ home/doris-dashboard/doris-dashboard.service | 17 + home/doris-dashboard/public/style.css | 11 + 11 files changed, 977 insertions(+), 1 deletion(-) create mode 100644 home/doris-dashboard/.gitignore create mode 100644 home/doris-dashboard/README.md create mode 100755 home/doris-dashboard/bin/cron_generate_dashboard.sh create mode 100755 home/doris-dashboard/bin/dashboard_server.py create mode 100755 home/doris-dashboard/bin/generate_dashboard.py create mode 100644 home/doris-dashboard/data/calendar.json create mode 100644 home/doris-dashboard/data/notes.json create mode 100644 home/doris-dashboard/data/paperless_review.example.json create mode 100644 home/doris-dashboard/doris-dashboard.service create mode 100644 home/doris-dashboard/public/style.css diff --git a/README.md b/README.md index 607c6db..e192d79 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ databases/ # shared-postgres, shared-mariadb, shared-redis — deploy FIRS infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated photos/ # immich server + machine learning -home/ # kitchenowl, donetick, shlink, qui, dakboard bridges +home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard dev/ # gitea, rackpeek ai/ # ollama, openwebui meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map diff --git a/home/doris-dashboard/.gitignore b/home/doris-dashboard/.gitignore new file mode 100644 index 0000000..b838a2e --- /dev/null +++ b/home/doris-dashboard/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +logs/ +public/index.html +public/style.css.bak.* +bin/generate_dashboard.py.bak.* +data/articles.json +data/dashboard.json +data/homelab.json +data/sky.json +data/todos.json +data/weather.json +data/paperless_review.json +data/paperless_review_state.json diff --git a/home/doris-dashboard/README.md b/home/doris-dashboard/README.md new file mode 100644 index 0000000..56b0d89 --- /dev/null +++ b/home/doris-dashboard/README.md @@ -0,0 +1,238 @@ +# Doris Dashboard + +Local static homepage for John’s morning briefing and peek-anytime news dashboard. + +Canonical path: + +```bash +/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +``` + +Generated page: + +```bash +/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html +``` + +## What it shows + +- Morning briefing with fleshed-out cards +- Today panel for calendar placeholders +- To-do panel for Donetick/to-do placeholders +- Notes panel +- Sky / Space +- Homelab / Smart Home / Ham Radio +- Local news +- Other signal +- Source/candidate stats + +## Generate manually + +```bash +cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +bin/generate_dashboard.py +``` + +## Preview locally + +```bash +cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public +python3 -m http.server 8787 +``` + +Then open: + +```text +http://localhost:8787/ +``` + +If viewing from another machine on the LAN, use NOMAD’s LAN IP instead of localhost and make sure the firewall allows the port. + +## Cron refresh + +Installed in `fizzlepoof`’s crontab: + +```cron +# BEGIN DORIS DASHBOARD +DORIS_DASHBOARD_DIR=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +# Refresh local dashboard after digest collection windows. +5,35 6-23 * * * cd $DORIS_DASHBOARD_DIR && bin/generate_dashboard.py >> logs/generate.log 2>&1 +# END DORIS DASHBOARD +``` + +Inspect with: + +```bash +crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p' +``` + +## Data inputs + +Main news source: + +```bash +/home/fizzlepoof/.openclaw/workspace/doris-digest/data/candidates.json +/home/fizzlepoof/.openclaw/workspace/doris-digest/data/preferences.json +``` + +Local dashboard placeholders: + +```bash +data/todos.json +data/calendar.json +data/notes.json +``` + +These are intentionally simple JSON arrays so calendar/Donetick integrations can be added later without rewriting the page. + +## Next integrations to add + +1. Donetick tasks +2. Calendar events +3. Weather / severe weather +4. Moon phase / ISS passes / meteor shower calendar +5. Homelab health pulse +6. Article feedback buttons or quick links back to Doris + +## Privacy/deployment note + +This dashboard is local-only on NOMAD unless John explicitly asks to expose it. Do not publish it externally without asking. + +## LAN service + +The dashboard is exposed on NOMAD's LAN IP via systemd: + +```text +http://10.5.1.16:8787/ +``` + +Service file: + +```bash +/etc/systemd/system/doris-dashboard.service +``` + +Manage it: + +```bash +sudo systemctl status doris-dashboard.service +sudo systemctl restart doris-dashboard.service +sudo systemctl stop doris-dashboard.service +``` + +The service runs as `fizzlepoof`, serves `public/`, binds `0.0.0.0:8787`, and regenerates the page before starting. + +At setup time UFW was inactive, so no firewall rule was required. + +## Buildout added 2026-05-12 + +The dashboard generator now builds a richer local homepage directly on NOMAD. + +Added sections/data: + +- Weather metrics for Clarksville via Open-Meteo (`data/weather.json`) +- Moon phase / sky note calculated locally (`data/sky.json`) +- Homelab pulse checks (`data/homelab.json`): dashboard service, cron, digest freshness, workspace disk +- Article export from Doris Digest candidates (`data/articles.json`) +- Dashboard metadata (`data/dashboard.json`) + +The generated page still lives at: + +```bash +/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/public/index.html +``` + +And the LAN service remains: + +```text +http://10.5.1.16:8787/ +``` + +Verification commands: + +```bash +cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +python3 -m py_compile bin/generate_dashboard.py +bin/generate_dashboard.py +curl -fsS http://127.0.0.1:8787/ | grep 'Doris Dashboard' +systemctl is-active doris-dashboard.service +crontab -l | sed -n '/# BEGIN DORIS DASHBOARD/,/# END DORIS DASHBOARD/p' +``` + +Notes: + +- Weather.gov and NASA API are not used; Open-Meteo worked reliably without an API key. +- Donetick live task import is still not enabled in the dashboard; `data/todos.json` remains the safe placeholder until the exact read-only endpoint is confirmed. +- Raw secrets are not written to dashboard output. + +## Compact news layout + +2026-05-13: News/article sections were changed from large cards to compact rows so the dashboard can keep plenty of articles without making John scroll forever. + +Implementation: + +- `generate_dashboard.py` renders article sections with `compact-card` rows by default. +- `public/style.css` contains the compact layout rules. +- Metrics/weather/homelab pulse remain large cards. + +## Donetick integration + +2026-05-13: The dashboard now imports open Donetick chores read-only during generation. + +Implementation notes: + +- Secrets remain encrypted in `/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc`. +- `bin/generate_dashboard.py` decrypts secrets at runtime using the local encryption key and logs in with the Doris service account. +- It reads `/api/v1/chores` and `/api/v1/users` only. +- It writes sanitized task data to `data/todos.json` for the dashboard. +- It does not expose tokens, passwords, or raw secrets in generated HTML/JSON. + +Verify: + +```bash +cd /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +bin/generate_dashboard.py +python3 - <<'PY' +import json +from pathlib import Path +for item in json.loads(Path('data/todos.json').read_text()): + print(item.get('time'), item.get('title'), item.get('assignee')) +PY +``` + +## Donetick compact board + topic feedback + +2026-05-14: The dashboard's Donetick section was reworked to match the tighter PD Dakboard pattern more closely. + +Current behavior: + +- Donetick renders compact task cards with due cue, task title, label chips, and a short subtitle. +- Tasks are sorted by urgency first (overdue/today/upcoming/no due date), then due time. +- The dashboard injects a pinned Paperless follow-up card into the Donetick area so that cleanup work stays visible. +- News cards now show `👍 / 👎` topic buttons. +- Topic feedback is persisted through the local dashboard server into `../doris-digest/data/feedback.json` with `origin: "dashboard"`. +- `Reset hidden topics` clears both browser state and dashboard-origin feedback entries. +- Paperless review cards now have `Acknowledge` / `Dismiss` actions persisted to `data/paperless_review_state.json`. +- Major dashboard sections are collapsible and remember collapsed state in the browser. + +Important note: + +- The interactive actions require the dashboard to be served by `bin/dashboard_server.py`, not plain `python -m http.server`. + +## Cron fix — 2026-05-13 + +The dashboard cron now uses `bin/cron_generate_dashboard.sh`, which checks America/Chicago daytime internally. This avoids relying on `CRON_TZ`, which NOMAD's cron did not honor consistently. + +## Paperless review queue contract + +The dashboard displays sanitized Paperless intake triage items written by n8n at `data/paperless_review.json`. Example contract: `data/paperless_review.example.json`. + +Expected shape is a JSON array. Each item may include: + +- `id`, `title`, `original_filename`, `added`, `archive_url` +- `current.correspondent`, `current.document_type`, `current.tags` +- `triage.summary`, `triage.suggested_title`, `triage.document_type`, `triage.suggested_tags`, `triage.due_date`, `triage.needs_review`, `triage.urgency`, `triage.reason`, `triage.confidence` +- `safety.forced_review`, `safety.eligible_for_safe_apply`, `safety.apply_safe_enabled` +- `queued_at` + +Do not write tokens, raw OCR text, API responses, or credentials into this file. It is display-only derived data for local review. diff --git a/home/doris-dashboard/bin/cron_generate_dashboard.sh b/home/doris-dashboard/bin/cron_generate_dashboard.sh new file mode 100755 index 0000000..6bf59c1 --- /dev/null +++ b/home/doris-dashboard/bin/cron_generate_dashboard.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." +hour="$(TZ=America/Chicago date +%H)" +if (( 10#$hour < 6 || 10#$hour > 23 )); then exit 0; fi +bin/generate_dashboard.py diff --git a/home/doris-dashboard/bin/dashboard_server.py b/home/doris-dashboard/bin/dashboard_server.py new file mode 100755 index 0000000..fd21c3a --- /dev/null +++ b/home/doris-dashboard/bin/dashboard_server.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import json +import os +from functools import partial +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PUBLIC = ROOT / 'public' +DIGEST_ROOT = Path(os.environ.get('DORIS_DIGEST_DIR', '/home/fizzlepoof/.openclaw/workspace/doris-digest')) +DIGEST_FEEDBACK = DIGEST_ROOT / 'data' / 'feedback.json' +PAPERLESS_STATE = ROOT / 'data' / 'paperless_review_state.json' +HOST = os.environ.get('HOST', '0.0.0.0') +PORT = int(os.environ.get('PORT', '8787')) + + +def load_json(path: Path, default): + try: + return json.loads(path.read_text()) if path.exists() else default + except Exception: + return default + + +def save_json(path: Path, value) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2) + '\n') + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args, directory=None, **kwargs): + super().__init__(*args, directory=str(PUBLIC), **kwargs) + + def do_POST(self): + length = int(self.headers.get('Content-Length', '0') or '0') + raw = self.rfile.read(length) if length else b'{}' + try: + payload = json.loads(raw.decode('utf-8') or '{}') + except Exception: + return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'invalid_json'}) + + if self.path == '/api/topic-feedback': + return self._topic_feedback(payload) + if self.path == '/api/topic-feedback/reset': + return self._topic_reset() + if self.path == '/api/paperless-action': + return self._paperless_action(payload) + return self._json(HTTPStatus.NOT_FOUND, {'ok': False, 'error': 'not_found'}) + + def _topic_feedback(self, payload: dict): + term = str(payload.get('term', '')).strip() + vote = str(payload.get('vote', '')).strip().lower() + if not term or vote not in {'up', 'down'}: + return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'}) + items = load_json(DIGEST_FEEDBACK, []) + items = [x for x in items if not (str(x.get('origin', '')) == 'dashboard' and str(x.get('term', '')).strip().lower() == term.lower())] + items.append({ + 'term': term, + 'vote': vote, + 'note': f"Dashboard {'hide' if vote == 'down' else 'prefer'} topic: {term}", + 'origin': 'dashboard', + 'source': str(payload.get('source', '')).strip(), + 'domain': str(payload.get('domain', '')).strip(), + }) + save_json(DIGEST_FEEDBACK, items) + return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)}) + + def _topic_reset(self): + items = load_json(DIGEST_FEEDBACK, []) + items = [x for x in items if str(x.get('origin', '')) != 'dashboard'] + save_json(DIGEST_FEEDBACK, items) + return self._json(HTTPStatus.OK, {'ok': True, 'count': len(items)}) + + def _paperless_action(self, payload: dict): + item_id = str(payload.get('id', '')).strip() + action = str(payload.get('action', '')).strip().lower() + if not item_id or action not in {'acknowledge', 'dismiss'}: + return self._json(HTTPStatus.BAD_REQUEST, {'ok': False, 'error': 'bad_payload'}) + state = load_json(PAPERLESS_STATE, {}) + current = state.get(item_id, {}) if isinstance(state, dict) else {} + if action == 'acknowledge': + new_status = 'active' if current.get('status') == 'acknowledged' else 'acknowledged' + else: + new_status = 'dismissed' + state[item_id] = {'status': new_status} + save_json(PAPERLESS_STATE, state) + return self._json(HTTPStatus.OK, {'ok': True, 'id': item_id, 'status': new_status}) + + def _json(self, status: HTTPStatus, payload: dict): + body = json.dumps(payload).encode('utf-8') + self.send_response(status) + self.send_header('Content-Type', 'application/json; charset=utf-8') + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, fmt, *args): + print('%s - - [%s] %s' % (self.address_string(), self.log_date_time_string(), fmt % args)) + + +if __name__ == '__main__': + handler = partial(Handler, directory=str(PUBLIC)) + ThreadingHTTPServer((HOST, PORT), handler).serve_forever() diff --git a/home/doris-dashboard/bin/generate_dashboard.py b/home/doris-dashboard/bin/generate_dashboard.py new file mode 100755 index 0000000..ff784de --- /dev/null +++ b/home/doris-dashboard/bin/generate_dashboard.py @@ -0,0 +1,541 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import html, json, math, os, re, shutil, subprocess, urllib.error, urllib.request +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from urllib.parse import urlparse +from zoneinfo import ZoneInfo +from typing import Any + +ROOT=Path(__file__).resolve().parents[1] +DIGEST_ROOT=Path(os.environ.get('DORIS_DIGEST_DIR','/home/fizzlepoof/.openclaw/workspace/doris-digest')) +PUBLIC=ROOT/'public'; DATA=ROOT/'data'; LOGS=ROOT/'logs' +PAPERLESS_STATE_PATH=DATA/'paperless_review_state.json' +LAT=36.53; LON=-87.36 +LOCAL_TZ=ZoneInfo('America/Chicago') + +LABELS={ + 'astronomy':('🔭','Astronomy'),'homelab':('🏠','Homelab'),'smart_home':('🏡','Smart Home'), + 'local':('📍','Local'),'radio':('📻','Ham Radio'),'security':('⚠️','Security'), + 'tech':('🧰','Tech'),'us':('📰','News'),'world':('🌍','World'),'cooking':('🍳','Cooking'),'gaming':('🎮','Gaming')} +WEATHER_CODES={0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog',51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain',71:'Light snow',73:'Snow',75:'Heavy snow',80:'Rain showers',81:'Rain showers',82:'Heavy showers',95:'Thunderstorm'} +LOW_VALUE_LABELS={'shared','duesoon','submitted','waitingsubmission','notes'} +PRIORITY_COLORS={1:'#ff6b6b',2:'#ff9f1c',3:'#ffd166',4:'#8ecae6'} + +def esc(v:Any)->str: return html.escape(str(v or ''),quote=True) +def load_json(p:Path,d:Any)->Any: + try: return json.loads(p.read_text()) if p.exists() else d + except Exception: return d + +def strip_markup(text:str)->str: + text=re.sub(r'<[^>]+>',' ',text or '') + return re.sub(r'\s+',' ',html.unescape(text)).strip() + +def parse_time(v:str)->datetime: + try: return datetime.fromisoformat((v or '').replace('Z','+00:00')).astimezone(timezone.utc) + except Exception: return datetime.now(timezone.utc) + +def parse_dt(v:str|None)->datetime|None: + if not v: return None + try: return datetime.fromisoformat(v.replace('Z','+00:00')) + except Exception: return None + +def c_to_f(c:float|int|None)->str: + if c is None: return '—' + return f"{round(c*9/5+32)}°F" + +def kmh_to_mph(k:float|int|None)->str: + if k is None: return '—' + return f"{round(k*0.621371)} mph" + +def moon_phase(dt:datetime)->dict[str,str]: + # Known new moon: 2000-01-06 18:14 UTC; synodic month 29.53058867 days. + known=datetime(2000,1,6,18,14,tzinfo=timezone.utc) + days=(dt.astimezone(timezone.utc)-known).total_seconds()/86400 + age=days%29.53058867 + illum=(1-math.cos(2*math.pi*age/29.53058867))/2 + names=[(1.85,'New Moon','🌑'),(5.54,'Waxing Crescent','🌒'),(9.23,'First Quarter','🌓'),(12.92,'Waxing Gibbous','🌔'),(16.61,'Full Moon','🌕'),(20.30,'Waning Gibbous','🌖'),(23.99,'Last Quarter','🌗'),(27.68,'Waning Crescent','🌘'),(29.54,'New Moon','🌑')] + name,emoji='New Moon','🌑' + for limit,n,e in names: + if agedict[str,Any]: + url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}' + '¤t=temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code,apparent_temperature' + '&hourly=temperature_2m,precipitation_probability' + '&daily=precipitation_sum,sunrise,sunset&timezone=America/Chicago&forecast_days=3') + try: + with urllib.request.urlopen(url,timeout=12) as r: raw=json.load(r) + cur=raw.get('current',{}); daily=raw.get('daily',{}); hourly=raw.get('hourly',{}) + probs=hourly.get('precipitation_probability') or [] + return {'ok':True,'current':cur,'daily':daily,'max_precip':max(probs[:24] or [0]),'condition':WEATHER_CODES.get(cur.get('weather_code'),'Weather code '+str(cur.get('weather_code'))),'source':'Open-Meteo'} + except Exception as e: + return {'ok':False,'error':str(e),'source':'Open-Meteo'} + +def decrypt_donetick()->dict[str,Any]|None: + secret=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/donetick_secrets.json.enc') + keyfile=Path('/home/fizzlepoof/.openclaw/workspace/.secrets/encryption.key') + if not secret.exists() or not keyfile.exists(): return None + try: + res=subprocess.run(['openssl','enc','-aes-256-cbc','-d','-pbkdf2','-in',str(secret),'-k',keyfile.read_text().strip()],capture_output=True,text=True,timeout=8,check=True) + return json.loads(res.stdout).get('donetick') + except Exception: + return None + +def fetch_donetick_todos()->list[dict[str,Any]]: + sec=decrypt_donetick() + if not sec: return [] + try: + login=urllib.request.Request(sec['instance']+sec['jwt_endpoint'],data=json.dumps({'username':sec['service_account']['username'],'password':sec['service_account']['password']}).encode(),headers={'Content-Type':'application/json','Accept':'application/json'},method='POST') + with urllib.request.urlopen(login,timeout=12) as r: token=json.loads(r.read())['access_token'] + headers={'Authorization':'Bearer '+token,'Accept':'application/json'} + req=urllib.request.Request(sec['instance']+'/api/v1/chores',headers=headers) + with urllib.request.urlopen(req,timeout=12) as r: chores=json.loads(r.read()).get('res',[]) + users={} + try: + ureq=urllib.request.Request(sec['instance']+'/api/v1/users',headers=headers) + with urllib.request.urlopen(ureq,timeout=12) as r: + users={u.get('id'):u.get('displayName') or u.get('username') for u in json.loads(r.read()).get('res',[])} + except Exception: pass + out=[] + for c in chores: + if c.get('status') not in (0,None): continue + due=c.get('nextDueDate') or '' + assignee=users.get(c.get('assignedTo')) or ('Unassigned' if not c.get('assignedTo') else f"User {c.get('assignedTo')}") + due_title,state,due_local=due_title_and_state(due) + out.append({ + 'title':c.get('name','Untitled task'), + 'time':due[:16].replace('T',' '), + 'body':strip_markup(c.get('description','')), + 'assignee':assignee, + 'priority':priority_num(c.get('priority')) or 0, + 'priority_text':priority_text(c.get('priority')) or '', + 'due':due, + 'due_title':due_title, + 'state':state, + 'subtitle':subtitle_text(c), + 'chips':visible_label_chips(c), + 'source':'Donetick', + 'id':c.get('id'), + '_sort_overdue':0 if due_local and due_local < datetime.now().astimezone() else (2 if not due_local else 1), + '_sort_missing':0 if due_local else 1, + '_sort_due':due_local.timestamp() if due_local else 99999999999, + '_sort_name':str(c.get('name','')).lower(), + }) + out.sort(key=lambda x:(x.get('_sort_overdue',9),x.get('_sort_missing',9),x.get('_sort_due',99999999999),x.get('_sort_name',''))) + for item in out: + for k in ('_sort_overdue','_sort_missing','_sort_due','_sort_name'): + item.pop(k,None) + return out[:10] + except Exception: + return [] + +def priority_text(priority:Any)->str|None: + try: + p=int(priority) + return f'P{p}' if p in (1,2,3,4) else None + except Exception: + return None + +def priority_num(priority:Any)->int|None: + try: + p=int(priority) + return p if p in (1,2,3,4) else None + except Exception: + return None + +def recurrence_text(task:dict[str,Any])->str|None: + ftype=str(task.get('frequencyType','') or '').strip().lower() + freq=task.get('frequency') + try: freq_num=int(freq) if freq is not None else None + except Exception: freq_num=None + if not ftype: return None + if ftype=='daily': return 'Daily' if freq_num in (None,1) else f'Every {freq_num} days' + if ftype=='weekly': return 'Weekly' if freq_num in (None,1) else f'Every {freq_num} weeks' + if ftype=='monthly': return 'Monthly' if freq_num in (None,1) else f'Every {freq_num} months' + if ftype=='yearly': return 'Yearly' if freq_num in (None,1) else f'Every {freq_num} years' + if ftype=='once': return 'One-time' + return ftype.capitalize() + +def due_title_and_state(due_raw:str|None)->tuple[str,str,datetime|None]: + dt=parse_dt(due_raw) + if not dt: return ('No due date','nodue',None) + local=dt.astimezone(LOCAL_TZ) + now=datetime.now(LOCAL_TZ) + days=(local.date()-now.date()).days + time_text=local.strftime('%-I:%M %p') + if days < 0: + if abs(days)==1: return (f'Overdue • Yesterday {time_text}','overdue',local) + return (f"Overdue • {local.strftime('%a, %b %-d')} {time_text}",'overdue',local) + if days == 0: return (f'Due today • {time_text}','today',local) + if days == 1: return (f'Due tomorrow • {time_text}','tomorrow',local) + if days <= 6: return (f"Due {local.strftime('%a')} • {time_text}",'upcoming',local) + return (f"Due {local.strftime('%b %-d')} • {time_text}",'upcoming',local) + +def dedupe_parts(parts:list[str])->list[str]: + seen=set(); out=[] + for p in parts: + if not p: continue + key=p.strip().lower() + if key in seen: continue + seen.add(key); out.append(p) + return out + +def subtitle_text(task:dict[str,Any])->str: + parts=[] + labels=task.get('labelsV2') or [] + label_names=[str(lbl.get('name','')).strip() for lbl in labels if lbl.get('name')] + prominent=[x for x in label_names if x.strip().lower() not in LOW_VALUE_LABELS] + low=[x for x in label_names if x.strip().lower() in LOW_VALUE_LABELS] + parts.extend(prominent[:3]) + ptxt=priority_text(task.get('priority')) + if ptxt: parts.append(ptxt) + rtxt=recurrence_text(task) + if rtxt: + label_keys={x.strip().lower() for x in label_names} + if rtxt.strip().lower() not in label_keys: parts.append(rtxt) + if not prominent and low: parts.extend(low[:1]) + return ' • '.join(dedupe_parts(parts)) + +def visible_label_chips(task:dict[str,Any])->list[dict[str,str]]: + chips=[] + for lbl in task.get('labelsV2') or []: + name=str(lbl.get('name','')).strip() + if not name or name.lower() in LOW_VALUE_LABELS: continue + color=str(lbl.get('color','')).strip() or '#607d8b' + chips.append({'name':name,'color':color}) + return chips[:4] + +def run(cmd:list[str])->str: + try: return subprocess.check_output(cmd,stderr=subprocess.DEVNULL,text=True,timeout=8).strip() + except Exception: return '' + +def homelab_pulse(candidates:list[dict[str,Any]])->list[dict[str,str]]: + usage=shutil.disk_usage('/home/fizzlepoof/.openclaw/workspace') + disk=f'{usage.used/usage.total*100:.0f}% used ({usage.free/1024**3:.1f} GiB free)' + dash=run(['systemctl','is-active','doris-dashboard.service']) or 'unknown' + cron='present' if 'DORIS DASHBOARD' in run(['bash','-lc','crontab -l 2>/dev/null']) else 'missing' + newest=max((parse_time(i.get('collected_at') or i.get('published_at') or '') for i in candidates), default=datetime.now(timezone.utc)) + age=(datetime.now(timezone.utc)-newest).total_seconds()/3600 + return [ + {'label':'Dashboard service','value':dash,'state':'ok' if dash=='active' else 'warn'}, + {'label':'Dashboard cron','value':cron,'state':'ok' if cron=='present' else 'warn'}, + {'label':'Digest freshness','value':f'{age:.1f}h since newest candidate','state':'ok' if age<8 else 'warn'}, + {'label':'Workspace disk','value':disk,'state':'ok' if usage.free/usage.total>.15 else 'warn'}, + ] + +def score_item(item:dict[str,Any],prefs:dict[str,Any])->float: + score=float(item.get('priority',1)); text=f"{item.get('title','')} {item.get('summary','')}".lower() + if item.get('category') in prefs.get('boost_categories',[]): score+=2 + if item.get('category') in prefs.get('quiet_categories',[]): score-=3 + for kw in prefs.get('boost_keywords',[]): + if kw.lower() in text: score+=1.5 + for kw in prefs.get('mute_keywords',[]): + if kw.lower() in text: score-=3 + if item.get('source')=='CISA Alerts': + terms=[t.lower() for t in prefs.get('homelab_relevance_keywords',[])] + if terms and not any(t in text for t in terms): score-=8 + return score + +def select_items(items:list[dict[str,Any]],prefs:dict[str,Any],max_items:int=14)->list[dict[str,Any]]: + now=datetime.now(timezone.utc); recent=[] + for i in items: + if (now-parse_time(i.get('published_at',''))).total_seconds()<96*3600: recent.append(i) + recent.sort(key=lambda i:(score_item(i,prefs),i.get('published_at','')),reverse=True) + out=[]; seen=set(); sources=Counter() + for i in recent: + url=i.get('url',''); src=i.get('source','') + if url in seen or sources[src]>=2: continue + out.append(i); seen.add(url); sources[src]+=1 + if len(out)>=max_items: break + return out + +def label(cat:str)->str: + e,n=LABELS.get(cat,('🧩',(cat or 'Other').title())); return f'{e} {n}' + +def slugify(text:str)->str: + return re.sub(r'[^a-z0-9]+','-',(text or '').strip().lower()).strip('-') or 'section' + +def why(i:dict[str,Any])->str: + cat=i.get('category'); src=i.get('source','') + if cat=='astronomy': return 'Space/astronomy signal from your YouTube + feed tuning.' + if cat=='homelab': return 'Practical self-hosting / infrastructure signal.' + if cat=='smart_home': return 'Smart-home/Home Assistant adjacent.' + if cat=='local': return 'Local or regional relevance.' + if cat=='radio': return 'Ham-radio interest signal.' + if cat=='security': return 'Kept only because it appears practically relevant.' + return f'Interesting signal from {src}.' + +def card(i:dict[str,Any], compact:bool=False)->str: + summary=(i.get('summary') or '').strip()[:170 if compact else 300] + when=(i.get('published_at','')[:16].replace('T',' ')) + cls='compact-card' if compact else 'card' + why_html='' if compact else f"
Why: {esc(why(i))}
" + topic=(i.get('category') or 'other').strip() or 'other' + source=(i.get('source') or '').strip() + domain=(urlparse(i.get('url') or '').netloc or '').lower().removeprefix('www.') + feedback_html='' if not compact else f"" + return f"""
+
{esc(label(i.get('category','')))}{esc(i.get('source'))}{esc(when)} UTC
+

{esc(i.get('title'))}

+

{esc(summary)}

{feedback_html}{why_html}
""" + +def section(title:str,sub:str,items:list[dict[str,Any]],empty:str,compact:bool=True)->str: + body=''.join(card(i,compact=compact) for i in items) if items else f"

{esc(empty)}

" + style='news-list' if compact else 'grid' + return collapsible_section(title,sub,f"
{body}
",slugify(title),open_by_default=True) + +def collapsible_section(title:str,sub:str,inner_html:str,key:str,open_by_default:bool=True,extra_class:str='')->str: + open_attr=' open' if open_by_default else '' + sub_html=f"

{esc(sub)}

" if sub else '' + return f"

{esc(title)}

{sub_html}
{inner_html}
" + +def inject_dashboard_todos(items:list[dict[str,Any]])->list[dict[str,Any]]: + out=list(items or []) + title='Paperless notes/tags audit' + if not any((x.get('title') or '').strip().lower()==title.lower() for x in out): + out.insert(0,{"title":title,"time":"","body":"Review and correct all Paperless notes and tags. Keep this visible until the wider cleanup is actually done.","assignee":"fizzlepoof","priority":2,"priority_text":"P2","due":"","due_title":"Pinned follow-up","state":"today","subtitle":"Dashboard • P2","chips":[],"source":"Dashboard"}) + return out + +def load_paperless_review()->list[dict[str,Any]]: + p=DATA/'paperless_review.json' + remote=os.environ.get('PAPERLESS_TRIAGE_REMOTE_QUEUE','truenas_admin@10.5.1.6:/mnt/docker-ssd/docker/appdata/n8n/paperless-triage/paperless_review.json') + ssh_key=os.environ.get('PAPERLESS_TRIAGE_SSH_KEY','/home/fizzlepoof/.ssh/id_ed25519_andys') + if remote: + tmp=DATA/'paperless_review.remote.tmp' + try: + cmd=['scp','-q','-o','ConnectTimeout=8'] + if ssh_key and Path(ssh_key).exists(): + cmd.extend(['-i',ssh_key]) + cmd.extend([remote,str(tmp)]) + res=subprocess.run(cmd,capture_output=True,text=True,timeout=12) + if res.returncode==0 and tmp.exists(): tmp.replace(p) + elif tmp.exists(): tmp.unlink() + except Exception: + try: tmp.unlink() + except Exception: pass + try: return json.loads(p.read_text()) + except Exception: return [] + +def load_paperless_state()->dict[str,Any]: + return load_json(PAPERLESS_STATE_PATH,{}) or {} + +def paperless_review_section()->str: + items=load_paperless_review() + state=load_paperless_state() + if not items: return collapsible_section('📄 Paperless Review','No documents waiting.','

No documents waiting.

','paperless-review',open_by_default=False,extra_class='panel') + urgency_class={'high':'warn','medium':'info','low':'ok','none':'ok'} + active=[]; acknowledged=[] + for item in items: + status=((state.get(str(item.get('id'))) or {}).get('status') or '').lower() + if status=='dismissed': + continue + (acknowledged if status=='acknowledged' else active).append(item) + def render_card(item:dict[str,Any], acknowledged_card:bool=False)->str: + status_class=' acknowledged' if acknowledged_card else '' + return f"""
+
{esc(item.get('triage',{}).get('urgency','none').upper())}{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}
+

{esc(item.get('title') or item.get('triage',{}).get('suggested_title','Untitled'))}

+

{esc(item.get('triage',{}).get('summary',''))}

+

Type: {esc(item.get('triage',{}).get('document_type',''))} | Correspondent: {esc(item.get('current',{}).get('correspondent',''))} | {esc(item.get('triage',{}).get('reason',''))}

+
{f"View in Paperless" if item.get('archive_url') else ''}
+
""" + active_cards=''.join(render_card(item,False) for item in active[:8]) + acked_cards=''.join(render_card(item,True) for item in acknowledged[:8]) + count=len(active) + ack_count=len(acknowledged) + body=f"
{active_cards or '

No active documents waiting.

'}
" + if ack_count: + body += f"
Acknowledged {esc(str(ack_count))}
{acked_cards}
" + if count+ack_count>8: + body += f"

Showing {esc(str(min(count,8)))} active and {esc(str(min(ack_count,8)))} acknowledged items from the local queue snapshot.

" + return collapsible_section(f'📄 Paperless Review ({count} active)',f'{ack_count} acknowledged · review queue written by n8n paperless-intake-triage workflow.',body,'paperless-review',open_by_default=count>0,extra_class='panel') + +def list_panel(title:str,items:list[dict[str,Any]],empty:str)->str: + if not items: + body=f"
  • {esc(empty)}
  • " + else: + rows=[] + for x in items[:8]: + kicker=x.get('time') or x.get('title') or x.get('label') + headline=x.get('title') if x.get('time') else '' + assignee=x.get('assignee') or '' + detail=x.get('body') or x.get('summary') or x.get('value') or '' + rows.append(f"
  • {esc(kicker)}{f'{esc(assignee)}' if assignee else ''}
    {f'
    {esc(headline)}
    ' if headline else ''}{f'
    {esc(detail)}
    ' if detail else ''}
  • ") + body=''.join(rows) + return f"

    {esc(title)}

      {body}
    " + +def donetick_panel(items:list[dict[str,Any]],empty:str)->str: + if not items: + return f"

    Donetick

    {esc(empty)}
    " + rows=[] + for x in items[:6]: + chips=''.join(f"{esc(c.get('name',''))}" for c in (x.get('chips') or [])) + subtitle=' • '.join([p for p in [x.get('subtitle') or '', x.get('assignee') or ''] if p]) + state=esc(x.get('state') or 'upcoming') + due_style='' + if state=='overdue': due_style=' style="color:#ff6b6b;"' + elif x.get('priority') in PRIORITY_COLORS: due_style=f' style="color:{esc(PRIORITY_COLORS[x.get("priority")])};"' + rows.append(f"
    {esc(x.get('due_title') or x.get('time') or 'No due date')}
    {f'{esc(x.get("assignee") or "")}' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}
    {esc(x.get('title') or 'Untitled task')}
    {f'
    {chips}
    ' if chips else ''}{f'
    {esc(x.get("subtitle") or "")}
    ' if x.get('subtitle') else ''}
    ") + return f"

    Donetick

    {''.join(rows)}
    " + +def feedback_bootstrap()->str: + return """""" + +def metric_card(title:str,value:str,sub:str,cls:str='')->str: + return f"
    {esc(title)}{esc(value)}{esc(sub)}
    " + +def forecast_panel(weather:dict[str,Any])->str: + if not weather.get('ok'): + return "" + daily=weather.get('daily',{}) or {} + days=daily.get('time') or [] + precip=daily.get('precipitation_sum') or [] + sunrise=daily.get('sunrise') or [] + sunset=daily.get('sunset') or [] + cards=[] + for idx, day in enumerate(days[:3]): + try: + label=datetime.fromisoformat(day).strftime('%a') + except Exception: + label=day + rain=precip[idx] if idx < len(precip) else '—' + rise=(sunrise[idx][-5:] if idx < len(sunrise) and sunrise[idx] else '—') + set_=(sunset[idx][-5:] if idx < len(sunset) and sunset[idx] else '—') + cards.append(f"""
    + {esc(label)} + {esc(str(rain))} mm rain + {esc(rise)} sunrise · {esc(set_)} sunset +
    """) + if not cards: + return "" + return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"
    {''.join(cards)}
    ",'forecast',open_by_default=False,extra_class='panel forecast-panel') + +def render()->str: + candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{}) + donetick_todos=fetch_donetick_todos(); todos=inject_dashboard_todos(donetick_todos or load_json(DATA/'todos.json',[])); calendar=load_json(DATA/'calendar.json',[]); notes=load_json(DATA/'notes.json',[]) + selected=select_items(candidates,prefs); top_briefing=selected[:6]; top_urls={i.get('url','') for i in top_briefing if i.get('url')}; groups={'space':[],'homelab':[],'local':[],'signals':[]} + for i in selected: + if i.get('url','') in top_urls: continue + cat=i.get('category') + if cat=='astronomy': groups['space'].append(i) + elif cat in {'homelab','smart_home','radio'}: groups['homelab'].append(i) + elif cat=='local': groups['local'].append(i) + else: groups['signals'].append(i) + weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates) + now=datetime.now(LOCAL_TZ); counts=Counter(i.get('category','other') for i in candidates) + if weather['ok']: + cur=weather['current']; daily=weather.get('daily',{}) + weather_metrics=''.join([ + metric_card('Clarksville',c_to_f(cur.get('temperature_2m')),weather.get('condition',''), 'weather'), + metric_card('Feels / humidity',f"{c_to_f(cur.get('apparent_temperature'))} / {cur.get('relative_humidity_2m','—')}%",'right now','weather'), + metric_card('Wind',kmh_to_mph(cur.get('wind_speed_10m')),'Open-Meteo','weather'), + metric_card('Rain chance',f"{weather.get('max_precip',0)}%",'next 24h','weather'), + metric_card('Sunrise / sunset',f"{(daily.get('sunrise') or ['—'])[0][-5:]} / {(daily.get('sunset') or ['—'])[0][-5:]}",'local time','weather')]) + else: + weather_metrics=metric_card('Weather','Unavailable',weather.get('error','API issue'),'warn') + moon_metrics=''.join([ + metric_card('Moon',f"{moon['emoji']} {moon['name']}",f"{moon['illumination']} lit, age {moon['age']}",'space'), + metric_card('Sky note','Space feeds live','Moon phase is computed locally','space')]) + pulse_metrics=''.join(metric_card(x['label'],x['value'],x['state'],x['state']) for x in pulse) + forecast_html=forecast_panel(weather) + return f"""Doris Dashboard
    +

    PC Doris Thatcher

    Doris Dashboard

    Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.

    Hidden topics: 0
    {esc(now.strftime('%a %b %-d, %-I:%M %p'))}Last generated Central
    {len(candidates)}Candidates
    {len(set(i.get('source','') for i in candidates))}Sources
    +{collapsible_section('At a glance','Weather and sky cards.',f"
    {weather_metrics}{moon_metrics}
    ",'at-a-glance',open_by_default=True)} +{collapsible_section('Today','Calendar, Donetick, and notes.',f"
    {list_panel('Today',calendar,'Calendar integration not wired yet.')}{donetick_panel(todos,'No open Donetick tasks found; JSON placeholder active.')}{list_panel('Notes',notes,'No notes.')}
    ",'today-overview',open_by_default=True)} +{forecast_html} +{paperless_review_section()} +{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"
    {pulse_metrics}
    ",'homelab-pulse',open_by_default=False)} +{section('Top briefing','Compact scan of what is worth your time.',top_briefing,'Nothing worth bothering you with.',compact=True)} +{section('Sky / Space','SpaceX, NASA, planetary science, and astronomy.',groups['space'][:6],'No sky signal right now.',compact=True)} +{section('Homelab / Smart Home / Radio','Infrastructure, self-hosting, smart home, and ham radio.',groups['homelab'][:4],'No homelab signal right now.',compact=True)} +{section('Local','Clarksville / Middle Tennessee items that seem useful.',groups['local'][:4],'No local signal right now.',compact=True)} +{section('Other signals','Everything else that survived filtering.',groups['signals'][:3],'No extra signals.',compact=True)} +
    {feedback_bootstrap()}""" + +def main()->int: + PUBLIC.mkdir(parents=True,exist_ok=True); LOGS.mkdir(parents=True,exist_ok=True); DATA.mkdir(parents=True,exist_ok=True) + candidates=load_json(DIGEST_ROOT/'data/candidates.json',[]); prefs=load_json(DIGEST_ROOT/'data/preferences.json',{}) + weather=fetch_weather(); moon=moon_phase(datetime.now(timezone.utc)); pulse=homelab_pulse(candidates); articles=select_items(candidates,prefs,20); todos=inject_dashboard_todos(fetch_donetick_todos()) + generated={'generated_at':datetime.now(timezone.utc).isoformat(),'generated_at_local':datetime.now(LOCAL_TZ).isoformat(),'timezone':'America/Chicago','candidate_count':len(candidates),'source_count':len(set(i.get('source','') for i in candidates))} + (DATA/'dashboard.json').write_text(json.dumps(generated,indent=2)+'\n') + (DATA/'weather.json').write_text(json.dumps(weather,indent=2)+'\n') + (DATA/'sky.json').write_text(json.dumps({'moon':moon},indent=2)+'\n') + (DATA/'homelab.json').write_text(json.dumps(pulse,indent=2)+'\n') + (DATA/'articles.json').write_text(json.dumps(articles,indent=2)+'\n') + if todos: (DATA/'todos.json').write_text(json.dumps(todos,indent=2)+'\n') + (PUBLIC/'index.html').write_text(render(),encoding='utf-8') + return 0 +if __name__=='__main__': raise SystemExit(main()) diff --git a/home/doris-dashboard/data/calendar.json b/home/doris-dashboard/data/calendar.json new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/home/doris-dashboard/data/calendar.json @@ -0,0 +1 @@ +[] diff --git a/home/doris-dashboard/data/notes.json b/home/doris-dashboard/data/notes.json new file mode 100644 index 0000000..05c30d4 --- /dev/null +++ b/home/doris-dashboard/data/notes.json @@ -0,0 +1,12 @@ +[ + { + "title": "Dashboard MVP", + "body": "To-do and calendar sections are wired as local JSON placeholders until Donetick/calendar integrations are added.", + "tag": "setup" + }, + { + "title": "Paperless notes/tags audit", + "body": "Explicit follow-up: review and correct all Paperless notes and tags, not just school-intake edge cases. Keep this visible until the broader cleanup pass is actually finished.", + "tag": "paperless" + } +] diff --git a/home/doris-dashboard/data/paperless_review.example.json b/home/doris-dashboard/data/paperless_review.example.json new file mode 100644 index 0000000..9562bc8 --- /dev/null +++ b/home/doris-dashboard/data/paperless_review.example.json @@ -0,0 +1,32 @@ +[ + { + "id": 12345, + "title": "scan_2026_05_12", + "original_filename": "utility_statement_may.pdf", + "added": "2026-05-13T02:14:00Z", + "archive_url": "https://paperless.paccoco.com/documents/12345/details", + "current": { + "correspondent": "CDE Lightband", + "document_type": "Statement", + "tags": ["inbox"] + }, + "triage": { + "summary": "Monthly utility bill from CDE Lightband for $142.17 due on 2026-06-05.", + "suggested_title": "CDE Lightband Utility Bill - May 2026", + "document_type": "bill", + "suggested_correspondent": "CDE Lightband", + "suggested_tags": ["paperless-triage", "utility", "bill"], + "due_date": "2026-06-05", + "needs_review": true, + "urgency": "medium", + "reason": "Contains a payment due date; safety gate requires human review.", + "confidence": "high" + }, + "safety": { + "forced_review": true, + "eligible_for_safe_apply": false, + "apply_safe_enabled": false + }, + "queued_at": "2026-05-13T03:22:00Z" + } +] diff --git a/home/doris-dashboard/doris-dashboard.service b/home/doris-dashboard/doris-dashboard.service new file mode 100644 index 0000000..d14bb05 --- /dev/null +++ b/home/doris-dashboard/doris-dashboard.service @@ -0,0 +1,17 @@ +[Unit] +Description=Doris Dashboard local LAN web server +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=fizzlepoof +Group=fizzlepoof +WorkingDirectory=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard +ExecStartPre=/home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/generate_dashboard.py +ExecStart=/usr/bin/python3 /home/fizzlepoof/.openclaw/workspace/sgt_angel/truenas-stacks/home/doris-dashboard/bin/dashboard_server.py +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/home/doris-dashboard/public/style.css b/home/doris-dashboard/public/style.css new file mode 100644 index 0000000..5018ee9 --- /dev/null +++ b/home/doris-dashboard/public/style.css @@ -0,0 +1,11 @@ +:root{color-scheme:dark;--bg:#0c111b;--panel:#151b27;--panel2:#202939;--text:#eef4ff;--muted:#9cabbe;--line:#2d394b;--accent:#8fd3ff;--space:#9f8cff;--home:#4fd1a5;--local:#ffd166;--warn:#ff6b6b;--ok:#5ee787}*{box-sizing:border-box}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:radial-gradient(circle at top left,#213455,#0c111b 42rem);color:var(--text)}a{color:inherit}main{max-width:1220px;margin:0 auto;padding:18px}.hero{display:grid;grid-template-columns:1fr auto;gap:18px;align-items:end;margin-bottom:18px}.eyebrow{color:var(--accent);text-transform:uppercase;letter-spacing:.16em;font-size:.78rem;margin:0 0 8px}h1{font-size:clamp(2.2rem,6vw,4.5rem);line-height:.94;margin:0}.sub{color:var(--muted);font-size:1rem;max-width:760px}.hero-actions{display:flex;gap:12px;align-items:center;margin-top:10px;flex-wrap:wrap}.reset-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:8px 12px;font:inherit;cursor:pointer}.reset-btn:hover{border-color:var(--accent)}.status,.metrics{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px}.status div,.metric,.panel,.card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-radius:20px;padding:14px;box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px)}.status strong,.metric strong{display:block;font-size:1.1rem}.status span,.metric span,.metric em,.meta,footer,.empty{color:var(--muted)}.metric{min-height:92px;border-top:4px solid var(--accent)}.metric strong{font-size:1.35rem;margin:6px 0}.metric em{font-style:normal;font-size:.84rem}.metric.weather{border-top-color:var(--local)}.metric.space{border-top-color:var(--space)}.metric.ok{border-top-color:var(--ok)}.metric.warn{border-top-color:var(--warn)}.today{display:grid;grid-template-columns:1fr 1.3fr 1fr;gap:12px;margin:0}.panel h2,section h2{margin:0 0 8px}ul{padding-left:18px;margin:0}.panel ul{list-style:none;padding-left:0;display:grid;gap:8px}.panel li{margin:0}section{margin:18px 0}.grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{min-height:245px;display:flex;flex-direction:column;gap:10px;border-top:4px solid var(--accent)}.card.astronomy{border-top-color:var(--space)}.card.homelab,.card.smart_home,.card.radio{border-top-color:var(--home)}.card.local{border-top-color:var(--local)}.card.security{border-top-color:var(--warn)}.meta{display:flex;flex-wrap:wrap;gap:6px;font-size:.74rem}.meta span{padding:3px 8px;border-radius:999px;background:var(--panel2)}h3{font-size:1rem;line-height:1.2;margin:0}h3 a{text-decoration:none}h3 a:hover{text-decoration:underline}p{line-height:1.35}.why{margin-top:auto;color:#c8d5ea;background:rgba(143,211,255,.08);padding:10px;border-radius:12px;font-size:.88rem}footer{border-top:1px solid var(--line);padding:18px 0 26px}.collapsible{margin:16px 0;border:1px solid var(--line);border-radius:20px;background:rgba(21,27,39,.9);box-shadow:0 10px 30px rgba(0,0,0,.18);backdrop-filter:blur(6px);overflow:hidden}.collapsible>summary{list-style:none;cursor:pointer;padding:14px 16px;display:flex;justify-content:space-between;gap:12px;align-items:center}.collapsible>summary::-webkit-details-marker{display:none}.collapsible-heading h2{margin:0}.collapsible-heading p{margin:4px 0 0;color:var(--muted)}.collapsible-body{padding:0 14px 14px}.collapse-indicator{color:var(--muted);transition:transform .15s ease}.collapsible[open] .collapse-indicator{transform:rotate(180deg)}@media(max-width:1050px){.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr 1fr}.hero{grid-template-columns:1fr}}@media(max-width:650px){main{padding:14px}.status,.metrics,.today,.grid,.forecast-grid,.news-list,.task-grid{grid-template-columns:1fr}.card{min-height:auto}} + +/* Compact article layout: keep the number of stories, reduce scroll debt. */ +.news-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.compact-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:10px 12px;box-shadow:0 6px 18px rgba(0,0,0,.14)}.compact-card.astronomy{border-left-color:var(--space)}.compact-card.homelab,.compact-card.smart_home,.compact-card.radio{border-left-color:var(--home)}.compact-card.local{border-left-color:var(--local)}.compact-card.security{border-left-color:var(--warn)}.compact-card .meta{margin-bottom:4px}.compact-card h3{font-size:.98rem;line-height:1.18;margin:0 0 3px}.compact-card p{margin:0;color:#c8d5ea;font-size:.88rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}.compact-card .meta span{padding:2px 7px}.compact-card:nth-child(n+7){display:none}section header{display:flex;justify-content:space-between;gap:12px;align-items:end}section header p{margin:0}.feedback{display:flex;gap:6px;justify-content:flex-end;margin-top:8px}.feedback-btn{background:transparent;border:1px solid var(--line);border-radius:999px;color:var(--text);padding:2px 8px;font:inherit;cursor:pointer}.feedback-btn:hover{border-color:var(--accent);transform:translateY(-1px)}.topic-liked{box-shadow:0 0 0 1px rgba(94,231,135,.45),0 6px 18px rgba(0,0,0,.14)}@media(max-width:650px){.compact-card p{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}section header{display:block}} +.muted{color:var(--muted);font-size:.86rem;margin:0 .25rem}.panel-item{background:rgba(32,41,57,.55);border:1px solid var(--line);border-radius:14px;padding:10px 12px}.panel-item-top{display:flex;justify-content:space-between;gap:10px;align-items:baseline}.panel-item-title{margin-top:2px;font-weight:600;color:#dbe7f7}.panel-item-body{margin-top:4px;color:#c8d5ea;font-size:.9rem;line-height:1.25;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}.panel .empty{padding:12px 0}.donetick-panel{padding:12px 14px}.task-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}.task-card{background:rgba(32,41,57,.58);border:1px solid var(--line);border-radius:16px;padding:10px 12px;min-width:0}.task-top{display:flex;justify-content:space-between;gap:8px;align-items:center;margin-bottom:4px}.task-due{font-size:.8rem;font-weight:800;letter-spacing:.02em;text-transform:none;color:#dbe7f7}.task-assignee{font-size:.72rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--muted)}.task-name{font-size:1rem;font-weight:700;line-height:1.08;color:var(--text);margin-bottom:6px}.task-subtitle{font-size:.78rem;line-height:1.16;color:var(--muted)}.task-chips{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:5px}.task-chip{display:inline-block;padding:3px 8px;border-radius:999px;color:#fff;font-size:.72rem;font-weight:700;line-height:1.05}.task-card.overdue{border-color:rgba(255,107,107,.55)}.task-card.today{border-color:rgba(255,209,102,.5)}.task-card.tomorrow{border-color:rgba(142,202,230,.45)}.task-card.nodue{opacity:.92} + +/* Forecast */ +.forecast-panel{padding:14px 16px}.forecast-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}.forecast-card{background:rgba(32,41,57,.65);border:1px solid var(--line);border-radius:16px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.forecast-day{color:var(--accent);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.forecast-card strong{font-size:1rem}.forecast-card em{font-style:normal;color:var(--muted);font-size:.88rem} + +/* Paperless review queue */ +.badge{display:inline-block;margin-left:.35rem;padding:2px 8px;border-radius:999px;background:var(--panel2);color:var(--accent);font-size:.85rem}.paperless-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.paperless-card{background:rgba(21,27,39,.9);border:1px solid var(--line);border-left:5px solid var(--accent);border-radius:16px;padding:12px 14px}.paperless-card.warn{border-left-color:var(--warn)}.paperless-card.info{border-left-color:var(--local)}.paperless-card.ok{border-left-color:var(--ok)}.paperless-card.acknowledged{opacity:.7}.paperless-card h4{margin:.4rem 0;font-size:1rem}.paperless-card .summary{margin:.35rem 0;color:#d7e3f5}.paperless-card .link{display:inline-block;margin-top:.25rem;color:var(--accent);text-decoration:none}.paperless-card .link:hover{text-decoration:underline}.paperless-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px}.paperless-btn{background:var(--panel2);border:1px solid var(--line);border-radius:999px;color:var(--text);padding:6px 10px;font:inherit;cursor:pointer}.paperless-btn.danger{border-color:rgba(255,107,107,.4);color:#ffd9d9}.paperless-btn:hover{border-color:var(--accent)}.paperless-acked{margin-top:12px;border-top:1px solid var(--line);padding-top:12px}.paperless-acked summary{cursor:pointer;color:var(--muted)}.urgency{font-weight:700}.urgency.warn{color:var(--warn)}.urgency.info{color:var(--local)}.urgency.ok{color:var(--ok)}@media(max-width:650px){.paperless-grid{grid-template-columns:1fr}}