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:
@@ -2,9 +2,10 @@
|
||||
|
||||
A small, self-hosted to-do app for one person. It works in a desktop browser and on a phone, and you can install it to your home screen.
|
||||
|
||||
- Tasks have an optional **deadline**, **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps.
|
||||
- Tasks have an optional **deadline** with an optional **time range** (e.g. 10:00–12:00), **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps.
|
||||
- **Recurring tasks** (every N days/weeks/months/years): completing one creates the next occurrence.
|
||||
- Views: **Today** (overdue, due today, high priority), **Upcoming**, **All open**, **Done**, plus one view per category.
|
||||
- When you pick a date and time, the task form lists what else is planned that day and warns about overlaps.
|
||||
- Views: **Today** (overdue, due today, high priority), **Upcoming**, **Calendar** (day timeline with week strip; tap an hour to add a task there), **All open**, **Done**, plus one view 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.
|
||||
|
||||
@@ -3,7 +3,7 @@ import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
|
||||
DEFAULT_CATEGORIES = [
|
||||
("Work", "#2563eb"),
|
||||
@@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
|
||||
priority INTEGER NOT NULL DEFAULT 0,
|
||||
due_date TEXT,
|
||||
start_time TEXT,
|
||||
end_time TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]',
|
||||
recurrence TEXT NOT NULL DEFAULT 'none',
|
||||
recur_interval INTEGER NOT NULL DEFAULT 1,
|
||||
@@ -65,7 +67,9 @@ def init() -> None:
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
conn.executescript(SCHEMA)
|
||||
row = conn.execute("SELECT value FROM meta WHERE key = 'schema_version'").fetchone()
|
||||
if row is None:
|
||||
if row is not None and int(row["value"]) < SCHEMA_VERSION:
|
||||
migrate(conn, int(row["value"]))
|
||||
elif row is None:
|
||||
# Fresh database: seed default categories.
|
||||
with conn:
|
||||
for i, (name, color) in enumerate(DEFAULT_CATEGORIES):
|
||||
@@ -79,3 +83,19 @@ def init() -> None:
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def migrate(conn: sqlite3.Connection, version: int) -> None:
|
||||
# Keep a copy of the database as it was before migrating, next to it.
|
||||
backup = sqlite3.connect(f"{DB_PATH}.before-v{SCHEMA_VERSION}.bak")
|
||||
try:
|
||||
conn.backup(backup)
|
||||
finally:
|
||||
backup.close()
|
||||
with conn:
|
||||
if version < 2:
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT")
|
||||
conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT")
|
||||
conn.execute(
|
||||
"UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),)
|
||||
)
|
||||
|
||||
+43
-6
@@ -8,13 +8,14 @@ import time
|
||||
from collections import defaultdict, deque
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import time as Time
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from . import db
|
||||
|
||||
@@ -30,7 +31,7 @@ if not APP_PASSWORD or not SECRET_KEY or "change-me" in (APP_PASSWORD, SECRET_KE
|
||||
|
||||
COOKIE_NAME = "whattodo_session"
|
||||
CSRF_HEADER = "x-whattodo"
|
||||
EXPORT_VERSION = 1
|
||||
EXPORT_VERSION = 2
|
||||
STATIC_DIR = Path(__file__).parent / "static"
|
||||
|
||||
# Changing the password (or secret) invalidates all existing sessions.
|
||||
@@ -186,6 +187,21 @@ def norm_tags(tags: list[str]) -> list[str]:
|
||||
return out[:20]
|
||||
|
||||
|
||||
def fmt_time(t) -> Optional[str]:
|
||||
return t.strftime("%H:%M") if isinstance(t, Time) else t
|
||||
|
||||
|
||||
def check_times(due, start: Optional[str], end: Optional[str]) -> Optional[str]:
|
||||
"""Return an error message if the time fields don't make sense together."""
|
||||
if (start or end) and not due:
|
||||
return "A time needs a deadline date"
|
||||
if end and not start:
|
||||
return "An end time needs a start time"
|
||||
if start and end and end <= start:
|
||||
return "End time must be after start time"
|
||||
return None
|
||||
|
||||
|
||||
def clean_title(v: Optional[str]) -> Optional[str]:
|
||||
if v is None:
|
||||
return v
|
||||
@@ -212,6 +228,8 @@ class TaskIn(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
priority: int = Field(0, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: list[str] = []
|
||||
recurrence: Recurrence = "none"
|
||||
recur_interval: int = Field(1, ge=1, le=365)
|
||||
@@ -219,6 +237,13 @@ class TaskIn(BaseModel):
|
||||
|
||||
_title = field_validator("title")(clean_title)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _times(self):
|
||||
error = check_times(self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
|
||||
if error:
|
||||
raise ValueError(error)
|
||||
return self
|
||||
|
||||
|
||||
class TaskPatch(BaseModel):
|
||||
title: Optional[str] = Field(None, max_length=500)
|
||||
@@ -226,6 +251,8 @@ class TaskPatch(BaseModel):
|
||||
category_id: Optional[int] = None
|
||||
priority: Optional[int] = Field(None, ge=0, le=3)
|
||||
due_date: Optional[date] = None
|
||||
start_time: Optional[Time] = None
|
||||
end_time: Optional[Time] = None
|
||||
tags: Optional[list[str]] = None
|
||||
recurrence: Optional[Recurrence] = None
|
||||
recur_interval: Optional[int] = Field(None, ge=1, le=365)
|
||||
@@ -281,6 +308,8 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
|
||||
"category_id": r["category_id"],
|
||||
"priority": r["priority"],
|
||||
"due_date": r["due_date"],
|
||||
"start_time": r["start_time"],
|
||||
"end_time": r["end_time"],
|
||||
"tags": json.loads(r["tags"]),
|
||||
"recurrence": r["recurrence"],
|
||||
"recur_interval": r["recur_interval"],
|
||||
@@ -330,12 +359,13 @@ def insert_task(conn: sqlite3.Connection, t: dict, done: bool = False, done_at:
|
||||
now = now_iso()
|
||||
due = t.get("due_date")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, tags, recurrence,
|
||||
recur_interval, done, done_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, start_time, end_time,
|
||||
tags, recurrence, recur_interval, done, done_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0),
|
||||
due.isoformat() if isinstance(due, date) else due,
|
||||
fmt_time(t.get("start_time")), fmt_time(t.get("end_time")),
|
||||
json.dumps(norm_tags(t.get("tags", []))), t.get("recurrence", "none"),
|
||||
t.get("recur_interval", 1), int(done), done_at, created_at or now, updated_at or now,
|
||||
),
|
||||
@@ -413,7 +443,7 @@ def create_task(body: TaskIn, conn=Depends(get_conn)):
|
||||
|
||||
@app.patch("/api/tasks/{task_id}")
|
||||
def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
|
||||
get_task(conn, task_id)
|
||||
current = get_task(conn, task_id)
|
||||
fields = body.model_dump(exclude_unset=True)
|
||||
subtasks = fields.pop("subtasks", None)
|
||||
for key in ("title", "notes", "priority", "tags", "recurrence", "recur_interval"):
|
||||
@@ -425,6 +455,13 @@ def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
|
||||
fields["tags"] = json.dumps(norm_tags(fields["tags"]))
|
||||
if fields.get("due_date") is not None:
|
||||
fields["due_date"] = fields["due_date"].isoformat()
|
||||
for key in ("start_time", "end_time"):
|
||||
if key in fields:
|
||||
fields[key] = fmt_time(fields[key])
|
||||
merged = {k: fields.get(k, current[k]) for k in ("due_date", "start_time", "end_time")}
|
||||
error = check_times(merged["due_date"], merged["start_time"], merged["end_time"])
|
||||
if error:
|
||||
raise HTTPException(422, error)
|
||||
fields["updated_at"] = now_iso()
|
||||
with conn:
|
||||
conn.execute(
|
||||
|
||||
+233
-5
@@ -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', {
|
||||
el.replaceChildren(h('span', {}, message));
|
||||
if (action) {
|
||||
el.append(h('button', {
|
||||
type: 'button', class: 'link',
|
||||
onclick: () => { el.hidden = true; action.fn(); },
|
||||
}, action.label) : null);
|
||||
}, 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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@
|
||||
<button class="btn primary" type="submit">Add</button>
|
||||
<button id="new-detailed" class="btn" type="button" title="New task with details">+ Details</button>
|
||||
</form>
|
||||
<div id="cal-nav" class="cal-nav" hidden>
|
||||
<button id="cal-prev" class="btn" type="button" aria-label="Previous day">‹</button>
|
||||
<input id="cal-date" type="date" aria-label="Day">
|
||||
<button id="cal-next" class="btn" type="button" aria-label="Next day">›</button>
|
||||
<button id="cal-today" class="btn" type="button">Today</button>
|
||||
</div>
|
||||
<div id="list" class="list"></div>
|
||||
</main>
|
||||
</div>
|
||||
@@ -99,6 +105,17 @@
|
||||
<button type="button" class="chip-btn" data-days="7">Next week</button>
|
||||
<button type="button" class="chip-btn" data-days="">No date</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label class="field">
|
||||
<span>From <small>(optional)</small></span>
|
||||
<input id="t-start" type="time" step="300">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>To</span>
|
||||
<input id="t-end" type="time" step="300">
|
||||
</label>
|
||||
</div>
|
||||
<div id="t-day" class="day-preview" hidden></div>
|
||||
<label class="field">
|
||||
<span>Tags <small>(comma separated)</small></span>
|
||||
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
|
||||
|
||||
@@ -235,3 +235,68 @@ dialog::backdrop { background: rgba(10, 12, 20, 0.5); }
|
||||
.row .field.narrow { flex: 0 0 80px; }
|
||||
.dialog-body { padding: 16px; }
|
||||
}
|
||||
|
||||
/* Times in the task dialog */
|
||||
input:disabled { opacity: 0.5; }
|
||||
.day-preview { background: var(--panel-2); border-radius: 8px; padding: 10px 12px; margin-top: -4px; font-size: 14px; }
|
||||
.day-head { font-weight: 600; margin-bottom: 4px; color: var(--muted); }
|
||||
.day-head.clash { color: var(--today); }
|
||||
.day-preview ul { list-style: none; margin: 0; padding: 0; }
|
||||
.day-preview li { padding: 2px 0; }
|
||||
.day-preview li.clash { color: var(--today); font-weight: 600; }
|
||||
.day-time { font-variant-numeric: tabular-nums; color: var(--muted); }
|
||||
.day-preview li.clash .day-time { color: inherit; }
|
||||
|
||||
/* Calendar */
|
||||
.cal-nav { display: flex; gap: 8px; padding: 0 24px 8px; align-items: center; }
|
||||
.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 {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 1px; padding: 6px 0;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; cursor: pointer;
|
||||
}
|
||||
.week-day:hover { border-color: var(--accent); }
|
||||
.week-day .wd { font-size: 12px; color: var(--muted); }
|
||||
.week-day .dn { font-size: 17px; font-weight: 600; }
|
||||
.week-day .dots { font-size: 12px; line-height: 1; min-height: 12px; color: var(--accent); letter-spacing: 1px; }
|
||||
.week-day.today .dn { color: var(--accent); }
|
||||
.week-day.active { background: var(--accent); border-color: var(--accent); }
|
||||
.week-day.active .wd, .week-day.active .dn, .week-day.active .dots { color: var(--accent-text); }
|
||||
|
||||
.timeline {
|
||||
position: relative; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.hour { position: absolute; left: 0; right: 0; border-top: 1px solid var(--border); cursor: copy; }
|
||||
.hour:first-child { border-top: none; }
|
||||
.hour:hover { background: var(--panel-2); }
|
||||
.hour-label {
|
||||
position: absolute; left: 8px; top: 3px; font-size: 11px; color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.block {
|
||||
position: absolute; display: flex; flex-direction: column; align-items: flex-start; gap: 1px;
|
||||
overflow: hidden; text-align: left; padding: 3px 7px; margin-top: 1px; cursor: pointer;
|
||||
background: var(--accent-weak); border: 1px solid var(--border); border-left: 4px solid var(--accent);
|
||||
border-radius: 6px; font-size: 13px; line-height: 1.25;
|
||||
}
|
||||
.block:hover { filter: brightness(0.97); border-color: var(--accent); }
|
||||
.block.short { flex-direction: row; align-items: center; gap: 6px; white-space: nowrap; }
|
||||
.block-time { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; flex: none; }
|
||||
.block-title { font-weight: 600; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
|
||||
.block.short .block-title { white-space: nowrap; }
|
||||
.block.done { opacity: 0.55; }
|
||||
.block.done .block-title { text-decoration: line-through; }
|
||||
.block.projected { background: transparent; border-style: dashed; border-left-style: solid; }
|
||||
.now-line { position: absolute; left: 48px; right: 0; height: 2px; background: var(--overdue); pointer-events: none; }
|
||||
.now-line::before {
|
||||
content: ""; position: absolute; left: -4px; top: -3px; width: 8px; height: 8px;
|
||||
border-radius: 50%; background: var(--overdue);
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
.cal-nav { padding: 0 16px 8px; }
|
||||
.cal-nav input[type="date"] { max-width: none; flex: 1; }
|
||||
.week-strip { gap: 4px; }
|
||||
}
|
||||
.block.short .block-time { order: 2; }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// Network-first service worker: always fresh when online, app shell still opens offline.
|
||||
const CACHE = 'whattodo-v1';
|
||||
const CACHE = 'whattodo-v2';
|
||||
const SHELL = [
|
||||
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
|
||||
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
|
||||
|
||||
@@ -120,3 +120,63 @@ def test_export_import_roundtrip(client):
|
||||
assert cats[exported_task["category_id"]] == "Work"
|
||||
|
||||
assert client.post("/api/import", headers=H, json={"app": "other", "version": 1}).status_code == 400
|
||||
|
||||
|
||||
def test_times(client):
|
||||
t = client.post("/api/tasks", headers=H, json={
|
||||
"title": "Meeting", "due_date": "2026-10-08", "start_time": "10:00", "end_time": "12:00",
|
||||
"recurrence": "weekly",
|
||||
})
|
||||
assert t.status_code == 201, t.text
|
||||
t = t.json()
|
||||
assert (t["start_time"], t["end_time"]) == ("10:00", "12:00")
|
||||
|
||||
bad = [
|
||||
{"title": "x", "start_time": "10:00"}, # no date
|
||||
{"title": "x", "due_date": "2026-10-08", "end_time": "10:00"}, # end without start
|
||||
{"title": "x", "due_date": "2026-10-08", "start_time": "10:00", "end_time": "09:00"},
|
||||
]
|
||||
for body in bad:
|
||||
assert client.post("/api/tasks", headers=H, json=body).status_code == 422, body
|
||||
|
||||
# Clearing the date while a time is set is rejected; clearing all three works.
|
||||
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"due_date": None}).status_code == 422
|
||||
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"end_time": "09:30"}).status_code == 422
|
||||
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"end_time": None}).json()["end_time"] is None
|
||||
|
||||
# The next occurrence of a recurring task keeps its time.
|
||||
nxt = client.post(f"/api/tasks/{t['id']}/complete", headers=H, json={"today": "2026-10-08"}).json()["next"]
|
||||
assert (nxt["due_date"], nxt["start_time"]) == ("2026-10-15", "10:00")
|
||||
|
||||
cleared = client.patch(f"/api/tasks/{nxt['id']}", headers=H,
|
||||
json={"due_date": None, "start_time": None, "end_time": None}).json()
|
||||
assert cleared["due_date"] is None and cleared["start_time"] is None
|
||||
|
||||
|
||||
def test_migration_from_v1(tmp_path, monkeypatch):
|
||||
import sqlite3
|
||||
from app import db
|
||||
path = tmp_path / "old.db"
|
||||
conn = sqlite3.connect(path)
|
||||
conn.executescript("""
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
INSERT INTO meta VALUES ('schema_version', '1');
|
||||
CREATE TABLE categories (id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
color TEXT NOT NULL DEFAULT '#6b7280', position INTEGER NOT NULL DEFAULT 0);
|
||||
CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL, notes TEXT NOT NULL DEFAULT '',
|
||||
category_id INTEGER, priority INTEGER NOT NULL DEFAULT 0, due_date TEXT,
|
||||
tags TEXT NOT NULL DEFAULT '[]', recurrence TEXT NOT NULL DEFAULT 'none',
|
||||
recur_interval INTEGER NOT NULL DEFAULT 1, done INTEGER NOT NULL DEFAULT 0, done_at TEXT,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
||||
CREATE TABLE subtasks (id INTEGER PRIMARY KEY, task_id INTEGER NOT NULL, title TEXT NOT NULL,
|
||||
done INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT INTO tasks (title, created_at, updated_at) VALUES ('old task', 'x', 'x');
|
||||
""")
|
||||
conn.close()
|
||||
monkeypatch.setattr(db, "DB_PATH", str(path))
|
||||
db.init()
|
||||
conn = sqlite3.connect(path)
|
||||
assert conn.execute("SELECT title, start_time FROM tasks").fetchall() == [("old task", None)]
|
||||
assert conn.execute("SELECT value FROM meta").fetchone()[0] == "2"
|
||||
conn.close()
|
||||
assert (tmp_path / "old.db.before-v2.bak").exists()
|
||||
|
||||
Reference in New Issue
Block a user