Compare commits

5 Commits

Author SHA1 Message Date
goyban 119b8a0205 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 <noreply@anthropic.com>
2026-10-13 10:31:53 +02:00
goyban 0ed1bc4a0e Publish images under the git version tag too
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:48:56 +02:00
goyban c015e5663b Add an Overview start page combining today and the coming week
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 10:45:49 +02:00
goyban 0060cd11bb Run from a published image; move source build to docker-compose.build.yaml
docker-compose.yaml now pulls git.goyban.com/goyban/whattodo so it can be used
without the source. publish.sh builds the committed version and pushes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:58:25 +02:00
goyban 39842c72d7 Add plan dates, postponing, backlog and projects
- Tasks get an optional plan date separate from the deadline. Unfinished
  plans roll over in Today; only missed deadlines count as overdue.
- Postpone sheet on every task (tomorrow, weekend, next week, ...) that
  can move either the plan date or the deadline, with undo.
- Backlog view for undated tasks, with suggestions in Today.
- Projects with deadline, notes, progress, next step and "falling
  behind" warnings, shown in Today and in their own view.
- Times and the calendar now follow the plan date (else the deadline).
- Schema v3 migration (backup taken first); export format v3 includes
  projects and still imports older files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 14:26:32 +02:00
13 changed files with 1291 additions and 198 deletions
+3
View File
@@ -18,3 +18,6 @@ BIND_ADDR=127.0.0.1
# Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting).
# FORWARDED_ALLOW_IPS=127.0.0.1
# Development only: build from source instead of pulling the published image.
# COMPOSE_FILE=docker-compose.build.yaml
+1 -1
View File
@@ -10,7 +10,7 @@ COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app ./app
# `docker compose run --rm test`
# `docker compose -f docker-compose.build.yaml run --rm test`
FROM base AS test
COPY requirements-dev.txt .
RUN pip install -r requirements-dev.txt
+38 -11
View File
@@ -2,38 +2,65 @@
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** with an optional **time range** (e.g. 10:00–12:00), **priority** (none / low / medium / high), **category**, **tags**, **notes**, and a **checklist** of steps.
- Tasks have an optional **plan date** (when you'll do it), an optional **deadline** (when it must be done), and an optional **time range** (e.g. 10:00–12:00). Each task also has a **priority**, **category**, **tags**, **notes**, and a **checklist** of steps.
- **Postpone** any task from the ⏭ button: today, tomorrow, the weekend, next week, in 2 weeks, next month, or a date you pick. You can move either the plan date or the deadline.
- Unfinished planned tasks **roll over**: they stay in Today until you do them or postpone them, and they never turn red. Only missed **deadlines** count as overdue.
- **Backlog** lists tasks with no date at all. Today suggests a couple of them so they don't get forgotten.
- **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** (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.
- 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.
Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker.
## Run locally
## Run
Requirements: Docker with the Compose plugin. Nothing else is installed on the host.
Requirements: Docker with the Compose plugin. You don't need the source code: only `docker-compose.yaml` and a `.env`.
```sh
mkdir whattodo && cd whattodo
# copy docker-compose.yaml and .env.example from this repo into this folder
cp .env.example .env # then edit .env: set APP_PASSWORD and SECRET_KEY
docker compose up -d --build
docker compose up -d
```
Open <http://localhost:8080> and log in with `APP_PASSWORD`.
The database lives in `./data/whattodo.db`, which is git-ignored.
The database lives in `./data/whattodo.db`.
Useful commands:
```sh
docker compose logs -f # follow logs
docker compose down # stop
docker compose up -d --build # rebuild after pulling changes
docker compose pull && docker compose up -d # update to the newest published version
```
To pin a version, set `WHATTODO_TAG=<version>` in `.env`, e.g. `v1.0` or a commit (default: `latest`).
## Develop (build from source)
`docker-compose.build.yaml` builds the image from this checkout. Add this line to your `.env` so plain `docker compose` uses it:
```sh
COMPOSE_FILE=docker-compose.build.yaml
```
```sh
docker compose up -d --build # build and start
docker compose run --rm test # run the test suite (in Docker)
```
### Publishing the image
`./publish.sh` builds the last commit (uncommitted changes are left out) and pushes it to
`git.goyban.com/goyban/whattodo` as `:latest` and `:<commit>`. `./publish.sh <tag|branch|commit>` publishes another version.
To release a numbered version, tag the commit first: `git tag v1.1 && ./publish.sh v1.1` also pushes the image as `:v1.1`.
Log in once first with `docker login git.goyban.com`, using a Gitea token with package write access as the password.
### Testing on your phone (same Wi-Fi)
Set `BIND_ADDR=0.0.0.0` in `.env`, run `docker compose up -d`, then open `http://<this-computer's-LAN-IP>:8080` on the phone.
@@ -41,8 +68,8 @@ Browsers only allow "Install app" / "Add to Home Screen" with offline support ov
## Deploy to production
1. On the server, clone the repo and create `.env` from `.env.example`. Use a **new strong** `APP_PASSWORD` and `SECRET_KEY`, and set `COOKIE_SECURE=true`.
2. `docker compose up -d --build`. The app listens on `127.0.0.1:${PORT}`.
1. On the server, put `docker-compose.yaml` in a folder and create `.env` from `.env.example`. Use a **new strong** `APP_PASSWORD` and `SECRET_KEY`, and set `COOKIE_SECURE=true`.
2. `docker compose up -d`. The app listens on `127.0.0.1:${PORT}`.
3. Put it behind a reverse proxy with HTTPS. For example, with Caddy:
```
@@ -55,7 +82,7 @@ Browsers only allow "Install app" / "Add to Home Screen" with offline support ov
### Moving your data from this PC to production
1. Local app → **Settings → Export data**. This downloads `whattodo-export-YYYYMMDD-HHMM.json`.
1. Local app → **Settings → Export data**. The export includes tasks, projects and categories. This downloads `whattodo-export-YYYYMMDD-HHMM.json`.
2. Production app → **Settings → Import**, pick the file:
- **Replace** deletes everything on that install and loads the file. Use this for the first move to prod.
- **Merge** adds the file's tasks to what is already there. Categories with the same name are reused.
@@ -79,6 +106,6 @@ Export regularly from Settings, or copy `./data/whattodo.db` while the app is st
app/main.py API: auth, tasks, categories, export/import
app/db.py SQLite schema and setup
app/static/ frontend (index.html, app.js, style.css, PWA files, icons)
tests/ API tests (run with: docker compose run --rm test)
tests/ API tests (run with: docker compose -f docker-compose.build.yaml run --rm test)
tools/make_icons.py regenerates the PNG icons
```
+20 -1
View File
@@ -3,7 +3,7 @@ import sqlite3
from pathlib import Path
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
SCHEMA_VERSION = 2
SCHEMA_VERSION = 3
DEFAULT_CATEGORIES = [
("Work", "#2563eb"),
@@ -23,13 +23,26 @@ CREATE TABLE IF NOT EXISTS categories (
color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
due_date TEXT,
done INTEGER NOT NULL DEFAULT 0,
done_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY,
title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
priority INTEGER NOT NULL DEFAULT 0,
due_date TEXT,
planned_date TEXT,
start_time TEXT,
end_time TEXT,
tags TEXT NOT NULL DEFAULT '[]',
@@ -96,6 +109,12 @@ def migrate(conn: sqlite3.Connection, version: int) -> None:
if version < 2:
conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT")
conn.execute("ALTER TABLE tasks ADD COLUMN end_time TEXT")
if version < 3:
# The projects table itself is created by SCHEMA before migrating.
conn.execute("ALTER TABLE tasks ADD COLUMN planned_date TEXT")
conn.execute(
"ALTER TABLE tasks ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
)
conn.execute(
"UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),)
)
+159 -17
View File
@@ -31,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 = 2
EXPORT_VERSION = 3
STATIC_DIR = Path(__file__).parent / "static"
# Changing the password (or secret) invalidates all existing sessions.
@@ -191,10 +191,13 @@ 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"
def check_times(day, start: Optional[str], end: Optional[str]) -> Optional[str]:
"""Return an error message if the time fields don't make sense together.
`day` is the date the time applies to: the planned date if set, else the deadline.
"""
if (start or end) and not day:
return "A time needs a date (planned or deadline)"
if end and not start:
return "An end time needs a start time"
if start and end and end <= start:
@@ -226,8 +229,10 @@ class TaskIn(BaseModel):
title: str = Field(max_length=500)
notes: str = Field("", max_length=20000)
category_id: Optional[int] = None
project_id: Optional[int] = None
priority: int = Field(0, ge=0, le=3)
due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None
end_time: Optional[Time] = None
tags: list[str] = []
@@ -239,7 +244,7 @@ class TaskIn(BaseModel):
@model_validator(mode="after")
def _times(self):
error = check_times(self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
error = check_times(self.planned_date or self.due_date, fmt_time(self.start_time), fmt_time(self.end_time))
if error:
raise ValueError(error)
return self
@@ -249,8 +254,10 @@ class TaskPatch(BaseModel):
title: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=20000)
category_id: Optional[int] = None
project_id: Optional[int] = None
priority: Optional[int] = Field(None, ge=0, le=3)
due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None
end_time: Optional[Time] = None
tags: Optional[list[str]] = None
@@ -277,6 +284,26 @@ class CategoryPatch(BaseModel):
position: Optional[int] = None
class ProjectIn(BaseModel):
name: str = Field(max_length=100)
notes: str = Field("", max_length=20000)
category_id: Optional[int] = None
due_date: Optional[date] = None
done: bool = False
_name = field_validator("name")(clean_title)
class ProjectPatch(BaseModel):
name: Optional[str] = Field(None, max_length=100)
notes: Optional[str] = Field(None, max_length=20000)
category_id: Optional[int] = None
due_date: Optional[date] = None
done: Optional[bool] = None
_name = field_validator("name")(clean_title)
class ExportCategory(BaseModel):
id: int
name: str = Field(min_length=1, max_length=50)
@@ -291,10 +318,18 @@ class ExportTask(TaskIn):
updated_at: Optional[str] = None
class ExportProject(ProjectIn):
id: int
done_at: Optional[str] = None
created_at: Optional[str] = None
updated_at: Optional[str] = None
class ExportData(BaseModel):
app: str
version: int
categories: list[ExportCategory] = []
projects: list[ExportProject] = []
tasks: list[ExportTask] = []
@@ -306,8 +341,10 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
"title": r["title"],
"notes": r["notes"],
"category_id": r["category_id"],
"project_id": r["project_id"],
"priority": r["priority"],
"due_date": r["due_date"],
"planned_date": r["planned_date"],
"start_time": r["start_time"],
"end_time": r["end_time"],
"tags": json.loads(r["tags"]),
@@ -345,6 +382,17 @@ def ensure_category(conn: sqlite3.Connection, category_id: Optional[int]) -> Non
raise HTTPException(400, "Unknown category")
def ensure_project(conn: sqlite3.Connection, project_id: Optional[int]) -> None:
if project_id is not None and not conn.execute(
"SELECT 1 FROM projects WHERE id = ?", (project_id,)
).fetchone():
raise HTTPException(400, "Unknown project")
def iso(d) -> Optional[str]:
return d.isoformat() if isinstance(d, date) else d
def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) -> None:
conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,))
for i, s in enumerate(subtasks):
@@ -357,14 +405,14 @@ def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict])
def insert_task(conn: sqlite3.Connection, t: dict, done: bool = False, done_at: Optional[str] = None,
created_at: Optional[str] = None, updated_at: Optional[str] = None) -> int:
now = now_iso()
due = t.get("due_date")
cur = conn.execute(
"""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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
"""INSERT INTO tasks (title, notes, category_id, project_id, priority, due_date, planned_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,
t["title"], t.get("notes", ""), t.get("category_id"), t.get("project_id"), t.get("priority", 0),
iso(t.get("due_date")), iso(t.get("planned_date")),
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,
@@ -436,6 +484,7 @@ def get_tasks(conn=Depends(get_conn)):
@app.post("/api/tasks", status_code=201)
def create_task(body: TaskIn, conn=Depends(get_conn)):
ensure_category(conn, body.category_id)
ensure_project(conn, body.project_id)
with conn:
task_id = insert_task(conn, body.model_dump())
return get_task(conn, task_id)
@@ -451,15 +500,18 @@ def update_task(task_id: int, body: TaskPatch, conn=Depends(get_conn)):
del fields[key] # these columns can't be cleared, only changed
if "category_id" in fields:
ensure_category(conn, fields["category_id"])
if "project_id" in fields:
ensure_project(conn, fields["project_id"])
if "tags" in fields:
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 ("due_date", "planned_date"):
if key in fields:
fields[key] = iso(fields[key])
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"])
merged = {k: fields.get(k, current[k]) for k in ("due_date", "planned_date", "start_time", "end_time")}
error = check_times(merged["planned_date"] or merged["due_date"], merged["start_time"], merged["end_time"])
if error:
raise HTTPException(422, error)
fields["updated_at"] = now_iso()
@@ -493,8 +545,15 @@ def complete_task(task_id: int, body: CompleteIn, conn=Depends(get_conn)):
conn.execute("UPDATE tasks SET done = 1, done_at = ?, updated_at = ? WHERE id = ?", (now, now, task_id))
if task["recurrence"] != "none":
due = date.fromisoformat(task["due_date"]) if task["due_date"] else None
planned = date.fromisoformat(task["planned_date"]) if task["planned_date"] else None
nxt = dict(task)
if due:
# The deadline drives the series; a plan date keeps its distance to the deadline.
nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today)
nxt["planned_date"] = planned + (nxt["due_date"] - due) if planned else None
else:
# No deadline: repeat on plan dates, so the task never shows up as overdue.
nxt["planned_date"] = next_due(planned, task["recurrence"], task["recur_interval"], today)
nxt["subtasks"] = [{"title": s["title"], "done": False} for s in task["subtasks"]]
next_id = insert_task(conn, nxt)
return {"task": get_task(conn, task_id), "next": get_task(conn, next_id) if next_id else None}
@@ -510,6 +569,80 @@ def reopen_task(task_id: int, conn=Depends(get_conn)):
return get_task(conn, task_id)
# --- Project endpoints -------------------------------------------------------
def get_project(conn: sqlite3.Connection, project_id: int) -> dict:
row = conn.execute("SELECT * FROM projects WHERE id = ?", (project_id,)).fetchone()
if row is None:
raise HTTPException(404, "Project not found")
return {**dict(row), "done": bool(row["done"])}
def list_projects(conn: sqlite3.Connection) -> list[dict]:
return [{**dict(r), "done": bool(r["done"])} for r in conn.execute("SELECT * FROM projects ORDER BY id")]
def insert_project(conn: sqlite3.Connection, p: dict) -> int:
now = now_iso()
done = bool(p.get("done"))
cur = conn.execute(
"""INSERT INTO projects (name, notes, category_id, due_date, done, done_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
p["name"], p.get("notes", ""), p.get("category_id"), iso(p.get("due_date")), int(done),
p.get("done_at") or (now if done else None), p.get("created_at") or now, p.get("updated_at") or now,
),
)
return cur.lastrowid
@app.get("/api/projects")
def get_projects(conn=Depends(get_conn)):
return list_projects(conn)
@app.post("/api/projects", status_code=201)
def create_project(body: ProjectIn, conn=Depends(get_conn)):
ensure_category(conn, body.category_id)
with conn:
project_id = insert_project(conn, body.model_dump())
return get_project(conn, project_id)
@app.patch("/api/projects/{project_id}")
def update_project(project_id: int, body: ProjectPatch, conn=Depends(get_conn)):
current = get_project(conn, project_id)
fields = body.model_dump(exclude_unset=True)
for key in ("name", "notes", "done"):
if key in fields and fields[key] is None:
del fields[key]
if "category_id" in fields:
ensure_category(conn, fields["category_id"])
if "due_date" in fields:
fields["due_date"] = iso(fields["due_date"])
if "done" in fields:
if fields["done"] and not current["done"]:
fields["done_at"] = now_iso()
elif not fields["done"]:
fields["done_at"] = None
fields["done"] = int(fields["done"])
fields["updated_at"] = now_iso()
with conn:
conn.execute(
f"UPDATE projects SET {', '.join(f'{k} = ?' for k in fields)} WHERE id = ?",
(*fields.values(), project_id),
)
return get_project(conn, project_id)
@app.delete("/api/projects/{project_id}", status_code=204)
def delete_project(project_id: int, conn=Depends(get_conn)):
"""Deletes the project only; its tasks are kept and simply no longer belong to a project."""
with conn:
conn.execute("DELETE FROM projects WHERE id = ?", (project_id,))
return Response(status_code=204)
# --- Export / import ---------------------------------------------------------
@app.get("/api/export")
@@ -520,6 +653,7 @@ def export_data(conn=Depends(get_conn)):
"version": EXPORT_VERSION,
"exported_at": now_iso(),
"categories": list_categories(conn),
"projects": list_projects(conn),
"tasks": load_tasks(conn),
}
return Response(
@@ -538,6 +672,7 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
if mode == "replace":
conn.execute("DELETE FROM subtasks")
conn.execute("DELETE FROM tasks")
conn.execute("DELETE FROM projects")
conn.execute("DELETE FROM categories")
existing = {r["name"].lower(): r["id"] for r in conn.execute("SELECT id, name FROM categories")}
next_pos = conn.execute("SELECT COALESCE(MAX(position), -1) + 1 FROM categories").fetchone()[0]
@@ -552,12 +687,19 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
existing[key] = cur.lastrowid
next_pos += 1
cat_map[c.id] = existing[key]
project_map: dict[int, int] = {}
for p in data.projects:
fields = p.model_dump()
fields["category_id"] = cat_map.get(p.category_id) if p.category_id is not None else None
project_map[p.id] = insert_project(conn, fields)
for t in data.tasks:
fields = t.model_dump()
fields["category_id"] = cat_map.get(t.category_id) if t.category_id is not None else None
fields["project_id"] = project_map.get(t.project_id) if t.project_id is not None else None
insert_task(conn, fields, done=t.done, done_at=t.done_at,
created_at=t.created_at, updated_at=t.updated_at)
return {"ok": True, "mode": mode, "categories": len(data.categories), "tasks": len(data.tasks)}
return {"ok": True, "mode": mode, "categories": len(data.categories), "projects": len(data.projects),
"tasks": len(data.tasks)}
# Static frontend (must be mounted last so it doesn't shadow /api routes).
+696 -121
View File
File diff suppressed because it is too large Load Diff
+104 -28
View File
@@ -28,7 +28,12 @@
<aside id="sidebar" class="sidebar">
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
<nav id="views" class="nav"></nav>
<div class="nav-heading">Categories</div>
<div class="nav-heading">
<span>Projects</span>
<button id="new-project" class="icon-btn small" type="button" aria-label="New project" title="New project">+</button>
</div>
<nav id="projects" class="nav"></nav>
<div class="nav-heading"><span>Categories</span></div>
<nav id="cats" class="nav"></nav>
<div class="sidebar-foot">
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
@@ -49,9 +54,14 @@
</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">
<input id="cal-date" type="date" aria-label="Date">
<button id="cal-next" class="btn" type="button" aria-label="Next day">›</button>
<button id="cal-today" class="btn" type="button">Today</button>
<div id="cal-mode" class="segmented" role="group" aria-label="Calendar view">
<button type="button" data-mode="day">Day</button>
<button type="button" data-mode="week">Week</button>
<button type="button" data-mode="month">Month</button>
</div>
</div>
<div id="list" class="list"></div>
</main>
@@ -65,10 +75,45 @@
<input id="t-title" required maxlength="500" autocomplete="off">
</label>
<div class="row">
<label class="field">
<span>Plan to do on</span>
<input id="t-planned" type="date">
</label>
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
</div>
<div class="quick-dates">
<span class="muted small-label">Plan:</span>
<button type="button" class="chip-btn" data-plan="today">Today</button>
<button type="button" class="chip-btn" data-plan="tomorrow">Tomorrow</button>
<button type="button" class="chip-btn" data-plan="weekend">Weekend</button>
<button type="button" class="chip-btn" data-plan="nextweek">Next week</button>
<button type="button" class="chip-btn" data-plan="">No plan</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>
<div class="row">
<label class="field">
<span>Project</span>
<select id="t-project"></select>
</label>
<label class="field">
<span>Category</span>
<select id="t-category"></select>
</label>
</div>
<div class="row">
<label class="field">
<span>Priority</span>
<select id="t-priority">
@@ -78,12 +123,6 @@
<option value="3">High</option>
</select>
</label>
</div>
<div class="row">
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
<label class="field">
<span>Repeat</span>
<select id="t-repeat">
@@ -99,30 +138,13 @@
<input id="t-interval" type="number" min="1" max="365" value="1">
</label>
</div>
<div class="quick-dates">
<button type="button" class="chip-btn" data-days="0">Today</button>
<button type="button" class="chip-btn" data-days="1">Tomorrow</button>
<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">
</label>
<label class="field">
<span>Notes</span>
<textarea id="t-notes" rows="4" maxlength="20000"></textarea>
<textarea id="t-notes" rows="3" maxlength="20000"></textarea>
</label>
<div class="field">
<span>Checklist</span>
@@ -141,6 +163,60 @@
</form>
</dialog>
<dialog id="plan-dialog" class="sheet">
<div class="dialog-body">
<h3 id="p-title">Postpone</h3>
<div id="p-mode" class="segmented" role="group" aria-label="What to move">
<button type="button" data-mode="plan">Plan date</button>
<button type="button" data-mode="due">Deadline</button>
</div>
<p id="p-current" class="muted"></p>
<div id="p-options" class="plan-options"></div>
<div class="row">
<input id="p-date" type="date" aria-label="Pick a date">
<button id="p-set" class="btn primary" type="button">Set</button>
</div>
<div class="dialog-actions">
<button id="p-clear" class="btn" type="button">Remove date</button>
<span class="spacer"></span>
<button id="p-cancel" class="btn" type="button">Cancel</button>
</div>
</div>
</dialog>
<dialog id="project-dialog">
<form id="project-form" class="dialog-body">
<h3 id="pr-dialog-title">Project</h3>
<label class="field">
<span>Name</span>
<input id="pr-name" required maxlength="100" autocomplete="off">
</label>
<div class="row">
<label class="field">
<span>Category</span>
<select id="pr-category"></select>
</label>
<label class="field">
<span>Deadline</span>
<input id="pr-due" type="date">
</label>
</div>
<label class="field">
<span>Goal / notes</span>
<textarea id="pr-notes" rows="4" maxlength="20000"></textarea>
</label>
<label class="check-field">
<input id="pr-done" type="checkbox"> Finished
</label>
<div class="dialog-actions">
<button id="pr-delete" type="button" class="btn danger">Delete</button>
<span class="spacer"></span>
<button id="pr-cancel" type="button" class="btn">Cancel</button>
<button type="submit" class="btn primary">Save</button>
</div>
</form>
</dialog>
<dialog id="settings-dialog">
<div class="dialog-body">
<h3>Settings</h3>
@@ -157,8 +233,8 @@
<section class="settings-section">
<h4>Export / import</h4>
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another
install, such as production, to move your data there.</p>
<p class="muted">Export downloads all tasks, projects and categories as a JSON file. Import it on
another install, such as production, to move your data there.</p>
<div class="row wrap">
<a id="s-export" class="btn primary" href="api/export" download>Export data</a>
</div>
+137 -2
View File
@@ -104,7 +104,10 @@ textarea { resize: vertical; }
}
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; padding: 4px 10px 16px; }
.nav { display: flex; flex-direction: column; gap: 2px; }
.nav-heading { font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px; }
.nav-heading {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); padding: 18px 10px 6px;
}
.nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer;
@@ -248,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 {
@@ -294,9 +299,139 @@ 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; }
/* Small helpers */
.small { font-size: 12px; }
.small-label { font-size: 13px; align-self: center; }
.icon-btn.small { padding: 2px 6px; font-size: 15px; line-height: 1; }
/* Sidebar: projects */
.nav-item.muted { color: var(--muted); }
.nav-toggle {
background: none; border: none; color: var(--muted); font-size: 12px; cursor: pointer;
text-align: left; padding: 4px 10px 4px 40px;
}
.nav-toggle:hover { color: var(--text); }
.nav-empty { color: var(--muted); font-size: 13px; padding: 2px 10px 2px 40px; }
/* Task row: plan chip and postpone button */
.chip.plan { color: var(--accent); }
.chip.plan.now { background: var(--accent-weak); font-weight: 600; }
.link-chip { cursor: pointer; }
.link-chip:hover { color: var(--accent); }
.plan-btn { align-self: center; font-size: 16px; }
.more-link { display: block; margin: 18px auto 0; padding: 8px 12px; border: 0; background: none; color: var(--muted); font: inherit; font-size: 14px; cursor: pointer; }
.more-link:hover { color: var(--accent); }
.group-hint { margin: -4px 0 8px; font-size: 13px; color: var(--muted); }
/* Projects */
.progress { flex: 1; height: 8px; background: var(--panel-2); border-radius: 999px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--ok); border-radius: 999px; }
.project-progress { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.project-progress.big .progress { height: 10px; }
.project-card {
background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent);
border-radius: var(--radius); padding: 11px 14px; margin-bottom: 8px; cursor: pointer; box-shadow: var(--shadow);
}
.project-card:hover { border-color: var(--accent); }
.project-card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.project-name { font-weight: 600; }
.project-next { margin-top: 6px; font-size: 14px; overflow-wrap: anywhere; }
.project-warn { margin-top: 6px; font-size: 13px; color: var(--today); font-weight: 600; }
.project-header {
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px; margin-top: 6px; box-shadow: var(--shadow);
}
.project-header-top { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; }
.project-header-top .task-meta { margin-top: 0; }
.project-notes { white-space: pre-wrap; margin: 10px 0 0; color: var(--muted); overflow-wrap: anywhere; }
.check-field { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.deadline-line {
display: block; width: 100%; text-align: left; background: var(--panel); border: 1px dashed var(--border);
border-radius: var(--radius); padding: 10px 12px; margin-bottom: 8px; cursor: pointer;
}
/* Postpone sheet */
.segmented { display: flex; background: var(--panel-2); border-radius: 9px; padding: 3px; gap: 3px; }
.segmented button {
flex: 1; border: none; background: none; border-radius: 7px; padding: 7px; cursor: pointer; color: var(--muted);
}
.segmented button.active { background: var(--panel); color: var(--text); font-weight: 600; box-shadow: var(--shadow); }
#p-current { margin: 0; }
.plan-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.plan-option {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px; text-align: left;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; cursor: pointer;
}
.plan-option:hover { border-color: var(--accent); background: var(--accent-weak); }
dialog.sheet { width: min(420px, calc(100vw - 24px)); }
@media (max-width: 800px) {
/* The postpone sheet slides up from the bottom on phones. */
dialog.sheet {
width: 100vw; max-width: 100vw; margin: auto 0 0; border-radius: 16px 16px 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
}
#plan-dialog .row { flex-wrap: nowrap; }
#plan-dialog .row input { flex: 1; }
+1 -1
View File
@@ -1,5 +1,5 @@
// Network-first service worker: always fresh when online, app shell still opens offline.
const CACHE = 'whattodo-v2';
const CACHE = 'whattodo-v3';
const SHELL = [
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png',
@@ -1,3 +1,5 @@
# Builds the image from source (development). Used instead of docker-compose.yaml when
# COMPOSE_FILE=docker-compose.build.yaml is set in .env, or with -f docker-compose.build.yaml.
services:
whattodo:
build:
+20
View File
@@ -0,0 +1,20 @@
# Runs the published image; no source code needed. Put this file and a .env (from .env.example) in a folder, then:
# docker compose up -d
# Update to the newest published version:
# docker compose pull && docker compose up -d
# To build from source instead, use docker-compose.build.yaml.
services:
whattodo:
image: git.goyban.com/goyban/whattodo:${WHATTODO_TAG:-latest}
restart: unless-stopped
env_file: .env
ports:
# BIND_ADDR=127.0.0.1 -> this computer only; 0.0.0.0 -> also reachable from your phone on the LAN.
- "${BIND_ADDR:-127.0.0.1}:${PORT:-8080}:8000"
volumes:
- ./data:/data
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/api/health')"]
interval: 30s
timeout: 5s
retries: 3
Executable
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Build the app image from a committed version and push it to the Gitea registry.
# Only committed files are used (via `git archive`), so uncommitted work never ends up in the image.
#
# ./publish.sh # builds HEAD
# ./publish.sh v1.2 # builds a tag/branch/commit
#
# The image is tagged with the commit and `latest`, plus the version when the commit has a git tag
# like v1.2. To release a new version: git tag v1.2 && ./publish.sh v1.2
#
# One-time setup: docker login git.goyban.com (password = a Gitea token with package write access)
set -euo pipefail
REGISTRY=git.goyban.com
IMAGE="$REGISTRY/goyban/whattodo"
REF="${1:-HEAD}"
cd "$(dirname "$0")"
SHA="$(git rev-parse --short "$REF^{commit}")"
# Newest version tag (v1.2, v1.2.3) on this commit, if any.
VERSION="$(git tag --points-at "$SHA" --list 'v[0-9]*' --sort=-v:refname | head -n 1)"
TAGS=("$SHA" latest)
if [ -n "$VERSION" ]; then TAGS+=("$VERSION"); fi
echo "Building $IMAGE from $REF ($SHA${VERSION:+, $VERSION})"
BUILD_TAGS=()
for t in "${TAGS[@]}"; do BUILD_TAGS+=(-t "$IMAGE:$t"); done
git archive --format=tar "$REF" | docker build --target runtime "${BUILD_TAGS[@]}" -
for t in "${TAGS[@]}"; do docker push "$IMAGE:$t"; done
echo "Pushed $IMAGE with tags: ${TAGS[*]}"
+64 -2
View File
@@ -177,6 +177,68 @@ def test_migration_from_v1(tmp_path, monkeypatch):
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"
assert conn.execute("SELECT value FROM meta").fetchone()[0] == str(db.SCHEMA_VERSION)
conn.close()
assert (tmp_path / "old.db.before-v2.bak").exists()
assert (tmp_path / f"old.db.before-v{db.SCHEMA_VERSION}.bak").exists()
cols = {r[1] for r in sqlite3.connect(path).execute("PRAGMA table_info(tasks)")}
assert {"start_time", "planned_date", "project_id"} <= cols
def test_planned_date(client):
t = client.post("/api/tasks", headers=H, json={
"title": "Clean garage", "planned_date": "2026-10-12", "start_time": "09:00",
})
assert t.status_code == 201, t.text
t = t.json()
assert (t["planned_date"], t["due_date"]) == ("2026-10-12", None)
# Removing the plan date while a time is set is rejected (time needs a date).
assert client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": None}).status_code == 422
r = client.patch(f"/api/tasks/{t['id']}", headers=H, json={"planned_date": "2026-10-19"}).json()
assert r["planned_date"] == "2026-10-19"
def test_recurring_with_plan_dates(client):
# No deadline: repeats on plan dates.
gym = client.post("/api/tasks", headers=H, json={
"title": "Gym", "planned_date": "2026-10-05", "recurrence": "daily",
}).json()
nxt = client.post(f"/api/tasks/{gym['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["planned_date"], nxt["due_date"]) == ("2026-10-08", None)
# Deadline + plan date: the plan keeps its distance to the deadline.
rpt = client.post("/api/tasks", headers=H, json={
"title": "Report", "due_date": "2026-10-09", "planned_date": "2026-10-07", "recurrence": "weekly",
}).json()
nxt = client.post(f"/api/tasks/{rpt['id']}/complete", headers=H, json={"today": "2026-10-07"}).json()["next"]
assert (nxt["due_date"], nxt["planned_date"]) == ("2026-10-16", "2026-10-14")
def test_projects(client):
p = client.post("/api/projects", headers=H, json={
"name": "Launch course", "due_date": "2026-12-31", "category_id": 1, "notes": "big one",
})
assert p.status_code == 201, p.text
p = p.json()
assert p["done"] is False and p["due_date"] == "2026-12-31"
t = client.post("/api/tasks", headers=H, json={"title": "Outline", "project_id": p["id"]}).json()
assert t["project_id"] == p["id"]
assert client.post("/api/tasks", headers=H, json={"title": "x", "project_id": 9999}).status_code == 400
assert client.post("/api/projects", headers=H, json={"name": " "}).status_code == 422
done = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": True}).json()
assert done["done"] is True and done["done_at"]
reopened = client.patch(f"/api/projects/{p['id']}", headers=H, json={"done": False}).json()
assert reopened["done_at"] is None
# Export/import keeps the project link.
data = client.get("/api/export").json()
client.post("/api/import?mode=replace", headers=H, json=data)
projects = client.get("/api/projects").json()
assert [x["name"] for x in projects] == ["Launch course"]
imported = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert imported["project_id"] == projects[0]["id"]
# Deleting a project keeps its tasks.
assert client.delete(f"/api/projects/{projects[0]['id']}", headers=H).status_code == 204
kept = next(x for x in client.get("/api/tasks").json() if x["title"] == "Outline")
assert kept["project_id"] is None