Files
goyban 119b8a0205 Add week and month views to the calendar
The calendar now opens in a week view: the day timeline as seven columns
lined up with the week strip. A month grid is also available, and the
chosen view is remembered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:31:53 +02:00

1411 lines
57 KiB
JavaScript
Raw Permalink 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: 'overview', label: 'Overview', icon: '🧭' },
{ 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 CAL_MODES = ['day', 'week', 'month'];
const MONTH_CELL_ITEMS = 4; // lines per day in the month view, the last one becomes "+n more"
const BACKLOG_SUGGESTIONS = 2;
const DEFAULT_VIEW = 'overview';
const OVERVIEW_DAYS = 7; // days after today that the overview lists one by one
const state = {
tasks: [], categories: [], projects: [],
view: DEFAULT_VIEW, categoryId: null, projectId: null,
search: '', calDate: null, calMode: 'week', 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 -------------------------------------------------------
const PREFS_VERSION = 2;
function savePrefs() {
try {
localStorage.setItem('whattodo.view', JSON.stringify({
view: state.view, categoryId: state.categoryId, projectId: state.projectId, calMode: state.calMode, v: PREFS_VERSION,
}));
} 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;
if (CAL_MODES.includes(p.calMode)) state.calMode = p.calMode;
// Today used to be the start page; move people who were left on it to the overview once.
if (!p.v && p.view === 'today') state.view = DEFAULT_VIEW;
}
} 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 === 'overview') {
const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id));
const days = Array.from({ length: OVERVIEW_DAYS }, (_, i) => addDays(t, i + 1));
return [
group('Overdue', overdue),
group('Today', today),
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
...days.map((day) => group(dayName(day), (x) => later(x) && whenDate(x) === day)),
];
}
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 laterThan = addDays(t, OVERVIEW_DAYS);
const c = {
later: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x) > laterThan).length,
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: {},
};
c.overview = c.today;
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: ['overview', 'today'].includes(v.id) && 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 = DEFAULT_VIEW;
if (state.view === 'project' && !projectById(state.projectId)) state.view = DEFAULT_VIEW;
if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = DEFAULT_VIEW;
const c = counts();
renderNav(c);
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 === 'overview' ? 'Nothing planned for today or the coming week. 🎉'
: 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));
}
if (state.view === 'overview' && !state.search && c.later) {
sections.push(h('button', { type: 'button', class: 'more-link', onclick: () => setView('upcoming') },
`${c.later} more planned later →`));
}
$('#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;
}
const mondayOf = (day) => addDays(day, -((parseDate(day).getDay() + 6) % 7));
// Within one day: timed tasks in order, then the rest by priority.
const cmpDay = (a, b) => {
if (a.task.start_time !== b.task.start_time) {
if (!a.task.start_time) return 1;
if (!b.task.start_time) return -1;
return a.task.start_time < b.task.start_time ? -1 : 1;
}
return (b.task.priority - a.task.priority) || (a.task.id - b.task.id);
};
// Deadlines that fall on this day but are planned for another day, and project deadlines.
const deadlinesOn = (day) => ({
tasks: state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day),
projects: state.projects.filter((p) => !p.done && p.due_date === day),
});
// With a gutter the strip lines up with the day columns of the week view.
function weekStrip(day, gutter) {
const monday = mondayOf(day);
const t = todayStr();
return h('div', { class: 'week-strip' + (gutter ? ' week-row' : '') },
gutter ? h('span', {}) : null,
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' : ''),
title: gutter && date === day ? 'Open this day' : null,
// In the week view a second click on the selected day opens it.
onclick: () => (gutter && date === day ? setCalMode('day', date) : 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)) : ' '));
}));
}
// Hours the timeline has to cover: 7 to 22, stretched to fit every timed item.
function hourRange(timed) {
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));
}
return [startHour, endHour];
}
// One day's schedule. Compact is a week column: no hour labels, blocks use the full width.
function timeline(day, timed, startHour, endHour, compact) {
const gutter = compact ? 2 : 52;
const grid = h('div', {
class: 'timeline' + (compact ? ' compact' : '') + (compact && day === todayStr() ? ' today' : ''),
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)),
}),
}, compact ? null : 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)' : `${timeText(t)} ${t.title}`,
style: {
top: `${top}px`, height: `${height}px`,
left: `calc(${gutter}px + (100% - ${gutter + 4}px) * ${it.col / it.cols})`,
width: `calc((100% - ${gutter + 4}px) / ${it.cols} - ${compact ? 1 : 3}px)`,
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` } }));
}
}
return grid;
}
// One-line task for the cramped cells of the week and month views.
function miniItem(it, { deadline = false, time = false } = {}) {
const t = it.task;
const cat = catById(t.category_id);
return h('button', {
type: 'button',
class: 'mini' + (t.done ? ' done' : '') + (it.projected ? ' projected' : ''),
title: [deadline ? 'Deadline:' : timeText(t), t.title].filter(Boolean).join(' '),
style: { borderLeftColor: cat ? cat.color : 'var(--accent)' },
onclick: (e) => { e.stopPropagation(); openTask(t); },
}, deadline ? '📅 ' : '', time && t.start_time ? h('span', { class: 'block-time' }, t.start_time, ' ') : null, t.title);
}
function miniProject(p) {
return h('button', {
type: 'button', class: 'mini projected', title: `Project deadline: ${p.name}`,
onclick: (e) => { e.stopPropagation(); setView('project', { projectId: p.id }); },
}, '🏁 ', p.name);
}
function renderCalDay(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);
const deadlines = deadlinesOn(day);
const sections = [weekStrip(day)];
if (deadlines.tasks.length || deadlines.projects.length) {
sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' }, 'Deadlines this day'),
deadlines.projects.map((p) => h('button', {
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
deadlines.tasks.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'), timeline(day, timed, ...hourRange(timed))));
$('#list').replaceChildren(...sections);
}
function renderCalWeek(day) {
const monday = mondayOf(day);
const days = Array.from({ length: 7 }, (_, i) => {
const date = addDays(monday, i);
const items = itemsOn(date);
return {
date,
timed: items.filter((it) => it.task.start_time),
untimed: items.filter((it) => !it.task.start_time).sort(cmpDay),
deadlines: deadlinesOn(date),
};
});
const [startHour, endHour] = hourRange(days.flatMap((d) => d.timed));
const sections = [weekStrip(day, true)];
if (days.some((d) => d.untimed.length || d.deadlines.tasks.length || d.deadlines.projects.length)) {
sections.push(h('div', { class: 'week-row all-day' }, h('span', {}),
days.map((d) => h('div', { class: 'day-cell' },
d.deadlines.projects.map(miniProject),
d.deadlines.tasks.map((t) => miniItem({ task: t }, { deadline: true })),
d.untimed.map((it) => miniItem(it))))));
}
const labels = h('div', { class: 'hour-gutter' });
for (let hr = startHour; hr < endHour; hr++) {
labels.append(h('span', { class: 'hour-label', style: { top: `${(hr - startHour) * HOUR_PX + 3}px` } }, `${pad(hr)}:00`));
}
sections.push(h('div', { class: 'week-row week-grid' }, labels,
days.map((d) => timeline(d.date, d.timed, startHour, endHour, true))));
$('#list').replaceChildren(...sections);
}
function renderCalMonth(day) {
const first = `${day.slice(0, 8)}01`;
const start = mondayOf(first);
const weeks = Math.ceil(dayDiff(start, addMonths(first, 1)) / 7);
const t = todayStr();
const cells = Array.from({ length: weeks * 7 }, (_, i) => {
const date = addDays(start, i);
const deadlines = deadlinesOn(date);
const minis = [
...deadlines.projects.map(miniProject),
...deadlines.tasks.map((x) => miniItem({ task: x }, { deadline: true })),
...itemsOn(date).sort(cmpDay).map((it) => miniItem(it, { time: true })),
];
const shown = minis.length > MONTH_CELL_ITEMS ? minis.slice(0, MONTH_CELL_ITEMS - 1) : minis;
return h('div', {
class: 'month-cell' + (date.slice(0, 7) !== day.slice(0, 7) ? ' other' : '')
+ (date === day ? ' active' : '') + (date === t ? ' today' : ''),
onclick: () => setCalDate(date),
},
h('button', {
type: 'button', class: 'month-num', title: 'Open this day',
onclick: (e) => { e.stopPropagation(); setCalMode('day', date); },
}, parseDate(date).getDate()),
shown,
minis.length > shown.length ? h('button', {
type: 'button', class: 'month-more',
onclick: (e) => { e.stopPropagation(); setCalMode('day', date); },
}, `+${minis.length - shown.length} more`) : null);
});
$('#list').replaceChildren(
h('h3', { class: 'group-title month-title' },
parseDate(first).toLocaleDateString(undefined, { month: 'long', year: 'numeric' })),
h('div', { class: 'month-grid month-head' }, Array.from({ length: 7 }, (_, i) => h('span', {},
parseDate(addDays(start, i)).toLocaleDateString(undefined, { weekday: 'short' })))),
h('div', { class: 'month-grid' }, cells));
}
function renderCalendar() {
const day = state.calDate || todayStr();
$('#cal-date').value = day;
for (const btn of $('#cal-mode').querySelectorAll('button')) {
btn.classList.toggle('active', btn.dataset.mode === state.calMode);
}
$('#cal-prev').setAttribute('aria-label', `Previous ${state.calMode}`);
$('#cal-next').setAttribute('aria-label', `Next ${state.calMode}`);
if (state.calMode === 'day') renderCalDay(day);
else if (state.calMode === 'month') renderCalMonth(day);
else renderCalWeek(day);
}
function setCalDate(day) {
state.calDate = day;
render();
}
function setCalMode(mode, day) {
state.calMode = mode;
if (day) state.calDate = day;
savePrefs();
render();
}
// Step back or forward by one day, week or month, whichever is showing.
function stepCal(dir) {
const day = state.calDate || todayStr();
if (state.calMode === 'month') setCalDate(addMonths(day, dir));
else setCalDate(addDays(day, state.calMode === 'week' ? 7 * dir : dir));
}
// --- 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 (['overview', 'today'].includes(state.view)) 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', () => stepCal(-1));
$('#cal-next').addEventListener('click', () => stepCal(1));
for (const btn of $('#cal-mode').querySelectorAll('button')) {
btn.addEventListener('click', () => setCalMode(btn.dataset.mode));
}
$('#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();