From 119b8a02059198874937847c06f51561e66e00a8 Mon Sep 17 00:00:00 2001 From: goyban Date: Tue, 13 Oct 2026 10:31:53 +0200 Subject: [PATCH] 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 --- README.md | 2 +- app/static/app.js | 226 +++++++++++++++++++++++++++++++++++------- app/static/index.html | 7 +- app/static/style.css | 58 ++++++++++- 4 files changed, 252 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 7da4344..85af9f2 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ A small, self-hosted to-do app for one person. It works in a desktop browser and - **Projects** group the tasks of a bigger goal and have their own deadline, notes and progress bar. Today shows each active project's **next step**, how much time is left, and a warning if it's falling behind or nothing was done on it in over a week. - **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence. - When you pick a date and time, the task form lists what else is planned that day and warns about overlaps. -- Views: **Today**, **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and per category. +- Views: **Today**, **Upcoming**, **Calendar** (week, day and month views; tap an hour to add a task there), **Backlog**, **All open**, **Done**, plus one view per project and per category. - You can rename, recolor, reorder, add, or delete categories in **Settings**. It starts with Work, Life, Personal, YouTube. - **Export / import** of all data as JSON, from **Settings**. - Login with a single password. diff --git a/app/static/app.js b/app/static/app.js index 56e9472..7c5c2f3 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -12,6 +12,8 @@ const VIEWS = [ 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 @@ -19,7 +21,7 @@ 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, showDoneProjects: false, + search: '', calDate: null, calMode: 'week', showDoneProjects: false, }; let editing = null; // task open in the dialog; null when creating let editingProject = null; @@ -206,7 +208,7 @@ const PREFS_VERSION = 2; function savePrefs() { try { localStorage.setItem('whattodo.view', JSON.stringify({ - view: state.view, categoryId: state.categoryId, projectId: state.projectId, v: PREFS_VERSION, + view: state.view, categoryId: state.categoryId, projectId: state.projectId, calMode: state.calMode, v: PREFS_VERSION, })); } catch { /* ignore */ } } @@ -217,6 +219,7 @@ function loadPrefs() { 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; } @@ -643,34 +646,46 @@ function layoutBlocks(items) { return sorted; } -function weekStrip(day) { - const d = parseDate(day); - const monday = addDays(day, -((d.getDay() + 6) % 7)); +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' }, 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)) : ' ')); - })); + 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)) : ' ')); + })); } -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); - // 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); - +// 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) { @@ -678,8 +693,16 @@ function renderCalendar() { startHour = Math.min(startHour, Math.floor(a / 60)); endHour = Math.max(endHour, Math.ceil(b / 60)); } + return [startHour, endHour]; +} - const grid = h('div', { class: 'timeline', style: { height: `${(endHour - startHour) * HOUR_PX}px` } }); +// 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` }, @@ -687,7 +710,7 @@ function renderCalendar() { 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`))); + }, compact ? null : h('span', { class: 'hour-label' }, `${pad(hr)}:00`))); } for (const it of layoutBlocks(timed)) { const t = it.task; @@ -697,11 +720,11 @@ function renderCalendar() { 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, + 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(52px + (100% - 56px) * ${it.col / it.cols})`, - width: `calc((100% - 56px) / ${it.cols} - 3px)`, + 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), @@ -716,30 +739,154 @@ function renderCalendar() { 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.length || projectDeadlines.length) { + if (deadlines.tasks.length || deadlines.projects.length) { sections.push(h('section', { class: 'group' }, h('h3', { class: 'group-title' }, 'Deadlines this day'), - projectDeadlines.map((p) => h('button', { + deadlines.projects.map((p) => h('button', { type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }), }, '🏁 ', h('strong', {}, p.name), ' (project)')), - deadlines.map(taskRow))); + 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'), grid)); + 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 } = {}) { @@ -1186,8 +1333,11 @@ function bind() { } // Calendar - $('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1))); - $('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1))); + $('#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); }); diff --git a/app/static/index.html b/app/static/index.html index c652114..c2ff8e2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -54,9 +54,14 @@
diff --git a/app/static/style.css b/app/static/style.css index fd272b6..b469662 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -251,7 +251,9 @@ input:disabled { opacity: 0.5; } .day-preview li.clash .day-time { color: inherit; } /* Calendar */ -.cal-nav { display: flex; gap: 8px; padding: 0 24px 8px; align-items: center; } +.cal-nav { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 24px 8px; align-items: center; } +.cal-nav .segmented { margin-left: auto; } +.cal-nav .segmented button { padding: 6px 12px; } .cal-nav input[type="date"] { max-width: 180px; } .week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 6px; } .week-day { @@ -297,10 +299,64 @@ input:disabled { opacity: 0.5; } border-radius: 50%; background: var(--overdue); } + +/* Week view: the strip, the untimed row and the day columns share one grid */ +.week-row { + display: grid; grid-template-columns: 36px repeat(7, minmax(0, 1fr)); gap: 6px; margin-top: 6px; +} +.all-day .day-cell { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.hour-gutter { position: relative; } +.hour-gutter .hour-label { left: auto; right: 0; } +.timeline.compact { border-radius: 10px; } +.timeline.compact.today { border-color: var(--accent); } +.timeline.compact .block { padding: 2px 4px; font-size: 12px; border-left-width: 3px; } +.timeline.compact .block-title { overflow-wrap: anywhere; } +.timeline.compact .block.short .block-time { display: none; } +.timeline.compact .now-line { left: 0; } +.mini { + display: block; width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + text-align: left; padding: 2px 5px; font-size: 12px; line-height: 1.3; cursor: pointer; + background: var(--accent-weak); border: 1px solid var(--border); border-left: 3px solid var(--accent); + border-radius: 5px; +} +.mini:hover { border-color: var(--accent); } +.mini.done { opacity: 0.55; text-decoration: line-through; } +.mini.projected { background: transparent; border-style: dashed; border-left-style: solid; } + +/* Month view */ +.month-title { margin-top: 10px; } +.month-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; } +.month-head { margin-bottom: 4px; font-size: 12px; color: var(--muted); text-align: center; } +.month-cell { + display: flex; flex-direction: column; gap: 2px; min-height: 104px; min-width: 0; padding: 4px; + background: var(--panel); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; +} +.month-cell:hover, .month-cell.active { border-color: var(--accent); } +.month-cell.active { box-shadow: inset 0 0 0 1px var(--accent); } +.month-cell.other { background: none; } +.month-cell.other .month-num { color: var(--muted); font-weight: 400; } +.month-num { + align-self: flex-start; min-width: 24px; padding: 1px 4px; border: none; background: none; + border-radius: 12px; font-size: 13px; font-weight: 600; cursor: pointer; +} +.month-num:hover { background: var(--panel-2); } +.month-cell.today .month-num { background: var(--accent); color: var(--accent-text); } +.month-more { border: none; background: none; padding: 0 2px; text-align: left; font-size: 11px; color: var(--muted); cursor: pointer; } +.month-more:hover { color: var(--text); } + @media (max-width: 800px) { .cal-nav { padding: 0 16px 8px; } .cal-nav input[type="date"] { max-width: none; flex: 1; } + .cal-nav .segmented { flex-basis: 100%; } .week-strip { gap: 4px; } + .week-row { grid-template-columns: 28px repeat(7, minmax(0, 1fr)); gap: 3px; } + .hour-gutter .hour-label { font-size: 10px; } + .timeline.compact .block { padding: 1px 2px; font-size: 11px; } + .timeline.compact .block-time { display: none; } + .timeline.compact .block-title { overflow-wrap: normal; } + .mini { padding: 1px 3px; font-size: 10px; border-left-width: 2px; } + .month-grid { gap: 2px; } + .month-cell { min-height: 72px; padding: 2px; border-radius: 6px; } } .block.short .block-time { order: 2; }