Files
2026-10-07 08:27:00 -05:00

241 lines
17 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const ZONE = 'America/Chicago';
const TASKS = ['Next'];
export function resolveTheme(value) {return value==='homeassistant'?'homeassistant':'skylight';}
let theme='skylight';
function eventColor(color) {
if(theme==='homeassistant')return color;
const hex=color.replace('#','');
return '#'+[0,2,4].map(i=>Math.round(parseInt(hex.slice(i,i+2),16)*.28+255*.72).toString(16).padStart(2,'0')).join('');
}
const dayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,year:'numeric',month:'2-digit',day:'2-digit'});
const hourFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'2-digit',minute:'2-digit',hourCycle:'h23'});
const clockFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,hour:'numeric',minute:'2-digit',hour12:true});
const weekdayFormatter = new Intl.DateTimeFormat('en-US', {timeZone:ZONE,weekday:'short'});
export function dayKey(value) {
const p=dayFormatter.formatToParts(new Date(value));
return ['year','month','day'].map(k=>p.find(v=>v.type===k).value).join('-');
}
export function addDays(key,count) {
const date=new Date(`${key}T12:00:00Z`);date.setUTCDate(date.getUTCDate()+count);
return date.toISOString().slice(0,10);
}
export function wallHour(value,key) {
const p=hourFormatter.formatToParts(new Date(value));
const offset=(Date.parse(`${dayKey(value)}T12:00:00Z`)-Date.parse(`${key}T12:00:00Z`))/86400000;
return offset*24+Number(p.find(v=>v.type==='hour').value)+Number(p.find(v=>v.type==='minute').value)/60;
}
export function layoutEvents(events,key,minimumDuration=.25) {
const blocks=[];
for(const event of events) {
if(event.allDay)continue;
try {
let start=wallHour(event.start,key),end=wallHour(event.end,key);
const elapsed=(Date.parse(event.end)-Date.parse(event.start))/3600000;
if(elapsed>0&&end<=start)end=start+elapsed; // The repeated hour at fall DST transition.
if(start>=24||end<0||(end===0&&start<0)||(event.point&&dayKey(event.start)!==key))continue;
start=Math.max(0,start);end=Math.min(24,end);
if(end<start||(!event.point&&end===start))continue;
blocks.push({event,start,end,displayEnd:Math.min(24,Math.max(end,start+minimumDuration))});
} catch { /* A malformed item must not blank the entire schedule. */ }
}
blocks.sort((a,b)=>a.start-b.start||b.displayEnd-a.displayEnd);
let group=[],end=-1;
function finish() {
const columns=[];
for(const block of group) {
let col=columns.findIndex(lastEnd=>lastEnd<=block.start);
if(col===-1)col=columns.length;
columns[col]=block.displayEnd;block.column=col;
}
for(const block of group)block.columns=columns.length;
}
for(const block of blocks) {
if(block.start>=end){finish();group=[];end=-1;}
group.push(block);end=Math.max(end,block.displayEnd);
}
finish();return blocks;
}
export function paginateHeights(heights,available) {
const pages=[];let page=[],used=0;
for(let i=0;i<heights.length;i++) {
if(page.length&&used+heights[i]>available){pages.push(page);page=[];used=0;}
page.push(i);used+=heights[i];
}
if(page.length)pages.push(page);
return pages;
}
export function sourceState(source,now,staleAfter=900) {
if(!source?.updated)return source?.status||'loading';
if(now-Date.parse(source.updated)>staleAfter*1000)return 'stale';
return source.status;
}
const formatTime=value=>clockFormatter.format(new Date(value)).replace(':00','');
const el=(tag,className,text)=>{const node=document.createElement(tag);if(className)node.className=className;if(text!==undefined)node.textContent=text;return node;};
let data,failed=false,inFlight=false,retryCount=0,pollTimer,rotation=0,lastReceived=null,serverOffset=0,lastDay;
const taskPages=new Map();
function now(){return Date.now()+serverOffset;}
function tickClock() {
const p=clockFormatter.formatToParts(new Date());
document.querySelector('#clock').textContent=p.filter(v=>['hour','minute','literal'].includes(v.type)).map(v=>v.value).join('').trim();
document.querySelector('#period').textContent=p.find(v=>v.type==='dayPeriod').value;
const dateNode=document.querySelector('#today-date');
if(dateNode)dateNode.textContent=new Intl.DateTimeFormat('en-US',{timeZone:ZONE,weekday:'short',month:'short',day:'numeric'}).format(new Date());
const day=dayKey(new Date());
if(lastDay&&lastDay!==day){renderCalendar();refresh();}
lastDay=day;
}
function eventTime(event) {return event.point?formatTime(event.start):`${formatTime(event.start)}–${formatTime(event.end)}`;}
function contrast(color) {
const hex=color.replace('#','');const rgb=[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16));
const linear=rgb.map(value=>value/255<=.04045?value/255/12.92:((value/255+.055)/1.055)**2.4);
return linear[0]*.2126+linear[1]*.7152+linear[2]*.0722>.179?'#151515':'#fff';
}
function renderCalendar() {
const container=document.querySelector('#calendar');
const loaded=data&&Object.entries(data.sources).some(([key,s])=>key.startsWith('calendar:')&&s.updated);
if(!loaded){container.replaceChildren(el('p','loading',data?'Calendar temporarily unavailable':'Loading household calendar…'));return;}
const today=dayKey(new Date()),days=Array.from({length:7},(_,i)=>addDays(today,i));
const raw=days.map(key=>layoutEvents(data.events,key,0)).flat();
const first=Math.min(8,...raw.map(e=>Math.floor(e.start)));
const last=Math.min(24,Math.max(21,...raw.map(e=>Math.floor(e.end)+1)));
const head=el('div','schedule-head');head.append(el('div'));
for(const key of days) {
const date=new Date(`${key}T12:00:00Z`),heading=el('div',`day-heading${key===today?' today':''}`);
heading.append(el('span','day-name',weekdayFormatter.format(date)));
const summary=el('div','day-summary');summary.append(el('strong','',String(date.getUTCDate())));
const daily=data.weather?.daily?.find(item=>dayKey(item.datetime)===key);
if(daily){const weather=el('span','day-weather');weather.append(weatherIcon(daily.condition),el('span','',`${Math.round(daily.temperature)}°\n${daily.templow!=null?Math.round(daily.templow)+'°':''}`));summary.append(weather);}
heading.append(summary);head.append(heading);
}
const allDay=el('div','all-day');allDay.append(el('div','all-day-label','All day'));
const dayEvents=days.map(key=>data.events.filter(e=>e.allDay&&e.start<=key&&e.end>key));
const bubbleHeight=window.innerWidth>=2400?34:24;
const cap=Math.max(1,Math.floor((container.clientHeight*.18-18)/bubbleHeight));
const maximum=Math.max(1,...dayEvents.map(events=>Math.min(cap,events.length)));
allDay.style.height=`${maximum*bubbleHeight+18}px`;
const dense=[];
dayEvents.forEach(events=>{
const cell=el('div','all-day-cell'),pages=Math.max(1,Math.ceil(events.length/cap)),offset=(rotation%pages)*cap;
for(const event of events.slice(offset,offset+cap)) {
const node=el('div','all-day-event',event.title);node.style.backgroundColor=eventColor(event.color);node.style.setProperty('--event-text',contrast(eventColor(event.color)));cell.append(node);
}
if(pages>1){cell.append(el('div','all-day-more',`${offset+1}–${Math.min(offset+cap,events.length)} of ${events.length}`));dense.push(...events);}
allDay.append(cell);
});
const body=el('div','schedule-body'),detail=el('div','calendar-detail');
container.replaceChildren(head,allDay,body,detail);
const hourHeight=Math.max(1,body.clientHeight/(last-first));body.style.setProperty('--hour-height',`${hourHeight}px`);
const hours=el('div','hours');
for(let h=first;h<last;h++){const label=el('span','hour-label',`${h%12||12} ${h<12?'AM':'PM'}`);label.style.top=`${(h-first)*hourHeight}px`;hours.append(label);}
body.append(hours);
days.forEach(key=>{
const column=el('div','day-column');
for(const block of layoutEvents(data.events,key,26/hourHeight)) {
const start=Math.max(first,block.start),end=Math.min(last,block.displayEnd);if(end<=start)continue;
const height=(end-start)*hourHeight-2,compact=height<48;
const node=el('div',`event${Date.parse(block.event.end)<now()?' past':''}${compact?' compact':''}`);
Object.assign(node.style,{backgroundColor:eventColor(block.event.color),top:`${(start-first)*hourHeight}px`,height:`${Math.max(18,height)}px`,left:`calc(4px + (100% - 8px) * ${block.column/block.columns})`,width:`calc((100% - 8px) / ${block.columns} - 2px)`});
node.style.setProperty('--event-text',contrast(eventColor(block.event.color)));
node.append(el('div','event-title',block.event.title),el('div','event-time',eventTime(block.event)));
if(block.event.location)node.append(el('div','event-location',block.event.location));
column.append(node);
if(compact||block.columns>1||block.event.title.length>30)dense.push(block.event);
}
if(key===today){const current=wallHour(new Date(),key);if(current>=first&&current<last){const line=el('div','now-line');line.style.top=`${(current-first)*hourHeight}px`;column.append(line);}}
body.append(column);
});
const unique=[...new Map(dense.map(e=>[`${e.id}:${e.start}`,e])).values()];
if(unique.length){const event=unique[rotation%unique.length];detail.textContent=`${weekdayFormatter.format(new Date(event.allDay?event.start+'T12:00:00Z':event.start))} · ${event.allDay?'All day':eventTime(event)} · ${event.title}${event.location?' · '+event.location:''}`;}
}
function weatherIcon(condition) {
const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');
svg.setAttribute('viewBox','0 0 32 32');svg.setAttribute('class','weather-icon');
svg.setAttribute('role','img');svg.setAttribute('aria-label',condition||'Weather');
const sun='<circle cx="16" cy="16" r="6" fill="#ffce54"/><path d="M16 2v4m0 20v4M2 16h4m20 0h4M6 6l3 3m14 14 3 3M6 26l3-3M23 9l3-3" stroke="#ffce54" stroke-width="2" stroke-linecap="round"/>';
const cloud='<path d="M8 23h17a5 5 0 0 0 0-10h-1a8 8 0 0 0-15-2 6 6 0 0 0-1 12Z" fill="#c9d2dc"/>';
const moon='<path d="M25 22A12 12 0 0 1 10 6a12 12 0 1 0 15 16Z" fill="#fff59d"/>';
let shape=condition==='sunny'?sun:condition==='clear-night'?moon:cloud;
if(condition==='partlycloudy')shape='<g transform="translate(-2 -4) scale(.85)">'+sun+'</g>'+cloud;
if(['rainy','pouring','lightning-rainy','snowy-rainy'].includes(condition))shape+='<path d="m10 26-2 4m10-4-2 4m10-4-2 4" stroke="#55b8ff" stroke-width="2" stroke-linecap="round"/>';
if(['lightning','lightning-rainy'].includes(condition))shape+='<path d="m18 17-6 9h5l-2 6 8-11h-6Z" fill="#ffce54"/>';
if(['snowy','snowy-rainy','hail'].includes(condition))shape+='<path d="M10 25v6m-3-3h6m10-3v6m-3-3h6" stroke="#c4e8ff" stroke-width="2"/>';
if(condition==='fog')shape+='<path d="M3 26h26M6 30h20" stroke="#b0bec5" stroke-width="2"/>';
if(['windy','windy-variant'].includes(condition))shape='<path d="M2 12h21a4 4 0 1 0-4-4M2 18h25a4 4 0 1 1-4 4M2 24h12" fill="none" stroke="#b0bec5" stroke-width="2" stroke-linecap="round"/>';
svg.innerHTML=shape;return svg;
}
function renderWeather() {
const container=document.querySelector('#weather');
if(!data?.weather){container.replaceChildren(el('p','loading',data?'Weather temporarily unavailable':'Loading weather…'));return;}
const cutoff=Math.floor(now()/3600000)*3600000;
const items=data.weather.forecast.filter(item=>Date.parse(item.datetime)>=cutoff).slice(0,12);
if(!items.length){container.replaceChildren(el('p','loading','Weather forecast is out of date'));return;}
const forecast=el('div','forecast');
for(const item of items){const slot=el('div','forecast-slot');slot.append(el('span','',formatTime(item.datetime)),weatherIcon(item.condition),el('span','temperature',`${Math.round(item.temperature)}°`),el('span','rain',item.precipitation_probability!=null?`${Math.round(item.precipitation_probability)}%`:''));forecast.append(slot);}
container.replaceChildren(forecast);
}
function showTaskPages() {
for(const entry of taskPages.values()) {
const page=rotation%entry.pages.length,indices=entry.pages[page];
const list=el('ul');for(const index of indices)list.append(entry.rows[index]);entry.window.replaceChildren(list);
entry.label.textContent=entry.pages.length===1?`${entry.rows.length} ${entry.rows.length===1?'task':'tasks'}`:`${indices[0]+1}–${indices.at(-1)+1} of ${entry.rows.length} · ${page+1}/${entry.pages.length}`;
}
}
function renderTasks() {
taskPages.clear();const grid=document.querySelector('#tasks');grid.replaceChildren();
for(const name of TASKS) {
const card=el('section','card task-list');card.append(el('h2','',name));
const window=el('div','task-window'),label=el('div','task-count');card.append(window,label);grid.append(card);
if(!data?.sources[`tasks:${name}`]?.updated){window.append(el('p','empty',data?'Temporarily unavailable':'Loading…'));continue;}
const tasks=data.tasks[name];
if(!tasks.length){window.append(el('p','empty','No tasks'));continue;}
const list=el('ul'),rows=[];
for(const task of tasks) {
const row=el('li'),text=el('span','task-text');text.append(el('span','task-title',task.title));
if(task.description)text.append(el('small','task-description',task.description));
row.append(el('span','task-dot'),text);list.append(row);rows.push(row);
}
window.append(list);
const heights=rows.map(row=>row.getBoundingClientRect().height);
taskPages.set(name,{window,label,rows,pages:paginateHeights(heights,Math.max(1,window.clientHeight-2))});
}
showTaskPages();
}
function status() {
const node=document.querySelector('#status');
if(!data){node.textContent=failed?'Unable to reach household data · retrying':'Connecting to household data…';node.classList.toggle('warning',failed);return;}
const problems=Object.entries(data.sources).filter(([key,source])=>(!key.startsWith('tasks:')||TASKS.includes(key.slice(6)))&&sourceState(source,now(),data.staleAfterSeconds)!=='fresh');
const updated=Object.values(data.sources).map(s=>s.updated).filter(Boolean).sort();
let text=updated.length?`Updated ${formatTime(updated[0])}`:'Waiting for household data';
if(failed)text=`Connection lost · last received ${lastReceived?formatTime(lastReceived):'unknown'}`;
else if(problems.length){const [key,source]=problems[rotation%problems.length];const state=sourceState(source,now(),data.staleAfterSeconds);const reason=state==='degraded'?'some items unavailable':source.updated?'showing older data':'temporarily unavailable';text=`${source.label}: ${reason}${source.updated?' · last updated '+formatTime(source.updated):''}${problems.length>1?' · '+problems.length+' sources affected':''}`;}
node.textContent=text;node.classList.toggle('warning',failed||problems.length>0);
document.querySelector('.weather').classList.toggle('source-stale',sourceState(data.sources['weather:hourly'],now(),data.staleAfterSeconds)!=='fresh');
for(const card of document.querySelectorAll('.task-list')){const name=card.querySelector('h2').textContent;card.classList.toggle('source-stale',sourceState(data.sources[`tasks:${name}`],now(),data.staleAfterSeconds)!=='fresh');}
}
function validSnapshot(value) {
return value&&typeof value.version==='string'&&Array.isArray(value.events)&&value.tasks&&TASKS.every(name=>Array.isArray(value.tasks[name]))&&value.sources&&Number.isFinite(Date.parse(value.generatedAt))&&Number.isFinite(value.staleAfterSeconds);
}
async function refresh() {
if(inFlight)return;
inFlight=true;clearTimeout(pollTimer);
const controller=new AbortController(),timeout=setTimeout(()=>controller.abort(),10000);
try {
const response=await fetch('/api/dashboard',{cache:'no-store',signal:controller.signal});if(!response.ok)throw new Error('Unavailable');
const next=await response.json();if(!validSnapshot(next))throw new Error('Invalid snapshot');
const version=document.querySelector('meta[name="app-version"]')?.content;
if(version&&version!=='APP_VERSION'&&next.version!==version){location.reload();return;}
data=next;lastReceived=Date.now();serverOffset=Date.parse(next.generatedAt)-Date.now();failed=false;retryCount=0;
renderCalendar();renderWeather();renderTasks();
} catch {failed=true;retryCount++;}
finally {clearTimeout(timeout);inFlight=false;status();pollTimer=setTimeout(refresh,failed?Math.min(60000,15000*2**Math.min(retryCount-1,2)):60000);}
}
if(typeof document!=='undefined') {
theme=resolveTheme(new URLSearchParams(location.search).get('theme'));
document.documentElement.dataset.theme=theme;
tickClock();renderTasks();status();refresh();
setInterval(tickClock,1000);
setInterval(()=>{rotation++;if(data){renderCalendar();renderWeather();showTaskPages();}status();},20000);
let resizeTimer;window.addEventListener('resize',()=>{clearTimeout(resizeTimer);resizeTimer=setTimeout(()=>{if(data){renderCalendar();renderTasks();}},150);});
document.addEventListener('visibilitychange',()=>{if(!document.hidden)refresh();});
}