Initial WhatToDo app

Self-hosted personal task list: FastAPI + SQLite in Docker, mobile-friendly
PWA frontend, deadlines, priorities, categories, tags, notes, checklists,
recurring tasks, single-password login, and JSON export/import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-10-07 11:43:56 +02:00
commit c5b049edba
23 changed files with 2045 additions and 0 deletions
+607
View File
@@ -0,0 +1,607 @@
'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();