Offlin33er commited on
Commit
bb221ed
·
verified ·
1 Parent(s): a61a096

Planner UI translated to English

Browse files
Files changed (1) hide show
  1. private/app.html +99 -99
private/app.html CHANGED
@@ -1,11 +1,11 @@
1
  <!DOCTYPE html>
2
- <html lang="pt-BR">
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Planner — autenticado</title>
7
  <style>
8
- :root { --accent: #0d9488; --accent-2: #99f6e4; --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,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.alta { background: #ffe4e6; color: #be123c; }
39
- .chip.media { background: #fef3c7; color: #b45309; }
40
- .chip.baixa { 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,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="hoje" class="on">Hoje</button>
78
- <button data-v="habitos">Hábitos</button>
79
- <button data-v="tarefas">Tarefas</button>
80
- <button data-v="foco">Foco</button>
81
- <button data-v="sair">Sair</button>
82
  </nav>
83
  </div>
84
  </header>
85
  <main>
86
- <h1 id="main-title">Hoje</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
- /* Planner autenticado: TODOS os dados vêm do servidor, identificados pela
94
- sessão (cookie httpOnly). Qualquer 401 devolve o usuário ao login. */
95
  const $ = (id) => document.getElementById(id);
96
  const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
97
- let me = null, habits = [], checkins = [], tasks = [], sessions = [], view = 'hoje';
98
- let selEmoji = '💧', selColor = '#0d9488', taskFilter = 'todas';
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('Sessão expirada'); }
111
  const data = await res.json().catch(() => ({}));
112
- if (!res.ok) throw new Error(data.error || 'Erro (' + res.status + ')');
113
  return data;
114
  }
115
 
116
- /* ---------- datas ---------- */
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('pt-BR', { 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,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, isErr) {
149
  const el = $('msg');
150
- el.className = 'msg ' + (isErr ? 'err' : 'err');
151
- el.textContent = (isErr ? '⚠ ' : 'ℹ ') + t;
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 === 'hoje') vHoje();
160
- else if (view === 'habitos') vHabitos();
161
- else if (view === 'tarefas') vTarefas();
162
- else if (view === 'foco') vFoco();
163
  bind();
164
  }
165
 
166
- function vHoje() {
167
- $('main-title').textContent = 'Hoje';
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 = ['seg','ter','qua','qui','sex','sáb','dom'];
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>hábitos de hoje</span></div>
178
- <div class="card stat"><b style="color:${late.length ? 'var(--danger)' : 'inherit'}">${late.length}</b><span>tarefas atrasadas</span></div>
179
- <div class="card stat"><b>${focusMin(today())} min</b><span>de foco hoje</span></div>
180
  </div>
181
  <div class="two">
182
  <div class="card">
183
- <h1 style="font-size:15px">Hábitos de hoje</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 media">🔥 ${streak(h)}</span>` : ''}
191
  </div>`;
192
- }).join('') : '<div class="empty">Nenhum hábito diário. Crie um na aba Hábitos.</div>'}
193
  </div>
194
  <div class="card">
195
- <h1 style="font-size:15px">Tarefas para hoje</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() ? 'atrasada' : 'hoje'}</span>
201
- </div>`).join('') : '<div class="empty">Tudo em ordem por aqui ✨</div>'}
202
  </div>
203
  </div>
204
  <div class="card">
