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.
This commit is contained in:
Fizzlepoof
2026-05-14 18:14:34 +00:00
parent e4e48394b8
commit 4efc8dfbf3
11 changed files with 977 additions and 1 deletions

View File

@@ -17,7 +17,7 @@ databases/ # shared-postgres, shared-mariadb, shared-redis — deploy FIRS
infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND infrastructure/ # newt, homepage, uptime-kuma, dockhand, netdata, tailscale — deploy SECOND
media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated media/ # plex, tautulli, audiobookshelf, seerr, calibre-web-automated
photos/ # immich server + machine learning photos/ # immich server + machine learning
home/ # kitchenowl, donetick, shlink, qui, dakboard bridges home/ # kitchenowl, donetick, shlink, qui, dakboard bridges, doris-dashboard
dev/ # gitea, rackpeek dev/ # gitea, rackpeek
ai/ # ollama, openwebui ai/ # ollama, openwebui
meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map meshtastic/ # meshmonitor, mqtt-proxy, tileserver, meshtastic-map

13
home/doris-dashboard/.gitignore vendored Normal file
View File

@@ -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

View File

@@ -0,0 +1,238 @@
# Doris Dashboard
Local static homepage for Johns 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 NOMADs 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.

View File

@@ -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

View File

@@ -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()

View File

