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). # Behind a reverse proxy, the IPs allowed to set X-Forwarded-For (for login rate limiting).
# FORWARDED_ALLOW_IPS=127.0.0.1 # 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 RUN pip install -r requirements.txt
COPY app ./app COPY app ./app
# `docker compose run --rm test` # `docker compose -f docker-compose.build.yaml run --rm test`
FROM base AS test FROM base AS test
COPY requirements-dev.txt . COPY requirements-dev.txt .
RUN pip install -r 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. 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. - **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. - 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. - 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**. - **Export / import** of all data as JSON, from **Settings**.
- Login with a single password. - Login with a single password.
Stack: Python (FastAPI) + SQLite with a plain HTML/CSS/JS frontend and no build step. Everything runs in Docker. 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 ```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 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`. 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: Useful commands:
```sh ```sh
docker compose logs -f # follow logs docker compose logs -f # follow logs
docker compose down # stop 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) 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) ### 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. 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 ## 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`. 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 --build`. The app listens on `127.0.0.1:${PORT}`. 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: 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 ### 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: 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. - **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. - **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/main.py API: auth, tasks, categories, export/import
app/db.py SQLite schema and setup app/db.py SQLite schema and setup
app/static/ frontend (index.html, app.js, style.css, PWA files, icons) 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 tools/make_icons.py regenerates the PNG icons
``` ```
+20 -1
View File
@@ -3,7 +3,7 @@ import sqlite3
from pathlib import Path from pathlib import Path
DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db") DB_PATH = os.environ.get("DB_PATH", "/data/whattodo.db")
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
DEFAULT_CATEGORIES = [ DEFAULT_CATEGORIES = [
("Work", "#2563eb"), ("Work", "#2563eb"),
@@ -23,13 +23,26 @@ CREATE TABLE IF NOT EXISTS categories (
color TEXT NOT NULL DEFAULT '#6b7280', color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0 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 ( CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
title TEXT NOT NULL, title TEXT NOT NULL,
notes TEXT NOT NULL DEFAULT '', notes TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, 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, priority INTEGER NOT NULL DEFAULT 0,
due_date TEXT, due_date TEXT,
planned_date TEXT,
start_time TEXT, start_time TEXT,
end_time TEXT, end_time TEXT,
tags TEXT NOT NULL DEFAULT '[]', tags TEXT NOT NULL DEFAULT '[]',
@@ -96,6 +109,12 @@ def migrate(conn: sqlite3.Connection, version: int) -> None:
if version < 2: if version < 2:
conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT") conn.execute("ALTER TABLE tasks ADD COLUMN start_time TEXT")
conn.execute("ALTER TABLE tasks ADD COLUMN end_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( conn.execute(
"UPDATE meta SET value = ? WHERE key = 'schema_version'", (str(SCHEMA_VERSION),) "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" COOKIE_NAME = "whattodo_session"
CSRF_HEADER = "x-whattodo" CSRF_HEADER = "x-whattodo"
EXPORT_VERSION = 2 EXPORT_VERSION = 3
STATIC_DIR = Path(__file__).parent / "static" STATIC_DIR = Path(__file__).parent / "static"
# Changing the password (or secret) invalidates all existing sessions. # 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 return t.strftime("%H:%M") if isinstance(t, Time) else t
def check_times(due, start: Optional[str], end: Optional[str]) -> Optional[str]: 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.""" """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" `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: if end and not start:
return "An end time needs a start time" return "An end time needs a start time"
if start and end and end <= start: if start and end and end <= start:
@@ -226,8 +229,10 @@ class TaskIn(BaseModel):
title: str = Field(max_length=500) title: str = Field(max_length=500)
notes: str = Field("", max_length=20000) notes: str = Field("", max_length=20000)
category_id: Optional[int] = None category_id: Optional[int] = None
project_id: Optional[int] = None
priority: int = Field(0, ge=0, le=3) priority: int = Field(0, ge=0, le=3)
due_date: Optional[date] = None due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None start_time: Optional[Time] = None
end_time: Optional[Time] = None end_time: Optional[Time] = None
tags: list[str] = [] tags: list[str] = []
@@ -239,7 +244,7 @@ class TaskIn(BaseModel):
@model_validator(mode="after") @model_validator(mode="after")
def _times(self): 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: if error:
raise ValueError(error) raise ValueError(error)
return self return self
@@ -249,8 +254,10 @@ class TaskPatch(BaseModel):
title: Optional[str] = Field(None, max_length=500) title: Optional[str] = Field(None, max_length=500)
notes: Optional[str] = Field(None, max_length=20000) notes: Optional[str] = Field(None, max_length=20000)
category_id: Optional[int] = None category_id: Optional[int] = None
project_id: Optional[int] = None
priority: Optional[int] = Field(None, ge=0, le=3) priority: Optional[int] = Field(None, ge=0, le=3)
due_date: Optional[date] = None due_date: Optional[date] = None
planned_date: Optional[date] = None
start_time: Optional[Time] = None start_time: Optional[Time] = None
end_time: Optional[Time] = None end_time: Optional[Time] = None
tags: Optional[list[str]] = None tags: Optional[list[str]] = None
@@ -277,6 +284,26 @@ class CategoryPatch(BaseModel):
position: Optional[int] = None 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): class ExportCategory(BaseModel):
id: int id: int
name: str = Field(min_length=1, max_length=50) name: str = Field(min_length=1, max_length=50)
@@ -291,10 +318,18 @@ class ExportTask(TaskIn):
updated_at: Optional[str] = None 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): class ExportData(BaseModel):
app: str app: str
version: int version: int
categories: list[ExportCategory] = [] categories: list[ExportCategory] = []
projects: list[ExportProject] = []
tasks: list[ExportTask] = [] tasks: list[ExportTask] = []
@@ -306,8 +341,10 @@ def row_to_task(r: sqlite3.Row, subtasks: list[dict]) -> dict:
"title": r["title"], "title": r["title"],
"notes": r["notes"], "notes": r["notes"],
"category_id": r["category_id"], "category_id": r["category_id"],
"project_id": r["project_id"],
"priority": r["priority"], "priority": r["priority"],
"due_date": r["due_date"], "due_date": r["due_date"],
"planned_date": r["planned_date"],
"start_time": r["start_time"], "start_time": r["start_time"],
"end_time": r["end_time"], "end_time": r["end_time"],
"tags": json.loads(r["tags"]), "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") 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: def write_subtasks(conn: sqlite3.Connection, task_id: int, subtasks: list[dict]) -> None:
conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,)) conn.execute("DELETE FROM subtasks WHERE task_id = ?", (task_id,))
for i, s in enumerate(subtasks): 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, 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: created_at: Optional[str] = None, updated_at: Optional[str] = None) -> int:
now = now_iso() now = now_iso()
due = t.get("due_date")
cur = conn.execute( cur = conn.execute(
"""INSERT INTO tasks (title, notes, category_id, priority, due_date, start_time, end_time, """INSERT INTO tasks (title, notes, category_id, project_id, priority, due_date, planned_date,
tags, recurrence, recur_interval, done, done_at, created_at, updated_at) start_time, end_time, tags, recurrence, recur_interval, done, done_at,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
( (
t["title"], t.get("notes", ""), t.get("category_id"), t.get("priority", 0), t["title"], t.get("notes", ""), t.get("category_id"), t.get("project_id"), t.get("priority", 0),
due.isoformat() if isinstance(due, date) else due, iso(t.get("due_date")), iso(t.get("planned_date")),
fmt_time(t.get("start_time")), fmt_time(t.get("end_time")), fmt_time(t.get("start_time")), fmt_time(t.get("end_time")),
json.dumps(norm_tags(t.get("tags", []))), t.get("recurrence", "none"), 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, 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) @app.post("/api/tasks", status_code=201)
def create_task(body: TaskIn, conn=Depends(get_conn)): def create_task(body: TaskIn, conn=Depends(get_conn)):
ensure_category(conn, body.category_id) ensure_category(conn, body.category_id)
ensure_project(conn, body.project_id)
with conn: with conn:
task_id = insert_task(conn, body.model_dump()) task_id = insert_task(conn, body.model_dump())
return get_task(conn, task_id) 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 del fields[key] # these columns can't be cleared, only changed
if "category_id" in fields: if "category_id" in fields:
ensure_category(conn, fields["category_id"]) ensure_category(conn, fields["category_id"])
if "project_id" in fields:
ensure_project(conn, fields["project_id"])
if "tags" in fields: if "tags" in fields:
fields["tags"] = json.dumps(norm_tags(fields["tags"])) fields["tags"] = json.dumps(norm_tags(fields["tags"]))
if fields.get("due_date") is not None: for key in ("due_date", "planned_date"):
fields["due_date"] = fields["due_date"].isoformat() if key in fields:
fields[key] = iso(fields[key])
for key in ("start_time", "end_time"): for key in ("start_time", "end_time"):
if key in fields: if key in fields:
fields[key] = fmt_time(fields[key]) fields[key] = fmt_time(fields[key])
merged = {k: fields.get(k, current[k]) for k in ("due_date", "start_time", "end_time")} merged = {k: fields.get(k, current[k]) for k in ("due_date", "planned_date", "start_time", "end_time")}
error = check_times(merged["due_date"], merged["start_time"], merged["end_time"]) error = check_times(merged["planned_date"] or merged["due_date"], merged["start_time"], merged["end_time"])
if error: if error:
raise HTTPException(422, error) raise HTTPException(422, error)
fields["updated_at"] = now_iso() 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)) conn.execute("UPDATE tasks SET done = 1, done_at = ?, updated_at = ? WHERE id = ?", (now, now, task_id))
if task["recurrence"] != "none": if task["recurrence"] != "none":
due = date.fromisoformat(task["due_date"]) if task["due_date"] else 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) 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["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"]] nxt["subtasks"] = [{"title": s["title"], "done": False} for s in task["subtasks"]]
next_id = insert_task(conn, nxt) next_id = insert_task(conn, nxt)
return {"task": get_task(conn, task_id), "next": get_task(conn, next_id) if next_id else None} 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) 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 --------------------------------------------------------- # --- Export / import ---------------------------------------------------------
@app.get("/api/export") @app.get("/api/export")
@@ -520,6 +653,7 @@ def export_data(conn=Depends(get_conn)):
"version": EXPORT_VERSION, "version": EXPORT_VERSION,
"exported_at": now_iso(), "exported_at": now_iso(),
"categories": list_categories(conn), "categories": list_categories(conn),
"projects": list_projects(conn),
"tasks": load_tasks(conn), "tasks": load_tasks(conn),
} }
return Response( return Response(
@@ -538,6 +672,7 @@ def import_data(data: ExportData, mode: Literal["replace", "merge"] = "merge", c
if mode == "replace": if mode == "replace":
conn.execute("DELETE FROM subtasks") conn.execute("DELETE FROM subtasks")
conn.execute("DELETE FROM tasks") conn.execute("DELETE FROM tasks")
conn.execute("DELETE FROM projects")
conn.execute("DELETE FROM categories") conn.execute("DELETE FROM categories")
existing = {r["name"].lower(): r["id"] for r in conn.execute("SELECT id, name 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] 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 existing[key] = cur.lastrowid
next_pos += 1 next_pos += 1
cat_map[c.id] = existing[key] 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: for t in data.tasks:
fields = t.model_dump() fields = t.model_dump()
fields["category_id"] = cat_map.get(t.category_id) if t.category_id is not None else None 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, insert_task(conn, fields, done=t.done, done_at=t.done_at,
created_at=t.created_at, updated_at=t.updated_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). # 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"> <aside id="sidebar" class="sidebar">
<div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div> <div class="brand"><img src="icons/icon.svg" alt="" width="28" height="28"> WhatToDo</div>
<nav id="views" class="nav"></nav> <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> <nav id="cats" class="nav"></nav>
<div class="sidebar-foot"> <div class="sidebar-foot">
<button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button> <button id="settings-btn" class="nav-item" type="button"><span class="nav-icon">⚙️</span> Settings</button>
@@ -49,9 +54,14 @@
</form> </form>
<div id="cal-nav" class="cal-nav" hidden> <div id="cal-nav" class="cal-nav" hidden>
<button id="cal-prev" class="btn" type="button" aria-label="Previous day">‹</button> <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-next" class="btn" type="button" aria-label="Next day">›</button>
<button id="cal-today" class="btn" type="button">Today</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>
<div id="list" class="list"></div> <div id="list" class="list"></div>
</main> </main>
@@ -65,10 +75,45 @@
<input id="t-title" required maxlength="500" autocomplete="off"> <input id="t-title" required maxlength="500" autocomplete="off">
</label> </label>
<div class="row"> <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"> <label class="field">
<span>Category</span> <span>Category</span>
<select id="t-category"></select> <select id="t-category"></select>
</label> </label>
</div>
<div class="row">
<label class="field"> <label class="field">
<span>Priority</span> <span>Priority</span>
<select id="t-priority"> <select id="t-priority">
@@ -78,12 +123,6 @@
<option value="3">High</option> <option value="3">High</option>
</select> </select>
</label> </label>
</div>
<div class="row">
<label class="field">
<span>Deadline</span>
<input id="t-due" type="date">
</label>
<label class="field"> <label class="field">
<span>Repeat</span> <span>Repeat</span>
<select id="t-repeat"> <select id="t-repeat">
@@ -99,30 +138,13 @@
<input id="t-interval" type="number" min="1" max="365" value="1"> <input id="t-interval" type="number" min="1" max="365" value="1">
</label> </label>
</div> </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"> <label class="field">
<span>Tags <small>(comma separated)</small></span> <span>Tags <small>(comma separated)</small></span>
<input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off"> <input id="t-tags" placeholder="e.g. editing, urgent" autocomplete="off">
</label> </label>
<label class="field"> <label class="field">
<span>Notes</span> <span>Notes</span>
<textarea id="t-notes" rows="4" maxlength="20000"></textarea> <textarea id="t-notes" rows="3" maxlength="20000"></textarea>
</label> </label>
<div class="field"> <div class="field">
<span>Checklist</span> <span>Checklist</span>
@@ -141,6 +163,60 @@
</form> </form>
</dialog> </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"> <dialog id="settings-dialog">
<div class="dialog-body"> <div class="dialog-body">
<h3>Settings</h3> <h3>Settings</h3>
@@ -157,8 +233,8 @@
<section class="settings-section"> <section class="settings-section">
<h4>Export / import</h4> <h4>Export / import</h4>
<p class="muted">Export downloads all tasks and categories as a JSON file. Import it on another <p class="muted">Export downloads all tasks, projects and categories as a JSON file. Import it on
install, such as production, to move your data there.</p> another install, such as production, to move your data there.</p>
<div class="row wrap"> <div class="row wrap">
<a id="s-export" class="btn primary" href="api/export" download>Export data</a> <a id="s-export" class="btn primary" href="api/export" download>Export data</a>
</div> </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; } .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 { 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 { .nav-item {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: none; border-radius: 8px; padding: 8px 10px; cursor: pointer; 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; } .day-preview li.clash .day-time { color: inherit; }
/* Calendar */ /* 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; } .cal-nav input[type="date"] { max-width: 180px; }
.week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 6px; } .week-strip { display: grid; grid-template-columns: repeat(7, 1fr); gap: 6px; margin-top: 6px; }
.week-day { .week-day {
@@ -294,9 +299,139 @@ input:disabled { opacity: 0.5; }
border-radius: 50%; background: var(--overdue); 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) { @media (max-width: 800px) {
.cal-nav { padding: 0 16px 8px; } .cal-nav { padding: 0 16px 8px; }
.cal-nav input[type="date"] { max-width: none; flex: 1; } .cal-nav input[type="date"] { max-width: none; flex: 1; }
.cal-nav .segmented { flex-basis: 100%; }
.week-strip { gap: 4px; } .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; } .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. // Network-first service worker: always fresh when online, app shell still opens offline.
const CACHE = 'whattodo-v2'; const CACHE = 'whattodo-v3';
const SHELL = [ const SHELL = [
'./', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest', './', 'index.html', 'style.css', 'app.js', 'manifest.webmanifest',
'icons/icon.svg', 'icons/icon-192.png', 'icons/icon-512.png', '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: services:
whattodo: whattodo:
build: 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() db.init()
conn = sqlite3.connect(path) conn = sqlite3.connect(path)
assert conn.execute("SELECT title, start_time FROM tasks").fetchall() == [("old task", None)] 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() 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