241 lines
17 KiB
JavaScript
241 lines
17 KiB
JavaScript
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&¤t<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();});
|
||
}
|