diff --git a/README.md b/README.md index aab40e0..28ff4be 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/db.py b/app/db.py index f8c3755..c0cfa91 100644 --- a/app/db.py +++ b/app/db.py @@ -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),) + ) diff --git a/app/main.py b/app/main.py index 05c5ba7..0dd48e4 100644 --- a/app/main.py +++ b/app/main.py @@ -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( diff --git a/app/static/app.js b/app/static/app.js index 944f0df..360fbed 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -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(); }); } diff --git a/app/static/index.html b/app/static/index.html index a2e726b..8e8182c 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -47,6 +47,12 @@ +
@@ -99,6 +105,17 @@ +