'use strict'; const VIEWS = [ { id: 'today', label: 'Today', icon: 'โ˜€๏ธ' }, { id: 'upcoming', label: 'Upcoming', icon: '๐Ÿ“…' }, { id: 'calendar', label: 'Calendar', icon: '๐Ÿ—“๏ธ' }, { id: 'backlog', label: 'Backlog', 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 HOUR_PX = 48; const BACKLOG_SUGGESTIONS = 2; const state = { tasks: [], categories: [], projects: [], view: 'today', categoryId: null, projectId: null, search: '', calDate: null, showDoneProjects: false, }; let editing = null; // task open in the dialog; null when creating let editingProject = null; let planning = null; // { task, mode } for the postpone sheet 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); }; const addMonths = (s, n) => { const [y, m, d] = s.split('-').map(Number); const total = m - 1 + n; const yy = y + Math.floor(total / 12); const mm = total % 12; return fmtDate(new Date(yy, mm, Math.min(d, new Date(yy, mm + 1, 0).getDate()))); }; // Same stepping rules as the server, so projected repeats land where the real ones will. function stepDate(s, recurrence, n) { if (recurrence === 'daily') return addDays(s, n); if (recurrence === 'weekly') return addDays(s, 7 * n); if (recurrence === 'monthly') return addMonths(s, n); return addMonths(s, 12 * n); } // Named dates used by the postpone sheet and the "Plan:" shortcuts. function namedDate(name) { const t = todayStr(); const dow = parseDate(t).getDay(); // 0 = Sunday const nextMonday = addDays(t, ((8 - dow) % 7) || 7); switch (name) { case 'today': return t; case 'tomorrow': return addDays(t, 1); case 'weekend': return dow === 6 || dow === 0 ? addDays(nextMonday, 5) : addDays(t, 6 - dow); case 'nextweek': return nextMonday; case 'twoweeks': return addDays(nextMonday, 7); case 'nextmonth': { const d = parseDate(t); return fmtDate(new Date(d.getFullYear(), d.getMonth() + 1, 1)); } default: return null; } } function dayName(day) { const diff = dayDiff(todayStr(), day); const d = parseDate(day); if (diff === 0) return 'Today'; if (diff === 1) return 'Tomorrow'; if (diff === -1) return 'Yesterday'; if (diff > 1 && diff < 7) return d.toLocaleDateString(undefined, { weekday: 'long' }); const opts = { weekday: 'short', day: 'numeric', month: 'short' }; if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric'; return d.toLocaleDateString(undefined, opts); } function dueInfo(due) { const diff = dayDiff(todayStr(), due); let text = dayName(due); if (diff < -1) text += ` ยท ${-diff} days late`; return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' }; } const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; }; const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`; // A start time without an end counts as one hour. const span = (t) => { const start = toMin(t.start_time); return [start, t.end_time ? toMin(t.end_time) : Math.min(start + 60, 24 * 60)]; }; const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}โ€“${t.end_time}` : t.start_time) : ''); // --- Task date semantics ------------------------------------------------------ // planned_date = when I intend to do it; due_date = when it must be done. // The "when" date (plan, else deadline) decides where a task is scheduled; times apply to it. const whenDate = (t) => t.planned_date || t.due_date || null; const isOverdue = (t, today) => !t.done && t.due_date && t.due_date < today; // Planned for today or earlier (unfinished plans roll over), or due today. const isForToday = (t, today) => !t.done && !isOverdue(t, today) && ((t.planned_date && t.planned_date <= today) || t.due_date === today); const isBacklog = (t) => !t.done && !t.planned_date && !t.due_date; // Tasks on a given day (by "when" date), including future repeats of open recurring tasks. function itemsOn(day) { const out = []; for (const t of state.tasks) { const w = whenDate(t); if (!w) continue; if (w === day) { out.push({ task: t, projected: false }); } else if (!t.done && t.recurrence !== 'none' && w < day) { let d = w; for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval); if (d === day) out.push({ task: t, projected: true }); } } return out; } // --- 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) => { const text = e.msg.replace(/^Value error, /, ''); const where = e.loc.slice(1).join('.'); return where ? `${where}: ${text}` : text; }).join('; '); } } catch { /* not JSON */ } throw new Error(msg); } return res.status === 204 ? null : res.json(); } async function loadAll() { const [categories, projects, tasks] = await Promise.all([ api('api/categories'), api('api/projects'), api('api/tasks'), ]); state.categories = categories; state.projects = projects; 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, projectId: state.projectId, })); } 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; state.projectId = p.projectId ?? null; } } catch { /* ignore */ } } // --- Lookups, filtering & grouping --------------------------------------------- const catById = (id) => state.categories.find((c) => c.id === id); const projectById = (id) => state.projects.find((p) => p.id === id); const projectTasks = (p) => state.tasks.filter((t) => t.project_id === p.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 proj = projectById(t.project_id); const hay = [t.title, t.notes, cat ? cat.name : '', proj ? proj.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase(); return hay.includes(term); }); } function cmpOpen(a, b) { const wa = whenDate(a); const wb = whenDate(b); if (wa !== wb) { if (!wa) return 1; if (!wb) return -1; return wa < wb ? -1 : 1; } if (a.start_time !== b.start_time) { if (!a.start_time) return 1; if (!b.start_time) return -1; return a.start_time < b.start_time ? -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.view === 'project') tasks = tasks.filter((t) => t.project_id === state.projectId); if (state.search.trim()) tasks = tasks.filter(matchesSearch); return tasks; } // Backlog tasks worth suggesting for today: highest priority first, then the oldest. function backlogSuggestions() { return state.tasks.filter((t) => isBacklog(t) && t.priority < 3) .sort((a, b) => (b.priority - a.priority) || (a.id - b.id)) .slice(0, BACKLOG_SUGGESTIONS); } 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, extra = {}) => ({ title, tasks: open.filter(pred), ...extra }); const overdue = (x) => isOverdue(x, t); const today = (x) => isForToday(x, t); const later = (x) => !overdue(x) && !today(x); if (state.view === 'today') { const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id)); return [ group('Overdue', overdue), group('Today', today), // High priority things you haven't scheduled yet keep showing up until you plan them. group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date), { title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' }, ]; } if (state.view === 'upcoming') { return [ group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow), group('Next 7 days', (x) => later(x) && whenDate(x) > tomorrow && whenDate(x) <= week), group('Later', (x) => later(x) && whenDate(x) > week), ]; } if (state.view === 'backlog') { const backlog = (p) => (x) => isBacklog(x) && x.priority === p; return [ group('High priority', backlog(3)), group('Medium priority', backlog(2)), group('Low priority', backlog(1)), group('No priority', backlog(0)), ]; } const groups = [ group('Overdue', overdue), group('Today', today), group('Next 7 days', (x) => later(x) && whenDate(x) && whenDate(x) <= week), group('Later', (x) => later(x) && whenDate(x) > week), group('No date', isBacklog), ]; if (state.view === 'project') { const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || '')); groups.push({ title: 'Completed', tasks: done }); } return groups; } function counts() { const t = todayStr(); const open = state.tasks.filter((x) => !x.done); const overdue = open.filter((x) => isOverdue(x, t)).length; const c = { today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length, upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length, backlog: open.filter(isBacklog).length, all: open.length, done: null, calendar: null, overdue, cats: {}, projects: {}, }; for (const x of open) { c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1; if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1; } return c; } // Progress and warnings for a project. function projectHealth(p) { const t = todayStr(); const tasks = projectTasks(p); const total = tasks.length; const done = tasks.filter((x) => x.done).length; const open = tasks.filter((x) => !x.done).sort(cmpOpen); const info = { total, done, pct: total ? Math.round((done / total) * 100) : 0, next: open[0] || null, warn: null, daysLeft: null }; if (p.done) return info; const started = p.created_at.slice(0, 10); if (p.due_date) { info.daysLeft = dayDiff(t, p.due_date); const totalDays = dayDiff(started, p.due_date); const timeFrac = totalDays > 0 ? Math.min(1, dayDiff(started, t) / totalDays) : 1; if (info.daysLeft < 0) info.warn = 'Deadline passed'; else if (total && timeFrac - done / total > 0.25) { info.warn = `Behind: ${Math.round(timeFrac * 100)}% of the time used, ${info.pct}% done`; } } if (!info.warn && !total) info.warn = 'No tasks yet. Break it into steps.'; if (!info.warn && open.length && dayDiff(started, t) >= 7) { const lastDone = tasks.map((x) => x.done_at || '').sort().pop(); if (!lastDone || dayDiff(lastDone.slice(0, 10), t) >= 7) info.warn = 'Nothing done on this in over a week'; } return info; } function daysLeftText(n) { if (n == null) return ''; if (n < 0) return `${-n} days overdue`; if (n === 0) return 'due today'; if (n === 1) return '1 day left'; if (n < 60) return `${n} days left`; return `${Math.round(n / 7)} weeks left`; } function sortedProjects() { return [...state.projects].sort((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 a.id - b.id; }); } // --- Rendering ---------------------------------------------------------------- function navItem({ active, icon, label, count, onclick, color, alert, muted }) { return h('button', { type: 'button', class: 'nav-item' + (active ? ' active' : '') + (muted ? ' muted' : ''), 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); } function renderNav(c) { $('#views').replaceChildren(...VIEWS.map((v) => navItem({ active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id], onclick: () => setView(v.id), alert: v.id === 'today' && c.overdue > 0, }))); const projects = sortedProjects(); const active = projects.filter((p) => !p.done); const finished = projects.filter((p) => p.done); const projItem = (p) => navItem({ active: state.view === 'project' && state.projectId === p.id, icon: p.done ? 'โœ”' : '๐Ÿ“', label: p.name, count: c.projects[p.id], onclick: () => setView('project', { projectId: p.id }), muted: p.done, alert: !p.done && p.due_date && p.due_date < todayStr(), }); const items = active.map(projItem); if (finished.length) { items.push(h('button', { type: 'button', class: 'nav-toggle', onclick: () => { state.showDoneProjects = !state.showDoneProjects; render(); }, }, state.showDoneProjects ? 'Hide finished' : `Show finished (${finished.length})`)); if (state.showDoneProjects) items.push(...finished.map(projItem)); } if (!projects.length) items.push(h('div', { class: 'nav-empty' }, 'No projects yet')); $('#projects').replaceChildren(...items); const catItems = state.categories.map((cat) => navItem({ active: state.view === 'cat' && state.categoryId === cat.id, label: cat.name, count: c.cats[cat.id], onclick: () => setView('cat', { categoryId: cat.id }), color: cat.color, })); if (c.cats[0]) { catItems.push(navItem({ active: state.view === 'cat' && state.categoryId === 0, label: 'No category', count: c.cats[0], onclick: () => setView('cat', { categoryId: 0 }), color: 'var(--muted)', })); } $('#cats').replaceChildren(...catItems); } function render() { if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today'; if (state.view === 'project' && !projectById(state.projectId)) state.view = 'today'; if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = 'today'; renderNav(counts()); let title = (VIEWS.find((v) => v.id === state.view) || {}).label; if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category'; if (state.view === 'project') title = projectById(state.projectId).name; $('#view-title').textContent = title || ''; document.title = `${title} ยท WhatToDo`; $('#quick-add').hidden = state.view === 'done'; $('#search').hidden = state.view === 'calendar'; $('#cal-nav').hidden = state.view !== 'calendar'; if (state.view === 'calendar') { renderCalendar(); return; } const sections = []; if (state.view === 'project') sections.push(projectHeader(projectById(state.projectId))); const groups = buildGroups(viewTasks()); if (state.view === 'today' && !state.search) { const cards = sortedProjects().filter((p) => !p.done).map(projectCard); // Projects go right after the things that are due/planned today. const at = groups.findIndex((g) => g.title === 'High priority, not planned'); if (cards.length) groups.splice(at, 0, { title: 'Projects', nodes: cards }); } for (const g of groups) { const items = g.nodes || g.tasks.map(taskRow); if (!items.length) continue; sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, g.tasks ? h('span', { class: 'muted' }, ` ${g.tasks.length}`) : null), g.hint ? h('p', { class: 'group-hint' }, g.hint) : null, items)); } if (sections.length === (state.view === 'project' ? 1 : 0)) { const msg = state.search ? 'No tasks match your search.' : state.view === 'done' ? 'Nothing completed yet.' : state.view === 'today' ? 'Nothing planned for today. ๐ŸŽ‰' : state.view === 'backlog' ? 'Backlog is empty: everything has a date.' : state.view === 'project' ? 'No tasks in this project yet. Add the first step above.' : 'No tasks here.'; sections.push(h('div', { class: 'empty' }, msg)); } $('#list').replaceChildren(...sections); } function progressBar(pct) { return h('div', { class: 'progress', role: 'progressbar', 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' }, h('div', { class: 'progress-fill', style: { width: `${pct}%` } })); } function projectCard(p) { const info = projectHealth(p); const cat = catById(p.category_id); return h('div', { class: 'project-card', tabindex: '0', onclick: () => setView('project', { projectId: p.id }), onkeydown: (e) => { if (e.key === 'Enter') setView('project', { projectId: p.id }); }, style: { borderLeftColor: cat ? cat.color : 'var(--accent)' }, }, h('div', { class: 'project-card-head' }, h('span', { class: 'project-name' }, '๐Ÿ“ ', p.name), h('span', { class: 'muted small' }, [ p.due_date ? `${dayName(p.due_date)} ยท ${daysLeftText(info.daysLeft)}` : 'no deadline', ])), h('div', { class: 'project-progress' }, progressBar(info.pct), h('span', { class: 'small muted' }, `${info.done}/${info.total}`)), info.next ? h('div', { class: 'project-next' }, h('span', { class: 'muted' }, 'Next: '), info.next.title, whenDate(info.next) ? h('span', { class: 'muted' }, ` ยท ${dayName(whenDate(info.next))}`) : null) : null, info.warn ? h('div', { class: 'project-warn' }, 'โš  ', info.warn) : null); } function projectHeader(p) { const info = projectHealth(p); const cat = catById(p.category_id); return h('section', { class: 'project-header' }, h('div', { class: 'project-header-top' }, h('div', { class: 'task-meta' }, cat ? h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name) : null, p.due_date ? h('span', { class: `chip due ${info.daysLeft < 0 ? 'overdue' : ''}` }, '๐Ÿ ', dayName(p.due_date), ` ยท ${daysLeftText(info.daysLeft)}`) : null, p.done ? h('span', { class: 'chip' }, 'โœ” Finished') : null), h('button', { type: 'button', class: 'btn', onclick: () => openProject(p) }, 'Edit project')), h('div', { class: 'project-progress big' }, progressBar(info.pct), h('span', { class: 'small' }, `${info.done}/${info.total} done (${info.pct}%)`)), info.warn ? h('div', { class: 'project-warn' }, 'โš  ', info.warn) : null, p.notes.trim() ? h('p', { class: 'project-notes' }, p.notes) : null); } function taskRow(task) { const t = todayStr(); 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)); } const proj = projectById(task.project_id); if (proj && state.view !== 'project') { meta.push(h('button', { type: 'button', class: 'chip link-chip', title: 'Open project', onclick: (e) => { e.stopPropagation(); setView('project', { projectId: proj.id }); }, }, '๐Ÿ“ ', proj.name)); } const time = timeText(task); if (task.planned_date) { const diff = dayDiff(t, task.planned_date); const label = diff < 0 && !task.done ? `from ${dayName(task.planned_date)}` : dayName(task.planned_date); meta.push(h('span', { class: 'chip plan' + (diff <= 0 && !task.done ? ' now' : ''), title: 'Planned to do on' }, 'โ–ถ ', label, time ? ` ยท ${time}` : '')); } if (task.due_date) { const d = dueInfo(task.due_date); meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}`, title: 'Deadline' }, '๐Ÿ“… ', task.planned_date ? 'due ' : '', d.text, !task.planned_date && time ? ` ยท ${time}` : '')); } 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), task.done ? null : h('button', { type: 'button', class: 'icon-btn plan-btn', 'aria-label': 'Plan or postpone', title: 'Plan / postpone', onclick: (e) => { e.stopPropagation(); openPlan(task); }, }, 'โญ')); } // --- Calendar ----------------------------------------------------------------- // Lay out overlapping blocks side by side: each cluster of overlaps shares its width. function layoutBlocks(items) { const sorted = items.map((it) => ({ ...it, span: span(it.task) })) .sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1]); let cluster = []; let clusterEnd = -1; const flush = () => { const cols = Math.max(...cluster.map((it) => it.col)) + 1; for (const it of cluster) it.cols = cols; cluster = []; }; for (const it of sorted) { if (cluster.length && it.span[0] >= clusterEnd) flush(); const colEnds = []; for (const other of cluster) colEnds[other.col] = Math.max(colEnds[other.col] ?? -1, other.span[1]); let col = 0; while (colEnds[col] !== undefined && colEnds[col] > it.span[0]) col++; it.col = col; cluster.push(it); clusterEnd = Math.max(clusterEnd, it.span[1]); } if (cluster.length) flush(); return sorted; } function weekStrip(day) { const d = parseDate(day); const monday = addDays(day, -((d.getDay() + 6) % 7)); const t = todayStr(); return h('div', { class: 'week-strip' }, Array.from({ length: 7 }, (_, i) => { const date = addDays(monday, i); const n = itemsOn(date).filter((it) => !it.task.done).length; return h('button', { type: 'button', class: 'week-day' + (date === day ? ' active' : '') + (date === t ? ' today' : ''), onclick: () => setCalDate(date), }, h('span', { class: 'wd' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short' })), h('span', { class: 'dn' }, parseDate(date).getDate()), h('span', { class: 'dots' }, n ? 'โ€ข'.repeat(Math.min(n, 4)) : ' ')); })); } function renderCalendar() { const day = state.calDate || todayStr(); $('#cal-date').value = day; const items = itemsOn(day); const timed = items.filter((it) => it.task.start_time); const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen); // Deadlines that fall on this day but are planned for another day, and project deadlines. const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day); const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day); let startHour = 7; let endHour = 22; for (const it of timed) { const [a, b] = span(it.task); startHour = Math.min(startHour, Math.floor(a / 60)); endHour = Math.max(endHour, Math.ceil(b / 60)); } const grid = h('div', { class: 'timeline', style: { height: `${(endHour - startHour) * HOUR_PX}px` } }); for (let hr = startHour; hr < endHour; hr++) { grid.append(h('div', { class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` }, title: 'Add a task at this time', onclick: () => openTask(null, { planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)), }), }, h('span', { class: 'hour-label' }, `${pad(hr)}:00`))); } for (const it of layoutBlocks(timed)) { const t = it.task; const cat = catById(t.category_id); const top = ((it.span[0] - startHour * 60) / 60) * HOUR_PX; const height = Math.max(((it.span[1] - it.span[0]) / 60) * HOUR_PX - 2, 20); grid.append(h('button', { type: 'button', class: 'block' + (t.done ? ' done' : '') + (it.projected ? ' projected' : '') + (height < 40 ? ' short' : ''), title: it.projected ? 'Repeat of a recurring task (opens the current occurrence)' : t.title, style: { top: `${top}px`, height: `${height}px`, left: `calc(52px + (100% - 56px) * ${it.col / it.cols})`, width: `calc((100% - 56px) / ${it.cols} - 3px)`, borderLeftColor: cat ? cat.color : 'var(--accent)', }, onclick: () => openTask(t), }, h('span', { class: 'block-time' }, it.projected ? 'โ†ป ' : '', t.start_time, t.end_time ? `โ€“${t.end_time}` : ''), h('span', { class: 'block-title' }, t.title))); } if (day === todayStr()) { const now = new Date(); const mins = now.getHours() * 60 + now.getMinutes(); if (mins >= startHour * 60 && mins <= endHour * 60) { grid.append(h('div', { class: 'now-line', style: { top: `${((mins - startHour * 60) / 60) * HOUR_PX}px` } })); } } const sections = [weekStrip(day)]; if (deadlines.length || projectDeadlines.length) { sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'Deadlines this day'), projectDeadlines.map((p) => h('button', { type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }), }, '๐Ÿ ', h('strong', {}, p.name), ' (project)')), deadlines.map(taskRow))); } if (untimed.length) { sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)), untimed.map(taskRow))); } sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'Schedule'), grid)); $('#list').replaceChildren(...sections); } function setCalDate(day) { state.calDate = day; render(); } // --- Actions ------------------------------------------------------------------ function setView(view, { categoryId = null, projectId = null } = {}) { state.view = view; state.categoryId = categoryId; state.projectId = projectId; 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: ${dayName(whenDate(res.next))}.` : ''; 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' }); }), }); }); } // Defaults for a task created from the current view. function viewDefaults() { const d = {}; if (state.view === 'cat' && state.categoryId) d.category_id = state.categoryId; if (state.view === 'project') { const p = projectById(state.projectId); d.project_id = p.id; if (p.category_id) d.category_id = p.category_id; } if (state.view === 'today') d.planned_date = todayStr(); if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1); if (state.view === 'calendar') d.planned_date = state.calDate || todayStr(); return d; } function quickAdd(title) { act(() => api('api/tasks', { method: 'POST', body: { title, ...viewDefaults() } })); } function toast(message, action) { const el = $('#toast'); el.replaceChildren(h('span', {}, message)); if (action) { el.append(h('button', { type: 'button', class: 'link', onclick: () => { el.hidden = true; action.fn(); }, }, action.label)); } el.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000); } // --- Postpone / plan sheet ---------------------------------------------------- const PLAN_OPTIONS = [ ['today', 'Today'], ['tomorrow', 'Tomorrow'], ['weekend', 'This weekend'], ['nextweek', 'Next week'], ['twoweeks', 'In 2 weeks'], ['nextmonth', 'Next month'], ]; function openPlan(task) { // Overdue tasks can't be fixed by planning, so default to moving the deadline for them. const mode = task.due_date && task.due_date < todayStr() ? 'due' : 'plan'; planning = { task, mode }; $('#p-title').textContent = task.title; renderPlan(); $('#plan-dialog').showModal(); } function renderPlan() { const { task, mode } = planning; const field = mode === 'plan' ? 'planned_date' : 'due_date'; $('#p-mode').hidden = !task.due_date; for (const btn of $('#p-mode').querySelectorAll('button')) btn.classList.toggle('active', btn.dataset.mode === mode); const current = task[field]; $('#p-current').textContent = mode === 'plan' ? (current ? `Planned for ${dayName(current)}. Move it to:` : 'Not planned yet. Do it on:') : `Deadline is ${dayName(current)}. Move it to:`; $('#p-options').replaceChildren(...PLAN_OPTIONS.map(([key, label]) => { const date = namedDate(key); if (key === 'weekend' && [0, 6].includes(parseDate(todayStr()).getDay())) label = 'Next weekend'; return h('button', { type: 'button', class: 'plan-option', onclick: () => applyPlan(date) }, h('span', {}, label), h('span', { class: 'muted small' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' }))); })); $('#p-date').value = current || ''; $('#p-clear').textContent = mode === 'plan' ? 'Remove plan date' : 'Remove deadline'; $('#p-clear').hidden = !current; } function applyPlan(date) { const { task, mode } = planning; const field = mode === 'plan' ? 'planned_date' : 'due_date'; const before = { planned_date: task.planned_date, due_date: task.due_date, start_time: task.start_time, end_time: task.end_time, }; const body = { [field]: date }; const after = { ...before, ...body }; if (!after.planned_date && !after.due_date) { body.start_time = null; body.end_time = null; } $('#plan-dialog').close(); act(async () => { await api(`api/tasks/${task.id}`, { method: 'PATCH', body }); const what = mode === 'plan' ? 'Planned for' : 'Deadline moved to'; toast(date ? `${what} ${dayName(date)}.` : (mode === 'plan' ? 'Plan date removed.' : 'Deadline removed.'), { label: 'Undo', fn: () => act(() => api(`api/tasks/${task.id}`, { method: 'PATCH', body: before })), }); }); } // --- Task dialog -------------------------------------------------------------- function fillSelect(select, items, selected, noneLabel = 'โ€” None โ€”') { select.replaceChildren( h('option', { value: '' }, noneLabel), ...items.map((x) => h('option', { value: String(x.id) }, x.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, project_id: null, priority: 0, due_date: null, planned_date: null, tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults, }; $('#task-dialog-title').textContent = task ? 'Edit task' : 'New task'; $('#t-title').value = t.title; fillSelect($('#t-category'), state.categories, t.category_id); // Finished projects are only listed for the task already in one. fillSelect($('#t-project'), sortedProjects().filter((p) => !p.done || p.id === t.project_id), t.project_id); $('#t-priority').value = String(t.priority); $('#t-planned').value = t.planned_date || ''; $('#t-due').value = t.due_date || ''; $('#t-start').value = t.start_time || ''; $('#t-end').value = t.end_time || ''; $('#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(); updateTimeFields(); $('#task-dialog').showModal(); if (!task) $('#t-title').focus(); } // Times apply to the plan date (or the deadline). Show what else is scheduled that day and flag overlaps. function updateTimeFields() { const day = $('#t-planned').value || $('#t-due').value; for (const id of ['#t-start', '#t-end']) { $(id).disabled = !day; if (!day) $(id).value = ''; } const box = $('#t-day'); const others = day ? itemsOn(day) .filter((it) => it.task.start_time && !it.task.done && !(editing && it.task.id === editing.id)) .sort((a, b) => toMin(a.task.start_time) - toMin(b.task.start_time)) : []; if (!others.length) { box.hidden = true; return; } const start = $('#t-start').value; const end = $('#t-end').value; const mine = start ? [toMin(start), end ? toMin(end) : toMin(start) + 60] : null; let clashes = 0; const rows = others.map((it) => { const [a, b] = span(it.task); const clash = mine && a < mine[1] && mine[0] < b; if (clash) clashes++; return h('li', { class: clash ? 'clash' : '' }, h('span', { class: 'day-time' }, timeText(it.task)), ' ', it.task.title, it.projected ? ' โ†ป' : ''); }); box.replaceChildren( h('div', { class: 'day-head' + (clashes ? ' clash' : '') }, clashes ? `โš  Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dayName(day)}`), h('ul', {}, rows)); box.hidden = false; } function onStartChange() { const start = $('#t-start').value; const end = $('#t-end').value; if (start && (!end || end <= start)) $('#t-end').value = fromMin(Math.min(toMin(start) + 60, 23 * 60 + 59)); if (!start) $('#t-end').value = ''; updateTimeFields(); } function onProjectChange() { const p = projectById(Number($('#t-project').value)); if (p && p.category_id && !$('#t-category').value) $('#t-category').value = String(p.category_id); } 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, project_id: $('#t-project').value ? Number($('#t-project').value) : null, priority: Number($('#t-priority').value), planned_date: $('#t-planned').value || null, due_date: $('#t-due').value || null, start_time: $('#t-start').value || null, end_time: ($('#t-start').value && $('#t-end').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' })); } // --- Project dialog ----------------------------------------------------------- function openProject(project) { editingProject = project; document.body.classList.remove('nav-open'); const p = project || { name: '', notes: '', category_id: null, due_date: null, done: false }; $('#pr-dialog-title').textContent = project ? 'Edit project' : 'New project'; $('#pr-name').value = p.name; fillSelect($('#pr-category'), state.categories, p.category_id); $('#pr-due').value = p.due_date || ''; $('#pr-notes').value = p.notes; $('#pr-done').checked = p.done; $('#pr-done').closest('label').hidden = !project; $('#pr-delete').hidden = !project; $('#project-dialog').showModal(); if (!project) $('#pr-name').focus(); } function saveProject(e) { e.preventDefault(); const body = { name: $('#pr-name').value.trim(), category_id: $('#pr-category').value ? Number($('#pr-category').value) : null, due_date: $('#pr-due').value || null, notes: $('#pr-notes').value, done: $('#pr-done').checked, }; if (!body.name) return; const project = editingProject; $('#project-dialog').close(); act(async () => { if (project) { await api(`api/projects/${project.id}`, { method: 'PATCH', body }); } else { const created = await api('api/projects', { method: 'POST', body }); state.view = 'project'; state.projectId = created.id; savePrefs(); toast('Project created. Add its steps as tasks, with deadlines for milestones.'); } }); } function deleteProject() { const p = editingProject; if (!p) return; const n = projectTasks(p).length; if (!confirm(`Delete project "${p.name}"? Its ${n} task(s) are kept, just no longer in a project.`)) return; $('#project-dialog').close(); act(() => api(`api/projects/${p.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, projects 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, ${res.projects} projects 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 = { ...viewDefaults(), 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-planned').addEventListener('change', updateTimeFields); $('#t-due').addEventListener('change', updateTimeFields); $('#t-start').addEventListener('change', onStartChange); $('#t-end').addEventListener('change', updateTimeFields); $('#t-project').addEventListener('change', onProjectChange); $('#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-planned').value = namedDate(btn.dataset.plan) || ''; updateTimeFields(); }); } // Calendar $('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1))); $('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1))); $('#cal-today').addEventListener('click', () => setCalDate(todayStr())); $('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); }); // Postpone sheet for (const btn of $('#p-mode').querySelectorAll('button')) { btn.addEventListener('click', () => { planning.mode = btn.dataset.mode; renderPlan(); }); } $('#p-set').addEventListener('click', () => { if ($('#p-date').value) applyPlan($('#p-date').value); }); $('#p-clear').addEventListener('click', () => applyPlan(null)); $('#p-cancel').addEventListener('click', () => $('#plan-dialog').close()); // Projects $('#new-project').addEventListener('click', () => openProject(null)); $('#project-form').addEventListener('submit', saveProject); $('#pr-cancel').addEventListener('click', () => $('#project-dialog').close()); $('#pr-delete').addEventListener('click', deleteProject); // 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();