@@ -0,0 +1,368 @@
/* 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 ;
}
let outfitRequestId = 0 ;
async function loadWeatherOutfits ( container , options = { } , refresh = false ) {
const requestId = ++ outfitRequestId ;
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 ( options . query ) q . set ( 'q' , options . query ) ;
if ( options . date ) q . set ( 'date' , options . date ) ;
if ( options . label ) q . set ( 'label' , options . label ) ;
if ( refresh ) q . set ( 'refresh_illustration' , '1' ) ;
const data = await api ( '/api/ha/weather/outfits' + ( q . toString ( ) ? '?' + q . toString ( ) : '' ) ) ;
if ( requestId !== outfitRequestId ) return ;
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 , options , 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' , data . illustration _error ? 'Illustration indisponible : ' + data . illustration _error : 'Illustration désactivée.' ) ) ;
}
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 forecast = ( data . daily || [ ] ) . find ( x => x . date === data . focus _date ) ;
const c = ( data . focus _date && data . focus _date !== localKey ( new Date ( ) ) && forecast ) ? forecast : ( 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 , { date : data . focus _date || '' , label : data . focus _label || 'aujourd’ hui' } ) ;
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 ( 'button' , 'weather-day' ) ;
box . type = 'button' ;
box . title = 'Voir les tenues pour ce jour' ;
if ( day . date === data . focus _date ) box . classList . add ( 'is-selected' ) ;
box . addEventListener ( 'click' , ( ) => {
cards . querySelectorAll ( '.weather-day' ) . forEach ( b => b . classList . remove ( 'is-selected' ) ) ;
box . classList . add ( 'is-selected' ) ;
const label = d . toLocaleDateString ( 'fr-FR' , { weekday : 'long' , day : 'numeric' , month : 'long' } ) ;
loadWeatherOutfits ( outfitHost , { date : day . date , label } ) ;
} ) ;
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 } ;
} ) ( ) ;