Files
truenas-stacks/home/doris-dashboard/bin/generate_dashboard.py
Fizzlepoof d7670fde4b 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.
2026-05-14 18:14:34 +00:00

542 lines
33 KiB
Python
Executable File

#!/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())