Add optional time ranges and a calendar day view
Tasks can now have a start/end time on their deadline date. The new Calendar view shows a day as a timeline (with a week strip and projected repeats of recurring tasks), and the task form lists what is already planned that day and flags overlaps. Existing databases are migrated to schema v2 automatically, with a backup copy taken first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+235
-7
@@ -3,13 +3,15 @@
|
||||
const VIEWS = [
|
||||
{ id: 'today', label: 'Today', icon: '☀️' },
|
||||
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
|
||||
{ id: 'calendar', label: 'Calendar', icon: '🗓️' },
|
||||
{ id: 'all', label: 'All open', icon: '📋' },
|
||||
{ id: 'done', label: 'Done', icon: '✅' },
|
||||
];
|
||||
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
|
||||
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
|
||||
const HOUR_PX = 48;
|
||||
|
||||
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '' };
|
||||
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null };
|
||||
let editing = null; // task open in the dialog; null when creating
|
||||
let draftSubtasks = [];
|
||||
let toastTimer = null;
|
||||
@@ -47,6 +49,46 @@ const dayDiff = (a, b) => {
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000);
|
||||
};
|
||||
|
||||
const addMonths = (s, n) => {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
const total = m - 1 + n;
|
||||
const yy = y + Math.floor(total / 12);
|
||||
const mm = total % 12;
|
||||
return fmtDate(new Date(yy, mm, Math.min(d, new Date(yy, mm + 1, 0).getDate())));
|
||||
};
|
||||
// Same stepping rules as the server, so projected repeats land where the real ones will.
|
||||
function stepDate(s, recurrence, n) {
|
||||
if (recurrence === 'daily') return addDays(s, n);
|
||||
if (recurrence === 'weekly') return addDays(s, 7 * n);
|
||||
if (recurrence === 'monthly') return addMonths(s, n);
|
||||
return addMonths(s, 12 * n);
|
||||
}
|
||||
|
||||
const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; };
|
||||
const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
|
||||
// A start time without an end counts as one hour.
|
||||
const span = (t) => {
|
||||
const start = toMin(t.start_time);
|
||||
return [start, t.end_time ? toMin(t.end_time) : Math.min(start + 60, 24 * 60)];
|
||||
};
|
||||
const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}–${t.end_time}` : t.start_time) : '');
|
||||
|
||||
// Tasks on a given day, including future repeats of open recurring tasks (marked projected).
|
||||
function itemsOn(day) {
|
||||
const out = [];
|
||||
for (const t of state.tasks) {
|
||||
if (!t.due_date) continue;
|
||||
if (t.due_date === day) {
|
||||
out.push({ task: t, projected: false });
|
||||
} else if (!t.done && t.recurrence !== 'none' && t.due_date < day) {
|
||||
let d = t.due_date;
|
||||
for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval);
|
||||
if (d === day) out.push({ task: t, projected: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function dueInfo(due) {
|
||||
const diff = dayDiff(todayStr(), due);
|
||||
const d = parseDate(due);
|
||||
@@ -82,7 +124,13 @@ async function api(path, { method = 'GET', body } = {}) {
|
||||
try {
|
||||
const j = await res.json();
|
||||
if (typeof j.detail === 'string') msg = j.detail;
|
||||
else if (Array.isArray(j.detail)) msg = j.detail.map((e) => `${e.loc.slice(1).join('.')}: ${e.msg}`).join('; ');
|
||||
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);
|
||||
}
|
||||
@@ -140,6 +188,11 @@ function cmpOpen(a, b) {
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date < b.due_date ? -1 : 1;
|
||||
}
|
||||
if (a.start_time !== b.start_time) {
|
||||
if (!a.start_time) return 1;
|
||||
if (!b.start_time) return -1;
|
||||
return a.start_time < b.start_time ? -1 : 1;
|
||||
}
|
||||
return (b.priority - a.priority) || (a.id - b.id);
|
||||
}
|
||||
|
||||
@@ -231,6 +284,12 @@ function render() {
|
||||
$('#view-title').textContent = title || '';
|
||||
document.title = `${title} · WhatToDo`;
|
||||
$('#quick-add').hidden = state.view === 'done';
|
||||
$('#search').hidden = state.view === 'calendar';
|
||||
$('#cal-nav').hidden = state.view !== 'calendar';
|
||||
if (state.view === 'calendar') {
|
||||
renderCalendar();
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
|
||||
if (!groups.length) {
|
||||
@@ -253,7 +312,8 @@ function taskRow(task) {
|
||||
}
|
||||
if (task.due_date) {
|
||||
const d = dueInfo(task.due_date);
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text));
|
||||
const time = timeText(task);
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, time ? ` · ${time}` : ''));
|
||||
}
|
||||
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
|
||||
if (task.recurrence !== 'none') {
|
||||
@@ -287,6 +347,117 @@ function taskRow(task) {
|
||||
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
|
||||
}
|
||||
|
||||
// --- Calendar -----------------------------------------------------------------
|
||||
|
||||
// Lay out overlapping blocks side by side: each cluster of overlaps shares its width.
|
||||
function layoutBlocks(items) {
|
||||
const sorted = items.map((it) => ({ ...it, span: span(it.task) }))
|
||||
.sort((a, b) => a.span[0] - b.span[0] || b.span[1] - a.span[1]);
|
||||
let cluster = [];
|
||||
let clusterEnd = -1;
|
||||
const flush = () => {
|
||||
const cols = Math.max(...cluster.map((it) => it.col)) + 1;
|
||||
for (const it of cluster) it.cols = cols;
|
||||
cluster = [];
|
||||
};
|
||||
for (const it of sorted) {
|
||||
if (cluster.length && it.span[0] >= clusterEnd) flush();
|
||||
const colEnds = [];
|
||||
for (const other of cluster) colEnds[other.col] = Math.max(colEnds[other.col] ?? -1, other.span[1]);
|
||||
let col = 0;
|
||||
while (colEnds[col] !== undefined && colEnds[col] > it.span[0]) col++;
|
||||
it.col = col;
|
||||
cluster.push(it);
|
||||
clusterEnd = Math.max(clusterEnd, it.span[1]);
|
||||
}
|
||||
if (cluster.length) flush();
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function weekStrip(day) {
|
||||
const d = parseDate(day);
|
||||
const monday = addDays(day, -((d.getDay() + 6) % 7));
|
||||
const t = todayStr();
|
||||
return h('div', { class: 'week-strip' }, Array.from({ length: 7 }, (_, i) => {
|
||||
const date = addDays(monday, i);
|
||||
const n = itemsOn(date).filter((it) => !it.task.done).length;
|
||||
return h('button', {
|
||||
type: 'button',
|
||||
class: 'week-day' + (date === day ? ' active' : '') + (date === t ? ' today' : ''),
|
||||
onclick: () => setCalDate(date),
|
||||
},
|
||||
h('span', { class: 'wd' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short' })),
|
||||
h('span', { class: 'dn' }, parseDate(date).getDate()),
|
||||
h('span', { class: 'dots' }, n ? '•'.repeat(Math.min(n, 4)) : ' '));
|
||||
}));
|
||||
}
|
||||
|
||||
function renderCalendar() {
|
||||
const day = state.calDate || todayStr();
|
||||
$('#cal-date').value = day;
|
||||
const items = itemsOn(day);
|
||||
const timed = items.filter((it) => it.task.start_time);
|
||||
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
|
||||
|
||||
let startHour = 7;
|
||||
let endHour = 22;
|
||||
for (const it of timed) {
|
||||
const [a, b] = span(it.task);
|
||||
startHour = Math.min(startHour, Math.floor(a / 60));
|
||||
endHour = Math.max(endHour, Math.ceil(b / 60));
|
||||
}
|
||||
|
||||
const grid = h('div', { class: 'timeline', style: { height: `${(endHour - startHour) * HOUR_PX}px` } });
|
||||
for (let hr = startHour; hr < endHour; hr++) {
|
||||
grid.append(h('div', {
|
||||
class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` },
|
||||
title: 'Add a task at this time',
|
||||
onclick: () => openTask(null, { due_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)) }),
|
||||
}, h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
|
||||
}
|
||||
for (const it of layoutBlocks(timed)) {
|
||||
const t = it.task;
|
||||
const cat = catById(t.category_id);
|
||||
const top = ((it.span[0] - startHour * 60) / 60) * HOUR_PX;
|
||||
const height = Math.max(((it.span[1] - it.span[0]) / 60) * HOUR_PX - 2, 20);
|
||||
grid.append(h('button', {
|
||||
type: 'button',
|
||||
class: 'block' + (t.done ? ' done' : '') + (it.projected ? ' projected' : '') + (height < 40 ? ' short' : ''),
|
||||
title: it.projected ? 'Repeat of a recurring task (opens the current occurrence)' : t.title,
|
||||
style: {
|
||||
top: `${top}px`, height: `${height}px`,
|
||||
left: `calc(52px + (100% - 56px) * ${it.col / it.cols})`,
|
||||
width: `calc((100% - 56px) / ${it.cols} - 3px)`,
|
||||
borderLeftColor: cat ? cat.color : 'var(--accent)',
|
||||
},
|
||||
onclick: () => openTask(t),
|
||||
},
|
||||
h('span', { class: 'block-time' }, it.projected ? '↻ ' : '', t.start_time, t.end_time ? `–${t.end_time}` : ''),
|
||||
h('span', { class: 'block-title' }, t.title)));
|
||||
}
|
||||
if (day === todayStr()) {
|
||||
const now = new Date();
|
||||
const mins = now.getHours() * 60 + now.getMinutes();
|
||||
if (mins >= startHour * 60 && mins <= endHour * 60) {
|
||||
grid.append(h('div', { class: 'now-line', style: { top: `${((mins - startHour * 60) / 60) * HOUR_PX}px` } }));
|
||||
}
|
||||
}
|
||||
|
||||
const sections = [weekStrip(day)];
|
||||
if (untimed.length) {
|
||||
sections.push(h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
|
||||
untimed.map(taskRow)));
|
||||
}
|
||||
sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'Schedule'), grid));
|
||||
$('#list').replaceChildren(...sections);
|
||||
}
|
||||
|
||||
function setCalDate(day) {
|
||||
state.calDate = day;
|
||||
render();
|
||||
}
|
||||
|
||||
// --- Actions ------------------------------------------------------------------
|
||||
|
||||
function setView(view, categoryId = null) {
|
||||
@@ -327,15 +498,19 @@ function quickAdd(title) {
|
||||
if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId;
|
||||
if (state.view === 'today') body.due_date = todayStr();
|
||||
if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1);
|
||||
if (state.view === 'calendar') body.due_date = state.calDate || todayStr();
|
||||
act(() => api('api/tasks', { method: 'POST', body }));
|
||||
}
|
||||
|
||||
function toast(message, action) {
|
||||
const el = $('#toast');
|
||||
el.replaceChildren(h('span', {}, message), action ? h('button', {
|
||||
type: 'button', class: 'link',
|
||||
onclick: () => { el.hidden = true; action.fn(); },
|
||||
}, action.label) : null);
|
||||
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);
|
||||
@@ -366,6 +541,8 @@ function openTask(task, defaults = {}) {
|
||||
fillCategorySelect($('#t-category'), t.category_id);
|
||||
$('#t-priority').value = String(t.priority);
|
||||
$('#t-due').value = t.due_date || '';
|
||||
$('#t-start').value = t.start_time || '';
|
||||
$('#t-end').value = t.end_time || '';
|
||||
$('#t-repeat').value = t.recurrence;
|
||||
$('#t-interval').value = t.recur_interval;
|
||||
$('#t-tags').value = t.tags.join(', ');
|
||||
@@ -375,10 +552,49 @@ function openTask(task, defaults = {}) {
|
||||
draftSubtasks = t.subtasks.map((s) => ({ title: s.title, done: s.done }));
|
||||
renderDraftSubtasks();
|
||||
updateIntervalVisibility();
|
||||
updateTimeFields();
|
||||
$('#task-dialog').showModal();
|
||||
if (!task) $('#t-title').focus();
|
||||
}
|
||||
|
||||
// Times only make sense with a date; show what else is scheduled that day and flag overlaps.
|
||||
function updateTimeFields() {
|
||||
const day = $('#t-due').value;
|
||||
for (const id of ['#t-start', '#t-end']) {
|
||||
$(id).disabled = !day;
|
||||
if (!day) $(id).value = '';
|
||||
}
|
||||
const box = $('#t-day');
|
||||
const others = day ? itemsOn(day)
|
||||
.filter((it) => it.task.start_time && !it.task.done && !(editing && it.task.id === editing.id))
|
||||
.sort((a, b) => toMin(a.task.start_time) - toMin(b.task.start_time)) : [];
|
||||
if (!others.length) { box.hidden = true; return; }
|
||||
const start = $('#t-start').value;
|
||||
const end = $('#t-end').value;
|
||||
const mine = start ? [toMin(start), end ? toMin(end) : toMin(start) + 60] : null;
|
||||
let clashes = 0;
|
||||
const rows = others.map((it) => {
|
||||
const [a, b] = span(it.task);
|
||||
const clash = mine && a < mine[1] && mine[0] < b;
|
||||
if (clash) clashes++;
|
||||
return h('li', { class: clash ? 'clash' : '' },
|
||||
h('span', { class: 'day-time' }, timeText(it.task)), ' ', it.task.title, it.projected ? ' ↻' : '');
|
||||
});
|
||||
box.replaceChildren(
|
||||
h('div', { class: 'day-head' + (clashes ? ' clash' : '') },
|
||||
clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dueInfo(day).text.split(' · ')[0]}`),
|
||||
h('ul', {}, rows));
|
||||
box.hidden = false;
|
||||
}
|
||||
|
||||
function onStartChange() {
|
||||
const start = $('#t-start').value;
|
||||
const end = $('#t-end').value;
|
||||
if (start && (!end || end <= start)) $('#t-end').value = fromMin(Math.min(toMin(start) + 60, 23 * 60 + 59));
|
||||
if (!start) $('#t-end').value = '';
|
||||
updateTimeFields();
|
||||
}
|
||||
|
||||
function renderDraftSubtasks() {
|
||||
$('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' },
|
||||
h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }),
|
||||
@@ -407,6 +623,8 @@ function saveTask(e) {
|
||||
category_id: $('#t-category').value ? Number($('#t-category').value) : null,
|
||||
priority: Number($('#t-priority').value),
|
||||
due_date: $('#t-due').value || null,
|
||||
start_time: $('#t-start').value || null,
|
||||
end_time: ($('#t-start').value && $('#t-end').value) || null,
|
||||
recurrence: $('#t-repeat').value,
|
||||
recur_interval: Math.max(1, Number($('#t-interval').value) || 1),
|
||||
tags: $('#t-tags').value.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
@@ -527,6 +745,7 @@ function bind() {
|
||||
const defaults = {};
|
||||
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
|
||||
if (state.view === 'today') defaults.due_date = todayStr();
|
||||
if (state.view === 'calendar') defaults.due_date = state.calDate || todayStr();
|
||||
defaults.title = $('#quick-title').value.trim();
|
||||
$('#quick-title').value = '';
|
||||
openTask(null, defaults);
|
||||
@@ -542,6 +761,14 @@ function bind() {
|
||||
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
|
||||
$('#t-delete').addEventListener('click', deleteTask);
|
||||
$('#t-repeat').addEventListener('change', updateIntervalVisibility);
|
||||
$('#t-due').addEventListener('change', updateTimeFields);
|
||||
$('#t-start').addEventListener('change', onStartChange);
|
||||
$('#t-end').addEventListener('change', updateTimeFields);
|
||||
|
||||
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
|
||||
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
|
||||
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
|
||||
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
|
||||
$('#t-subtask-add').addEventListener('click', addDraftSubtask);
|
||||
$('#t-subtask-new').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
|
||||
@@ -549,6 +776,7 @@ function bind() {
|
||||
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
|
||||
btn.addEventListener('click', () => {
|
||||
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
|
||||
updateTimeFields();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user