/* Alfred • visual views, powered by first-party Flask endpoints. No extra CDN. */ (() => { 'use strict'; const root = document.getElementById('content'); let current = null, camerasRefresh = null; const DAYS = ['lun.','mar.','mer.','jeu.','ven.','sam.','dim.']; const PALETTE = ['#77bcdf','#e5b677','#9dc99a','#d3a2d5','#8cb7c9','#ec9f95','#b6b5e1','#b9d78d']; const el = (tag, cls, text) => { const x=document.createElement(tag); if(cls)x.className=cls; if(text!==undefined)x.textContent=String(text); return x; }; const btn = (label, fn, cls='view-button') => { const b=el('button',cls,label); b.type='button'; b.addEventListener('click',fn);return b; }; const api = async (url, options) => { const r=await fetch(url,{cache:'no-store', ...options}); const data=await r.json().catch(()=>({error:`Erreur HTTP ${r.status}`})); if(!r.ok)throw new Error(data.error || `HTTP ${r.status}`); return data; }; const post = (url,data) => api(url,{method:'POST',headers:{'Content-Type':'application/json','X-Alfred-Request':'1'},body:JSON.stringify(data)}); const dateLabel = iso => new Date(iso).toLocaleDateString('fr-FR',{day:'numeric',month:'long',year:'numeric'}); const timeLabel = iso => new Date(iso).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}); const localKey = d => `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`; const atDay = (d,h=0) => new Date(d.getFullYear(),d.getMonth(),d.getDate(),h); const dayShift = (d,n) => new Date(d.getFullYear(),d.getMonth(),d.getDate()+n,d.getHours(),d.getMinutes()); const eventOnDay=(ev,day)=> {const start=new Date(ev.start),end=new Date(ev.end);return startatDay(day);}; const fmtRange=(a,b)=>`${dateLabel(a)} — ${dateLabel(new Date(new Date(b).getTime()-1000).toISOString())}`; const wrap = (title) => { clearInterval(camerasRefresh);camerasRefresh=null;root.replaceChildren(); root.classList.add('alfred-view'); const pane=el('div','view-pane'),head=el('header','view-head'); head.append(el('h1','view-title',title)); head.append(btn('×',goHome,'view-close')); pane.append(head);root.append(pane);return {pane,head}; }; const showError=(err,pane)=>pane.append(el('p','view-error',err.message||String(err))); const canRender = data=>['calendar','calendar_form','delete_candidates','temperatures','cameras','weather','home'].includes(data?.type); function render(data){ if(!canRender(data))return false; current=data; if(data.type==='calendar')drawCalendar(data); if(data.type==='calendar_form')drawForm(data); if(data.type==='delete_candidates')drawDeleteChoices(data); if(data.type==='temperatures')drawTemps(data); if(data.type==='weather')drawWeather(data); if(data.type==='cameras')drawCameras(data); if(data.type==='home'){root.classList.remove('alfred-view');root.replaceChildren(el('h1','', 'Bonjour.'), el('p','',data.text||'À ton écoute.')); clearInterval(camerasRefresh);camerasRefresh=null;} return true; } const getSelected=()=>document.getElementById('calendar-source')?.value || ''; async function loadCalendar(start,end,view,filter){ const q=new URLSearchParams({start:new Date(start).toISOString(),end:new Date(end).toISOString()}); if(filter)q.append('calendar_id',filter); const data=await api('/api/calendar/events?'+q); render({type:'calendar',view,start:new Date(start).toISOString(),end:new Date(end).toISOString(),...data,selected:filter||''}); } function moveCalendar(direction){ if(!current || current.type!=='calendar')return; const start=new Date(current.start),end=new Date(current.end),view=current.view; let a,b; if(view==='month'){ a=new Date(start.getFullYear(),start.getMonth()+direction,1); b=new Date(a.getFullYear(),a.getMonth()+1,1); }else{ const days=Math.round((end-start)/86400000) || 1; a=dayShift(start,days*direction);b=dayShift(end,days*direction); } loadCalendar(a,b,view,getSelected()).catch(err=>{const {pane}=wrap('Agenda');showError(err,pane)}); } const details = (event) => { const d=el('div','detail-modal'),box=el('section','detail-box'); box.setAttribute('role','dialog');box.setAttribute('aria-modal','true');box.setAttribute('aria-label','Détails de l’événement'); box.append(el('h2','',event.title)); box.append(el('p','',`${dateLabel(event.start)}${event.all_day?' · Toute la journée':' · '+timeLabel(event.start)+'–'+timeLabel(event.end)}`)); box.append(el('p','',event.calendar_name)); if(event.location)box.append(el('p','',`Lieu : ${event.location}`)); if(event.description)box.append(el('p','',event.description)); const acts=el('div','view-actions');acts.append(btn('Fermer',()=>d.remove())); if(event.can_delete) acts.append(btn('Supprimer…',async()=>{ const recurrence=event.recurrence_id ? 'Cela supprimera cette occurrence.' : 'Pour une série récurrente, cela peut supprimer toute la série.'; if(!confirm(`Supprimer « ${event.title} » du calendrier « ${event.calendar_name} » ?\n${recurrence}`))return; try{await post('/api/calendar/delete',{calendar_id:event.calendar_id,uid:event.id,start:event.start,end:event.end,recurrence_id:event.recurrence_id||''}); d.remove();await loadCalendar(current.start,current.end,current.view,getSelected());} catch(err){alert(err.message)} },'view-danger')); box.append(acts);d.append(box);document.body.append(d); d.addEventListener('click',e=>{if(e.target===d)d.remove()}); }; function drawCalendar(data){ const {pane,head}=wrap('Planning'); const toolbar=el('div','view-toolbar'); const first=el('div','view-nav'); first.append(btn('‹',()=>moveCalendar(-1)),el('span','view-period',fmtRange(data.start,data.end)),btn('›',()=>moveCalendar(1))); toolbar.append(first); const modes=el('div','view-modes'); [['Jour',1,'day'],['3 jours',3,'days'],['7 jours',7,'days'],['Semaine',7,'week'],['Mois',31,'month']].forEach(([label,n,v])=>{ const b=btn(label,()=>{ const now=new Date(),start=(v==='week')?dayShift(atDay(now),-((now.getDay()+6)%7)):v==='month'?new Date(now.getFullYear(),now.getMonth(),1):atDay(now); const end=v==='month'?new Date(start.getFullYear(),start.getMonth()+1,1):dayShift(start,n); loadCalendar(start,end,v,getSelected()).catch(err=>showError(err,pane)); },'view-pill'+(data.view===v?' active':''));modes.append(b); });toolbar.append(modes); const src=el('select','view-select');src.id='calendar-source'; src.append(new Option('Tous les calendriers','')); (data.calendars||[]).forEach(c=>src.append(new Option(c.name,c.entity_id))); src.value=data.selected||''; src.addEventListener('change',()=>loadCalendar(data.start,data.end,data.view,src.value).catch(err=>showError(err,pane))); toolbar.append(src,btn('+ Rendez-vous',()=>openForm())); pane.append(toolbar); if(data.errors?.length)pane.append(el('p','view-error','Calendriers partiellement indisponibles : '+data.errors.join(' · '))); const grid=el('div','agenda-grid'); const start=new Date(data.start),end=new Date(data.end); let days=[],cursor=atDay(start); const month=data.view==='month'; if(month) cursor=dayShift(cursor,-((cursor.getDay()+6)%7)); let limit=0; while(cursor[c.entity_id, PALETTE[i%PALETTE.length]])); days.forEach(d=>{ const cell=el('section','agenda-day'); if(month && d.getMonth()!==start.getMonth())cell.classList.add('out-month'); if(localKey(d)===localKey(new Date()))cell.classList.add('is-today'); cell.append(el('div','agenda-date',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:month?undefined:'short'}))); const events=(data.events||[]).filter(ev=>eventOnDay(ev,d)); if(!events.length)cell.append(el('div','agenda-empty','—')); events.forEach(ev=>{ const e=btn((ev.all_day?'':timeLabel(ev.start)+' ')+ev.title,()=>details(ev),'agenda-event'); e.style.setProperty('--event-color',colors.get(ev.calendar_id)||'#7bbbe2'); e.title=`${ev.title} · ${ev.calendar_name}`;cell.append(e); }); grid.append(cell); }); pane.append(grid); pane.append(el('p','view-footer',`${data.events?.length||0} événement(s) · ${data.calendars?.length||0} calendrier(s) Home Assistant`)); } async function openForm(values){ let calendars=[]; try {const info=await api('/api/calendar/calendars');calendars=info.calendars||[];} catch(err){const {pane}=wrap('Nouveau rendez-vous');showError(err,pane);return;} render({type:'calendar_form',calendars,values:values||{}}); } function drawForm(data){ const {pane}=wrap('Ajouter un événement'); const v=data.values||{}; const form=el('form','agenda-form');form.autocomplete='off'; const field=(label,node)=>{const row=el('label','form-field');row.append(el('span','',label),node);form.append(row);return node}; const makeInput=(type,value)=>{const i=el('input');i.type=type;i.value=value||'';i.required=true;return i}; const title=field('Titre',makeInput('text',v.title));title.maxLength=200; const src=el('select');src.required=true;src.append(new Option('Choisir un calendrier','')); data.calendars.filter(c=>c.can_create).forEach(c=>src.append(new Option(c.name,c.entity_id))); src.value=v.calendar_id||'';field('Calendrier',src); const day=field('Date',makeInput('date',v.date||localKey(new Date()))); const startTime=field('Début',makeInput('time',v.time||'09:00')); const duration=field('Durée (minutes)',makeInput('number',v.duration_minutes||60));duration.min=15;duration.max=1440; const location=field('Lieu (facultatif)',makeInput('text',''));location.required=false; const description=field('Description (facultatif)',el('textarea'));description.required=false;description.rows=2; const allDay=el('input');allDay.type='checkbox';allDay.required=false; const allDayWrap=el('label','form-check');allDayWrap.append(allDay,el('span','','Toute la journée'));form.append(allDayWrap); allDay.addEventListener('change',()=>{startTime.disabled=allDay.checked;duration.disabled=allDay.checked}); const msg=el('p','form-message'); const actions=el('div','view-actions');actions.append(btn('Annuler',goHome)); const submit=el('button','view-button primary','Enregistrer');submit.type='submit';actions.append(submit); form.append(actions,msg);pane.append(form); form.addEventListener('submit',async e=>{ e.preventDefault();submit.disabled=true;msg.textContent='Enregistrement…'; try{ const from=allDay.checked?new Date(day.value+'T00:00'):new Date(day.value+'T'+startTime.value+':00'); if(Number.isNaN(from.getTime()))throw new Error('Date invalide.'); const end=allDay.checked?dayShift(from,1):new Date(from.getTime()+Number(duration.value)*60000); await post('/api/calendar/create',{calendar_id:src.value,title:title.value,start:from.toISOString(),end:end.toISOString(),all_day:allDay.checked,location:location.value,description:description.value}); const first=atDay(from);await loadCalendar(first,dayShift(first,7),'days'); }catch(err){msg.textContent=err.message;submit.disabled=false} }); } function drawDeleteChoices(data){ const {pane}=wrap('Choisir un événement à supprimer'); const arr=el('div','delete-list'); (data.events||[]).forEach(ev=>{ const row=el('div','delete-row');row.append(el('span','',`${ev.title} • ${dateLabel(ev.start)} • ${ev.calendar_name}`)); row.append(btn('Détails / Supprimer',()=>details(ev),'view-button'));arr.append(row); });pane.append(arr); if(data.awaiting_confirmation)pane.append(el('p','view-footer','Dis « confirme » pour supprimer, ou « annule ».')); } function drawTemps(data){ const {pane}=wrap('Températures Home Assistant'); if(!data.items?.length){pane.append(el('p','view-empty','Aucun capteur de température trouvé.'));return} const grid=el('div','temperature-grid');pane.append(grid); data.items.forEach(item=>{ const c=btn(`${item.name}\n${item.value.toFixed(1)} ${item.unit}` ,async()=>{ const q=new URLSearchParams({entity_id:item.entity_id,hours:'24'}); try{const data=await api('/api/ha/temperature/history?'+q);showHistory(item,data.items||[],pane)} catch(err){showError(err,pane)} },'temperature-card');grid.append(c); });pane.append(el('p','view-footer','Touchez un capteur pour afficher son historique sur 24 h.')); } function showHistory(item,points,pane){ pane.querySelector('.temperature-history')?.remove(); const section=el('section','temperature-history');section.append(el('h2','',item.name+' · 24 dernières heures')); if(points.length<2){section.append(el('p','', 'Historique insuffisant.'));pane.append(section);return} const vals=points.map(p=>p.value);const lo=Math.min(...vals)-0.4,hi=Math.max(...vals)+0.4; const a=Date.parse(points[0].time),b=Date.parse(points[points.length-1].time); const coords=points.map((p,i)=>`${30+560*((Date.parse(p.time)-a)/Math.max(1,b-a))},${140-110*((p.value-lo)/Math.max(0.1,hi-lo))}`).join(' '); const ns='http://www.w3.org/2000/svg',svg=document.createElementNS(ns,'svg');svg.setAttribute('viewBox','0 0 620 170');svg.setAttribute('role','img');svg.setAttribute('aria-label','Courbe de température'); const poly=document.createElementNS(ns,'polyline');poly.setAttribute('points',coords);poly.setAttribute('fill','none');poly.setAttribute('stroke','#83c5cc');poly.setAttribute('stroke-width','3');svg.append(poly); section.append(svg,el('p','',`Min ${Math.min(...vals).toFixed(1)}° · Max ${Math.max(...vals).toFixed(1)}°`));pane.append(section); } function drawCameras(data){ const {pane}=wrap('Caméras Home Assistant'); if(!data.items?.length){pane.append(el('p','view-empty','Aucune caméra Home Assistant disponible.'));return} const grid=el('div','ha-cameras');pane.append(grid); const imgs=[]; data.items.forEach(item=>{ const card=el('div','ha-camera-card');card.append(el('h2','',item.name)); const img=el('img');img.alt='Instantané caméra '+item.name;img.loading='lazy';imgs.push([img,item.entity_id]);card.append(img);grid.append(card); }); const reload=()=>{if(document.hidden)return; imgs.forEach(([img,eid])=>img.src='/api/ha/camera/'+encodeURIComponent(eid)+'/snapshot?t='+Date.now())}; reload();camerasRefresh=setInterval(reload,15000); pane.append(el('p','view-footer','Instantanés réactualisés toutes les 15 s lorsque cette vue est ouverte.')); } const WEATHER_ICONS = { 'sunny':'☀', 'clear-night':'☾', 'cloudy':'☁', 'partlycloudy':'⛅', 'fog':'≋', 'rainy':'🌧', 'pouring':'🌧', 'hail':'🌨', 'lightning':'⛈', 'lightning-rainy':'⛈', 'snowy':'❄', 'snowy-rainy':'🌨', 'windy':'〰', 'windy-variant':'〰' }; const weatherIcon = cond => WEATHER_ICONS[cond] || '◌'; const tempText = (val, unit='°C') => typeof val === 'number' ? `${Math.round(val)}${unit}` : '—'; function drawWeather(data){ const {pane}=wrap('Météo · '+(data.name||'Home Assistant')); const nav=el('div','weather-toolbar'); nav.append(btn('Actualiser',openWeather,'view-pill')); nav.append(el('span','view-footer','Source : Home Assistant')); pane.append(nav); const c=data.current||{},now=el('section','weather-now'); const badge=el('div','weather-current-icon',weatherIcon(c.condition));badge.setAttribute('aria-hidden','true'); const details=el('div','weather-now-details'); details.append(el('h2','',`${tempText(c.temperature,data.temperature_unit)} · ${c.description||'Conditions inconnues'}`)); const metrics=[]; if(c.apparent_temperature!==null && c.apparent_temperature!==undefined)metrics.push(`Ressenti ${tempText(c.apparent_temperature,data.temperature_unit)}`); if(c.humidity!==null && c.humidity!==undefined)metrics.push(`Humidité ${c.humidity}%`); if(c.wind_speed!==null && c.wind_speed!==undefined)metrics.push(`Vent ${c.wind_speed} ${data.wind_speed_unit||'km/h'}`); details.append(el('p','',metrics.join(' · ')||'Conditions actuelles'));now.append(badge,details);pane.append(now); const heading = text => pane.append(el('h2','weather-section-title',text)); heading('Prévisions pour les prochains jours'); if(data.daily?.length){ const cards=el('div','weather-days'); data.daily.slice(0,7).forEach(day=>{ const d=new Date(day.datetime); const box=el('article','weather-day'); box.append(el('p','weather-day-name',d.toLocaleDateString('fr-FR',{weekday:'short',day:'numeric',month:'short'}))); box.append(el('div','weather-day-icon',weatherIcon(day.condition))); box.append(el('p','weather-day-condition',day.description)); box.append(el('p','weather-day-temp',tempText(day.temperature,data.temperature_unit))); if(day.templow!==null)box.append(el('p','weather-day-low','Min '+tempText(day.templow,data.temperature_unit))); if(day.precipitation_probability!==null)box.append(el('p','weather-rain','Pluie '+day.precipitation_probability+' %')); cards.append(box); });pane.append(cards); }else pane.append(el('p','view-empty','Prévisions journalières non disponibles.')); heading('Prochaines heures'); if(data.hourly?.length){ const list=el('div','weather-hours'), nowMs=Date.now(); data.hourly.filter(x=>new Date(x.datetime).getTime()>=nowMs-3600000).slice(0,12).forEach(h=>{ const box=el('div','weather-hour'); box.append(el('span','',new Date(h.datetime).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'}))); box.append(el('span','weather-hour-icon',weatherIcon(h.condition))); box.append(el('strong','',tempText(h.temperature,data.temperature_unit))); if(h.precipitation_probability!==null)box.append(el('span','weather-rain',h.precipitation_probability+' %')); list.append(box); });pane.append(list); }else pane.append(el('p','view-empty','Prévisions horaires non disponibles.')); if(data.errors?.length)pane.append(el('p','view-footer','Certaines prévisions ne sont pas fournies par cette source météo.')); } async function openWeather(){ try{render(await api('/api/ha/weather'))} catch(err){const {pane}=wrap('Météo');showError(err,pane)} } async function goHome(){ try{await post('/api/ui/home',{})}catch(err){render({type:'home'})} } async function openCalendar(){ const d=atDay(new Date());try{await loadCalendar(d,dayShift(d,7),'days')}catch(err){const {pane}=wrap('Agenda');showError(err,pane)} } async function openTemperatures(){ try{render({type:'temperatures',...await api('/api/ha/temperatures')})}catch(err){const {pane}=wrap('Températures');showError(err,pane)} } async function openCameras(){ try{render({type:'cameras',...await api('/api/ha/cameras')})}catch(err){const {pane}=wrap('Caméras');showError(err,pane)} } document.getElementById('viewHome')?.addEventListener('click',goHome); document.getElementById('viewCalendar')?.addEventListener('click',openCalendar); document.getElementById('viewTemperatures')?.addEventListener('click',openTemperatures); document.getElementById('viewWeather')?.addEventListener('click',openWeather); document.getElementById('viewCameras')?.addEventListener('click',openCameras); const socket=window.alfredSocket; socket?.on('calendar_changed',()=>{if(current?.type==='calendar') loadCalendar(current.start,current.end,current.view,getSelected()).catch(console.warn)}); window.AlfredViews={render,canRender,openCalendar,openTemperatures,openCameras,openWeather,goHome}; })();