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:
goyban
2026-10-07 12:08:09 +02:00
parent c5b049edba
commit 509b1a1320
8 changed files with 447 additions and 19 deletions
+23 -3
View File
@@ -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,7 +30,9 @@ 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,
tags TEXT NOT NULL DEFAULT '[]',
start_time TEXT,
end_time 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,
@@ -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
View File
@@ -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(
+235 -7
View File
@@ -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();
});
}
+17
View File
@@ -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">
+65
View File
@@ -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
View File
@@ -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',