This commit is contained in:
pporcheret
2026-10-26 00:06:24 +01:00
parent c9c3d5f223
commit cd37ac3c0a
207 changed files with 19205 additions and 54 deletions
@@ -0,0 +1,91 @@
/* Calendar and Home Assistant views — preserves original dark Alfred layout */
#stage:has(.alfred-view) {padding:0 0 6px;justify-content:stretch;align-items:stretch}
#content.alfred-view {width:100%;height:100%;text-align:left;min-height:0;}
.view-pane {display:flex;flex-direction:column;gap:12px;max-height:100%;height:100%;min-height:0;padding:8px 1px 14px;}
.view-head {display:flex;justify-content:space-between;align-items:center;gap:12px;}
#content .view-title {font-size:clamp(1.2rem,1.7vw,1.85rem);margin:0;font-weight:500}
.view-close,.view-button,.view-pill {background:#363d42;color:#f3f7fa;border:1px solid #ffffff24;border-radius:10px;padding:9px 14px;cursor:pointer;font:inherit}
.view-close {font-size:1.4rem;min-width:40px;height:40px;padding:0}
.view-button:hover,.view-pill:hover,.view-close:hover {background:#455057}
.view-pill.active,.view-button.primary {background:#427789;border-color:#619aad}
.view-toolbar {display:flex;gap:9px;align-items:center;flex-wrap:wrap;}
.view-nav,.view-modes {display:flex;gap:5px;align-items:center}
.view-period {font-size:.85rem;min-width:215px;text-align:center;color:#d8e0e4}
.view-select {padding:9px;max-width:220px;border-radius:9px;background:#333a3e;color:#eff3f5;border:1px solid #ffffff2b}
.agenda-grid {display:grid;gap:5px;min-height:0;flex:1;overflow:auto;padding:2px;align-content:stretch;}
.agenda-seven,.agenda-month {grid-template-columns:repeat(7,minmax(0,1fr))}
.agenda-month {grid-template-rows:repeat(6,minmax(110px,1fr));}
.agenda-three {grid-template-columns:repeat(3,minmax(0,1fr))}
.agenda-one {grid-template-columns:1fr}
.agenda-day {border:1px solid #ffffff1c;border-radius:11px;min-width:0;min-height:120px;background:#ffffff08;padding:9px;display:flex;flex-direction:column;gap:6px;overflow-y:auto}
.agenda-day.is-today {border-color:#86bfda7a;background:#57839923}
.agenda-day.out-month {opacity:.42}
.agenda-date {font-size:.83rem;font-weight:600;color:#dce2e6;letter-spacing:.01em;padding-bottom:7px;border-bottom:1px solid #ffffff14}
.agenda-event {width:100%;text-align:left;background:color-mix(in srgb,var(--event-color) 17%,#2a2e32);color:#f1f3f5;border:0;border-left:3px solid var(--event-color);border-radius:5px;padding:7px 5px;font-size:clamp(.72rem,.85vw,.95rem);line-height:1.3;cursor:pointer;overflow-wrap:anywhere}
.agenda-event:hover {background:color-mix(in srgb,var(--event-color) 30%,#2a2e32)}
.agenda-empty {font-size:.8rem;color:#667781;margin:auto}
#content .view-footer {color:#9aa8b3;font-size:.76rem;margin:0}
#content .view-error {color:#f7adad;font-size:.85rem;margin:0}
#content .view-empty {font-size:1rem;color:#b8c2ca}
.agenda-form {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));align-content:start;gap:14px;max-width:850px;width:100%;overflow-y:auto;margin:12px auto;}
.form-field {display:flex;flex-direction:column;gap:7px;color:#cfd6dd;font-size:.83rem}
.form-field input,.form-field select,.form-field textarea {padding:12px;border-radius:10px;color:#eff3f5;background:#2e3439;border:1px solid #ffffff32;font:inherit;min-width:0}
.form-field textarea {resize:vertical}
.form-check {display:flex;align-items:center;gap:10px}
.view-actions {display:flex;align-items:center;gap:10px;justify-content:flex-end;flex-wrap:wrap;}
.form-message {grid-column:1 / -1;}
.agenda-form .view-actions {grid-column:1 / -1}
.view-danger {color:#ffc4c4;background:#8f383850;border-color:#ed929255}
.detail-modal {position:fixed;inset:0;z-index:30;display:grid;place-items:center;background:#000b;padding:20px}
.detail-box {min-width:min(92vw,300px);max-width:540px;width:100%;border:1px solid #ffffff36;border-radius:19px;background:#292e33;box-shadow:0 25px 90px #000a;padding:24px;}
.detail-box h2 {font-size:1.4rem;margin:0 0 20px}.detail-box p {font-size:1rem;color:#d7dfe2;overflow-wrap:anywhere}
.delete-list {display:grid;gap:8px;overflow:auto}.delete-row {display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid #ffffff1c;border-radius:9px;padding:12px}
.temperature-grid {display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;overflow:auto;padding:4px;max-height:53%;}
.temperature-card {border:1px solid #ffffff22;background:#ffffff0a;border-radius:12px;padding:22px;color:#eaf1f3;white-space:pre-line;text-align:left;font:inherit;cursor:pointer;line-height:1.9}
.temperature-card:hover {background:#ffffff18}
.temperature-history {max-width:700px;text-align:center;margin:auto;width:100%;}
.temperature-history svg {width:100%;max-height:155px}
.temperature-history h2 {font-size:1.1rem;font-weight:450}
.ha-cameras {display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px;overflow:auto;}
.ha-camera-card {background:#ffffff0a;border-radius:13px;overflow:hidden;}
.ha-camera-card h2 {font-size:.95rem;padding:10px;font-weight:500;margin:0}
.ha-camera-card img {display:block;width:100%;aspect-ratio:16/9;object-fit:contain;background:#121619}
@media(max-width:1100px){.view-toolbar{gap:6px}.view-button,.view-pill {padding:7px;font-size:.77rem}.view-period{min-width:150px}.agenda-day{padding:5px}.agenda-event{padding:4px;font-size:.74rem}}
@media(max-width:760px){#stage:has(.alfred-view){min-height:460px}.agenda-grid{min-height:280px}.agenda-seven,.agenda-month{min-width:630px}.agenda-day{min-height:105px}.view-pane{max-height:600px}.view-head{padding:0 4px}.agenda-form{grid-template-columns:1fr}.header-actions{gap:3px}.header-actions .icon-button {width:38px;height:38px}.view-toolbar{overflow:auto}}
/* Weather dynamic view — same dark UI as calendar. */
.weather-toolbar{display:flex;align-items:center;justify-content:space-between;gap:12px}
.weather-now{display:flex;align-items:center;gap:22px;background:#293238;border:1px solid #ffffff16;border-radius:16px;padding:18px 24px;}
.weather-current-icon{font-size:clamp(44px,6vw,80px);line-height:1;color:#edcb82}
.weather-now-details h2{margin:0 0 8px;font-size:clamp(20px,2.1vw,32px);font-weight:500;color:#f5f6f7}
.weather-now-details p{margin:0;color:#b6c4ce;font-size:clamp(12px,1vw,17px)}
.weather-section-title{font-size:15px;letter-spacing:.02em;color:#bfd0da;margin:7px 0 -2px;font-weight:550}
.weather-days{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:7px}
.weather-day{min-width:0;display:flex;flex-direction:column;align-items:center;text-align:center;gap:5px;background:#293035;border-radius:12px;border:1px solid #ffffff12;padding:12px 5px 10px}
.weather-day p{margin:0}
.weather-day-name{font-size:12px;color:#d4e0e6}
.weather-day-icon{font-size:clamp(22px,2.8vw,40px);color:#eaca85}
.weather-day-condition{font-size:11px;color:#adbcc5;min-height:30px;line-height:1.3}
.weather-day-temp{font-size:21px;color:#f5f7f8;font-weight:600}
.weather-day-low,.weather-rain{font-size:11px;color:#a7b6c0}
.weather-rain{color:#8dc8e4 !important}
.weather-hours{display:flex;gap:7px;overflow-x:auto;padding:3px 0 10px;scrollbar-color:#637480 #2a3237}
.weather-hour{display:flex;flex:0 0 80px;align-items:center;flex-direction:column;gap:9px;padding:10px 6px;background:#293035;border:1px solid #ffffff16;border-radius:12px;color:#cbd7df;font-size:12px}
.weather-hour-icon{font-size:27px;color:#edd298}
@media(max-width:840px){.weather-days{grid-template-columns:repeat(4,minmax(0,1fr))}.weather-now{padding:14px;gap:12px}.weather-current-icon{font-size:48px}}
@media(max-width:470px){.weather-days{grid-template-columns:repeat(3,minmax(0,1fr))}}
/* Weather outfits + ComfyUI illustration */
.weather-outfit-host{margin:8px 0 4px}
.weather-outfit-section{display:flex;flex-direction:column;gap:12px;background:#252d32;border:1px solid #ffffff16;border-radius:16px;padding:16px}
.weather-outfit-head{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap}
.weather-outfit-grid{display:grid;grid-template-columns:minmax(260px,360px) minmax(320px,1fr);gap:14px;align-items:stretch}
.weather-outfit-guide{display:grid;gap:12px}
.weather-person-card{background:#ffffff08;border:1px solid #ffffff16;border-radius:14px;padding:14px}
.weather-person-title{margin:0 0 6px;font-size:1.05rem;color:#f2f5f7}
.weather-person-subtitle{margin:10px 0 4px;color:#b8c7d1;font-size:.86rem}
.weather-outfit-list{margin:0;padding-left:18px;color:#e0e6ea;font-size:.95rem;line-height:1.55}
.weather-illustration-card{display:flex;flex-direction:column;justify-content:center;align-items:center;background:#ffffff07;border:1px solid #ffffff16;border-radius:14px;padding:12px;min-height:320px}
.weather-illustration-image{display:block;max-width:100%;max-height:430px;border-radius:12px;box-shadow:0 16px 40px #0005;object-fit:contain}
@media(max-width:980px){.weather-outfit-grid{grid-template-columns:1fr}.weather-illustration-card{min-height:220px}}
@@ -0,0 +1,353 @@
/* 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 start<dayShift(atDay(day),1) && end>atDay(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<end && limit++<42){days.push(cursor);cursor=dayShift(cursor,1)}
if(month) while(days.length%7)days.push(cursor),cursor=dayShift(cursor,1);
grid.classList.add(month?'agenda-month':days.length===1?'agenda-one':days.length<=3?'agenda-three':'agenda-seven');
const colors=new Map((data.calendars||[]).map((c,i)=>[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 weatherList(items){
const ul=el('ul','weather-outfit-list');
(items||[]).forEach(text=>ul.append(el('li','',text)));
if(!ul.childNodes.length)ul.append(el('li','','Aucun accessoire particulier'));
return ul;
}
async function loadWeatherOutfits(container, query='', refresh=false){
container.replaceChildren();
const loading=el('p','view-footer', refresh ? 'Régénération de l’illustration…' : 'Préparation des tenues conseillées…');
container.append(loading);
try{
const q = new URLSearchParams();
if(query)q.set('q',query);
if(refresh)q.set('refresh_illustration','1');
const data = await api('/api/ha/weather/outfits' + (q.toString() ? '?' + q.toString() : ''));
const section=el('section','weather-outfit-section');
const head=el('div','weather-outfit-head');
head.append(el('h2','weather-section-title',`Tenues conseillées · ${data.focus_label || 'aujourd’hui'}`));
head.append(btn('Régénérer l’illustration',()=>loadWeatherOutfits(container, query, true),'view-pill'));
section.append(head);
const grid=el('div','weather-outfit-grid');
const guide=el('div','weather-outfit-guide');
[data.child, data.adult].forEach(person=>{
const card=el('article','weather-person-card');
card.append(el('h3','weather-person-title',person.title));
card.append(el('p','weather-person-subtitle','Tenue recommandée'));
card.append(weatherList(person.clothes));
card.append(el('p','weather-person-subtitle','Accessoires'));
card.append(weatherList(person.accessories));
guide.append(card);
});
grid.append(guide);
const art=el('div','weather-illustration-card');
if(data.image?.url){
const img=el('img','weather-illustration-image');
img.src=data.image.url;
img.alt='Illustration des tenues conseillées selon la météo';
art.append(img);
const foot=el('p','view-footer', data.image.from_cache ? 'Illustration mise en cache.' : 'Illustration générée par ComfyUI.');
art.append(foot);
} else {
art.append(el('p','view-empty','Illustration indisponible pour le moment.'));
}
grid.append(art);
section.append(grid);
if(data.summary){ section.append(el('p','view-footer',`Conseil général : ${data.summary}.`)); }
container.replaceChildren(section);
}catch(err){
container.replaceChildren(el('p','view-error',`Tenues conseillées indisponibles : ${err.message || err}`));
}
}
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 outfitHost=el('div','weather-outfit-host');
pane.append(outfitHost);
loadWeatherOutfits(outfitHost, data.focus_label || '');
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};
})();