Add plan dates, postponing, backlog and projects

- Tasks get an optional plan date separate from the deadline. Unfinished
  plans roll over in Today; only missed deadlines count as overdue.
- Postpone sheet on every task (tomorrow, weekend, next week, ...) that
  can move either the plan date or the deadline, with undo.
- Backlog view for undated tasks, with suggestions in Today.
- Projects with deadline, notes, progress, next step and "falling
  behind" warnings, shown in Today and in their own view.
- Times and the calendar now follow the plan date (else the deadline).
- Schema v3 migration (backup taken first); export format v3 includes
  projects and still imports older files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
goyban
2026-10-07 14:26:32 +02:00
parent 509b1a1320
commit 39842c72d7
8 changed files with 931 additions and 157 deletions
+503 -104
View File
@@ -4,15 +4,23 @@ const VIEWS = [
{ id: 'today', label: 'Today', icon: '☀️' },
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
{ id: 'calendar', label: 'Calendar', icon: '🗓️' },
{ id: 'backlog', label: 'Backlog', icon: '📥' },
{ id: 'all', label: 'All open', icon: '📋' },
{ id: 'done', label: 'Done', icon: '✅' },
];
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
const HOUR_PX = 48;
const BACKLOG_SUGGESTIONS = 2;
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null };
const state = {
tasks: [], categories: [], projects: [],
view: 'today', categoryId: null, projectId: null,
search: '', calDate: null, showDoneProjects: false,
};
let editing = null; // task open in the dialog; null when creating
let editingProject = null;
let planning = null; // { task, mode } for the postpone sheet
let draftSubtasks = [];
let toastTimer = null;
@@ -48,7 +56,6 @@ const dayDiff = (a, b) => {
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;
@@ -64,6 +71,41 @@ function stepDate(s, recurrence, 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.
@@ -73,15 +115,27 @@ const span = (t) => {
};
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).
// --- 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) {
if (!t.due_date) continue;
if (t.due_date === day) {
const w = whenDate(t);
if (!w) continue;
if (w === day) {
out.push({ task: t, projected: false });
} else if (!t.done && t.recurrence !== 'none' && t.due_date < day) {
let d = t.due_date;
} 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 });
}
@@ -89,23 +143,6 @@ function itemsOn(day) {
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 } = {}) {
@@ -138,8 +175,11 @@ async function api(path, { method = 'GET', body } = {}) {
}
async function loadAll() {
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]);
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;
}
@@ -159,34 +199,48 @@ async function act(fn) {
// --- Persisted UI prefs -------------------------------------------------------
function savePrefs() {
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ }
try {
localStorage.setItem('whattodo.view', JSON.stringify({
view: state.view, categoryId: state.categoryId, projectId: state.projectId,
}));
} catch { /* ignore */ }
}
function loadPrefs() {
try {
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; }
if (p && typeof p.view === 'string') {
state.view = p.view;
state.categoryId = p.categoryId ?? null;
state.projectId = p.projectId ?? null;
}
} catch { /* ignore */ }
}
// --- Filtering & grouping -----------------------------------------------------
// --- 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 hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
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) {
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;
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;
@@ -199,10 +253,18 @@ function cmpOpen(a, b) {
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);
@@ -212,75 +274,179 @@ function buildGroups(tasks) {
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;
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),
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)),
// High priority things you haven't scheduled yet keep showing up until you plan them.
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
{ title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' },
];
}
if (state.view === 'upcoming') {
return [
group('Tomorrow', (x) => 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),
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),
];
}
return [
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) => 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),
group('Next 7 days', (x) => later(x) && whenDate(x) && whenDate(x) <= week),
group('Later', (x) => later(x) && whenDate(x) > week),
group('No date', isBacklog),
];
if (state.view === 'project') {
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
groups.push({ title: 'Completed', tasks: done });
}
return groups;
}
function counts() {
const t = todayStr();
const open = state.tasks.filter((x) => !x.done);
const overdue = open.filter((x) => isOverdue(x, t)).length;
const c = {
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length,
upcoming: open.filter((x) => x.due_date && x.due_date > t).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,
overdue: open.filter((x) => x.due_date && x.due_date < t).length,
calendar: null,
overdue,
cats: {},
projects: {},
};
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
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 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,
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);
}
$('#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,
)));
function renderNav(c) {
$('#views').replaceChildren(...VIEWS.map((v) => navItem({
active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id],
onclick: () => setView(v.id), alert: v.id === 'today' && c.overdue > 0,
})));
const 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,
));
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(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0],
() => setView('cat', 0), 'var(--muted)'));
catItems.push(navItem({
active: state.view === 'cat' && state.categoryId === 0, label: 'No category', count: c.cats[0],
onclick: () => setView('cat', { categoryId: 0 }), color: 'var(--muted)',
}));
}
$('#cats').replaceChildren(...catItems);
}
function render() {
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
if (state.view === 'project' && !projectById(state.projectId)) state.view = 'today';
if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
renderNav(counts());
let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category';
if (state.view === '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';
@@ -291,29 +457,102 @@ function render() {
return;
}
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
if (!groups.length) {
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 due today. 🎉' : 'No tasks here.';
$('#list').replaceChildren(h('div', { class: 'empty' }, msg));
return;
: state.view === 'today' ? 'Nothing planned for today. 🎉'
: state.view === 'backlog' ? 'Backlog is empty: everything has a date.'
: state.view === 'project' ? 'No tasks in this project yet. Add the first step above.'
: 'No tasks here.';
sections.push(h('div', { class: 'empty' }, msg));
}
$('#list').replaceChildren(...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))));
$('#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);
const time = timeText(task);
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, time ? ` · ${time}` : ''));
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') {
@@ -344,7 +583,11 @@ function taskRow(task) {
}, task.done ? '✓' : ''),
h('div', { class: 'task-body' },
h('div', { class: 'task-title' }, task.title),
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
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 -----------------------------------------------------------------
@@ -398,6 +641,9 @@ function renderCalendar() {
const items = itemsOn(day);
const timed = items.filter((it) => it.task.start_time);
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
// Deadlines that fall on this day but are planned for another day, and project deadlines.
const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day);
const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day);
let startHour = 7;
let endHour = 22;
@@ -412,7 +658,9 @@ function renderCalendar() {
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)) }),
onclick: () => openTask(null, {
planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)),
}),
}, h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
}
for (const it of layoutBlocks(timed)) {
@@ -444,6 +692,14 @@ function renderCalendar() {
}
const sections = [weekStrip(day)];
if (deadlines.length || projectDeadlines.length) {
sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' }, 'Deadlines this day'),
projectDeadlines.map((p) => h('button', {
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
deadlines.map(taskRow)));
}
if (untimed.length) {
sections.push(h('section', { class: 'group' },
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
@@ -460,9 +716,10 @@ function setCalDate(day) {
// --- Actions ------------------------------------------------------------------
function setView(view, categoryId = null) {
function setView(view, { categoryId = null, projectId = null } = {}) {
state.view = view;
state.categoryId = categoryId;
state.projectId = projectId;
savePrefs();
document.body.classList.remove('nav-open');
render();
@@ -482,7 +739,7 @@ function toggleDone(task) {
}
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}.` : '';
const extra = res.next ? ` Next one: ${dayName(whenDate(res.next))}.` : '';
toast(`Done: ${task.title}.${extra}`, {
label: 'Undo',
fn: () => act(async () => {
@@ -493,13 +750,23 @@ function toggleDone(task) {
});
}
// Defaults for a task created from the current view.
function viewDefaults() {
const d = {};
if (state.view === 'cat' && state.categoryId) d.category_id = state.categoryId;
if (state.view === 'project') {
const p = projectById(state.projectId);
d.project_id = p.id;
if (p.category_id) d.category_id = p.category_id;
}
if (state.view === 'today') d.planned_date = todayStr();
if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1);
if (state.view === 'calendar') d.planned_date = state.calDate || todayStr();
return d;
}
function quickAdd(title) {
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 }));
act(() => api('api/tasks', { method: 'POST', body: { title, ...viewDefaults() } }));
}
function toast(message, action) {
@@ -516,12 +783,69 @@ function toast(message, action) {
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 fillCategorySelect(select, selected) {
function fillSelect(select, items, selected, noneLabel = '— None —') {
select.replaceChildren(
h('option', { value: '' }, '— None —'),
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)),
h('option', { value: '' }, noneLabel),
...items.map((x) => h('option', { value: String(x.id) }, x.name)),
);
select.value = selected ? String(selected) : '';
}
@@ -533,13 +857,16 @@ function updateIntervalVisibility() {
function openTask(task, defaults = {}) {
editing = task;
const t = task || {
title: '', notes: '', category_id: null, priority: 0, due_date: null,
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;
fillCategorySelect($('#t-category'), t.category_id);
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 || '';
@@ -557,9 +884,9 @@ function openTask(task, defaults = {}) {
if (!task) $('#t-title').focus();
}
// Times only make sense with a date; show what else is scheduled that day and flag overlaps.
// Times apply to the plan date (or the deadline). Show what else is scheduled that day and flag overlaps.
function updateTimeFields() {
const day = $('#t-due').value;
const day = $('#t-planned').value || $('#t-due').value;
for (const id of ['#t-start', '#t-end']) {
$(id).disabled = !day;
if (!day) $(id).value = '';
@@ -582,7 +909,7 @@ function updateTimeFields() {
});
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]}`),
clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dayName(day)}`),
h('ul', {}, rows));
box.hidden = false;
}
@@ -595,6 +922,11 @@ function onStartChange() {
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; } }),
@@ -621,7 +953,9 @@ function saveTask(e) {
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,
@@ -646,6 +980,58 @@ function deleteTask() {
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() {
@@ -690,12 +1076,12 @@ async function importFile() {
}
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?`
? `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 and ${res.categories} categories (${res.mode}).`);
toast(`Imported ${res.tasks} tasks, ${res.projects} projects and ${res.categories} categories (${res.mode}).`);
});
$('#s-import-file').value = '';
renderSettingsCategories();
@@ -742,11 +1128,7 @@ function bind() {
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();
const defaults = { ...viewDefaults(), title: $('#quick-title').value.trim() };
$('#quick-title').value = '';
openTask(null, defaults);
});
@@ -761,25 +1143,42 @@ function bind() {
$('#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);
$('#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-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-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
$('#t-planned').value = namedDate(btn.dataset.plan) || '';
updateTimeFields();
});
}
// Calendar
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
// Postpone sheet
for (const btn of $('#p-mode').querySelectorAll('button')) {
btn.addEventListener('click', () => { planning.mode = btn.dataset.mode; renderPlan(); });
}
$('#p-set').addEventListener('click', () => { if ($('#p-date').value) applyPlan($('#p-date').value); });
$('#p-clear').addEventListener('click', () => applyPlan(null));
$('#p-cancel').addEventListener('click', () => $('#plan-dialog').close());
// Projects
$('#new-project').addEventListener('click', () => openProject(null));
$('#project-form').addEventListener('submit', saveProject);
$('#pr-cancel').addEventListener('click', () => $('#project-dialog').close());
$('#pr-delete').addEventListener('click', deleteProject);
// Settings dialog
$('#settings-btn').addEventListener('click', () => {
document.body.classList.remove('nav-open');