@@ -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 age<limit: name,emoji=n,e; break
return {'emoji':emoji,'name':name,'age':f'{age:.1f} days','illumination':f'{illum*100:.0f}%'}
def fetch_weather()->dict[str,Any]:
url=(f'https://api.open-meteo.com/v1/forecast?latitude={LAT}&longitude={LON}'
'&current=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"<div class='why'>Why: {esc(why(i))}</div>"
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"<div class='feedback'><button type='button' class='feedback-btn' data-feedback='up' title='More like this topic'>👍</button><button type='button' class='feedback-btn' data-feedback='down' title='Hide this topic'>👎</button></div>"
return f"""<article class='{cls} {esc(i.get('category',''))}' data-topic='{esc(topic)}' data-source='{esc(source)}' data-domain='{esc(domain)}'>
<div class='meta'><span>{esc(label(i.get('category','')))}</span><span>{esc(i.get('source'))}</span><span>{esc(when)} UTC</span></div>
<h3><a href='{esc(i.get('url'))}' target='_blank' rel='noreferrer'>{esc(i.get('title'))}</a></h3>
<p>{esc(summary)}</p>{feedback_html}{why_html}</article>"""
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"<p class='empty'>{esc(empty)}</p>"
style='news-list' if compact else 'grid'
return collapsible_section(title,sub,f"<div class='{style}'>{body}</div>",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"<p>{esc(sub)}</p>" if sub else ''
return f"<details class='collapsible {esc(extra_class)}' data-section-key='{esc(key)}'{open_attr}><summary><div class='collapsible-heading'><h2>{esc(title)}</h2>{sub_html}</div><span class='collapse-indicator' aria-hidden='true'>▾</span></summary><div class='collapsible-body'>{inner_html}</div></details>"
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.','<p class="empty">No documents waiting.</p>','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"""<article class='paperless-card {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}{status_class}' data-paperless-id='{esc(item.get('id'))}'>
<div class='meta'><span class='urgency {urgency_class.get(item.get('triage',{}).get('urgency','none'),'ok')}' data-urgency='{esc(item.get('triage',{}).get('urgency','none'))}'>{esc(item.get('triage',{}).get('urgency','none').upper())}</span><span>{esc(item.get('original_filename') or 'Document #'+str(item.get('id','')))}</span></div>
<h4>{esc(item.get('title') or item.get('triage',{}).get('suggested_title','Untitled'))}</h4>
<p class='summary'>{esc(item.get('triage',{}).get('summary',''))}</p>
<p class='why'>Type: {esc(item.get('triage',{}).get('document_type',''))} | Correspondent: {esc(item.get('current',{}).get('correspondent',''))} | {esc(item.get('triage',{}).get('reason',''))}</p>
<div class='paperless-actions'><button type='button' class='paperless-btn' data-paperless-action='acknowledge'>{'Unack' if acknowledged_card else 'Acknowledge'}</button><button type='button' class='paperless-btn danger' data-paperless-action='dismiss'>Dismiss</button>{f"<a href='{esc(item.get('archive_url',''))}' class='link' target='_blank' rel='noreferrer'>View in Paperless</a>" if item.get('archive_url') else ''}</div>
</article>"""
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"<div class='paperless-grid'>{active_cards or '<p class=\'empty\'>No active documents waiting.</p>'}</div>"
if ack_count:
body += f"<details class='paperless-acked'><summary>Acknowledged <span class='badge'>{esc(str(ack_count))}</span></summary><div class='paperless-grid acked-grid'>{acked_cards}</div></details>"
if count+ack_count>8:
body += f"<p class='empty'>Showing {esc(str(min(count,8)))} active and {esc(str(min(ack_count,8)))} acknowledged items from the local queue snapshot.</p>"
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"<li class='empty'>{esc(empty)}</li>"
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"<li class='panel-item'><div class='panel-item-top'><strong>{esc(kicker)}</strong>{f'<span class=\'muted\'>{esc(assignee)}</span>' if assignee else ''}</div>{f'<div class=\'panel-item-title\'>{esc(headline)}</div>' if headline else ''}{f'<div class=\'panel-item-body\'>{esc(detail)}</div>' if detail else ''}</li>")
body=''.join(rows)
return f"<section class='panel'><h2>{esc(title)}</h2><ul>{body}</ul></section>"
def donetick_panel(items:list[dict[str,Any]],empty:str)->str:
if not items:
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='empty'>{esc(empty)}</div></section>"
rows=[]
for x in items[:6]:
chips=''.join(f"<span class='task-chip' style='background:{esc(c.get('color','#607d8b'))};'>{esc(c.get('name',''))}</span>" 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"<article class='task-card {state}'><div class='task-top'><div class='task-due'{due_style}>{esc(x.get('due_title') or x.get('time') or 'No due date')}</div>{f'<span class=\'task-assignee\'>{esc(x.get("assignee") or "")}</span>' if x.get('assignee') and x.get('assignee')!='Unassigned' else ''}</div><div class='task-name'>{esc(x.get('title') or 'Untitled task')}</div>{f'<div class=\'task-chips\'>{chips}</div>' if chips else ''}{f'<div class=\'task-subtitle\'>{esc(x.get("subtitle") or "")}</div>' if x.get('subtitle') else ''}</article>")
return f"<section class='panel donetick-panel'><h2>Donetick</h2><div class='task-grid'>{''.join(rows)}</div></section>"
def feedback_bootstrap()->str:
return """<script>
(()=>{
const KEY='doris-dashboard-topic-prefs-v2';
const COLLAPSE_KEY='doris-dashboard-collapsed-v1';
const safeRead=()=>{try{return JSON.parse(localStorage.getItem(KEY)||'{"liked":[],"hidden":[]}')}catch(e){return {liked:[],hidden:[]}}};
const safeCollapse=()=>{try{return JSON.parse(localStorage.getItem(COLLAPSE_KEY)||'{}')}catch(e){return {}}};
const save=(prefs)=>localStorage.setItem(KEY, JSON.stringify({liked:[...new Set(prefs.liked||[])], hidden:[...new Set(prefs.hidden||[])]}));
const prefs=safeRead();
const collapsed=safeCollapse();
const cards=[...document.querySelectorAll('[data-topic]')];
const sections=[...document.querySelectorAll('details[data-section-key]')];
const postJson=(url,payload)=>fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.ok?r.json():r.json().catch(()=>({})).then(err=>Promise.reject(err)));
const sync=()=>{
cards.forEach(card=>{
const topic=card.dataset.topic||'other';
const hidden=(prefs.hidden||[]).includes(topic);
const liked=(prefs.liked||[]).includes(topic);
card.style.display=hidden?'none':'';
card.classList.toggle('topic-liked', liked);
});
const badge=document.querySelector('[data-hidden-topics-count]');
if (badge) badge.textContent=String((prefs.hidden||[]).length);
};
sections.forEach(sec=>{
const key=sec.dataset.sectionKey;
if(Object.prototype.hasOwnProperty.call(collapsed,key)) sec.open=!collapsed[key];
sec.addEventListener('toggle',()=>{
collapsed[key]=!sec.open;
localStorage.setItem(COLLAPSE_KEY, JSON.stringify(collapsed));
});
});
document.addEventListener('click', (ev)=>{
const btn=ev.target.closest('[data-feedback]');
if(!btn) return;
const card=btn.closest('[data-topic]');
if(!card) return;
const topic=card.dataset.topic||'other';
if(btn.dataset.feedback==='down'){
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
prefs.hidden.push(topic);
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
} else {
prefs.liked=(prefs.liked||[]).filter(x=>x!==topic);
prefs.liked.push(topic);
prefs.hidden=(prefs.hidden||[]).filter(x=>x!==topic);
}
save(prefs); sync();
postJson('/api/topic-feedback',{term:topic,vote:btn.dataset.feedback==='down'?'down':'up',source:card.dataset.source||'',domain:card.dataset.domain||''}).catch(err=>console.warn('topic feedback failed',err));
});
document.addEventListener('click',(ev)=>{
const btn=ev.target.closest('[data-paperless-action]');
if(!btn) return;
const card=btn.closest('[data-paperless-id]');
if(!card) return;
const id=card.dataset.paperlessId;
const action=btn.dataset.paperlessAction;
btn.disabled=true;
postJson('/api/paperless-action',{id,action}).then(()=>{
if(action==='dismiss'){
card.remove();
} else {
card.classList.toggle('acknowledged');
}
}).catch(err=>{console.warn('paperless action failed',err); btn.disabled=false;});
});
const reset=document.querySelector('[data-reset-topic-prefs]');
if(reset){reset.addEventListener('click',()=>{prefs.hidden=[]; prefs.liked=[]; save(prefs); sync(); postJson('/api/topic-feedback/reset',{}).catch(err=>console.warn('reset feedback failed',err));});}
sync();
})();
</script>"""
def metric_card(title:str,value:str,sub:str,cls:str='')->str:
return f"<div class='metric {esc(cls)}'><span>{esc(title)}</span><strong>{esc(value)}</strong><em>{esc(sub)}</em></div>"
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"""<article class='forecast-card'>
<span class='forecast-day'>{esc(label)}</span>
<strong>{esc(str(rain))} mm rain</strong>
<em>{esc(rise)} sunrise · {esc(set_)} sunset</em>
</article>""")
if not cards:
return ""
return collapsible_section('3-day forecast','Rain, sunrise, and sunset over the next three days.',f"<div class='forecast-grid'>{''.join(cards)}</div>",'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"""<!doctype html><html lang='en'><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><meta http-equiv='refresh' content='1800'><title>Doris Dashboard</title><link rel='stylesheet' href='style.css'></head><body><main>
<header class='hero'><div><p class='eyebrow'>PC Doris Thatcher</p><h1>Doris Dashboard</h1><p class='sub'>Morning briefing, sky watch, homelab pulse, and local signal — refreshed on NOMAD.</p><div class='hero-actions'><button type='button' class='reset-btn' data-reset-topic-prefs>Reset hidden topics</button><span class='muted'>Hidden topics: <strong data-hidden-topics-count>0</strong></span></div></div><div class='status'><div><strong>{esc(now.strftime('%a %b %-d, %-I:%M %p'))}</strong><span>Last generated Central</span></div><div><strong>{len(candidates)}</strong><span>Candidates</span></div><div><strong>{len(set(i.get('source','') for i in candidates))}</strong><span>Sources</span></div></div></header>
{collapsible_section('At a glance','Weather and sky cards.',f"<section class='metrics'>{weather_metrics}{moon_metrics}</section>",'at-a-glance',open_by_default=True)}
{collapsible_section('Today','Calendar, Donetick, and notes.',f"<section class='today'>{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.')}</section>",'today-overview',open_by_default=True)}
{forecast_html}
{paperless_review_section()}
{collapsible_section('Homelab pulse','Safe local checks on NOMAD only.',f"<div class='metrics'>{pulse_metrics}</div>",'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)}
<footer><p>Category mix: {esc(', '.join(f'{k}: {v}' for k,v in counts.most_common(10)))}</p><p>Local-only on NOMAD unless John says otherwise. Weather from Open-Meteo; moon phase calculated locally.</p></footer></main>{feedback_bootstrap()}</body></html>"""
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())

View File

@@ -0,0 +1 @@
[]

View File

@@ -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"
}
]

View File

@@ -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"
}
]

View File

@@ -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

View File

@@ -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}}