Files
whattodo/app/static/app.js
T
goyban 509b1a1320 Add optional time ranges and a calendar day view
Tasks can now have a start/end time on their deadline date. The new
Calendar view shows a day as a timeline (with a week strip and projected
repeats of recurring tasks), and the task form lists what is already
planned that day and flags overlaps. Existing databases are migrated to
schema v2 automatically, with a backup copy taken first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:08:09 +02:00

836 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const VIEWS = [
{ id: 'today', label: 'Today', icon: '☀️' },
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
{ id: 'calendar', label: 'Calendar', 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 state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null };
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);
};
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);
}
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) : '');
// Tasks on a given day, including future repeats of open recurring tasks (marked projected).
function itemsOn(day) {
const out = [];
for (const t of state.tasks) {
if (!t.due_date) continue;
if (t.due_date === day) {
out.push({ task: t, projected: false });
} else if (!t.done && t.recurrence !== 'none' && t.due_date < day) {
let d = t.due_date;
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;
}
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) => {
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, 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;
}
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.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';
$('#search').hidden = state.view === 'calendar';
$('#cal-nav').hidden = state.view !== 'calendar';
if (state.view === 'calendar') {
renderCalendar();
return;
}
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);
const time = timeText(task);
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, 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));
}
// --- 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);
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, { due_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 (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) {
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);
if (state.view === 'calendar') body.due_date = state.calDate || todayStr();
act(() => api('api/tasks', { method: 'POST', body }));
}
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);
}
// --- 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-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 only make sense with a date; show what else is scheduled that day and flag overlaps.
function updateTimeFields() {
const day = $('#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 ${dueInfo(day).text.split(' · ')[0]}`),
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 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,
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' }));
}
// --- 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();
if (state.view === 'calendar') defaults.due_date = state.calDate || 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-due').addEventListener('change', updateTimeFields);
$('#t-start').addEventListener('change', onStartChange);
$('#t-end').addEventListener('change', updateTimeFields);
$('#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); });
$('#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));
updateTimeFields();
});
}
// 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();