'use strict'; const VIEWS = [ { id: 'today', label: 'Today', icon: 'โ˜€๏ธ' }, { id: 'upcoming', label: 'Upcoming', icon: '๐Ÿ“…' }, { id: 'all', label: 'All open', icon: '๐Ÿ“‹' }, { id: 'done', label: 'Done', icon: 'โœ…' }, ]; const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High']; const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' }; const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '' }; let editing = null; // task open in the dialog; null when creating let draftSubtasks = []; let toastTimer = null; const $ = (sel) => document.querySelector(sel); // --- Small DOM helper --------------------------------------------------------- function h(tag, attrs, ...children) { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs || {})) { if (v == null || v === false) continue; if (k === 'class') el.className = v; else if (k === 'style') Object.assign(el.style, v); else if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'value' || k === 'checked') el[k] = v; else el.setAttribute(k, v === true ? '' : v); } for (const c of children.flat()) { if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); } return el; } // --- Dates (all local, as YYYY-MM-DD strings) --------------------------------- const pad = (n) => String(n).padStart(2, '0'); const fmtDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const parseDate = (s) => { const [y, m, d] = s.split('-').map(Number); return new Date(y, m - 1, d); }; const todayStr = () => fmtDate(new Date()); const addDays = (s, n) => { const d = parseDate(s); d.setDate(d.getDate() + n); return fmtDate(d); }; const dayDiff = (a, b) => { const [ay, am, ad] = a.split('-').map(Number); const [by, bm, bd] = b.split('-').map(Number); return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000); }; function dueInfo(due) { const diff = dayDiff(todayStr(), due); const d = parseDate(due); let text; if (diff === 0) text = 'Today'; else if (diff === 1) text = 'Tomorrow'; else if (diff === -1) text = 'Yesterday'; else if (diff > 1 && diff < 7) text = d.toLocaleDateString(undefined, { weekday: 'long' }); else { const opts = { weekday: 'short', day: 'numeric', month: 'short' }; if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; text = d.toLocaleDateString(undefined, opts); } if (diff < -1) text += ` ยท ${-diff} days late`; return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' }; } // --- API ---------------------------------------------------------------------- async function api(path, { method = 'GET', body } = {}) { const headers = { 'X-WhatToDo': '1' }; if (body !== undefined) headers['Content-Type'] = 'application/json'; const res = await fetch(path, { method, headers, credentials: 'same-origin', body: body !== undefined ? JSON.stringify(body) : undefined, }); if (res.status === 401 && path !== 'api/login') { showLogin(); throw new Error('Please log in'); } if (!res.ok) { let msg = `${res.status} ${res.statusText}`; try { const j = await res.json(); if (typeof j.detail === 'string') msg = j.detail; else if (Array.isArray(j.detail)) msg = j.detail.map((e) => `${e.loc.slice(1).join('.')}: ${e.msg}`).join('; '); } catch { /* not JSON */ } throw new Error(msg); } return res.status === 204 ? null : res.json(); } async function loadAll() { const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]); state.categories = categories; state.tasks = tasks; } // Run an action, report errors as a toast, then refresh. async function act(fn) { try { await fn(); } catch (e) { toast(e.message); } try { await loadAll(); render(); } catch { /* login screen already shown */ } } // --- Persisted UI prefs ------------------------------------------------------- function savePrefs() { try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ } } function loadPrefs() { try { const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null'); if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; } } catch { /* ignore */ } } // --- Filtering & grouping ----------------------------------------------------- const catById = (id) => state.categories.find((c) => c.id === id); function matchesSearch(t) { const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean); return terms.every((term) => { if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1))); const cat = catById(t.category_id); const hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase(); return hay.includes(term); }); } function cmpOpen(a, b) { if (a.due_date !== b.due_date) { if (!a.due_date) return 1; if (!b.due_date) return -1; return a.due_date < b.due_date ? -1 : 1; } return (b.priority - a.priority) || (a.id - b.id); } function viewTasks() { let tasks = state.tasks; if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId); if (state.search.trim()) tasks = tasks.filter(matchesSearch); return tasks; } function buildGroups(tasks) { const t = todayStr(); const tomorrow = addDays(t, 1); const week = addDays(t, 7); if (state.view === 'done') { const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || '')); return [{ title: 'Completed', tasks: done }]; } const open = tasks.filter((x) => !x.done).sort(cmpOpen); const group = (title, pred) => ({ title, tasks: open.filter(pred) }); const overdue = (x) => x.due_date && x.due_date < t; const today = (x) => x.due_date === t; if (state.view === 'today') { return [ group('Overdue', overdue), group('Today', today), group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)), ]; } if (state.view === 'upcoming') { return [ group('Tomorrow', (x) => x.due_date === tomorrow), group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week), group('Later', (x) => x.due_date && x.due_date > week), ]; } return [ group('Overdue', overdue), group('Today', today), group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week), group('Later', (x) => x.due_date && x.due_date > week), group('No deadline', (x) => !x.due_date), ]; } function counts() { const t = todayStr(); const open = state.tasks.filter((x) => !x.done); const c = { today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length, upcoming: open.filter((x) => x.due_date && x.due_date > t).length, all: open.length, done: null, overdue: open.filter((x) => x.due_date && x.due_date < t).length, cats: {}, }; for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; return c; } // --- Rendering ---------------------------------------------------------------- function render() { if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today'; if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today'; const c = counts(); const navItem = (active, icon, label, count, onclick, color, alert) => h('button', { type: 'button', class: 'nav-item' + (active ? ' active' : ''), onclick, }, color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon), h('span', { class: 'nav-label' }, label), count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null); $('#views').replaceChildren(...VIEWS.map((v) => navItem( state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0, ))); const catItems = state.categories.map((cat) => navItem( state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color, )); if (c.cats[0]) { catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0], () => setView('cat', 0), 'var(--muted)')); } $('#cats').replaceChildren(...catItems); let title = (VIEWS.find((v) => v.id === state.view) || {}).label; if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category'; $('#view-title').textContent = title || ''; document.title = `${title} ยท WhatToDo`; $('#quick-add').hidden = state.view === 'done'; const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length); if (!groups.length) { const msg = state.search ? 'No tasks match your search.' : state.view === 'done' ? 'Nothing completed yet.' : state.view === 'today' ? 'Nothing due today. ๐ŸŽ‰' : 'No tasks here.'; $('#list').replaceChildren(h('div', { class: 'empty' }, msg)); return; } $('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' }, h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)), g.tasks.map(taskRow)))); } function taskRow(task) { const meta = []; const cat = catById(task.category_id); if (cat && state.view !== 'cat') { meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name)); } if (task.due_date) { const d = dueInfo(task.due_date); meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '๐Ÿ“… ', d.text)); } if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, 'โš‘ ', PRIORITY_LABELS[task.priority])); if (task.recurrence !== 'none') { const unit = REPEAT_UNITS[task.recurrence]; meta.push(h('span', { class: 'chip' }, 'โ†ป ', task.recur_interval > 1 ? `every ${task.recur_interval} ${unit}s` : `every ${unit}`)); } if (task.subtasks.length) { const doneCount = task.subtasks.filter((s) => s.done).length; meta.push(h('span', { class: 'chip' }, `โ˜‘ ${doneCount}/${task.subtasks.length}`)); } if (task.notes.trim()) meta.push(h('span', { class: 'chip', title: 'Has notes' }, '๐Ÿ“')); for (const tag of task.tags) { meta.push(h('button', { type: 'button', class: 'chip tag', onclick: (e) => { e.stopPropagation(); setSearch(`#${tag}`); }, }, `#${tag}`)); } return h('div', { class: `task p${task.priority}` + (task.done ? ' done' : ''), tabindex: '0', onclick: () => openTask(task), onkeydown: (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) openTask(task); }, }, h('button', { type: 'button', class: 'check', 'aria-label': task.done ? 'Mark as not done' : 'Mark as done', onclick: (e) => { e.stopPropagation(); toggleDone(task); }, }, task.done ? 'โœ“' : ''), h('div', { class: 'task-body' }, h('div', { class: 'task-title' }, task.title), meta.length ? h('div', { class: 'task-meta' }, meta) : null)); } // --- Actions ------------------------------------------------------------------ function setView(view, categoryId = null) { state.view = view; state.categoryId = categoryId; savePrefs(); document.body.classList.remove('nav-open'); render(); $('#list').scrollTop = 0; } function setSearch(text) { state.search = text; $('#search').value = text; render(); } function toggleDone(task) { if (task.done) { act(() => api(`api/tasks/${task.id}/reopen`, { method: 'POST' })); return; } act(async () => { const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } }); const extra = res.next ? ` Next one: ${dueInfo(res.next.due_date).text}.` : ''; toast(`Done: ${task.title}.${extra}`, { label: 'Undo', fn: () => act(async () => { if (res.next) await api(`api/tasks/${res.next.id}`, { method: 'DELETE' }); await api(`api/tasks/${task.id}/reopen`, { method: 'POST' }); }), }); }); } function quickAdd(title) { const body = { title }; if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId; if (state.view === 'today') body.due_date = todayStr(); if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1); act(() => api('api/tasks', { method: 'POST', body })); } function toast(message, action) { const el = $('#toast'); el.replaceChildren(h('span', {}, message), action ? h('button', { type: 'button', class: 'link', onclick: () => { el.hidden = true; action.fn(); }, }, action.label) : null); el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000); } // --- Task dialog -------------------------------------------------------------- function fillCategorySelect(select, selected) { select.replaceChildren( h('option', { value: '' }, 'โ€” None โ€”'), ...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)), ); select.value = selected ? String(selected) : ''; } function updateIntervalVisibility() { $('#t-interval-wrap').hidden = $('#t-repeat').value === 'none'; } function openTask(task, defaults = {}) { editing = task; const t = task || { title: '', notes: '', category_id: null, priority: 0, due_date: null, tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults, }; $('#task-dialog-title').textContent = task ? 'Edit task' : 'New task'; $('#t-title').value = t.title; fillCategorySelect($('#t-category'), t.category_id); $('#t-priority').value = String(t.priority); $('#t-due').value = t.due_date || ''; $('#t-repeat').value = t.recurrence; $('#t-interval').value = t.recur_interval; $('#t-tags').value = t.tags.join(', '); $('#t-notes').value = t.notes; $('#t-subtask-new').value = ''; $('#t-delete').hidden = !task; draftSubtasks = t.subtasks.map((s) => ({ title: s.title, done: s.done })); renderDraftSubtasks(); updateIntervalVisibility(); $('#task-dialog').showModal(); if (!task) $('#t-title').focus(); } function renderDraftSubtasks() { $('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' }, h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }), h('input', { type: 'text', value: s.title, maxlength: '500', oninput: (e) => { s.title = e.target.value; } }), h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Remove step', onclick: () => { draftSubtasks.splice(i, 1); renderDraftSubtasks(); }, }, 'โœ•')))); } function addDraftSubtask() { const input = $('#t-subtask-new'); const title = input.value.trim(); if (!title) return; draftSubtasks.push({ title, done: false }); input.value = ''; renderDraftSubtasks(); input.focus(); } function saveTask(e) { e.preventDefault(); addDraftSubtask(); const body = { title: $('#t-title').value.trim(), category_id: $('#t-category').value ? Number($('#t-category').value) : null, priority: Number($('#t-priority').value), due_date: $('#t-due').value || null, recurrence: $('#t-repeat').value, recur_interval: Math.max(1, Number($('#t-interval').value) || 1), tags: $('#t-tags').value.split(',').map((s) => s.trim()).filter(Boolean), notes: $('#t-notes').value, subtasks: draftSubtasks.map((s) => ({ title: s.title.trim(), done: s.done })).filter((s) => s.title), }; if (!body.title) return; const task = editing; $('#task-dialog').close(); act(() => (task ? api(`api/tasks/${task.id}`, { method: 'PATCH', body }) : api('api/tasks', { method: 'POST', body }))); } function deleteTask() { const task = editing; if (!task || !confirm(`Delete "${task.title}"?`)) return; $('#task-dialog').close(); act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' })); } // --- Settings dialog ---------------------------------------------------------- function renderSettingsCategories() { const cats = state.categories; const patch = (cat, body) => act(async () => { await api(`api/categories/${cat.id}`, { method: 'PATCH', body }); }) .then(renderSettingsCategories); const move = (i, dir) => { const other = cats[i + dir]; if (!other) return; const order = cats.map((c) => c.id); [order[i], order[i + dir]] = [order[i + dir], order[i]]; act(() => Promise.all(order.map((id, pos) => api(`api/categories/${id}`, { method: 'PATCH', body: { position: pos } })))) .then(renderSettingsCategories); }; $('#s-cats').replaceChildren(...cats.map((cat, i) => h('li', { class: 'cat-edit' }, h('input', { type: 'color', value: cat.color, 'aria-label': 'Color', onchange: (e) => patch(cat, { color: e.target.value }) }), h('input', { type: 'text', value: cat.name, maxlength: '50', 'aria-label': 'Name', onchange: (e) => { const name = e.target.value.trim(); if (name) patch(cat, { name }); }, }), h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move up', disabled: i === 0, onclick: () => move(i, -1) }, 'โ†‘'), h('button', { type: 'button', class: 'icon-btn', 'aria-label': 'Move down', disabled: i === cats.length - 1, onclick: () => move(i, 1) }, 'โ†“'), h('button', { type: 'button', class: 'icon-btn danger', 'aria-label': 'Delete category', onclick: () => { if (!confirm(`Delete category "${cat.name}"? Its tasks are kept but will have no category.`)) return; act(() => api(`api/categories/${cat.id}`, { method: 'DELETE' })).then(renderSettingsCategories); }, }, '๐Ÿ—‘')))); } async function importFile() { const file = $('#s-import-file').files[0]; if (!file) { toast('Choose an export file first.'); return; } const mode = $('#s-import-mode').value; let data; try { data = JSON.parse(await file.text()); } catch { toast('That file is not valid JSON.'); return; } const n = Array.isArray(data.tasks) ? data.tasks.length : 0; const warning = mode === 'replace' ? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?` : `MERGE: add ${n} tasks from the file to your current data. Continue?`; if (!confirm(warning)) return; await act(async () => { const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data }); toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`); }); $('#s-import-file').value = ''; renderSettingsCategories(); } // --- Login -------------------------------------------------------------------- function showLogin() { $('#app').hidden = true; $('#login').hidden = false; for (const d of document.querySelectorAll('dialog[open]')) d.close(); $('#login-password').focus(); } async function showApp() { await loadAll(); $('#login').hidden = true; $('#app').hidden = false; render(); } // --- Wiring ------------------------------------------------------------------- function bind() { $('#login-form').addEventListener('submit', async (e) => { e.preventDefault(); const err = $('#login-error'); err.hidden = true; try { await api('api/login', { method: 'POST', body: { password: $('#login-password').value } }); $('#login-password').value = ''; await showApp(); } catch (ex) { err.textContent = ex.message; err.hidden = false; } }); $('#quick-add').addEventListener('submit', (e) => { e.preventDefault(); const title = $('#quick-title').value.trim(); if (!title) return; $('#quick-title').value = ''; quickAdd(title); }); $('#new-detailed').addEventListener('click', () => { const defaults = {}; if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId; if (state.view === 'today') defaults.due_date = todayStr(); defaults.title = $('#quick-title').value.trim(); $('#quick-title').value = ''; openTask(null, defaults); }); $('#search').addEventListener('input', (e) => { state.search = e.target.value; render(); }); $('#menu-btn').addEventListener('click', () => document.body.classList.add('nav-open')); $('#scrim').addEventListener('click', () => document.body.classList.remove('nav-open')); // Task dialog $('#task-form').addEventListener('submit', saveTask); $('#t-cancel').addEventListener('click', () => $('#task-dialog').close()); $('#t-delete').addEventListener('click', deleteTask); $('#t-repeat').addEventListener('change', updateIntervalVisibility); $('#t-subtask-add').addEventListener('click', addDraftSubtask); $('#t-subtask-new').addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); } }); for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) { btn.addEventListener('click', () => { $('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days)); }); } // Settings dialog $('#settings-btn').addEventListener('click', () => { document.body.classList.remove('nav-open'); renderSettingsCategories(); $('#settings-dialog').showModal(); }); $('#s-close').addEventListener('click', () => $('#settings-dialog').close()); $('#s-cat-add').addEventListener('submit', (e) => { e.preventDefault(); const name = $('#s-cat-name').value.trim(); if (!name) return; act(() => api('api/categories', { method: 'POST', body: { name, color: $('#s-cat-color').value } })) .then(() => { $('#s-cat-name').value = ''; renderSettingsCategories(); }); }); $('#s-import-btn').addEventListener('click', importFile); $('#s-logout').addEventListener('click', async () => { try { await api('api/logout', { method: 'POST' }); } catch { /* ignore */ } $('#settings-dialog').close(); showLogin(); }); // Clicking the backdrop closes a dialog. for (const dlg of document.querySelectorAll('dialog')) { dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); }); } // Keyboard shortcuts: "/" search, "n" new task. document.addEventListener('keydown', (e) => { if (document.querySelector('dialog[open]') || $('#app').hidden) return; if (['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return; if (e.key === '/') { e.preventDefault(); $('#search').focus(); } if (e.key === 'n') { e.preventDefault(); $('#quick-title').focus(); } }); // Refresh when returning to the app (e.g. phone unlocked, day changed). document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && !$('#app').hidden) act(async () => {}); }); } async function init() { loadPrefs(); bind(); if ('serviceWorker' in navigator) navigator.serviceWorker.register('sw.js').catch(() => {}); try { await showApp(); } catch (e) { // A 401 already switched to the login screen; anything else is worth reporting. if ($('#login').hidden) toast(`Could not load: ${e.message}`); } } init();