Spaces:
Sleeping
Sleeping
Download private/app.html from Offlin33er/planner-auth: direct link, hf CLI and curl.
- Browser
- Download file 25.3 kB
-
https://huggingface.co/spaces/Offlin33er/planner-auth/resolve/main/private/app.html
- Command line
-
hf download hf://spaces/Offlin33er/planner-auth/private/app.html
-
curl -L -o app.html https://huggingface.co/spaces/Offlin33er/planner-auth/resolve/main/private/app.html
25.3 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Planner — authenticated</title> | |
| <style> | |
| :root { --accent: #0d9488; --danger: #e11d48; --border: #e2e8f0; --muted: #64748b; --bg: #f8fafc; --card: #fff; } | |
| * { box-sizing: border-box; } | |
| body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: #1e293b; } | |
| header { background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; } | |
| .hwrap { max-width: 960px; margin: 0 auto; padding: 0 16px; height: 58px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } | |
| .brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 15px; } | |
| .badge { width: 34px; height: 34px; border-radius: 10px; background: var(--accent); color: #fff; display: flex; align-items: center; justify-content: center; } | |
| nav { display: flex; gap: 2px; flex-wrap: wrap; } | |
| nav button { border: 0; background: transparent; padding: 8px 12px; border-radius: 8px; font-size: 13px; font-weight: 500; color: var(--muted); cursor: pointer; font-family: inherit; } | |
| nav button.on { background: #f0fdfa; color: #0f766e; } | |
| .who { font-size: 12px; color: var(--muted); font-weight: 500; } | |
| main { max-width: 960px; margin: 0 auto; padding: 20px 16px 60px; } | |
| h1 { font-size: 20px; margin: 4px 0 14px; } | |
| .card { background: var(--card); border: 1px solid var(--border); border-radius: 14px; padding: 18px; margin-bottom: 16px; } | |
| .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 16px; } | |
| .stat b { font-size: 22px; display: block; } | |
| .stat span { font-size: 12px; color: var(--muted); } | |
| label { display: block; font-size: 12px; font-weight: 500; margin: 10px 0 4px; } | |
| input, select { width: 100%; padding: 9px 11px; border: 1px solid var(--border); border-radius: 9px; font-size: 14px; outline: none; font-family: inherit; background: #fff; color: inherit; } | |
| input:focus, select:focus { border-color: var(--accent); } | |
| button.pri { background: var(--accent); border: 0; color: #fff; padding: 9px 16px; border-radius: 9px; font-size: 13px; font-weight: 600; cursor: pointer; margin-top: 12px; font-family: inherit; } | |
| button.ghost { background: #fff; border: 1px solid var(--border); color: var(--muted); padding: 6px 10px; border-radius: 8px; font-size: 12px; cursor: pointer; font-family: inherit; } | |
| button.ghost:hover { border-color: var(--accent); color: #0f766e; } | |
| button.x { border: 0; background: none; color: #94a3b8; cursor: pointer; font-size: 14px; padding: 2px 6px; } | |
| button.x:hover { color: var(--danger); } | |
| .row { display: flex; align-items: center; gap: 10px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px; background: #fff; } | |
| .row .grow { flex: 1; min-width: 0; } | |
| .row .t { font-size: 14px; font-weight: 500; } | |
| .row .s { font-size: 11px; color: var(--muted); } | |
| .chip { font-size: 10px; padding: 2px 8px; border-radius: 99px; font-weight: 600; white-space: nowrap; } | |
| .chip.high { background: #ffe4e6; color: #be123c; } | |
| .chip.medium { background: #fef3c7; color: #b45309; } | |
| .chip.low { background: #e0f2fe; color: #0369a1; } | |
| .chip.late { background: #fef2f2; color: var(--danger); } | |
| .dot { width: 20px; height: 20px; border-radius: 6px; border: 2px solid #cbd5e1; background: #fff; cursor: pointer; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 12px; padding: 0; } | |
| .dot.on { border-color: var(--accent); background: var(--accent); } | |
| .days { display: flex; gap: 5px; margin: 10px 0 6px; } | |
| .day { flex: 1; height: 34px; border-radius: 8px; border: 1px solid var(--border); background: #fff; font-size: 10px; color: var(--muted); cursor: pointer; font-family: inherit; } | |
| .day.on { color: #fff; border-color: transparent; } | |
| .day:disabled { opacity: .4; cursor: default; } | |
| .week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; } | |
| .wd { border: 1px solid var(--border); border-radius: 10px; padding: 8px 4px; text-align: center; } | |
| .wd.today { border-color: var(--accent); background: #f0fdfa; } | |
| .wd .n { font-weight: 600; font-size: 13px; } | |
| .wd .bar { height: 5px; border-radius: 4px; background: #f1f5f9; margin-top: 6px; overflow: hidden; } | |
| .wd .bar i { display: block; height: 100%; background: var(--accent); } | |
| .emoji-pick, .color-pick { display: flex; gap: 6px; flex-wrap: wrap; margin: 4px 0 8px; } | |
| .emoji-pick button { width: 36px; height: 36px; border-radius: 10px; border: 1px solid var(--border); background: #fff; font-size: 18px; cursor: pointer; padding: 0; } | |
| .emoji-pick button.on { border-color: var(--accent); background: #f0fdfa; } | |
| .color-pick button { width: 26px; height: 26px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; padding: 0; margin-top: 0; } | |
| .color-pick button.on { border-color: #1e293b; } | |
| .msg { font-size: 13px; border-radius: 9px; padding: 9px 12px; margin: 12px 0 0; display: none; } | |
| .msg.err { display: block; background: #fef2f2; color: var(--danger); border: 1px solid #fecaca; } | |
| .empty { text-align: center; color: var(--muted); font-size: 13px; padding: 24px 0; } | |
| .timer { text-align: center; } | |
| .presets { display: flex; gap: 8px; justify-content: center; margin-bottom: 10px; } | |
| .presets button { border: 1px solid var(--border); background: #fff; padding: 6px 14px; border-radius: 99px; font-size: 12px; cursor: pointer; margin: 0; font-family: inherit; } | |
| .presets button.on { background: var(--accent); color: #fff; border-color: var(--accent); } | |
| .tmin { font-size: 40px; font-weight: 300; font-variant-numeric: tabular-nums; } | |
| .tmode { font-size: 11px; letter-spacing: 2px; text-transform: uppercase; color: var(--muted); } | |
| .two { display: grid; grid-template-columns: 2fr 1fr; gap: 16px; align-items: start; } | |
| @media (max-width: 700px) { .two { grid-template-columns: 1fr; } } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="hwrap"> | |
| <div class="brand"><div class="badge">🍃</div> Planner <span id="who" class="who"></span></div> | |
| <nav id="nav"> | |
| <button data-v="today" class="on">Today</button> | |
| <button data-v="habits">Habits</button> | |
| <button data-v="tasks">Tasks</button> | |
| <button data-v="focus">Focus</button> | |
| <button data-v="exit">Sign out</button> | |
| </nav> | |
| </div> | |
| </header> | |
| <main> | |
| <h1 id="main-title">Today</h1> | |
| <div id="page"></div> | |
| </main> | |
| <div class="msg" id="msg" style="max-width:960px;margin:0 auto 12px;"></div> | |
| <script> | |
| ; | |
| /* Authenticated planner: ALL data comes from the server, identified by the | |
| session (httpOnly cookie). Any 401 sends the user back to the login. */ | |
| const $ = (id) => document.getElementById(id); | |
| const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); | |
| let me = null, habits = [], checkins = [], tasks = [], sessions = [], view = 'today'; | |
| let taskFilter = 'all'; | |
| const EMOJIS = ['💧','🏃','📚','🧘','🥗','😴','✍️','💪','🌿','🎸']; | |
| const COLORS = ['#0d9488','#0ea5e9','#8b5cf6','#f59e0b','#f43f5e','#10b981']; | |
| async function api(path, body, method) { | |
| const res = await fetch(path, { | |
| method: method || (body ? 'POST' : 'GET'), | |
| headers: body ? { 'Content-Type': 'application/json' } : undefined, | |
| body: body ? JSON.stringify(body) : undefined, | |
| credentials: 'same-origin' | |
| }); | |
| if (res.status === 401) { location.href = '/'; throw new Error('Session expired'); } | |
| const data = await res.json().catch(() => ({})); | |
| if (!res.ok) throw new Error(data.error || 'Error (' + res.status + ')'); | |
| return data; | |
| } | |
| /* ---------- dates ---------- */ | |
| const iso = (d = new Date()) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0'); | |
| const today = () => iso(); | |
| const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; }; | |
| const lastNDays = (n) => Array.from({ length: n }, (_, i) => iso(addDays(new Date(), -(n - 1 - i)))); | |
| const fmt = (s) => new Intl.DateTimeFormat('en-US', { day: '2-digit', month: '2-digit' }).format(new Date(s + 'T12:00:00')); | |
| const done = (h, d) => checkins.some(c => c.habitId === h.id && c.date === d); | |
| function streak(h) { | |
| if (h.freq === 'weekly') return 0; | |
| let s = 0, d = new Date(); | |
| if (!done(h, iso(d))) d = addDays(d, -1); | |
| while (done(h, iso(d))) { s++; d = addDays(d, -1); } | |
| return s; | |
| } | |
| function rate30(h) { | |
| const n = lastNDays(30).filter(d => done(h, d)).length; | |
| const exp = h.freq === 'daily' ? 30 : (h.goal || 3) * 4.3; | |
| return Math.min(100, Math.round(n / exp * 100)); | |
| } | |
| const weekDates = () => { const x = new Date(); const off = (x.getDay() + 6) % 7; return Array.from({ length: 7 }, (_, i) => iso(addDays(x, i - off))); }; | |
| const weekCount = (h) => weekDates().filter(d => done(h, d)).length; | |
| const focusMin = (d) => sessions.filter(s => iso(new Date(s.at)) === d).reduce((a, s) => a + s.minutes, 0); | |
| function focusWeek() { | |
| const off = (new Date().getDay() + 6) % 7; | |
| const ws = new Date(); ws.setDate(ws.getDate() - off); ws.setHours(0, 0, 0, 0); | |
| return sessions.filter(s => new Date(s.at) >= ws).reduce((a, s) => a + s.minutes, 0); | |
| } | |
| async function loadAll() { | |
| const [h, c, t, s] = await Promise.all([api('/api/habits'), api('/api/checkins'), api('/api/tasks'), api('/api/sessions')]); | |
| habits = h.habits; checkins = c.checkins; tasks = t.tasks; sessions = s.sessions; | |
| } | |
| function note(t) { | |
| const el = $('msg'); | |
| el.className = 'msg err'; | |
| el.textContent = '⚠ ' + t; | |
| setTimeout(() => { el.className = 'msg'; }, 4000); | |
| } | |
| /* ---------------- Render ---------------- */ | |
| function render() { | |
| document.querySelectorAll('nav button').forEach(b => b.classList.toggle('on', b.dataset.v === view)); | |
| $('msg').className = 'msg'; | |
| if (view === 'today') vToday(); | |
| else if (view === 'habits') vHabits(); | |
| else if (view === 'tasks') vTasks(); | |
| else if (view === 'focus') vFocus(); | |
| bind(); | |
| } | |
| function vToday() { | |
| $('main-title').textContent = 'Today'; | |
| const daily = habits.filter(h => h.freq === 'daily'); | |
| const dn = daily.filter(h => done(h, today())).length; | |
| const pct = daily.length ? Math.round(dn / daily.length * 100) : 0; | |
| const late = tasks.filter(t => !t.done && t.due && t.due < today()); | |
| const dueToday = tasks.filter(t => !t.done && t.due === today()); | |
| const wd = weekDates(), dayNames = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; | |
| const tIdx = (new Date().getDay() + 6) % 7; | |
| $('page').innerHTML = ` | |
| <div class="grid"> | |
| <div class="card stat"><b style="color:${pct === 100 ? '#0d9488' : 'inherit'}">${dn}/${daily.length}</b><span>habits done today</span></div> | |
| <div class="card stat"><b style="color:${late.length ? 'var(--danger)' : 'inherit'}">${late.length}</b><span>overdue tasks</span></div> | |
| <div class="card stat"><b>${focusMin(today())} min</b><span>of focus today</span></div> | |
| </div> | |
| <div class="two"> | |
| <div class="card"> | |
| <h1 style="font-size:15px">Today's habits</h1> | |
| ${daily.length ? daily.map(h => { | |
| const d = done(h, today()); | |
| return `<div class="row"> | |
| <button class="dot ${d ? 'on' : ''}" data-tog="${h.id}">${d ? '✓' : ''}</button> | |
| <span style="font-size:17px">${h.emoji}</span> | |
| <div class="grow"><div class="t">${esc(h.name)}</div></div> | |
| ${streak(h) ? `<span class="chip medium">🔥 ${streak(h)}</span>` : ''} | |
| </div>`; | |
| }).join('') : '<div class="empty">No daily habits yet. Create one on the Habits tab.</div>'} | |
| </div> | |
| <div class="card"> | |
| <h1 style="font-size:15px">Tasks for today</h1> | |
| ${[...late, ...dueToday].length ? [...late, ...dueToday].map(t => ` | |
| <div class="row"> | |
| <button class="dot" data-task="${t.id}"></button> | |
| <div class="grow"><div class="t" style="font-size:13px">${esc(t.title)}</div></div> | |
| <span class="chip ${t.due < today() ? 'late' : ''}">${t.due < today() ? 'overdue' : 'today'}</span> | |
| </div>`).join('') : '<div class="empty">All clear here ✨</div>'} | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h1 style="font-size:15px">Your week</h1> | |
| <div class="week" style="margin-top:10px">${wd.map((d, i) => { | |
| const pd = habits.length ? Math.round(habits.filter(h => done(h, d)).length / habits.length * 100) : 0; | |
| const future = d > today(); | |
| return `<div class="wd ${i === tIdx ? 'today' : ''}" ${future ? 'style="opacity:.4"' : ''}> | |
| <div style="font-size:9px;color:var(--muted);text-transform:uppercase">${dayNames[i]}</div> | |
| <div class="n">${fmt(d).slice(0, 2)}</div> | |
| <div class="bar"><i style="width:${future ? 0 : pd}%"></i></div> | |
| </div>`; | |
| }).join('')}</div> | |
| </div>`; | |
| } | |
| function vHabits() { | |
| $('main-title').textContent = 'Habits'; | |
| let selE = '💧', selC = COLORS[0]; | |
| $('page').innerHTML = ` | |
| <div class="card"> | |
| <h1 style="font-size:15px">New habit</h1> | |
| <form id="hf"> | |
| <label>Name</label> | |
| <input id="hName" required maxlength="80" placeholder="e.g. Read 20 pages"> | |
| <label>Icon</label> | |
| <div class="emoji-pick">${EMOJIS.map((e, i) => `<button type="button" data-e="${e}" class="${i === 0 ? 'on' : ''}">${e}</button>`).join('')}</div> | |
| <label>Color</label> | |
| <div class="color-pick">${COLORS.map((c, i) => `<button type="button" data-c="${c}" class="${i === 0 ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div> | |
| <label>Frequency</label> | |
| <select id="hFreq"><option value="daily">Every day</option><option value="weekly">Weekly</option></select> | |
| <div id="goalWrap" style="display:none"><label>Weekly goal: <span id="goalV">3×</span></label> | |
| <input id="hGoal" type="range" min="1" max="7" value="3" style="accent-color:var(--accent)"></div> | |
| <button class="pri">Create habit</button> | |
| </form> | |
| </div> | |
| ${habits.map(h => habitCard(h)).join('') || '<div class="empty">No habits yet — create your first one above. 🌱</div>'}`; | |
| $('hFreq').onchange = (e) => { $('goalWrap').style.display = e.target.value === 'weekly' ? 'block' : 'none'; }; | |
| $('hGoal').oninput = (e) => { $('goalV').textContent = e.target.value + '×'; }; | |
| document.querySelectorAll('.emoji-pick button').forEach(b => b.onclick = () => { | |
| selE = b.dataset.e; | |
| document.querySelectorAll('.emoji-pick button').forEach(x => x.classList.toggle('on', x === b)); | |
| }); | |
| document.querySelectorAll('.color-pick button').forEach(b => b.onclick = () => { | |
| selC = b.dataset.c; | |
| document.querySelectorAll('.color-pick button').forEach(x => x.classList.toggle('on', x === b)); | |
| }); | |
| $('hf').onsubmit = async (e) => { | |
| e.preventDefault(); | |
| try { | |
| await api('/api/habits', { name: $('hName').value, emoji: selE, color: selC, freq: $('hFreq').value, goal: +$('hGoal').value }); | |
| await loadAll(); render(); | |
| } catch (er) { note(er.message); } | |
| }; | |
| } | |
| function habitCard(h) { | |
| const last7 = lastNDays(7); | |
| return `<div class="card"> | |
| <div style="display:flex;align-items:center;justify-content:space-between"> | |
| <div style="display:flex;align-items:center;gap:10px"> | |
| <span style="width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:19px;background:${h.color}1a">${esc(h.emoji)}</span> | |
| <div><div class="t">${esc(h.name)}</div><div class="s" style="font-size:11px;color:var(--muted)">${h.freq === 'daily' ? 'Every day' : (h.goal || 3) + '× per week · ' + weekCount(h) + '/' + (h.goal || 3) + ' this week'}</div></div> | |
| </div> | |
| <button class="x" data-delh="${h.id}" title="Delete">✕</button> | |
| </div> | |
| <div class="days">${last7.map(d => { | |
| const on = done(h, d), future = d > today(); | |
| return `<button class="day ${on ? 'on' : ''}" data-h="${h.id}" data-d="${d}" ${future ? 'disabled' : ''} ${on ? `style="background:${h.color}"` : ''}>${fmt(d).slice(0, 2)}</button>`; | |
| }).join('')}</div> | |
| <div style="font-size:12px;color:var(--muted)">🔥 ${streak(h)} day streak · ${rate30(h)}% in 30 days</div> | |
| </div>`; | |
| } | |
| function vTasks() { | |
| $('main-title').textContent = 'Tasks'; | |
| let list = tasks.filter(t => !t.done); | |
| if (taskFilter === 'today') list = list.filter(t => t.due && t.due <= today()); | |
| if (taskFilter === 'week') list = list.filter(t => t.due && t.due <= iso(addDays(new Date(), 7))); | |
| if (taskFilter === 'done') list = tasks.filter(t => t.done); | |
| $('page').innerHTML = ` | |
| <div class="card"> | |
| <form id="tf" style="display:flex;gap:8px;flex-wrap:wrap"> | |
| <input id="tTitle" required maxlength="200" placeholder="Add a task…" style="flex:2;min-width:180px"> | |
| <select id="tPrio" style="width:auto"><option value="alta">🔴 High</option><option value="media" selected>🟡 Medium</option><option value="baixa">🔵 Low</option></select> | |
| <input id="tDue" type="date" value="${today()}" style="width:auto"> | |
| <button class="pri" style="margin-top:0">Add</button> | |
| </form> | |
| <div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">${['all','today','week','done'].map(f => | |
| `<button class="ghost ${taskFilter === f ? 'on' : ''}" data-f="${f}" style="${taskFilter === f ? 'background:#f0fdfa;border-color:#0d9488;color:#0f766e' : ''}">${f}</button>`).join('')}</div> | |
| </div> | |
| <div>${list.map(t => { | |
| const late = !t.done && t.due && t.due < today(); | |
| return `<div class="row"> | |
| <button class="dot ${t.done ? 'on' : ''}" data-task="${t.id}">${t.done ? '✓' : ''}</button> | |
| <div class="grow"><div class="t" style="${t.done ? 'text-decoration:line-through;color:var(--muted)' : ''}">${esc(t.title)}</div></div> | |
| <span class="chip ${t.priority}">${({ alta: 'high', media: 'medium', baixa: 'low' })[t.priority]}</span> | |
| ${t.due ? `<span class="chip ${late ? 'late' : ''}">${late ? 'overdue · ' : ''}${fmt(t.due)}</span>` : ''} | |
| <button class="x" data-up="${t.id}" title="Move up">↑</button> | |
| <button class="x" data-delt="${t.id}" title="Delete">✕</button> | |
| </div>`; | |
| }).join('') || '<div class="empty">Nothing here. 🎉</div>'}</div>`; | |
| document.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { taskFilter = b.dataset.f; render(); }); | |
| $('tf').onsubmit = async (e) => { | |
| e.preventDefault(); | |
| try { | |
| await api('/api/tasks', { title: $('tTitle').value, priority: $('tPrio').value, due: $('tDue').value || null }); | |
| await loadAll(); render(); | |
| } catch (er) { note(er.message); } | |
| }; | |
| } | |
| /* ---------------- Focus ---------------- */ | |
| let pomo = { mode: 'work', total: 25 * 60, rem: 25 * 60, run: false, timer: null, label: '' }; | |
| function vFocus() { | |
| $('main-title').textContent = 'Focus'; | |
| const circ = 2 * Math.PI * 80; | |
| $('page').innerHTML = ` | |
| <div class="two"> | |
| <div class="card timer"> | |
| <div class="presets">${[15, 25, 50].map(m => `<button class="${pomo.total === m * 60 ? 'on' : ''}" data-p="${m}">${m} min</button>`).join('')}</div> | |
| <svg viewBox="0 0 180 180" style="width:180px;height:180px;margin:0 auto;transform:rotate(-90deg);display:block"> | |
| <circle cx="90" cy="90" r="80" fill="none" stroke="#e2e8f0" stroke-width="9"/> | |
| <circle id="ring" cx="90" cy="90" r="80" fill="none" stroke="${pomo.mode === 'work' ? '#0d9488' : '#f59e0b'}" stroke-width="9" stroke-linecap="round" | |
| stroke-dasharray="${circ}" stroke-dashoffset="${circ * (1 - pomo.rem / pomo.total)}" style="transition:stroke-dashoffset .3s linear"/> | |
| </svg> | |
| <div class="tmin" id="tmin">${fmtT(pomo.rem)}</div> | |
| <div class="tmode">${pomo.mode === 'work' ? 'FOCUS' : 'BREAK'}</div> | |
| <input id="plabel" placeholder="What will you focus on?" style="margin-top:14px" value="${esc(pomo.label || '')}"> | |
| <div style="display:flex;gap:8px;justify-content:center"> | |
| <button class="pri" id="ptog" style="min-width:110px">${pomo.run ? 'Pause' : 'Start'}</button> | |
| <button class="ghost" id="prst" style="margin-top:12px">Reset</button> | |
| </div> | |
| </div> | |
| <div class="card"> | |
| <h1 style="font-size:15px">Today's sessions</h1> | |
| ${sessions.filter(s => iso(new Date(s.at)) === today()).map(s => ` | |
| <div class="row" style="padding:8px 10px"> | |
| <div class="grow"><div class="t" style="font-size:13px">${esc(s.label || 'Focus session')}</div> | |
| <div class="s" style="font-size:11px;color:var(--muted)">${s.minutes} min · ${new Date(s.at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}</div></div> | |
| </div>`).join('') || '<div class="empty">No sessions today yet. 🍅</div>'} | |
| <p style="font-size:12px;color:var(--muted)">Today: <b>${focusMin(today())} min</b> · this week: <b>${focusWeek()} min</b></p> | |
| </div> | |
| </div>`; | |
| $('plabel').oninput = (e) => { pomo.label = e.target.value; }; | |
| document.querySelectorAll('[data-p]').forEach(b => b.onclick = () => { | |
| if (pomo.run) return note('Pause the timer before switching.'); | |
| pomo.total = +b.dataset.p * 60; pomo.rem = pomo.total; pomo.mode = 'work'; render(); | |
| }); | |
| $('ptog').onclick = () => { | |
| pomo.run = !pomo.run; | |
| if (pomo.run) pomo.timer = setInterval(tick, 1000); else clearInterval(pomo.timer); | |
| render(); | |
| }; | |
| $('prst').onclick = () => { clearInterval(pomo.timer); pomo.run = false; pomo.mode = 'work'; pomo.total = 25 * 60; pomo.rem = pomo.total; render(); }; | |
| } | |
| function fmtT(s) { return String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); } | |
| async function tick() { | |
| pomo.rem--; | |
| const ring = $('ring'), t = $('tmin'); | |
| if (ring && t) { | |
| ring.setAttribute('stroke-dashoffset', 2 * Math.PI * 80 * (1 - Math.max(0, pomo.rem) / pomo.total)); | |
| t.textContent = fmtT(Math.max(0, pomo.rem)); | |
| } | |
| if (pomo.rem <= 0) { | |
| clearInterval(pomo.timer); pomo.run = false; | |
| if (pomo.mode === 'work') { | |
| try { await api('/api/sessions', { label: pomo.label || '', minutes: Math.round(pomo.total / 60) }); } catch { /* saved next time */ } | |
| pomo.mode = 'break'; pomo.total = 5 * 60; pomo.rem = 5 * 60; | |
| note('Cycle complete! Take a 5 min break ☕'); | |
| } else { | |
| pomo.mode = 'work'; pomo.total = 25 * 60; pomo.rem = 25 * 60; | |
| note('Break over. Back to focus! 💪'); | |
| } | |
| try { await loadAll(); } catch {} | |
| render(); | |
| } | |
| } | |
| /* ---------------- Actions via data-* attributes ---------------- */ | |
| function bind() { | |
| document.querySelectorAll('[data-tog]').forEach(b => b.onclick = async () => { | |
| try { await api('/api/checkins/toggle', { habitId: b.dataset.tog, date: today() }); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| document.querySelectorAll('[data-h]').forEach(b => b.onclick = async () => { | |
| if (b.disabled) return; | |
| try { await api('/api/checkins/toggle', { habitId: b.dataset.h, date: b.dataset.d }); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| document.querySelectorAll('[data-task]').forEach(b => b.onclick = async () => { | |
| const t = tasks.find(x => x.id === b.dataset.task); | |
| if (!t) return; | |
| try { await api('/api/tasks/' + t.id, { done: !t.done }, 'PUT'); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| document.querySelectorAll('[data-delt]').forEach(b => b.onclick = async () => { | |
| try { await api('/api/tasks/' + b.dataset.delt, null, 'DELETE'); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| document.querySelectorAll('[data-delh]').forEach(b => b.onclick = async () => { | |
| if (!confirm('Delete this habit and its history?')) return; | |
| try { await api('/api/habits/' + b.dataset.delh, null, 'DELETE'); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| document.querySelectorAll('[data-up]').forEach(b => b.onclick = async () => { | |
| const i = tasks.findIndex(t => t.id === b.dataset.up); | |
| if (i <= 0) return; | |
| [tasks[i - 1], tasks[i]] = [tasks[i], tasks[i - 1]]; | |
| try { await api('/api/tasks/reorder', { ids: tasks.map(t => t.id) }); await loadAll(); render(); } | |
| catch (e) { note(e.message); } | |
| }); | |
| } | |
| document.querySelectorAll('nav button').forEach(b => b.onclick = async () => { | |
| if (b.dataset.v === 'exit') { | |
| try { await fetch('/api/logout', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } catch {} | |
| location.href = '/'; | |
| return; | |
| } | |
| view = b.dataset.v; | |
| render(); | |
| }); | |
| /* ---------------- Init: requires a valid session ---------------- */ | |
| (async function init() { | |
| try { | |
| const r = await api('/api/me'); | |
| me = r.user; | |
| $('who').textContent = '· ' + esc(me.name.split(' ')[0]); | |
| await loadAll(); | |
| render(); | |
| } catch (e) { | |
| if (e.message !== 'Session expired') { | |
| document.body.innerHTML = '<p style="font-family:sans-serif;padding:40px;text-align:center">Failed to load: ' + esc(e.message) + '</p>'; | |
| } | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> |