205
- <h1 style="font-size:15px">Sua semana</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,27 +215,27 @@ function vHoje() {
215
  </div>`;
216
  }
217
 
218
- function vHabitos() {
219
- $('main-title').textContent = 'Hábitos';
220
  let selE = '💧', selC = COLORS[0];
221
  $('page').innerHTML = `
222
  <div class="card">
223
- <h1 style="font-size:15px">Novo hábito</h1>
224
  <form id="hf">
225
- <label>Nome</label>
226
- <input id="hName" required maxlength="80" placeholder="Ex.: Ler 20 páginas">
227
- <label>Ícone</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>Cor</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>Frequência</label>
232
- <select id="hFreq"><option value="daily">Todos os dias</option><option value="weekly">Semanal</option></select>
233
- <div id="goalWrap" style="display:none"><label>Meta semanal: <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">Criar hábito</button>
236
  </form>
237
  </div>
238
- ${habits.map(h => habitCard(h)).join('') || '<div class="empty">Nenhum hábito ainda — crie o primeiro acima. 🌱</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,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, true); }
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' ? 'Todos os dias' : (h.goal || 3) + '× por semana · ' + weekCount(h) + '/' + (h.goal || 3) + ' nesta semana'}</div></div>
265
  </div>
266
- <button class="x" data-delh="${h.id}" title="Excluir">✕</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)} dias seguidos · ${rate30(h)}% em 30 dias</div>
273
  </div>`;
274
  }
275
 
276
- function vTarefas() {
277
- $('main-title').textContent = 'Tarefas';
278
  let list = tasks.filter(t => !t.done);
279
- if (taskFilter === 'hoje') list = list.filter(t => t.due && t.due <= today());
280
- if (taskFilter === 'semana') list = list.filter(t => t.due && t.due <= iso(addDays(new Date(), 7)));
281
- if (taskFilter === 'concluidas') 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="Adicionar uma tarefa…" style="flex:2;min-width:180px">
286
- <select id="tPrio" style="width:auto"><option value="alta">🔴 Alta</option><option value="media" selected>🟡 Média</option><option value="baixa">🔵 Baixa</option></select>
287
  <input id="tDue" type="date" value="${today()}" style="width:auto">
288
- <button class="pri" style="margin-top:0">Adicionar</button>
289
  </form>
290
- <div style="margin-top:12px;display:flex;gap:6px;flex-wrap:wrap">${['todas','hoje','semana','concluidas'].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,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: 'alta', media: 'média', baixa: 'baixa' })[t.priority]}</span>
299
- ${t.due ? `<span class="chip ${late ? 'late' : ''}">${late ? 'atrasada · ' : ''}${fmt(t.due)}</span>` : ''}
300
- <button class="x" data-up="${t.id}" title="Subir">↑</button>
301
- <button class="x" data-delt="${t.id}" title="Excluir">✕</button>
302
  </div>`;
303
- }).join('') || '<div class="empty">Nada aqui. 🎉</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, true); }
311
  };
312
  }
313
 
314
- /* ---------------- Foco ---------------- */
315
  let pomo = { mode: 'work', total: 25 * 60, rem: 25 * 60, run: false, timer: null, label: '' };
316
- function vFoco() {
317
- $('main-title').textContent = 'Foco';
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' ? 'FOCO' : 'PAUSA'}</div>
330
- <input id="plabel" placeholder="Em que você vai focar?" 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 ? 'Pausar' : 'Iniciar'}</button>
333
- <button class="ghost" id="prst" style="margin-top:12px">Zerar</button>
334
  </div>
335
  </div>
336
  <div class="card">
337
- <h1 style="font-size:15px">Sessões de hoje</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 || 'Sessão de foco')}</div>
341
- <div class="s" style="font-size:11px;color:var(--muted)">${s.minutes} min · ${new Date(s.at).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })}</div></div>
342
- </div>`).join('') || '<div class="empty">Nenhuma sessão hoje ainda. 🍅</div>'}
343
- <p style="font-size:12px;color:var(--muted)">Total hoje: <b>${focusMin(today())} min</b> · semana: <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 o timer antes de trocar.');
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 { /* sessão salva na próxima */ }
370
  pomo.mode = 'break'; pomo.total = 5 * 60; pomo.rem = 5 * 60;
371
- note('Ciclo concluído! Pausa de 5 min ☕');
372
  } else {
373
  pomo.mode = 'work'; pomo.total = 25 * 60; pomo.rem = 25 * 60;
374
- note('Pausa encerrada. De volta ao foco! 💪');
375
  }
376
  try { await loadAll(); } catch {}
377
  render();
378
  }
379
  }
380
 
381
- /* ---------------- Ações delegadas por data-* ---------------- */
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, true); }
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, true); }
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, true); }
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, true); }
401
  });
402
  document.querySelectorAll('[data-delh]').forEach(b => b.onclick = async () => {
403
- if (!confirm('Excluir este hábito e seu histórico?')) return;
404
  try { await api('/api/habits/' + b.dataset.delh, null, 'DELETE'); await loadAll(); render(); }
405
- catch (e) { note(e.message, true); }
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, true); }
413
  });
414
  }
415
 
416
  document.querySelectorAll('nav button').forEach(b => b.onclick = async () => {
417
- if (b.dataset.v === 'sair') {
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: exige sessão válida ---------------- */
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 !== 'Sessão expirada') {
436
- document.body.innerHTML = '<p style="font-family:sans-serif;padding:40px;text-align:center">Erro ao carregar: ' + esc(e.message) + '</p>';
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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
  })();