Spaces:
Sleeping
Sleeping
Planner UI translated to English
Browse files- private/app.html +99 -99
private/app.html
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
-
<html lang="
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
-
<title>Planner —
|
| 7 |
<style>
|
| 8 |
-
:root { --accent: #0d9488; --
|
| 9 |
* { box-sizing: border-box; }
|
| 10 |
body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: #1e293b; }
|
| 11 |
header { background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
@@ -35,9 +35,9 @@
|
|
| 35 |
.row .t { font-size: 14px; font-weight: 500; }
|
| 36 |
.row .s { font-size: 11px; color: var(--muted); }
|
| 37 |
.chip { font-size: 10px; padding: 2px 8px; border-radius: 99px; font-weight: 600; white-space: nowrap; }
|
| 38 |
-
.chip.
|
| 39 |
-
.chip.
|
| 40 |
-
.chip.
|
| 41 |
.chip.late { background: #fef2f2; color: var(--danger); }
|
| 42 |
.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; }
|
| 43 |
.dot.on { border-color: var(--accent); background: var(--accent); }
|
|
@@ -74,28 +74,28 @@
|
|
| 74 |
<div class="hwrap">
|
| 75 |
<div class="brand"><div class="badge">🍃</div> Planner <span id="who" class="who"></span></div>
|
| 76 |
<nav id="nav">
|
| 77 |
-
<button data-v="
|
| 78 |
-
<button data-v="
|
| 79 |
-
<button data-v="
|
| 80 |
-
<button data-v="
|
| 81 |
-
<button data-v="
|
| 82 |
</nav>
|
| 83 |
</div>
|
| 84 |
</header>
|
| 85 |
<main>
|
| 86 |
-
<h1 id="main-title">
|
| 87 |
<div id="page"></div>
|
| 88 |
</main>
|
| 89 |
<div class="msg" id="msg" style="max-width:960px;margin:0 auto 12px;"></div>
|
| 90 |
|
| 91 |
<script>
|
| 92 |
'use strict';
|
| 93 |
-
/*
|
| 94 |
-
|
| 95 |
const $ = (id) => document.getElementById(id);
|
| 96 |
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 97 |
-
let me = null, habits = [], checkins = [], tasks = [], sessions = [], view = '
|
| 98 |
-
let
|
| 99 |
|
| 100 |
const EMOJIS = ['💧','🏃','📚','🧘','🥗','😴','✍️','💪','🌿','🎸'];
|
| 101 |
const COLORS = ['#0d9488','#0ea5e9','#8b5cf6','#f59e0b','#f43f5e','#10b981'];
|
|
@@ -107,18 +107,18 @@ async function api(path, body, method) {
|
|
| 107 |
body: body ? JSON.stringify(body) : undefined,
|
| 108 |
credentials: 'same-origin'
|
| 109 |
});
|
| 110 |
-
if (res.status === 401) { location.href = '/'; throw new Error('
|
| 111 |
const data = await res.json().catch(() => ({}));
|
| 112 |
-
if (!res.ok) throw new Error(data.error || '
|
| 113 |
return data;
|
| 114 |
}
|
| 115 |
|
| 116 |
-
/* ----------
|
| 117 |
const iso = (d = new Date()) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
| 118 |
const today = () => iso();
|
| 119 |
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
| 120 |
const lastNDays = (n) => Array.from({ length: n }, (_, i) => iso(addDays(new Date(), -(n - 1 - i))));
|
| 121 |
-
const fmt = (s) => new Intl.DateTimeFormat('
|
| 122 |
const done = (h, d) => checkins.some(c => c.habitId === h.id && c.date === d);
|
| 123 |
function streak(h) {
|
| 124 |
if (h.freq === 'weekly') return 0;
|
|
@@ -145,10 +145,10 @@ async function loadAll() {
|
|
| 145 |
const [h, c, t, s] = await Promise.all([api('/api/habits'), api('/api/checkins'), api('/api/tasks'), api('/api/sessions')]);
|
| 146 |
habits = h.habits; checkins = c.checkins; tasks = t.tasks; sessions = s.sessions;
|
| 147 |
}
|
| 148 |
-
function note(t
|
| 149 |
const el = $('msg');
|
| 150 |
-
el.className = 'msg
|
| 151 |
-
el.textContent =
|
| 152 |
setTimeout(() => { el.className = 'msg'; }, 4000);
|
| 153 |
}
|
| 154 |
|
|
@@ -156,53 +156,53 @@ function note(t, isErr) {
|
|
| 156 |
function render() {
|
| 157 |
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('on', b.dataset.v === view));
|
| 158 |
$('msg').className = 'msg';
|
| 159 |
-
if (view === '
|
| 160 |
-
else if (view === '
|
| 161 |
-
else if (view === '
|
| 162 |
-
else if (view === '
|
| 163 |
bind();
|
| 164 |
}
|
| 165 |
|
| 166 |
-
function
|
| 167 |
-
$('main-title').textContent = '
|
| 168 |
const daily = habits.filter(h => h.freq === 'daily');
|
| 169 |
const dn = daily.filter(h => done(h, today())).length;
|
| 170 |
const pct = daily.length ? Math.round(dn / daily.length * 100) : 0;
|
| 171 |
const late = tasks.filter(t => !t.done && t.due && t.due < today());
|
| 172 |
const dueToday = tasks.filter(t => !t.done && t.due === today());
|
| 173 |
-
const wd = weekDates(), dayNames = ['
|
| 174 |
const tIdx = (new Date().getDay() + 6) % 7;
|
| 175 |
$('page').innerHTML = `
|
| 176 |
<div class="grid">
|
| 177 |
-
<div class="card stat"><b style="color:${pct === 100 ? '#0d9488' : 'inherit'}">${dn}/${daily.length}</b><span>
|
| 178 |
-
<div class="card stat"><b style="color:${late.length ? 'var(--danger)' : 'inherit'}">${late.length}</b><span>
|
| 179 |
-
<div class="card stat"><b>${focusMin(today())} min</b><span>
|
| 180 |
</div>
|
| 181 |
<div class="two">
|
| 182 |
<div class="card">
|
| 183 |
-
<h1 style="font-size:15px">
|
| 184 |
${daily.length ? daily.map(h => {
|
| 185 |
const d = done(h, today());
|
| 186 |
return `<div class="row">
|
| 187 |
<button class="dot ${d ? 'on' : ''}" data-tog="${h.id}">${d ? '✓' : ''}</button>
|
| 188 |
<span style="font-size:17px">${h.emoji}</span>
|
| 189 |
<div class="grow"><div class="t">${esc(h.name)}</div></div>
|
| 190 |
-
${streak(h) ? `<span class="chip
|
| 191 |
</div>`;
|
| 192 |
-
}).join('') : '<div class="empty">
|
| 193 |
</div>
|
| 194 |
<div class="card">
|
| 195 |
-
<h1 style="font-size:15px">
|
| 196 |
${[...late, ...dueToday].length ? [...late, ...dueToday].map(t => `
|
| 197 |
<div class="row">
|
| 198 |
<button class="dot" data-task="${t.id}"></button>
|
| 199 |
<div class="grow"><div class="t" style="font-size:13px">${esc(t.title)}</div></div>
|
| 200 |
-
<span class="chip ${t.due < today() ? 'late' : ''}">${t.due < today() ? '
|
| 201 |
-
</div>`).join('') : '<div class="empty">
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
<div class="card">
|
| 205 |
-
<h1 style="font-size:15px">
|
| 206 |
<div class="week" style="margin-top:10px">${wd.map((d, i) => {
|
| 207 |
const pd = habits.length ? Math.round(habits.filter(h => done(h, d)).length / habits.length * 100) : 0;
|
| 208 |
const future = d > today();
|
|
@@ -215,27 +215,27 @@ function vHoje() {
|
|
| 215 |
</div>`;
|
| 216 |
}
|
| 217 |
|
| 218 |
-
function
|
| 219 |
-
$('main-title').textContent = '
|
| 220 |
let selE = '💧', selC = COLORS[0];
|
| 221 |
$('page').innerHTML = `
|
| 222 |
<div class="card">
|
| 223 |
-
<h1 style="font-size:15px">
|
| 224 |
<form id="hf">
|
| 225 |
-
<label>
|
| 226 |
-
<input id="hName" required maxlength="80" placeholder="
|
| 227 |
-
<label>
|
| 228 |
<div class="emoji-pick">${EMOJIS.map((e, i) => `<button type="button" data-e="${e}" class="${i === 0 ? 'on' : ''}">${e}</button>`).join('')}</div>
|
| 229 |
-
<label>
|
| 230 |
<div class="color-pick">${COLORS.map((c, i) => `<button type="button" data-c="${c}" class="${i === 0 ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
|
| 231 |
-
<label>
|
| 232 |
-
<select id="hFreq"><option value="daily">
|
| 233 |
-
<div id="goalWrap" style="display:none"><label>
|
| 234 |
<input id="hGoal" type="range" min="1" max="7" value="3" style="accent-color:var(--accent)"></div>
|
| 235 |
-
<button class="pri">
|
| 236 |
</form>
|
| 237 |
</div>
|
| 238 |
-
${habits.map(h => habitCard(h)).join('') || '<div class="empty">
|
| 239 |
$('hFreq').onchange = (e) => { $('goalWrap').style.display = e.target.value === 'weekly' ? 'block' : 'none'; };
|
| 240 |
$('hGoal').oninput = (e) => { $('goalV').textContent = e.target.value + '×'; };
|
| 241 |
document.querySelectorAll('.emoji-pick button').forEach(b => b.onclick = () => {
|
|
@@ -251,7 +251,7 @@ function vHabitos() {
|
|
| 251 |
try {
|
| 252 |
await api('/api/habits', { name: $('hName').value, emoji: selE, color: selC, freq: $('hFreq').value, goal: +$('hGoal').value });
|
| 253 |
await loadAll(); render();
|
| 254 |
-
} catch (er) { note(er.message
|
| 255 |
};
|
| 256 |
}
|
| 257 |
|
|
@@ -261,33 +261,33 @@ function habitCard(h) {
|
|
| 261 |
<div style="display:flex;align-items:center;justify-content:space-between">
|
| 262 |
<div style="display:flex;align-items:center;gap:10px">
|
| 263 |
<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>
|
| 264 |
-
<div><div class="t">${esc(h.name)}</div><div class="s" style="font-size:11px;color:var(--muted)">${h.freq === 'daily' ? '
|
| 265 |
</div>
|
| 266 |
-
<button class="x" data-delh="${h.id}" title="
|
| 267 |
</div>
|
| 268 |
<div class="days">${last7.map(d => {
|
| 269 |
const on = done(h, d), future = d > today();
|
| 270 |
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>`;
|
| 271 |
}).join('')}</div>
|
| 272 |
-
<div style="font-size:12px;color:var(--muted)">🔥 ${streak(h)}
|
| 273 |
</div>`;
|
| 274 |
}
|
| 275 |
|
| 276 |
-
function
|
| 277 |
-
$('main-title').textContent = '
|
| 278 |
let list = tasks.filter(t => !t.done);
|
| 279 |
-
if (taskFilter === '
|
| 280 |
-
if (taskFilter === '
|
| 281 |
-
if (taskFilter === '
|
| 282 |
$('page').innerHTML = `
|
| 283 |
<div class="card">
|
| 284 |
<form id="tf" style="display:flex;gap:8px;flex-wrap:wrap">
|
| 285 |
-
<input id="tTitle" required maxlength="200" placeholder="
|
| 286 |
-
<select id="tPrio" style="width:auto"><option value="alta">🔴
|
| 287 |
<input id="tDue" type="date" value="${today()}" style="width:auto">
|
| 288 |
-
<button class="pri" style="margin-top:0">
|
| 289 |
</form>
|
| 290 |
-
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">${['
|
| 291 |
`<button class="ghost ${taskFilter === f ? 'on' : ''}" data-f="${f}" style="${taskFilter === f ? 'background:#f0fdfa;border-color:#0d9488;color:#0f766e' : ''}">${f}</button>`).join('')}</div>
|
| 292 |
</div>
|
| 293 |
<div>${list.map(t => {
|
|
@@ -295,26 +295,26 @@ function vTarefas() {
|
|
| 295 |
return `<div class="row">
|
| 296 |
<button class="dot ${t.done ? 'on' : ''}" data-task="${t.id}">${t.done ? '✓' : ''}</button>
|
| 297 |
<div class="grow"><div class="t" style="${t.done ? 'text-decoration:line-through;color:var(--muted)' : ''}">${esc(t.title)}</div></div>
|
| 298 |
-
<span class="chip ${t.priority}">${({ alta: '
|
| 299 |
-
${t.due ? `<span class="chip ${late ? 'late' : ''}">${late ? '
|
| 300 |
-
<button class="x" data-up="${t.id}" title="
|
| 301 |
-
<button class="x" data-delt="${t.id}" title="
|
| 302 |
</div>`;
|
| 303 |
-
}).join('') || '<div class="empty">
|
| 304 |
document.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { taskFilter = b.dataset.f; render(); });
|
| 305 |
$('tf').onsubmit = async (e) => {
|
| 306 |
e.preventDefault();
|
| 307 |
try {
|
| 308 |
await api('/api/tasks', { title: $('tTitle').value, priority: $('tPrio').value, due: $('tDue').value || null });
|
| 309 |
await loadAll(); render();
|
| 310 |
-
} catch (er) { note(er.message
|
| 311 |
};
|
| 312 |
}
|
| 313 |
|
| 314 |
-
/* ----------------
|
| 315 |
let pomo = { mode: 'work', total: 25 * 60, rem: 25 * 60, run: false, timer: null, label: '' };
|
| 316 |
-
function
|
| 317 |
-
$('main-title').textContent = '
|
| 318 |
const circ = 2 * Math.PI * 80;
|
| 319 |
$('page').innerHTML = `
|
| 320 |
<div class="two">
|
|
@@ -326,26 +326,26 @@ function vFoco() {
|
|
| 326 |
stroke-dasharray="${circ}" stroke-dashoffset="${circ * (1 - pomo.rem / pomo.total)}" style="transition:stroke-dashoffset .3s linear"/>
|
| 327 |
</svg>
|
| 328 |
<div class="tmin" id="tmin">${fmtT(pomo.rem)}</div>
|
| 329 |
-
<div class="tmode">${pomo.mode === 'work' ? '
|
| 330 |
-
<input id="plabel" placeholder="
|
| 331 |
<div style="display:flex;gap:8px;justify-content:center">
|
| 332 |
-
<button class="pri" id="ptog" style="min-width:110px">${pomo.run ? '
|
| 333 |
-
<button class="ghost" id="prst" style="margin-top:12px">
|
| 334 |
</div>
|
| 335 |
</div>
|
| 336 |
<div class="card">
|
| 337 |
-
<h1 style="font-size:15px">
|
| 338 |
${sessions.filter(s => iso(new Date(s.at)) === today()).map(s => `
|
| 339 |
<div class="row" style="padding:8px 10px">
|
| 340 |
-
<div class="grow"><div class="t" style="font-size:13px">${esc(s.label || '
|
| 341 |
-
<div class="s" style="font-size:11px;color:var(--muted)">${s.minutes} min · ${new Date(s.at).toLocaleTimeString('
|
| 342 |
-
</div>`).join('') || '<div class="empty">
|
| 343 |
-
<p style="font-size:12px;color:var(--muted)">
|
| 344 |
</div>
|
| 345 |
</div>`;
|
| 346 |
$('plabel').oninput = (e) => { pomo.label = e.target.value; };
|
| 347 |
document.querySelectorAll('[data-p]').forEach(b => b.onclick = () => {
|
| 348 |
-
if (pomo.run) return note('Pause
|
| 349 |
pomo.total = +b.dataset.p * 60; pomo.rem = pomo.total; pomo.mode = 'work'; render();
|
| 350 |
});
|
| 351 |
$('ptog').onclick = () => {
|
|
@@ -366,55 +366,55 @@ async function tick() {
|
|
| 366 |
if (pomo.rem <= 0) {
|
| 367 |
clearInterval(pomo.timer); pomo.run = false;
|
| 368 |
if (pomo.mode === 'work') {
|
| 369 |
-
try { await api('/api/sessions', { label: pomo.label || '', minutes: Math.round(pomo.total / 60) }); } catch { /*
|
| 370 |
pomo.mode = 'break'; pomo.total = 5 * 60; pomo.rem = 5 * 60;
|
| 371 |
-
note('
|
| 372 |
} else {
|
| 373 |
pomo.mode = 'work'; pomo.total = 25 * 60; pomo.rem = 25 * 60;
|
| 374 |
-
note('
|
| 375 |
}
|
| 376 |
try { await loadAll(); } catch {}
|
| 377 |
render();
|
| 378 |
}
|
| 379 |
}
|
| 380 |
|
| 381 |
-
/* ----------------
|
| 382 |
function bind() {
|
| 383 |
document.querySelectorAll('[data-tog]').forEach(b => b.onclick = async () => {
|
| 384 |
try { await api('/api/checkins/toggle', { habitId: b.dataset.tog, date: today() }); await loadAll(); render(); }
|
| 385 |
-
catch (e) { note(e.message
|
| 386 |
});
|
| 387 |
document.querySelectorAll('[data-h]').forEach(b => b.onclick = async () => {
|
| 388 |
if (b.disabled) return;
|
| 389 |
try { await api('/api/checkins/toggle', { habitId: b.dataset.h, date: b.dataset.d }); await loadAll(); render(); }
|
| 390 |
-
catch (e) { note(e.message
|
| 391 |
});
|
| 392 |
document.querySelectorAll('[data-task]').forEach(b => b.onclick = async () => {
|
| 393 |
const t = tasks.find(x => x.id === b.dataset.task);
|
| 394 |
if (!t) return;
|
| 395 |
try { await api('/api/tasks/' + t.id, { done: !t.done }, 'PUT'); await loadAll(); render(); }
|
| 396 |
-
catch (e) { note(e.message
|
| 397 |
});
|
| 398 |
document.querySelectorAll('[data-delt]').forEach(b => b.onclick = async () => {
|
| 399 |
try { await api('/api/tasks/' + b.dataset.delt, null, 'DELETE'); await loadAll(); render(); }
|
| 400 |
-
catch (e) { note(e.message
|
| 401 |
});
|
| 402 |
document.querySelectorAll('[data-delh]').forEach(b => b.onclick = async () => {
|
| 403 |
-
if (!confirm('
|
| 404 |
try { await api('/api/habits/' + b.dataset.delh, null, 'DELETE'); await loadAll(); render(); }
|
| 405 |
-
catch (e) { note(e.message
|
| 406 |
});
|
| 407 |
document.querySelectorAll('[data-up]').forEach(b => b.onclick = async () => {
|
| 408 |
const i = tasks.findIndex(t => t.id === b.dataset.up);
|
| 409 |
if (i <= 0) return;
|
| 410 |
[tasks[i - 1], tasks[i]] = [tasks[i], tasks[i - 1]];
|
| 411 |
try { await api('/api/tasks/reorder', { ids: tasks.map(t => t.id) }); await loadAll(); render(); }
|
| 412 |
-
catch (e) { note(e.message
|
| 413 |
});
|
| 414 |
}
|
| 415 |
|
| 416 |
document.querySelectorAll('nav button').forEach(b => b.onclick = async () => {
|
| 417 |
-
if (b.dataset.v === '
|
| 418 |
try { await fetch('/api/logout', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } catch {}
|
| 419 |
location.href = '/';
|
| 420 |
return;
|
|
@@ -423,7 +423,7 @@ document.querySelectorAll('nav button').forEach(b => b.onclick = async () => {
|
|
| 423 |
render();
|
| 424 |
});
|
| 425 |
|
| 426 |
-
/* ---------------- Init:
|
| 427 |
(async function init() {
|
| 428 |
try {
|
| 429 |
const r = await api('/api/me');
|
|
@@ -432,8 +432,8 @@ document.querySelectorAll('nav button').forEach(b => b.onclick = async () => {
|
|
| 432 |
await loadAll();
|
| 433 |
render();
|
| 434 |
} catch (e) {
|
| 435 |
-
if (e.message !== '
|
| 436 |
-
document.body.innerHTML = '<p style="font-family:sans-serif;padding:40px;text-align:center">
|
| 437 |
}
|
| 438 |
}
|
| 439 |
})();
|
|
|
|
| 1 |
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8">
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Planner — authenticated</title>
|
| 7 |
<style>
|
| 8 |
+
:root { --accent: #0d9488; --danger: #e11d48; --border: #e2e8f0; --muted: #64748b; --bg: #f8fafc; --card: #fff; }
|
| 9 |
* { box-sizing: border-box; }
|
| 10 |
body { margin: 0; font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif; background: var(--bg); color: #1e293b; }
|
| 11 |
header { background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
|
|
|
| 35 |
.row .t { font-size: 14px; font-weight: 500; }
|
| 36 |
.row .s { font-size: 11px; color: var(--muted); }
|
| 37 |
.chip { font-size: 10px; padding: 2px 8px; border-radius: 99px; font-weight: 600; white-space: nowrap; }
|
| 38 |
+
.chip.high { background: #ffe4e6; color: #be123c; }
|
| 39 |
+
.chip.medium { background: #fef3c7; color: #b45309; }
|
| 40 |
+
.chip.low { background: #e0f2fe; color: #0369a1; }
|
| 41 |
.chip.late { background: #fef2f2; color: var(--danger); }
|
| 42 |
.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; }
|
| 43 |
.dot.on { border-color: var(--accent); background: var(--accent); }
|
|
|
|
| 74 |
<div class="hwrap">
|
| 75 |
<div class="brand"><div class="badge">🍃</div> Planner <span id="who" class="who"></span></div>
|
| 76 |
<nav id="nav">
|
| 77 |
+
<button data-v="today" class="on">Today</button>
|
| 78 |
+
<button data-v="habits">Habits</button>
|
| 79 |
+
<button data-v="tasks">Tasks</button>
|
| 80 |
+
<button data-v="focus">Focus</button>
|
| 81 |
+
<button data-v="exit">Sign out</button>
|
| 82 |
</nav>
|
| 83 |
</div>
|
| 84 |
</header>
|
| 85 |
<main>
|
| 86 |
+
<h1 id="main-title">Today</h1>
|
| 87 |
<div id="page"></div>
|
| 88 |
</main>
|
| 89 |
<div class="msg" id="msg" style="max-width:960px;margin:0 auto 12px;"></div>
|
| 90 |
|
| 91 |
<script>
|
| 92 |
'use strict';
|
| 93 |
+
/* Authenticated planner: ALL data comes from the server, identified by the
|
| 94 |
+
session (httpOnly cookie). Any 401 sends the user back to the login. */
|
| 95 |
const $ = (id) => document.getElementById(id);
|
| 96 |
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
| 97 |
+
let me = null, habits = [], checkins = [], tasks = [], sessions = [], view = 'today';
|
| 98 |
+
let taskFilter = 'all';
|
| 99 |
|
| 100 |
const EMOJIS = ['💧','🏃','📚','🧘','🥗','😴','✍️','💪','🌿','🎸'];
|
| 101 |
const COLORS = ['#0d9488','#0ea5e9','#8b5cf6','#f59e0b','#f43f5e','#10b981'];
|
|
|
|
| 107 |
body: body ? JSON.stringify(body) : undefined,
|
| 108 |
credentials: 'same-origin'
|
| 109 |
});
|
| 110 |
+
if (res.status === 401) { location.href = '/'; throw new Error('Session expired'); }
|
| 111 |
const data = await res.json().catch(() => ({}));
|
| 112 |
+
if (!res.ok) throw new Error(data.error || 'Error (' + res.status + ')');
|
| 113 |
return data;
|
| 114 |
}
|
| 115 |
|
| 116 |
+
/* ---------- dates ---------- */
|
| 117 |
const iso = (d = new Date()) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
| 118 |
const today = () => iso();
|
| 119 |
const addDays = (d, n) => { const x = new Date(d); x.setDate(x.getDate() + n); return x; };
|
| 120 |
const lastNDays = (n) => Array.from({ length: n }, (_, i) => iso(addDays(new Date(), -(n - 1 - i))));
|
| 121 |
+
const fmt = (s) => new Intl.DateTimeFormat('en-US', { day: '2-digit', month: '2-digit' }).format(new Date(s + 'T12:00:00'));
|
| 122 |
const done = (h, d) => checkins.some(c => c.habitId === h.id && c.date === d);
|
| 123 |
function streak(h) {
|
| 124 |
if (h.freq === 'weekly') return 0;
|
|
|
|
| 145 |
const [h, c, t, s] = await Promise.all([api('/api/habits'), api('/api/checkins'), api('/api/tasks'), api('/api/sessions')]);
|
| 146 |
habits = h.habits; checkins = c.checkins; tasks = t.tasks; sessions = s.sessions;
|
| 147 |
}
|
| 148 |
+
function note(t) {
|
| 149 |
const el = $('msg');
|
| 150 |
+
el.className = 'msg err';
|
| 151 |
+
el.textContent = '⚠ ' + t;
|
| 152 |
setTimeout(() => { el.className = 'msg'; }, 4000);
|
| 153 |
}
|
| 154 |
|
|
|
|
| 156 |
function render() {
|
| 157 |
document.querySelectorAll('nav button').forEach(b => b.classList.toggle('on', b.dataset.v === view));
|
| 158 |
$('msg').className = 'msg';
|
| 159 |
+
if (view === 'today') vToday();
|
| 160 |
+
else if (view === 'habits') vHabits();
|
| 161 |
+
else if (view === 'tasks') vTasks();
|
| 162 |
+
else if (view === 'focus') vFocus();
|
| 163 |
bind();
|
| 164 |
}
|
| 165 |
|
| 166 |
+
function vToday() {
|
| 167 |
+
$('main-title').textContent = 'Today';
|
| 168 |
const daily = habits.filter(h => h.freq === 'daily');
|
| 169 |
const dn = daily.filter(h => done(h, today())).length;
|
| 170 |
const pct = daily.length ? Math.round(dn / daily.length * 100) : 0;
|
| 171 |
const late = tasks.filter(t => !t.done && t.due && t.due < today());
|
| 172 |
const dueToday = tasks.filter(t => !t.done && t.due === today());
|
| 173 |
+
const wd = weekDates(), dayNames = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun'];
|
| 174 |
const tIdx = (new Date().getDay() + 6) % 7;
|
| 175 |
$('page').innerHTML = `
|
| 176 |
<div class="grid">
|
| 177 |
+
<div class="card stat"><b style="color:${pct === 100 ? '#0d9488' : 'inherit'}">${dn}/${daily.length}</b><span>habits done today</span></div>
|
| 178 |
+
<div class="card stat"><b style="color:${late.length ? 'var(--danger)' : 'inherit'}">${late.length}</b><span>overdue tasks</span></div>
|
| 179 |
+
<div class="card stat"><b>${focusMin(today())} min</b><span>of focus today</span></div>
|
| 180 |
</div>
|
| 181 |
<div class="two">
|
| 182 |
<div class="card">
|
| 183 |
+
<h1 style="font-size:15px">Today's habits</h1>
|
| 184 |
${daily.length ? daily.map(h => {
|
| 185 |
const d = done(h, today());
|
| 186 |
return `<div class="row">
|
| 187 |
<button class="dot ${d ? 'on' : ''}" data-tog="${h.id}">${d ? '✓' : ''}</button>
|
| 188 |
<span style="font-size:17px">${h.emoji}</span>
|
| 189 |
<div class="grow"><div class="t">${esc(h.name)}</div></div>
|
| 190 |
+
${streak(h) ? `<span class="chip medium">🔥 ${streak(h)}</span>` : ''}
|
| 191 |
</div>`;
|
| 192 |
+
}).join('') : '<div class="empty">No daily habits yet. Create one on the Habits tab.</div>'}
|
| 193 |
</div>
|
| 194 |
<div class="card">
|
| 195 |
+
<h1 style="font-size:15px">Tasks for today</h1>
|
| 196 |
${[...late, ...dueToday].length ? [...late, ...dueToday].map(t => `
|
| 197 |
<div class="row">
|
| 198 |
<button class="dot" data-task="${t.id}"></button>
|
| 199 |
<div class="grow"><div class="t" style="font-size:13px">${esc(t.title)}</div></div>
|
| 200 |
+
<span class="chip ${t.due < today() ? 'late' : ''}">${t.due < today() ? 'overdue' : 'today'}</span>
|
| 201 |
+
</div>`).join('') : '<div class="empty">All clear here ✨</div>'}
|
| 202 |
</div>
|
| 203 |
</div>
|
| 204 |
<div class="card">
|
| 205 |
+
<h1 style="font-size:15px">Your week</h1>
|
| 206 |
<div class="week" style="margin-top:10px">${wd.map((d, i) => {
|
| 207 |
const pd = habits.length ? Math.round(habits.filter(h => done(h, d)).length / habits.length * 100) : 0;
|
| 208 |
const future = d > today();
|
|
|
|
| 215 |
</div>`;
|
| 216 |
}
|
| 217 |
|
| 218 |
+
function vHabits() {
|
| 219 |
+
$('main-title').textContent = 'Habits';
|
| 220 |
let selE = '💧', selC = COLORS[0];
|
| 221 |
$('page').innerHTML = `
|
| 222 |
<div class="card">
|
| 223 |
+
<h1 style="font-size:15px">New habit</h1>
|
| 224 |
<form id="hf">
|
| 225 |
+
<label>Name</label>
|
| 226 |
+
<input id="hName" required maxlength="80" placeholder="e.g. Read 20 pages">
|
| 227 |
+
<label>Icon</label>
|
| 228 |
<div class="emoji-pick">${EMOJIS.map((e, i) => `<button type="button" data-e="${e}" class="${i === 0 ? 'on' : ''}">${e}</button>`).join('')}</div>
|
| 229 |
+
<label>Color</label>
|
| 230 |
<div class="color-pick">${COLORS.map((c, i) => `<button type="button" data-c="${c}" class="${i === 0 ? 'on' : ''}" style="background:${c}"></button>`).join('')}</div>
|
| 231 |
+
<label>Frequency</label>
|
| 232 |
+
<select id="hFreq"><option value="daily">Every day</option><option value="weekly">Weekly</option></select>
|
| 233 |
+
<div id="goalWrap" style="display:none"><label>Weekly goal: <span id="goalV">3×</span></label>
|
| 234 |
<input id="hGoal" type="range" min="1" max="7" value="3" style="accent-color:var(--accent)"></div>
|
| 235 |
+
<button class="pri">Create habit</button>
|
| 236 |
</form>
|
| 237 |
</div>
|
| 238 |
+
${habits.map(h => habitCard(h)).join('') || '<div class="empty">No habits yet — create your first one above. 🌱</div>'}`;
|
| 239 |
$('hFreq').onchange = (e) => { $('goalWrap').style.display = e.target.value === 'weekly' ? 'block' : 'none'; };
|
| 240 |
$('hGoal').oninput = (e) => { $('goalV').textContent = e.target.value + '×'; };
|
| 241 |
document.querySelectorAll('.emoji-pick button').forEach(b => b.onclick = () => {
|
|
|
|
| 251 |
try {
|
| 252 |
await api('/api/habits', { name: $('hName').value, emoji: selE, color: selC, freq: $('hFreq').value, goal: +$('hGoal').value });
|
| 253 |
await loadAll(); render();
|
| 254 |
+
} catch (er) { note(er.message); }
|
| 255 |
};
|
| 256 |
}
|
| 257 |
|
|
|
|
| 261 |
<div style="display:flex;align-items:center;justify-content:space-between">
|
| 262 |
<div style="display:flex;align-items:center;gap:10px">
|
| 263 |
<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>
|
| 264 |
+
<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>
|
| 265 |
</div>
|
| 266 |
+
<button class="x" data-delh="${h.id}" title="Delete">✕</button>
|
| 267 |
</div>
|
| 268 |
<div class="days">${last7.map(d => {
|
| 269 |
const on = done(h, d), future = d > today();
|
| 270 |
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>`;
|
| 271 |
}).join('')}</div>
|
| 272 |
+
<div style="font-size:12px;color:var(--muted)">🔥 ${streak(h)} day streak · ${rate30(h)}% in 30 days</div>
|
| 273 |
</div>`;
|
| 274 |
}
|
| 275 |
|
| 276 |
+
function vTasks() {
|
| 277 |
+
$('main-title').textContent = 'Tasks';
|
| 278 |
let list = tasks.filter(t => !t.done);
|
| 279 |
+
if (taskFilter === 'today') list = list.filter(t => t.due && t.due <= today());
|
| 280 |
+
if (taskFilter === 'week') list = list.filter(t => t.due && t.due <= iso(addDays(new Date(), 7)));
|
| 281 |
+
if (taskFilter === 'done') list = tasks.filter(t => t.done);
|
| 282 |
$('page').innerHTML = `
|
| 283 |
<div class="card">
|
| 284 |
<form id="tf" style="display:flex;gap:8px;flex-wrap:wrap">
|
| 285 |
+
<input id="tTitle" required maxlength="200" placeholder="Add a task…" style="flex:2;min-width:180px">
|
| 286 |
+
<select id="tPrio" style="width:auto"><option value="alta">🔴 High</option><option value="media" selected>🟡 Medium</option><option value="baixa">🔵 Low</option></select>
|
| 287 |
<input id="tDue" type="date" value="${today()}" style="width:auto">
|
| 288 |
+
<button class="pri" style="margin-top:0">Add</button>
|
| 289 |
</form>
|
| 290 |
+
<div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">${['all','today','week','done'].map(f =>
|
| 291 |
`<button class="ghost ${taskFilter === f ? 'on' : ''}" data-f="${f}" style="${taskFilter === f ? 'background:#f0fdfa;border-color:#0d9488;color:#0f766e' : ''}">${f}</button>`).join('')}</div>
|
| 292 |
</div>
|
| 293 |
<div>${list.map(t => {
|
|
|
|
| 295 |
return `<div class="row">
|
| 296 |
<button class="dot ${t.done ? 'on' : ''}" data-task="${t.id}">${t.done ? '✓' : ''}</button>
|
| 297 |
<div class="grow"><div class="t" style="${t.done ? 'text-decoration:line-through;color:var(--muted)' : ''}">${esc(t.title)}</div></div>
|
| 298 |
+
<span class="chip ${t.priority}">${({ alta: 'high', media: 'medium', baixa: 'low' })[t.priority]}</span>
|
| 299 |
+
${t.due ? `<span class="chip ${late ? 'late' : ''}">${late ? 'overdue · ' : ''}${fmt(t.due)}</span>` : ''}
|
| 300 |
+
<button class="x" data-up="${t.id}" title="Move up">↑</button>
|
| 301 |
+
<button class="x" data-delt="${t.id}" title="Delete">✕</button>
|
| 302 |
</div>`;
|
| 303 |
+
}).join('') || '<div class="empty">Nothing here. 🎉</div>'}</div>`;
|
| 304 |
document.querySelectorAll('[data-f]').forEach(b => b.onclick = () => { taskFilter = b.dataset.f; render(); });
|
| 305 |
$('tf').onsubmit = async (e) => {
|
| 306 |
e.preventDefault();
|
| 307 |
try {
|
| 308 |
await api('/api/tasks', { title: $('tTitle').value, priority: $('tPrio').value, due: $('tDue').value || null });
|
| 309 |
await loadAll(); render();
|
| 310 |
+
} catch (er) { note(er.message); }
|
| 311 |
};
|
| 312 |
}
|
| 313 |
|
| 314 |
+
/* ---------------- Focus ---------------- */
|
| 315 |
let pomo = { mode: 'work', total: 25 * 60, rem: 25 * 60, run: false, timer: null, label: '' };
|
| 316 |
+
function vFocus() {
|
| 317 |
+
$('main-title').textContent = 'Focus';
|
| 318 |
const circ = 2 * Math.PI * 80;
|
| 319 |
$('page').innerHTML = `
|
| 320 |
<div class="two">
|
|
|
|
| 326 |
stroke-dasharray="${circ}" stroke-dashoffset="${circ * (1 - pomo.rem / pomo.total)}" style="transition:stroke-dashoffset .3s linear"/>
|
| 327 |
</svg>
|
| 328 |
<div class="tmin" id="tmin">${fmtT(pomo.rem)}</div>
|
| 329 |
+
<div class="tmode">${pomo.mode === 'work' ? 'FOCUS' : 'BREAK'}</div>
|
| 330 |
+
<input id="plabel" placeholder="What will you focus on?" style="margin-top:14px" value="${esc(pomo.label || '')}">
|
| 331 |
<div style="display:flex;gap:8px;justify-content:center">
|
| 332 |
+
<button class="pri" id="ptog" style="min-width:110px">${pomo.run ? 'Pause' : 'Start'}</button>
|
| 333 |
+
<button class="ghost" id="prst" style="margin-top:12px">Reset</button>
|
| 334 |
</div>
|
| 335 |
</div>
|
| 336 |
<div class="card">
|
| 337 |
+
<h1 style="font-size:15px">Today's sessions</h1>
|
| 338 |
${sessions.filter(s => iso(new Date(s.at)) === today()).map(s => `
|
| 339 |
<div class="row" style="padding:8px 10px">
|
| 340 |
+
<div class="grow"><div class="t" style="font-size:13px">${esc(s.label || 'Focus session')}</div>
|
| 341 |
+
<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>
|
| 342 |
+
</div>`).join('') || '<div class="empty">No sessions today yet. 🍅</div>'}
|
| 343 |
+
<p style="font-size:12px;color:var(--muted)">Today: <b>${focusMin(today())} min</b> · this week: <b>${focusWeek()} min</b></p>
|
| 344 |
</div>
|
| 345 |
</div>`;
|
| 346 |
$('plabel').oninput = (e) => { pomo.label = e.target.value; };
|
| 347 |
document.querySelectorAll('[data-p]').forEach(b => b.onclick = () => {
|
| 348 |
+
if (pomo.run) return note('Pause the timer before switching.');
|
| 349 |
pomo.total = +b.dataset.p * 60; pomo.rem = pomo.total; pomo.mode = 'work'; render();
|
| 350 |
});
|
| 351 |
$('ptog').onclick = () => {
|
|
|
|
| 366 |
if (pomo.rem <= 0) {
|
| 367 |
clearInterval(pomo.timer); pomo.run = false;
|
| 368 |
if (pomo.mode === 'work') {
|
| 369 |
+
try { await api('/api/sessions', { label: pomo.label || '', minutes: Math.round(pomo.total / 60) }); } catch { /* saved next time */ }
|
| 370 |
pomo.mode = 'break'; pomo.total = 5 * 60; pomo.rem = 5 * 60;
|
| 371 |
+
note('Cycle complete! Take a 5 min break ☕');
|
| 372 |
} else {
|
| 373 |
pomo.mode = 'work'; pomo.total = 25 * 60; pomo.rem = 25 * 60;
|
| 374 |
+
note('Break over. Back to focus! 💪');
|
| 375 |
}
|
| 376 |
try { await loadAll(); } catch {}
|
| 377 |
render();
|
| 378 |
}
|
| 379 |
}
|
| 380 |
|
| 381 |
+
/* ---------------- Actions via data-* attributes ---------------- */
|
| 382 |
function bind() {
|
| 383 |
document.querySelectorAll('[data-tog]').forEach(b => b.onclick = async () => {
|
| 384 |
try { await api('/api/checkins/toggle', { habitId: b.dataset.tog, date: today() }); await loadAll(); render(); }
|
| 385 |
+
catch (e) { note(e.message); }
|
| 386 |
});
|
| 387 |
document.querySelectorAll('[data-h]').forEach(b => b.onclick = async () => {
|
| 388 |
if (b.disabled) return;
|
| 389 |
try { await api('/api/checkins/toggle', { habitId: b.dataset.h, date: b.dataset.d }); await loadAll(); render(); }
|
| 390 |
+
catch (e) { note(e.message); }
|
| 391 |
});
|
| 392 |
document.querySelectorAll('[data-task]').forEach(b => b.onclick = async () => {
|
| 393 |
const t = tasks.find(x => x.id === b.dataset.task);
|
| 394 |
if (!t) return;
|
| 395 |
try { await api('/api/tasks/' + t.id, { done: !t.done }, 'PUT'); await loadAll(); render(); }
|
| 396 |
+
catch (e) { note(e.message); }
|
| 397 |
});
|
| 398 |
document.querySelectorAll('[data-delt]').forEach(b => b.onclick = async () => {
|
| 399 |
try { await api('/api/tasks/' + b.dataset.delt, null, 'DELETE'); await loadAll(); render(); }
|
| 400 |
+
catch (e) { note(e.message); }
|
| 401 |
});
|
| 402 |
document.querySelectorAll('[data-delh]').forEach(b => b.onclick = async () => {
|
| 403 |
+
if (!confirm('Delete this habit and its history?')) return;
|
| 404 |
try { await api('/api/habits/' + b.dataset.delh, null, 'DELETE'); await loadAll(); render(); }
|
| 405 |
+
catch (e) { note(e.message); }
|
| 406 |
});
|
| 407 |
document.querySelectorAll('[data-up]').forEach(b => b.onclick = async () => {
|
| 408 |
const i = tasks.findIndex(t => t.id === b.dataset.up);
|
| 409 |
if (i <= 0) return;
|
| 410 |
[tasks[i - 1], tasks[i]] = [tasks[i], tasks[i - 1]];
|
| 411 |
try { await api('/api/tasks/reorder', { ids: tasks.map(t => t.id) }); await loadAll(); render(); }
|
| 412 |
+
catch (e) { note(e.message); }
|
| 413 |
});
|
| 414 |
}
|
| 415 |
|
| 416 |
document.querySelectorAll('nav button').forEach(b => b.onclick = async () => {
|
| 417 |
+
if (b.dataset.v === 'exit') {
|
| 418 |
try { await fetch('/api/logout', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: '{}' }); } catch {}
|
| 419 |
location.href = '/';
|
| 420 |
return;
|
|
|
|
| 423 |
render();
|
| 424 |
});
|
| 425 |
|
| 426 |
+
/* ---------------- Init: requires a valid session ---------------- */
|
| 427 |
(async function init() {
|
| 428 |
try {
|
| 429 |
const r = await api('/api/me');
|
|
|
|
| 432 |
await loadAll();
|
| 433 |
render();
|
| 434 |
} catch (e) {
|
| 435 |
+
if (e.message !== 'Session expired') {
|
| 436 |
+
document.body.innerHTML = '<p style="font-family:sans-serif;padding:40px;text-align:center">Failed to load: ' + esc(e.message) + '</p>';
|
| 437 |
}
|
| 438 |
}
|
| 439 |
})();
|