Spaces:
Running
Running
Download index.html from ArthurZ/gouttes-yeux: direct link, hf CLI and curl.
- Browser
- Download file 23.6 kB
-
https://huggingface.co/spaces/ArthurZ/gouttes-yeux/resolve/main/index.html
- Command line
-
hf download hf://spaces/ArthurZ/gouttes-yeux/index.html
-
curl -L -o index.html https://huggingface.co/spaces/ArthurZ/gouttes-yeux/resolve/main/index.html
23.6 kB
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> | |
| <meta name="theme-color" content="#0b1220"> | |
| <title>Gouttes PKR 💧</title> | |
| <style> | |
| :root{ | |
| --bg:#0b1220; --card:#141d2e; --card2:#1b2740; --line:#26334d; | |
| --txt:#e8eefc; --muted:#8da0c4; --accent:#4f8cff; --ok:#39d98a; | |
| --warn:#ffb020; --danger:#ff5d6c; --chip:#22304d; | |
| --shadow:0 6px 22px rgba(0,0,0,.35); | |
| } | |
| *{box-sizing:border-box;-webkit-tap-highlight-color:transparent} | |
| body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; | |
| background:var(--bg);color:var(--txt);padding:14px 12px 90px;max-width:560px;margin:0 auto} | |
| h1{font-size:20px;margin:0 0 2px;display:flex;align-items:center;gap:8px} | |
| .sub{color:var(--muted);font-size:13px;margin-bottom:14px} | |
| .clock{font-variant-numeric:tabular-nums;font-weight:700} | |
| .topbar{display:flex;justify-content:space-between;align-items:center;gap:10px; | |
| background:var(--card);border:1px solid var(--line);border-radius:16px;padding:12px 14px;margin-bottom:12px} | |
| .topbar .now{font-size:30px;font-weight:800;letter-spacing:.5px} | |
| .topbar .day{font-size:12px;color:var(--muted);text-align:right} | |
| .modebar{display:flex;gap:6px;margin-bottom:10px;flex-wrap:wrap} | |
| .modebar button{flex:1;min-width:110px;background:var(--card);color:var(--muted);border:1px solid var(--line);padding:10px;font-size:13px;border-radius:12px} | |
| .modebar button.active{background:rgba(79,140,255,.2);color:#a9c6ff;border-color:var(--accent)} | |
| .modebar .notifbtn{flex:0 0 auto;min-width:0} | |
| .modebar .notifbtn.on{background:rgba(57,217,138,.2);color:#7af0b6;border-color:var(--ok)} | |
| .lasiknote{display:none;background:rgba(255,176,32,.1);border:1px solid rgba(255,176,32,.35);color:#ffd27a; | |
| font-size:12px;border-radius:12px;padding:10px 12px;margin-bottom:12px;line-height:1.45} | |
| .lasiknote.show{display:block} | |
| .banner{border-radius:14px;padding:12px 14px;margin-bottom:12px;font-weight:600;font-size:14px; | |
| display:none;align-items:center;gap:10px;border:1px solid} | |
| .banner.wait{display:flex;background:rgba(255,176,32,.12);border-color:rgba(255,176,32,.4);color:#ffd27a} | |
| .banner.go{display:flex;background:rgba(57,217,138,.12);border-color:rgba(57,217,138,.4);color:#7af0b6} | |
| .next{background:var(--card);border:1px solid var(--line);border-radius:16px;padding:12px 14px;margin-bottom:16px} | |
| .next h2{font-size:13px;text-transform:uppercase;letter-spacing:1px;color:var(--muted);margin:0 0 8px} | |
| .next .row{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-top:1px solid var(--line);font-size:14px} | |
| .next .row:first-of-type{border-top:none} | |
| .next .dot{width:10px;height:10px;border-radius:50%;display:inline-block;margin-right:8px} | |
| .next .t{font-weight:700;font-variant-numeric:tabular-nums} | |
| .next .late{color:var(--danger)} | |
| .next .soon{color:var(--warn)} | |
| .next .ok{color:var(--muted)} | |
| .next .empty{color:var(--muted);font-size:13px;padding:4px 0} | |
| .card{background:var(--card);border:1px solid var(--line);border-radius:18px;padding:14px;margin-bottom:14px;box-shadow:var(--shadow);border-left:5px solid var(--c)} | |
| .card .head{display:flex;justify-content:space-between;align-items:flex-start;gap:10px} | |
| .card .name{font-size:17px;font-weight:800;display:flex;align-items:center;gap:8px} | |
| .card .desc{font-size:12.5px;color:var(--muted);margin-top:2px;line-height:1.4} | |
| .pill{font-size:11px;font-weight:700;padding:4px 9px;border-radius:999px;background:var(--chip);color:var(--txt);white-space:nowrap} | |
| .pill.warnpill{background:rgba(255,176,32,.18);color:#ffd27a} | |
| .progress{display:flex;align-items:center;gap:8px;margin:12px 0 4px;font-size:13px;color:var(--muted)} | |
| .progress b{color:var(--txt)} | |
| .slots{display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 12px} | |
| .slot{font-size:12px;font-variant-numeric:tabular-nums;padding:5px 9px;border-radius:9px;background:var(--card2);color:var(--muted);border:1px solid var(--line)} | |
| .slot.done{background:rgba(57,217,138,.16);color:#7af0b6;border-color:rgba(57,217,138,.4)} | |
| .slot.next{background:rgba(79,140,255,.18);color:#a9c6ff;border-color:rgba(79,140,255,.5);font-weight:700} | |
| .slot.late{background:rgba(255,93,108,.16);color:#ff9aa4;border-color:rgba(255,93,108,.45);font-weight:700} | |
| .actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap} | |
| button{font-family:inherit;cursor:pointer;border:none;border-radius:12px;font-weight:700} | |
| .give{flex:1;min-width:150px;background:var(--accent);color:#fff;padding:13px;font-size:15px} | |
| .give:active{transform:scale(.98)} | |
| .give.go{background:var(--ok)} | |
| .give.blocked{background:transparent;color:var(--danger);border:1.5px dashed var(--danger);cursor:not-allowed;opacity:.85} | |
| .give.notyet{background:var(--card2);color:var(--muted);border:1px solid var(--line)} | |
| .mini{background:var(--card2);color:var(--txt);border:1px solid var(--line);padding:11px 12px;font-size:13px} | |
| .mini.undo{color:#ff9aa4} | |
| input[type=time]{background:var(--card2);color:var(--txt);border:1px solid var(--line);border-radius:10px;padding:9px;font-family:inherit;font-size:13px} | |
| .phase{display:flex;gap:6px;flex-wrap:wrap;margin:6px 0 10px} | |
| .phase button{font-size:11.5px;padding:6px 9px;background:var(--card2);color:var(--muted);border:1px solid var(--line)} | |
| .phase button.active{background:rgba(79,140,255,.2);color:#a9c6ff;border-color:var(--accent)} | |
| .footer{margin-top:18px;text-align:center} | |
| .footer button{background:transparent;color:var(--muted);border:1px solid var(--line);padding:9px 14px;font-size:12px;margin:3px} | |
| .note{font-size:11px;color:var(--muted);text-align:center;margin-top:14px;line-height:1.5} | |
| details{background:var(--card);border:1px solid var(--line);border-radius:14px;padding:10px 14px;margin-bottom:14px} | |
| summary{cursor:pointer;font-size:13px;font-weight:600;color:var(--muted)} | |
| details ol{font-size:12.5px;color:var(--muted);line-height:1.6;padding-left:18px;margin:8px 0 0} | |
| .howto{background:var(--card2);border:1px solid var(--line);border-radius:10px;padding:7px 11px;margin:2px 0 10px} | |
| .howto summary{font-size:12px;color:var(--muted)} | |
| .howto ol{font-size:12px;color:var(--muted);line-height:1.55;padding-left:16px;margin:7px 0 2px} | |
| .howto li{margin-bottom:3px} | |
| </style> | |
| </head> | |
| <body> | |
| <h1>💧 Mes gouttes — yeux</h1> | |
| <div class="sub">Clique « Donné » dès que tu mets une goutte → les prochaines heures se calculent toutes seules.</div> | |
| <div class="topbar"> | |
| <div> | |
| <div class="now clock" id="clock">--:--</div> | |
| <div class="day" id="dayinfo"></div> | |
| </div> | |
| <div class="day" id="startbox"></div> | |
| </div> | |
| <div class="modebar"> | |
| <button id="mode_pkr" onclick="setMode('pkr')">PKR · mon traitement</button> | |
| <button id="mode_lasik" onclick="setMode('lasik')">LASIK · type</button> | |
| <button id="notifbtn" class="notifbtn" onclick="toggleNotif()">🔔 Rappels</button> | |
| </div> | |
| <div class="lasiknote" id="lasiknote"></div> | |
| <div class="banner" id="banner"></div> | |
| <details> | |
| <summary>📋 Comment espacer (lis-moi)</summary> | |
| <ol> | |
| <li><b>5 min minimum entre deux produits différents</b> sinon la 1ère goutte fait déborder la 2ème.</li> | |
| <li>1 seule goutte suffit (l'œil ne contient pas plus).</li> | |
| <li>Ordre conseillé : antibiotique → corticoïde fort → cicatrisant/lubrifiant → rinçage en dernier. (Suis l'ordre de ton ophtalmo.)</li> | |
| <li>Dacudose se garde au frais ❄️.</li> | |
| </ol> | |
| </details> | |
| <div class="next"> | |
| <h2>⏭️ Prochaines doses</h2> | |
| <div id="nextlist"></div> | |
| </div> | |
| <div id="cards"></div> | |
| <div class="footer"> | |
| <button onclick="resetDay()">↺ Réinitialiser la journée</button> | |
| <button onclick="resetAll()">🗑️ Tout effacer</button> | |
| </div> | |
| <div class="note"> | |
| Outil personnel d'aide-mémoire, sans valeur médicale. En cas de douleur intense, baisse de vision ou doute → ophtalmo. | |
| </div> | |
| <script> | |
| /* ---------- Données des collyres ---------- */ | |
| const PKR_MEDS = [ | |
| { | |
| id:'maxidrol', name:'Maxidrol', emoji:'🔴', color:'#ff5d6c', | |
| desc:'Tobramycine + corticoïde — TRÈS fort. À donner en premier.', | |
| taper:[ | |
| {label:'4×/j (début)', freq:4, intervalH:4}, | |
| {label:'3×/j · 7 jours', freq:3, intervalH:5}, | |
| {label:'2×/j · 7 jours', freq:2, intervalH:7}, | |
| {label:'1×/j · 7 jours', freq:1, intervalH:24}, | |
| ], | |
| howto:[ | |
| 'Agiter le flacon avant usage 🫨', | |
| 'Se laver les mains', | |
| 'Ne pas toucher l\'œil ni les cils avec l\'embout', | |
| 'Regarder vers le haut, tirer la paupière du bas, déposer 1 goutte dans le cul-de-sac conjonctival inférieur', | |
| 'Fermer l\'œil et appuyer 2 min sur le coin interne (près du nez) 👃', | |
| ], | |
| }, | |
| { id:'quinofree', name:'Quinofree', emoji:'🟡', color:'#ffb020', | |
| desc:'Ofloxacine (antibiotique) — 1 goutte, 4×/j pendant 7 jours.', | |
| freq:4, intervalH:4, durationDays:7, | |
| howto:[ | |
| 'Se laver les mains', | |
| 'Ne pas toucher l\'œil avec l\'embout', | |
| 'Regarder en haut, tirer la paupière du bas, 1 goutte dans le cul-de-sac inférieur', | |
| 'Fermer l\'œil doucement quelques secondes', | |
| ] }, | |
| { id:'nebraya', name:'Nebraya', emoji:'🟢', color:'#39d98a', | |
| desc:'Cicatrisant — 1 goutte, 6×/j pendant 30 jours. + à volonté si douleur.', | |
| freq:6, intervalH:2.5, durationDays:30, asNeeded:true, | |
| howto:[ | |
| 'Se laver les mains', | |
| '1 goutte dans le cul-de-sac inférieur (paupière du bas)', | |
| 'Une goutte de plus possible si douleur', | |
| ] }, | |
| { id:'vitadrop', name:'Vitadrop', emoji:'🔵', color:'#4f8cff', | |
| desc:'Vitamine (solution ophtalmique) — 1 goutte, 4×/j pendant 15 jours.', | |
| freq:4, intervalH:4, durationDays:15, | |
| howto:[ | |
| 'Se laver les mains', | |
| 'Ne pas toucher l\'œil avec l\'embout', | |
| '1 goutte dans le cul-de-sac inférieur', | |
| 'Fermer l\'œil doucement', | |
| ] }, | |
| { id:'dacudose', name:'Dacudose', emoji:'❄️', color:'#9aa6c4', | |
| desc:'Acide borique (rinçage) — à volonté. Garder au frais.', | |
| unlimited:true, intervalH:2, | |
| howto:[ | |
| 'Se laver les mains', | |
| 'Garder au frais ❄️', | |
| 'Rincer l\'œil, puis jeter l\'unidose après usage', | |
| ] }, | |
| ]; | |
| /* Protocole LASIK — TYPE / GÉNÉRIQUE (cicatrisation plus rapide qu'en PKR). | |
| Les marques et durées exactes varient selon le chirurgien : à adapter à ton ordonnance. */ | |
| const LASIK_MEDS = [ | |
| { id:'lasik_antibio', name:'Antibiotique', emoji:'🟡', color:'#ffb020', | |
| desc:'Ofloxacine / moxifloxacine — 1 goutte, 4×/j pendant ~7 jours.', | |
| freq:4, intervalH:4, durationDays:7, | |
| howto:[ | |
| 'Se laver les mains', | |
| 'Ne pas toucher l\'œil avec l\'embout', | |
| 'Regarder en haut, tirer la paupière du bas, 1 goutte dans le cul-de-sac inférieur', | |
| 'Appuyer 1–2 min sur le coin interne (près du nez), fermer l\'œil', | |
| ] }, | |
| { id:'lasik_cortico', name:'Corticoïde', emoji:'🔴', color:'#ff5d6c', | |
| desc:'Dexaméthasone (anti-inflammatoire) — 1 goutte, 4×/j pendant ~7 jours (parfois toutes les 2h le jour J).', | |
| freq:4, intervalH:4, durationDays:7, | |
| howto:[ | |
| 'Agiter si suspension 🫨', | |
| 'Se laver les mains', | |
| 'Ne pas toucher l\'œil avec l\'embout', | |
| '1 goutte dans le cul-de-sac inférieur', | |
| 'Appuyer 1–2 min sur le coin interne (près du nez), fermer l\'œil', | |
| ] }, | |
| { id:'lasik_larmes', name:'Larmes artificielles', emoji:'🟢', color:'#39d98a', | |
| desc:'Sans conservateur — généreusement, jusqu\'à 1×/h la 1ère semaine, puis ≥4×/j pendant ~1 mois. À volonté.', | |
| freq:6, intervalH:2, durationDays:30, asNeeded:true, | |
| howto:[ | |
| 'Se laver les mains', | |
| '1 goutte dans le cul-de-sac inférieur', | |
| 'À renouveler dès que l\'œil est sec ou irrité', | |
| ] }, | |
| ]; | |
| let MEDS = PKR_MEDS; | |
| /* ---------- État ---------- */ | |
| const KEY='pkr_state_v2'; | |
| function todayKey(){const d=new Date();return d.getFullYear()+'-'+String(d.getMonth()+1).padStart(2,'0')+'-'+String(d.getDate()).padStart(2,'0');} | |
| function loadState(){ | |
| let s=null; try{s=JSON.parse(localStorage.getItem(KEY));}catch(e){} | |
| if(!s){ s={startDate:todayKey(), maxidrolPhase:0, logs:{}, lastDrop:null, mode:'pkr', notif:false}; } | |
| if(!s.logs) s.logs={}; | |
| if(!s.mode) s.mode='pkr'; | |
| // pré-remplissage : Maxidrol déjà donné à 12h10 aujourd'hui | |
| const tk=todayKey(); | |
| if(!s.logs[tk]){ s.logs[tk]={}; if(s.startDate===tk){ s.logs[tk].maxidrol=['12:10']; } } | |
| return s; | |
| } | |
| let S=loadState(); | |
| MEDS = (S.mode==='lasik') ? LASIK_MEDS : PKR_MEDS; | |
| function save(){localStorage.setItem(KEY,JSON.stringify(S));} | |
| function setMode(m){ S.mode=m; MEDS=(m==='lasik')?LASIK_MEDS:PKR_MEDS; firedNotif={}; save(); render(); } | |
| function today(){const tk=todayKey(); if(!S.logs[tk])S.logs[tk]={}; return S.logs[tk];} | |
| /* ---------- Helpers temps ---------- */ | |
| function nowStr(){const d=new Date();return String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0');} | |
| function hm(str){const[a,b]=str.split(':').map(Number);return a*60+b;} | |
| function fromMin(m){m=((m%1440)+1440)%1440;return String(Math.floor(m/60)).padStart(2,'0')+':'+String(m%60).padStart(2,'0');} | |
| function nowMin(){const d=new Date();return d.getHours()*60+d.getMinutes();} | |
| function daysSinceStart(){ | |
| const a=new Date(S.startDate+'T00:00:00'), b=new Date(todayKey()+'T00:00:00'); | |
| return Math.round((b-a)/86400000); | |
| } | |
| /* ---------- Logique d'un collyre ---------- */ | |
| function medCfg(m){ | |
| if(m.taper) return {...m, ...m.taper[S.maxidrolPhase]}; | |
| return m; | |
| } | |
| function give(id,time){ | |
| const t=today(); if(!t[id])t[id]=[]; | |
| t[id].push(time||nowStr()); | |
| t[id].sort((a,b)=>hm(a)-hm(b)); | |
| if(!time){ S.lastDrop={id, ts:Date.now()}; } | |
| save(); render(); | |
| } | |
| function undo(id){ const t=today(); if(t[id]&&t[id].length){t[id].pop(); save(); render();} } | |
| /* planned times: étalées de la 1ère goutte du jour → minuit max */ | |
| function planned(cfg){ | |
| const t=today(); const given=t[cfg.id]||[]; | |
| if(cfg.unlimited||!cfg.freq) return null; | |
| const anchor = given.length? hm(given[0]) : nowMin(); | |
| const END = 1440; // minuit | |
| if(cfg.freq===1) return [anchor]; | |
| const step = Math.max(0,(END-anchor))/(cfg.freq-1); | |
| const arr=[]; | |
| for(let k=0;k<cfg.freq;k++) arr.push(Math.round(anchor + k*step)); | |
| return arr; | |
| } | |
| /* ---------- Calcul par collyre (partagé render + tick) ---------- */ | |
| function blockState(){ | |
| if(!S.lastDrop) return {blocked:false,elapsed:1e9}; | |
| const elapsed=Math.floor((Date.now()-S.lastDrop.ts)/1000); | |
| return {blocked:elapsed<300, elapsed, rem:300-elapsed, fromId:S.lastDrop.id}; | |
| } | |
| function computeMed(m){ | |
| const cfg=medCfg(m); | |
| const given=(today()[m.id]||[]); | |
| const plan=planned(cfg); | |
| const nm=nowMin(); | |
| let nextMin=null,status=''; | |
| if(plan){ | |
| const idx=given.length; | |
| if(idx<plan.length){ nextMin=plan[idx]; status=(nm>=nextMin)?'late':(nextMin-nm<=30?'soon':'ok'); } | |
| } | |
| // slots | |
| let slotsHtml=''; | |
| if(plan){ | |
| plan.forEach((p,i)=>{ | |
| let cls='slot'; | |
| if(i<given.length) cls+=' done'; | |
| else if(i===given.length) cls+=(nm>=p?' late':' next'); | |
| slotsHtml+='<span class="'+cls+'">'+(i<given.length?'✓ '+given[i]:fromMin(p))+'</span>'; | |
| }); | |
| } else if(m.unlimited){ | |
| given.forEach(g=>slotsHtml+='<span class="slot done">✓ '+g+'</span>'); | |
| if(!given.length) slotsHtml='<span class="slot">à volonté</span>'; | |
| } | |
| // progress | |
| let progress=''; | |
| if(cfg.freq){ progress='Aujourd\'hui : <b>'+given.length+' / '+cfg.freq+'</b>'+(nextMin!=null?' · prochaine <b>'+fromMin(nextMin)+'</b>':' ✅ terminé'); } | |
| else if(m.unlimited){ progress='<b>'+given.length+'</b> aujourd\'hui'; } | |
| return {cfg,given,plan,nextMin,status,slotsHtml,progress,goNow:(nextMin!=null&&nm>=nextMin)}; | |
| } | |
| /* ---------- Rendu ---------- */ | |
| function render(){ | |
| document.getElementById('clock').textContent=nowStr(); | |
| document.getElementById('dayinfo').textContent='Jour '+(daysSinceStart()+1)+' · '+todayKey().split('-').reverse().join('/'); | |
| document.getElementById('startbox').innerHTML='Début traitement<br><input type="date" value="'+S.startDate+'" onchange="S.startDate=this.value;save();render()" style="background:var(--card2);color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:4px;font-size:11px;margin-top:3px">'; | |
| /* mode + notif */ | |
| document.getElementById('mode_pkr').className = (S.mode==='pkr'?'active':''); | |
| document.getElementById('mode_lasik').className = (S.mode==='lasik'?'active':''); | |
| const ln=document.getElementById('lasiknote'); | |
| if(S.mode==='lasik'){ ln.className='lasiknote show'; ln.innerHTML='ℹ️ Protocole LASIK <b>générique/type</b> (cicatrisation plus rapide). Marques et durées varient selon le chirurgien — adapte à ton ordonnance.'; } | |
| else ln.className='lasiknote'; | |
| const nb=document.getElementById('notifbtn'); | |
| const granted = ('Notification' in window) && Notification.permission==='granted' && S.notif; | |
| nb.className = 'notifbtn'+(granted?' on':''); nb.textContent = granted?'🔔 Rappels ON':'🔕 Rappels'; | |
| /* cartes */ | |
| const cardsEl=document.getElementById('cards'); cardsEl.innerHTML=''; | |
| MEDS.forEach(m=>{ | |
| const d=computeMed(m); | |
| /* pill */ | |
| let pill=''; | |
| if(m.durationDays!==undefined){ | |
| const rem=m.durationDays-daysSinceStart(); | |
| pill='<span class="pill">'+(rem>0?'reste '+rem+' j':'fini ?')+'</span>'; | |
| } else if(m.unlimited){ pill='<span class="pill warnpill">à volonté ❄️</span>'; } | |
| /* phases maxidrol */ | |
| let phaseHtml=''; | |
| if(m.taper){ | |
| phaseHtml='<div class="phase">'; | |
| m.taper.forEach((p,i)=>{ phaseHtml+='<button class="'+(i===S.maxidrolPhase?'active':'')+'" onclick="S.maxidrolPhase='+i+';save();render()">'+p.label+'</button>'; }); | |
| phaseHtml+='</div>'; | |
| } | |
| /* comment l'utiliser */ | |
| let howtoHtml=''; | |
| if(m.howto){ | |
| howtoHtml='<details class="howto"><summary>💧 Comment l\'utiliser</summary><ol>' | |
| +m.howto.map(s=>'<li>'+s+'</li>').join('')+'</ol></details>'; | |
| } | |
| const card=document.createElement('div'); | |
| card.className='card'; card.style.setProperty('--c',m.color); | |
| card.innerHTML= | |
| '<div class="head"><div><div class="name">'+m.emoji+' '+m.name+'</div><div class="desc">'+m.desc+'</div></div>'+pill+'</div>' | |
| +phaseHtml | |
| +'<div class="progress" id="prog_'+m.id+'">'+d.progress+'</div>' | |
| +howtoHtml | |
| +'<div class="slots" id="slots_'+m.id+'">'+d.slotsHtml+'</div>' | |
| +'<div class="actions">' | |
| +'<button class="give" id="give_'+m.id+'" onclick="give(\''+m.id+'\')"></button>' | |
| +'<input type="time" id="t_'+m.id+'" value="'+nowStr()+'">' | |
| +'<button class="mini" onclick="give(\''+m.id+'\',document.getElementById(\'t_'+m.id+'\').value)">+ heure</button>' | |
| +'<button class="mini undo" onclick="undo(\''+m.id+'\')">↶</button>' | |
| +'</div>'; | |
| cardsEl.appendChild(card); | |
| }); | |
| tick(); // remplit boutons, banner, liste, compteur | |
| } | |
| /* ---------- Ticker à la seconde (sans casser les inputs) ---------- */ | |
| function tick(){ | |
| document.getElementById('clock').textContent=nowStr(); | |
| const b=blockState(); | |
| const banner=document.getElementById('banner'); banner.className='banner'; | |
| if(b.blocked){ | |
| const mm=Math.floor(b.rem/60), ss=b.rem%60; | |
| banner.className='banner wait'; | |
| banner.innerHTML='⏳ Bloqué encore <b style="margin:0 4px">'+mm+':'+String(ss).padStart(2,'0')+'</b> — 5 min entre 2 produits.'; | |
| } else if(b.elapsed<900){ | |
| banner.className='banner go'; | |
| banner.innerHTML='✅ OK pour le produit suivant.'; | |
| } | |
| const upcoming=[]; | |
| MEDS.forEach(m=>{ | |
| const d=computeMed(m); | |
| const sl=document.getElementById('slots_'+m.id); if(sl) sl.innerHTML=d.slotsHtml; | |
| const pg=document.getElementById('prog_'+m.id); if(pg) pg.innerHTML=d.progress; | |
| const btn=document.getElementById('give_'+m.id); | |
| if(btn){ | |
| if(b.blocked){ | |
| btn.disabled=true; btn.className='give blocked'; btn.style.opacity=1; | |
| const mm=Math.floor(b.rem/60), ss=b.rem%60; | |
| btn.textContent='⛔ Attends '+mm+':'+String(ss).padStart(2,'0'); | |
| } else { | |
| btn.disabled=false; btn.style.opacity=1; | |
| if(d.cfg.unlimited){ | |
| btn.className='give'; btn.textContent='✓ Donné maintenant'; | |
| } else if(d.goNow || d.status==='soon'){ | |
| btn.className='give'+(d.goNow?' go':''); btn.textContent='✓ Donné'+(d.goNow?' (maintenant !)':' maintenant'); | |
| } else if(d.nextMin!=null){ | |
| btn.className='give notyet'; btn.textContent='⏰ Pas l\'heure · '+fromMin(d.nextMin); | |
| } else { | |
| btn.className='give notyet'; btn.textContent='✓ Fini aujourd\'hui'; | |
| } | |
| } | |
| } | |
| // rappel "tu peux donner X" (une fois par créneau, quand c'est l'heure et non bloqué) | |
| if(S.notif && d.nextMin!=null && d.goNow && !b.blocked){ | |
| const k=S.mode+':'+m.id+':'+d.given.length+':'+todayKey(); | |
| if(!firedNotif[k]){ firedNotif[k]=1; notify('💧 '+m.name,'Tu peux donner '+m.name+' maintenant.'); } | |
| } | |
| if(d.nextMin!=null) upcoming.push({m,min:d.nextMin,status:d.status}); | |
| }); | |
| // rappel fin d'espacement 5 min | |
| if(S.notif && wasBlocked && !b.blocked){ notify('✅ Espacement fini','Tu peux donner la goutte suivante (5 min écoulées).'); } | |
| wasBlocked=b.blocked; | |
| upcoming.sort((a,b)=>a.min-b.min); | |
| const nl=document.getElementById('nextlist'); | |
| if(!upcoming.length){ nl.innerHTML='<div class="empty">Tout est donné pour les créneaux planifiés 🎉</div>'; } | |
| else{ | |
| nl.innerHTML=upcoming.map(u=>{ | |
| const cls=u.status==='late'?'late':u.status==='soon'?'soon':'ok'; | |
| const txt=u.status==='late'?'à donner !':u.status==='soon'?'bientôt':''; | |
| return '<div class="row"><div><span class="dot" style="background:'+u.m.color+'"></span>'+u.m.emoji+' '+u.m.name+'</div>' | |
| +'<div class="t '+cls+'">'+fromMin(u.min)+(txt?' · '+txt:'')+'</div></div>'; | |
| }).join(''); | |
| } | |
| } | |
| /* ---------- Notifications ---------- */ | |
| let firedNotif={}; // clés déjà notifiées (en mémoire, par jour) | |
| let wasBlocked=false; | |
| function toggleNotif(){ | |
| if(!('Notification' in window)){ alert('Les notifications ne sont pas supportées par ce navigateur.'); return; } | |
| if(S.notif && Notification.permission==='granted'){ S.notif=false; save(); render(); return; } | |
| Notification.requestPermission().then(p=>{ | |
| S.notif=(p==='granted'); save(); render(); | |
| if(p==='granted') notify('🔔 Rappels activés','Tu seras prévenu quand une goutte est à donner.'); | |
| else alert('Autorise les notifications dans les réglages du navigateur pour activer les rappels.'); | |
| }); | |
| } | |
| function notify(title,body){ | |
| try{ | |
| if(('Notification' in window) && Notification.permission==='granted'){ new Notification(title,{body,tag:title}); } | |
| if(navigator.vibrate) navigator.vibrate(200); | |
| }catch(e){} | |
| } | |
| function resetDay(){ if(confirm('Effacer les gouttes données aujourd\'hui ?')){ S.logs[todayKey()]={}; S.lastDrop=null; firedNotif={}; save(); render(); } } | |
| function resetAll(){ if(confirm('Tout remettre à zéro (historique + date de début) ?')){ localStorage.removeItem(KEY); S=loadState(); save(); render(); } } | |
| render(); | |
| setInterval(tick,1000); // compteur 5 min + horloge + statuts (sans casser les champs heure) | |
| // réveille l'horloge quand on revient sur la page (les navigateurs gèlent les timers en arrière-plan) | |
| document.addEventListener('visibilitychange',()=>{ if(!document.hidden) tick(); }); | |
| window.addEventListener('focus',tick); | |
| window.addEventListener('pageshow',tick); | |
| </script> | |
| </body> | |
| </html> | |