Compare commits
4 Commits
509b1a1320
..
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed1bc4a0e | |||
| c015e5663b | |||
| 0060cd11bb | |||
| 39842c72d7 |
@@ -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
@@ -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
|
||||
|
||||
@@ -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** (day timeline with week strip; 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 logs -f # follow logs
|
||||
docker compose down # stop
|
||||
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
|
||||
```
|
||||
|
||||
@@ -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),)
|
||||
)
|
||||
|
||||
+160
-18
@@ -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)
|
||||
nxt["due_date"] = next_due(due, task["recurrence"], task["recur_interval"], today)
|
||||
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).
|
||||
|
||||
+529
-104
@@ -1,18 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
const VIEWS = [
|
||||
{ id: 'overview', label: 'Overview', icon: '🧭' },
|
||||
{ id: 'today', label: 'Today', icon: '☀️' },
|
||||
{ id: 'upcoming', label: 'Upcoming', icon: '📅' },
|
||||
{ id: 'calendar', label: 'Calendar', icon: '🗓️' },
|
||||
{ id: 'backlog', label: 'Backlog', icon: '📥' },
|
||||
{ id: 'all', label: 'All open', icon: '📋' },
|
||||
{ id: 'done', label: 'Done', icon: '✅' },
|
||||
];
|
||||
const PRIORITY_LABELS = ['', 'Low', 'Medium', 'High'];
|
||||
const REPEAT_UNITS = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' };
|
||||
const HOUR_PX = 48;
|
||||
const BACKLOG_SUGGESTIONS = 2;
|
||||
const DEFAULT_VIEW = 'overview';
|
||||
const OVERVIEW_DAYS = 7; // days after today that the overview lists one by one
|
||||
|
||||
const state = { tasks: [], categories: [], view: 'today', categoryId: null, search: '', calDate: null };
|
||||
const state = {
|
||||
tasks: [], categories: [], projects: [],
|
||||
view: DEFAULT_VIEW, categoryId: null, projectId: null,
|
||||
search: '', calDate: null, showDoneProjects: false,
|
||||
};
|
||||
let editing = null; // task open in the dialog; null when creating
|
||||
let editingProject = null;
|
||||
let planning = null; // { task, mode } for the postpone sheet
|
||||
let draftSubtasks = [];
|
||||
let toastTimer = null;
|
||||
|
||||
@@ -48,7 +59,6 @@ const dayDiff = (a, b) => {
|
||||
const [by, bm, bd] = b.split('-').map(Number);
|
||||
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000);
|
||||
};
|
||||
|
||||
const addMonths = (s, n) => {
|
||||
const [y, m, d] = s.split('-').map(Number);
|
||||
const total = m - 1 + n;
|
||||
@@ -64,6 +74,41 @@ function stepDate(s, recurrence, n) {
|
||||
return addMonths(s, 12 * n);
|
||||
}
|
||||
|
||||
// Named dates used by the postpone sheet and the "Plan:" shortcuts.
|
||||
function namedDate(name) {
|
||||
const t = todayStr();
|
||||
const dow = parseDate(t).getDay(); // 0 = Sunday
|
||||
const nextMonday = addDays(t, ((8 - dow) % 7) || 7);
|
||||
switch (name) {
|
||||
case 'today': return t;
|
||||
case 'tomorrow': return addDays(t, 1);
|
||||
case 'weekend': return dow === 6 || dow === 0 ? addDays(nextMonday, 5) : addDays(t, 6 - dow);
|
||||
case 'nextweek': return nextMonday;
|
||||
case 'twoweeks': return addDays(nextMonday, 7);
|
||||
case 'nextmonth': { const d = parseDate(t); return fmtDate(new Date(d.getFullYear(), d.getMonth() + 1, 1)); }
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
function dayName(day) {
|
||||
const diff = dayDiff(todayStr(), day);
|
||||
const d = parseDate(day);
|
||||
if (diff === 0) return 'Today';
|
||||
if (diff === 1) return 'Tomorrow';
|
||||
if (diff === -1) return 'Yesterday';
|
||||
if (diff > 1 && diff < 7) return d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
return d.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
|
||||
function dueInfo(due) {
|
||||
const diff = dayDiff(todayStr(), due);
|
||||
let text = dayName(due);
|
||||
if (diff < -1) text += ` · ${-diff} days late`;
|
||||
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
|
||||
}
|
||||
|
||||
const toMin = (t) => { const [hh, mm] = t.split(':').map(Number); return hh * 60 + mm; };
|
||||
const fromMin = (m) => `${pad(Math.floor(m / 60))}:${pad(m % 60)}`;
|
||||
// A start time without an end counts as one hour.
|
||||
@@ -73,15 +118,27 @@ const span = (t) => {
|
||||
};
|
||||
const timeText = (t) => (t.start_time ? (t.end_time ? `${t.start_time}–${t.end_time}` : t.start_time) : '');
|
||||
|
||||
// Tasks on a given day, including future repeats of open recurring tasks (marked projected).
|
||||
// --- Task date semantics ------------------------------------------------------
|
||||
// planned_date = when I intend to do it; due_date = when it must be done.
|
||||
// The "when" date (plan, else deadline) decides where a task is scheduled; times apply to it.
|
||||
|
||||
const whenDate = (t) => t.planned_date || t.due_date || null;
|
||||
const isOverdue = (t, today) => !t.done && t.due_date && t.due_date < today;
|
||||
// Planned for today or earlier (unfinished plans roll over), or due today.
|
||||
const isForToday = (t, today) => !t.done && !isOverdue(t, today)
|
||||
&& ((t.planned_date && t.planned_date <= today) || t.due_date === today);
|
||||
const isBacklog = (t) => !t.done && !t.planned_date && !t.due_date;
|
||||
|
||||
// Tasks on a given day (by "when" date), including future repeats of open recurring tasks.
|
||||
function itemsOn(day) {
|
||||
const out = [];
|
||||
for (const t of state.tasks) {
|
||||
if (!t.due_date) continue;
|
||||
if (t.due_date === day) {
|
||||
const w = whenDate(t);
|
||||
if (!w) continue;
|
||||
if (w === day) {
|
||||
out.push({ task: t, projected: false });
|
||||
} else if (!t.done && t.recurrence !== 'none' && t.due_date < day) {
|
||||
let d = t.due_date;
|
||||
} else if (!t.done && t.recurrence !== 'none' && w < day) {
|
||||
let d = w;
|
||||
for (let i = 0; d < day && i < 2000; i++) d = stepDate(d, t.recurrence, t.recur_interval);
|
||||
if (d === day) out.push({ task: t, projected: true });
|
||||
}
|
||||
@@ -89,23 +146,6 @@ function itemsOn(day) {
|
||||
return out;
|
||||
}
|
||||
|
||||
function dueInfo(due) {
|
||||
const diff = dayDiff(todayStr(), due);
|
||||
const d = parseDate(due);
|
||||
let text;
|
||||
if (diff === 0) text = 'Today';
|
||||
else if (diff === 1) text = 'Tomorrow';
|
||||
else if (diff === -1) text = 'Yesterday';
|
||||
else if (diff > 1 && diff < 7) text = d.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
else {
|
||||
const opts = { weekday: 'short', day: 'numeric', month: 'short' };
|
||||
if (d.getFullYear() !== new Date().getFullYear()) opts.year = 'numeric';
|
||||
text = d.toLocaleDateString(undefined, opts);
|
||||
}
|
||||
if (diff < -1) text += ` · ${-diff} days late`;
|
||||
return { text, cls: diff < 0 ? 'overdue' : diff === 0 ? 'today' : '' };
|
||||
}
|
||||
|
||||
// --- API ----------------------------------------------------------------------
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
@@ -138,8 +178,11 @@ async function api(path, { method = 'GET', body } = {}) {
|
||||
}
|
||||
|
||||
async function loadAll() {
|
||||
const [categories, tasks] = await Promise.all([api('api/categories'), api('api/tasks')]);
|
||||
const [categories, projects, tasks] = await Promise.all([
|
||||
api('api/categories'), api('api/projects'), api('api/tasks'),
|
||||
]);
|
||||
state.categories = categories;
|
||||
state.projects = projects;
|
||||
state.tasks = tasks;
|
||||
}
|
||||
|
||||
@@ -158,35 +201,53 @@ async function act(fn) {
|
||||
|
||||
// --- Persisted UI prefs -------------------------------------------------------
|
||||
|
||||
const PREFS_VERSION = 2;
|
||||
|
||||
function savePrefs() {
|
||||
try { localStorage.setItem('whattodo.view', JSON.stringify({ view: state.view, categoryId: state.categoryId })); } catch { /* ignore */ }
|
||||
try {
|
||||
localStorage.setItem('whattodo.view', JSON.stringify({
|
||||
view: state.view, categoryId: state.categoryId, projectId: state.projectId, v: PREFS_VERSION,
|
||||
}));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
function loadPrefs() {
|
||||
try {
|
||||
const p = JSON.parse(localStorage.getItem('whattodo.view') || 'null');
|
||||
if (p && typeof p.view === 'string') { state.view = p.view; state.categoryId = p.categoryId ?? null; }
|
||||
if (p && typeof p.view === 'string') {
|
||||
state.view = p.view;
|
||||
state.categoryId = p.categoryId ?? null;
|
||||
state.projectId = p.projectId ?? null;
|
||||
// Today used to be the start page; move people who were left on it to the overview once.
|
||||
if (!p.v && p.view === 'today') state.view = DEFAULT_VIEW;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Filtering & grouping -----------------------------------------------------
|
||||
// --- Lookups, filtering & grouping ---------------------------------------------
|
||||
|
||||
const catById = (id) => state.categories.find((c) => c.id === id);
|
||||
const projectById = (id) => state.projects.find((p) => p.id === id);
|
||||
const projectTasks = (p) => state.tasks.filter((t) => t.project_id === p.id);
|
||||
|
||||
function matchesSearch(t) {
|
||||
const terms = state.search.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
return terms.every((term) => {
|
||||
if (term.startsWith('#') && term.length > 1) return t.tags.some((tag) => tag.startsWith(term.slice(1)));
|
||||
const cat = catById(t.category_id);
|
||||
const hay = [t.title, t.notes, cat ? cat.name : '', ...t.tags, ...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
|
||||
const proj = projectById(t.project_id);
|
||||
const hay = [t.title, t.notes, cat ? cat.name : '', proj ? proj.name : '', ...t.tags,
|
||||
...t.subtasks.map((s) => s.title)].join(' ').toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function cmpOpen(a, b) {
|
||||
if (a.due_date !== b.due_date) {
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date < b.due_date ? -1 : 1;
|
||||
const wa = whenDate(a);
|
||||
const wb = whenDate(b);
|
||||
if (wa !== wb) {
|
||||
if (!wa) return 1;
|
||||
if (!wb) return -1;
|
||||
return wa < wb ? -1 : 1;
|
||||
}
|
||||
if (a.start_time !== b.start_time) {
|
||||
if (!a.start_time) return 1;
|
||||
@@ -199,10 +260,18 @@ function cmpOpen(a, b) {
|
||||
function viewTasks() {
|
||||
let tasks = state.tasks;
|
||||
if (state.view === 'cat') tasks = tasks.filter((t) => (t.category_id || 0) === state.categoryId);
|
||||
if (state.view === 'project') tasks = tasks.filter((t) => t.project_id === state.projectId);
|
||||
if (state.search.trim()) tasks = tasks.filter(matchesSearch);
|
||||
return tasks;
|
||||
}
|
||||
|
||||
// Backlog tasks worth suggesting for today: highest priority first, then the oldest.
|
||||
function backlogSuggestions() {
|
||||
return state.tasks.filter((t) => isBacklog(t) && t.priority < 3)
|
||||
.sort((a, b) => (b.priority - a.priority) || (a.id - b.id))
|
||||
.slice(0, BACKLOG_SUGGESTIONS);
|
||||
}
|
||||
|
||||
function buildGroups(tasks) {
|
||||
const t = todayStr();
|
||||
const tomorrow = addDays(t, 1);
|
||||
@@ -212,75 +281,193 @@ function buildGroups(tasks) {
|
||||
return [{ title: 'Completed', tasks: done }];
|
||||
}
|
||||
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
|
||||
const group = (title, pred) => ({ title, tasks: open.filter(pred) });
|
||||
const overdue = (x) => x.due_date && x.due_date < t;
|
||||
const today = (x) => x.due_date === t;
|
||||
const group = (title, pred, extra = {}) => ({ title, tasks: open.filter(pred), ...extra });
|
||||
const overdue = (x) => isOverdue(x, t);
|
||||
const today = (x) => isForToday(x, t);
|
||||
const later = (x) => !overdue(x) && !today(x);
|
||||
|
||||
if (state.view === 'today') {
|
||||
const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id));
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('High priority', (x) => x.priority === 3 && (!x.due_date || x.due_date > t)),
|
||||
// High priority things you haven't scheduled yet keep showing up until you plan them.
|
||||
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
|
||||
{ title: 'From your backlog', tasks: state.search ? [] : backlogSuggestions(), hint: 'No date yet. Plan one for today?' },
|
||||
];
|
||||
}
|
||||
if (state.view === 'overview') {
|
||||
const shown = new Set(open.filter((x) => overdue(x) || today(x)).map((x) => x.id));
|
||||
const days = Array.from({ length: OVERVIEW_DAYS }, (_, i) => addDays(t, i + 1));
|
||||
return [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('High priority, not planned', (x) => x.priority === 3 && !shown.has(x.id) && !x.planned_date),
|
||||
...days.map((day) => group(dayName(day), (x) => later(x) && whenDate(x) === day)),
|
||||
];
|
||||
}
|
||||
if (state.view === 'upcoming') {
|
||||
return [
|
||||
group('Tomorrow', (x) => x.due_date === tomorrow),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > tomorrow && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
group('Tomorrow', (x) => later(x) && whenDate(x) === tomorrow),
|
||||
group('Next 7 days', (x) => later(x) && whenDate(x) > tomorrow && whenDate(x) <= week),
|
||||
group('Later', (x) => later(x) && whenDate(x) > week),
|
||||
];
|
||||
}
|
||||
return [
|
||||
if (state.view === 'backlog') {
|
||||
const backlog = (p) => (x) => isBacklog(x) && x.priority === p;
|
||||
return [
|
||||
group('High priority', backlog(3)),
|
||||
group('Medium priority', backlog(2)),
|
||||
group('Low priority', backlog(1)),
|
||||
group('No priority', backlog(0)),
|
||||
];
|
||||
}
|
||||
const groups = [
|
||||
group('Overdue', overdue),
|
||||
group('Today', today),
|
||||
group('Next 7 days', (x) => x.due_date && x.due_date > t && x.due_date <= week),
|
||||
group('Later', (x) => x.due_date && x.due_date > week),
|
||||
group('No deadline', (x) => !x.due_date),
|
||||
group('Next 7 days', (x) => later(x) && whenDate(x) && whenDate(x) <= week),
|
||||
group('Later', (x) => later(x) && whenDate(x) > week),
|
||||
group('No date', isBacklog),
|
||||
];
|
||||
if (state.view === 'project') {
|
||||
const done = tasks.filter((x) => x.done).sort((a, b) => (b.done_at || '').localeCompare(a.done_at || ''));
|
||||
groups.push({ title: 'Completed', tasks: done });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function counts() {
|
||||
const t = todayStr();
|
||||
const open = state.tasks.filter((x) => !x.done);
|
||||
const overdue = open.filter((x) => isOverdue(x, t)).length;
|
||||
const laterThan = addDays(t, OVERVIEW_DAYS);
|
||||
const c = {
|
||||
today: open.filter((x) => (x.due_date && x.due_date <= t) || x.priority === 3).length,
|
||||
upcoming: open.filter((x) => x.due_date && x.due_date > t).length,
|
||||
later: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x) > laterThan).length,
|
||||
today: open.filter((x) => isOverdue(x, t) || isForToday(x, t) || (x.priority === 3 && !x.planned_date)).length,
|
||||
upcoming: open.filter((x) => !isOverdue(x, t) && !isForToday(x, t) && whenDate(x)).length,
|
||||
backlog: open.filter(isBacklog).length,
|
||||
all: open.length,
|
||||
done: null,
|
||||
overdue: open.filter((x) => x.due_date && x.due_date < t).length,
|
||||
calendar: null,
|
||||
overdue,
|
||||
cats: {},
|
||||
projects: {},
|
||||
};
|
||||
for (const x of open) c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
|
||||
c.overview = c.today;
|
||||
for (const x of open) {
|
||||
c.cats[x.category_id || 0] = (c.cats[x.category_id || 0] || 0) + 1;
|
||||
if (x.project_id) c.projects[x.project_id] = (c.projects[x.project_id] || 0) + 1;
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
// Progress and warnings for a project.
|
||||
function projectHealth(p) {
|
||||
const t = todayStr();
|
||||
const tasks = projectTasks(p);
|
||||
const total = tasks.length;
|
||||
const done = tasks.filter((x) => x.done).length;
|
||||
const open = tasks.filter((x) => !x.done).sort(cmpOpen);
|
||||
const info = { total, done, pct: total ? Math.round((done / total) * 100) : 0, next: open[0] || null, warn: null, daysLeft: null };
|
||||
if (p.done) return info;
|
||||
const started = p.created_at.slice(0, 10);
|
||||
if (p.due_date) {
|
||||
info.daysLeft = dayDiff(t, p.due_date);
|
||||
const totalDays = dayDiff(started, p.due_date);
|
||||
const timeFrac = totalDays > 0 ? Math.min(1, dayDiff(started, t) / totalDays) : 1;
|
||||
if (info.daysLeft < 0) info.warn = 'Deadline passed';
|
||||
else if (total && timeFrac - done / total > 0.25) {
|
||||
info.warn = `Behind: ${Math.round(timeFrac * 100)}% of the time used, ${info.pct}% done`;
|
||||
}
|
||||
}
|
||||
if (!info.warn && !total) info.warn = 'No tasks yet. Break it into steps.';
|
||||
if (!info.warn && open.length && dayDiff(started, t) >= 7) {
|
||||
const lastDone = tasks.map((x) => x.done_at || '').sort().pop();
|
||||
if (!lastDone || dayDiff(lastDone.slice(0, 10), t) >= 7) info.warn = 'Nothing done on this in over a week';
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
function daysLeftText(n) {
|
||||
if (n == null) return '';
|
||||
if (n < 0) return `${-n} days overdue`;
|
||||
if (n === 0) return 'due today';
|
||||
if (n === 1) return '1 day left';
|
||||
if (n < 60) return `${n} days left`;
|
||||
return `${Math.round(n / 7)} weeks left`;
|
||||
}
|
||||
|
||||
function sortedProjects() {
|
||||
return [...state.projects].sort((a, b) => {
|
||||
if (a.due_date !== b.due_date) {
|
||||
if (!a.due_date) return 1;
|
||||
if (!b.due_date) return -1;
|
||||
return a.due_date < b.due_date ? -1 : 1;
|
||||
}
|
||||
return a.id - b.id;
|
||||
});
|
||||
}
|
||||
|
||||
// --- Rendering ----------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = 'today';
|
||||
if (state.view !== 'cat' && !VIEWS.some((v) => v.id === state.view)) state.view = 'today';
|
||||
const c = counts();
|
||||
const navItem = (active, icon, label, count, onclick, color, alert) => h('button', {
|
||||
type: 'button', class: 'nav-item' + (active ? ' active' : ''), onclick,
|
||||
function navItem({ active, icon, label, count, onclick, color, alert, muted }) {
|
||||
return h('button', {
|
||||
type: 'button', class: 'nav-item' + (active ? ' active' : '') + (muted ? ' muted' : ''), onclick,
|
||||
},
|
||||
color ? h('span', { class: 'dot', style: { background: color } }) : h('span', { class: 'nav-icon' }, icon),
|
||||
h('span', { class: 'nav-label' }, label),
|
||||
count ? h('span', { class: 'count' + (alert ? ' alert' : '') }, count) : null);
|
||||
}
|
||||
|
||||
$('#views').replaceChildren(...VIEWS.map((v) => navItem(
|
||||
state.view === v.id, v.icon, v.label, c[v.id], () => setView(v.id), null, v.id === 'today' && c.overdue > 0,
|
||||
)));
|
||||
function renderNav(c) {
|
||||
$('#views').replaceChildren(...VIEWS.map((v) => navItem({
|
||||
active: state.view === v.id, icon: v.icon, label: v.label, count: c[v.id],
|
||||
onclick: () => setView(v.id), alert: ['overview', 'today'].includes(v.id) && c.overdue > 0,
|
||||
})));
|
||||
|
||||
const catItems = state.categories.map((cat) => navItem(
|
||||
state.view === 'cat' && state.categoryId === cat.id, null, cat.name, c.cats[cat.id], () => setView('cat', cat.id), cat.color,
|
||||
));
|
||||
const projects = sortedProjects();
|
||||
const active = projects.filter((p) => !p.done);
|
||||
const finished = projects.filter((p) => p.done);
|
||||
const projItem = (p) => navItem({
|
||||
active: state.view === 'project' && state.projectId === p.id, icon: p.done ? '✔' : '📁', label: p.name,
|
||||
count: c.projects[p.id], onclick: () => setView('project', { projectId: p.id }), muted: p.done,
|
||||
alert: !p.done && p.due_date && p.due_date < todayStr(),
|
||||
});
|
||||
const items = active.map(projItem);
|
||||
if (finished.length) {
|
||||
items.push(h('button', {
|
||||
type: 'button', class: 'nav-toggle',
|
||||
onclick: () => { state.showDoneProjects = !state.showDoneProjects; render(); },
|
||||
}, state.showDoneProjects ? 'Hide finished' : `Show finished (${finished.length})`));
|
||||
if (state.showDoneProjects) items.push(...finished.map(projItem));
|
||||
}
|
||||
if (!projects.length) items.push(h('div', { class: 'nav-empty' }, 'No projects yet'));
|
||||
$('#projects').replaceChildren(...items);
|
||||
|
||||
const catItems = state.categories.map((cat) => navItem({
|
||||
active: state.view === 'cat' && state.categoryId === cat.id, label: cat.name, count: c.cats[cat.id],
|
||||
onclick: () => setView('cat', { categoryId: cat.id }), color: cat.color,
|
||||
}));
|
||||
if (c.cats[0]) {
|
||||
catItems.push(navItem(state.view === 'cat' && state.categoryId === 0, null, 'No category', c.cats[0],
|
||||
() => setView('cat', 0), 'var(--muted)'));
|
||||
catItems.push(navItem({
|
||||
active: state.view === 'cat' && state.categoryId === 0, label: 'No category', count: c.cats[0],
|
||||
onclick: () => setView('cat', { categoryId: 0 }), color: 'var(--muted)',
|
||||
}));
|
||||
}
|
||||
$('#cats').replaceChildren(...catItems);
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.view === 'cat' && state.categoryId && !catById(state.categoryId)) state.view = DEFAULT_VIEW;
|
||||
if (state.view === 'project' && !projectById(state.projectId)) state.view = DEFAULT_VIEW;
|
||||
if (!['cat', 'project'].includes(state.view) && !VIEWS.some((v) => v.id === state.view)) state.view = DEFAULT_VIEW;
|
||||
|
||||
const c = counts();
|
||||
renderNav(c);
|
||||
|
||||
let title = (VIEWS.find((v) => v.id === state.view) || {}).label;
|
||||
if (state.view === 'cat') title = state.categoryId ? (catById(state.categoryId) || {}).name : 'No category';
|
||||
if (state.view === 'cat') title = state.categoryId ? catById(state.categoryId).name : 'No category';
|
||||
if (state.view === 'project') title = projectById(state.projectId).name;
|
||||
$('#view-title').textContent = title || '';
|
||||
document.title = `${title} · WhatToDo`;
|
||||
$('#quick-add').hidden = state.view === 'done';
|
||||
@@ -291,29 +478,107 @@ function render() {
|
||||
return;
|
||||
}
|
||||
|
||||
const groups = buildGroups(viewTasks()).filter((g) => g.tasks.length);
|
||||
if (!groups.length) {
|
||||
const sections = [];
|
||||
if (state.view === 'project') sections.push(projectHeader(projectById(state.projectId)));
|
||||
const groups = buildGroups(viewTasks());
|
||||
if (state.view === 'today' && !state.search) {
|
||||
const cards = sortedProjects().filter((p) => !p.done).map(projectCard);
|
||||
// Projects go right after the things that are due/planned today.
|
||||
const at = groups.findIndex((g) => g.title === 'High priority, not planned');
|
||||
if (cards.length) groups.splice(at, 0, { title: 'Projects', nodes: cards });
|
||||
}
|
||||
for (const g of groups) {
|
||||
const items = g.nodes || g.tasks.map(taskRow);
|
||||
if (!items.length) continue;
|
||||
sections.push(h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') },
|
||||
g.title, g.tasks ? h('span', { class: 'muted' }, ` ${g.tasks.length}`) : null),
|
||||
g.hint ? h('p', { class: 'group-hint' }, g.hint) : null,
|
||||
items));
|
||||
}
|
||||
if (sections.length === (state.view === 'project' ? 1 : 0)) {
|
||||
const msg = state.search ? 'No tasks match your search.'
|
||||
: state.view === 'done' ? 'Nothing completed yet.'
|
||||
: state.view === 'today' ? 'Nothing due today. 🎉' : 'No tasks here.';
|
||||
$('#list').replaceChildren(h('div', { class: 'empty' }, msg));
|
||||
return;
|
||||
: state.view === 'today' ? 'Nothing planned for today. 🎉'
|
||||
: state.view === 'overview' ? 'Nothing planned for today or the coming week. 🎉'
|
||||
: state.view === 'backlog' ? 'Backlog is empty: everything has a date.'
|
||||
: state.view === 'project' ? 'No tasks in this project yet. Add the first step above.'
|
||||
: 'No tasks here.';
|
||||
sections.push(h('div', { class: 'empty' }, msg));
|
||||
}
|
||||
$('#list').replaceChildren(...groups.map((g) => h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' + (g.title === 'Overdue' ? ' overdue' : '') }, g.title, h('span', { class: 'muted' }, ` ${g.tasks.length}`)),
|
||||
g.tasks.map(taskRow))));
|
||||
if (state.view === 'overview' && !state.search && c.later) {
|
||||
sections.push(h('button', { type: 'button', class: 'more-link', onclick: () => setView('upcoming') },
|
||||
`${c.later} more planned later →`));
|
||||
}
|
||||
$('#list').replaceChildren(...sections);
|
||||
}
|
||||
|
||||
function progressBar(pct) {
|
||||
return h('div', { class: 'progress', role: 'progressbar', 'aria-valuenow': String(pct), 'aria-valuemin': '0', 'aria-valuemax': '100' },
|
||||
h('div', { class: 'progress-fill', style: { width: `${pct}%` } }));
|
||||
}
|
||||
|
||||
function projectCard(p) {
|
||||
const info = projectHealth(p);
|
||||
const cat = catById(p.category_id);
|
||||
return h('div', {
|
||||
class: 'project-card', tabindex: '0',
|
||||
onclick: () => setView('project', { projectId: p.id }),
|
||||
onkeydown: (e) => { if (e.key === 'Enter') setView('project', { projectId: p.id }); },
|
||||
style: { borderLeftColor: cat ? cat.color : 'var(--accent)' },
|
||||
},
|
||||
h('div', { class: 'project-card-head' },
|
||||
h('span', { class: 'project-name' }, '📁 ', p.name),
|
||||
h('span', { class: 'muted small' }, [
|
||||
p.due_date ? `${dayName(p.due_date)} · ${daysLeftText(info.daysLeft)}` : 'no deadline',
|
||||
])),
|
||||
h('div', { class: 'project-progress' }, progressBar(info.pct), h('span', { class: 'small muted' }, `${info.done}/${info.total}`)),
|
||||
info.next ? h('div', { class: 'project-next' }, h('span', { class: 'muted' }, 'Next: '), info.next.title,
|
||||
whenDate(info.next) ? h('span', { class: 'muted' }, ` · ${dayName(whenDate(info.next))}`) : null) : null,
|
||||
info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null);
|
||||
}
|
||||
|
||||
function projectHeader(p) {
|
||||
const info = projectHealth(p);
|
||||
const cat = catById(p.category_id);
|
||||
return h('section', { class: 'project-header' },
|
||||
h('div', { class: 'project-header-top' },
|
||||
h('div', { class: 'task-meta' },
|
||||
cat ? h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name) : null,
|
||||
p.due_date ? h('span', { class: `chip due ${info.daysLeft < 0 ? 'overdue' : ''}` }, '🏁 ', dayName(p.due_date), ` · ${daysLeftText(info.daysLeft)}`) : null,
|
||||
p.done ? h('span', { class: 'chip' }, '✔ Finished') : null),
|
||||
h('button', { type: 'button', class: 'btn', onclick: () => openProject(p) }, 'Edit project')),
|
||||
h('div', { class: 'project-progress big' }, progressBar(info.pct),
|
||||
h('span', { class: 'small' }, `${info.done}/${info.total} done (${info.pct}%)`)),
|
||||
info.warn ? h('div', { class: 'project-warn' }, '⚠ ', info.warn) : null,
|
||||
p.notes.trim() ? h('p', { class: 'project-notes' }, p.notes) : null);
|
||||
}
|
||||
|
||||
function taskRow(task) {
|
||||
const t = todayStr();
|
||||
const meta = [];
|
||||
const cat = catById(task.category_id);
|
||||
if (cat && state.view !== 'cat') {
|
||||
meta.push(h('span', { class: 'chip' }, h('span', { class: 'dot', style: { background: cat.color } }), cat.name));
|
||||
}
|
||||
const proj = projectById(task.project_id);
|
||||
if (proj && state.view !== 'project') {
|
||||
meta.push(h('button', {
|
||||
type: 'button', class: 'chip link-chip', title: 'Open project',
|
||||
onclick: (e) => { e.stopPropagation(); setView('project', { projectId: proj.id }); },
|
||||
}, '📁 ', proj.name));
|
||||
}
|
||||
const time = timeText(task);
|
||||
if (task.planned_date) {
|
||||
const diff = dayDiff(t, task.planned_date);
|
||||
const label = diff < 0 && !task.done ? `from ${dayName(task.planned_date)}` : dayName(task.planned_date);
|
||||
meta.push(h('span', { class: 'chip plan' + (diff <= 0 && !task.done ? ' now' : ''), title: 'Planned to do on' },
|
||||
'▶ ', label, time ? ` · ${time}` : ''));
|
||||
}
|
||||
if (task.due_date) {
|
||||
const d = dueInfo(task.due_date);
|
||||
const time = timeText(task);
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}` }, '📅 ', d.text, time ? ` · ${time}` : ''));
|
||||
meta.push(h('span', { class: `chip due ${task.done ? '' : d.cls}`, title: 'Deadline' },
|
||||
'📅 ', task.planned_date ? 'due ' : '', d.text, !task.planned_date && time ? ` · ${time}` : ''));
|
||||
}
|
||||
if (task.priority) meta.push(h('span', { class: `chip prio-label p${task.priority}` }, '⚑ ', PRIORITY_LABELS[task.priority]));
|
||||
if (task.recurrence !== 'none') {
|
||||
@@ -344,7 +609,11 @@ function taskRow(task) {
|
||||
}, task.done ? '✓' : ''),
|
||||
h('div', { class: 'task-body' },
|
||||
h('div', { class: 'task-title' }, task.title),
|
||||
meta.length ? h('div', { class: 'task-meta' }, meta) : null));
|
||||
meta.length ? h('div', { class: 'task-meta' }, meta) : null),
|
||||
task.done ? null : h('button', {
|
||||
type: 'button', class: 'icon-btn plan-btn', 'aria-label': 'Plan or postpone', title: 'Plan / postpone',
|
||||
onclick: (e) => { e.stopPropagation(); openPlan(task); },
|
||||
}, '⏭'));
|
||||
}
|
||||
|
||||
// --- Calendar -----------------------------------------------------------------
|
||||
@@ -398,6 +667,9 @@ function renderCalendar() {
|
||||
const items = itemsOn(day);
|
||||
const timed = items.filter((it) => it.task.start_time);
|
||||
const untimed = items.filter((it) => !it.task.start_time).map((it) => it.task).sort(cmpOpen);
|
||||
// Deadlines that fall on this day but are planned for another day, and project deadlines.
|
||||
const deadlines = state.tasks.filter((t) => t.due_date === day && t.planned_date && t.planned_date !== day);
|
||||
const projectDeadlines = state.projects.filter((p) => !p.done && p.due_date === day);
|
||||
|
||||
let startHour = 7;
|
||||
let endHour = 22;
|
||||
@@ -412,7 +684,9 @@ function renderCalendar() {
|
||||
grid.append(h('div', {
|
||||
class: 'hour', style: { top: `${(hr - startHour) * HOUR_PX}px`, height: `${HOUR_PX}px` },
|
||||
title: 'Add a task at this time',
|
||||
onclick: () => openTask(null, { due_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)) }),
|
||||
onclick: () => openTask(null, {
|
||||
planned_date: day, start_time: fromMin(hr * 60), end_time: fromMin(Math.min(hr * 60 + 60, 23 * 60 + 59)),
|
||||
}),
|
||||
}, h('span', { class: 'hour-label' }, `${pad(hr)}:00`)));
|
||||
}
|
||||
for (const it of layoutBlocks(timed)) {
|
||||
@@ -444,6 +718,14 @@ function renderCalendar() {
|
||||
}
|
||||
|
||||
const sections = [weekStrip(day)];
|
||||
if (deadlines.length || projectDeadlines.length) {
|
||||
sections.push(h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' }, 'Deadlines this day'),
|
||||
projectDeadlines.map((p) => h('button', {
|
||||
type: 'button', class: 'deadline-line', onclick: () => setView('project', { projectId: p.id }),
|
||||
}, '🏁 ', h('strong', {}, p.name), ' (project)')),
|
||||
deadlines.map(taskRow)));
|
||||
}
|
||||
if (untimed.length) {
|
||||
sections.push(h('section', { class: 'group' },
|
||||
h('h3', { class: 'group-title' }, 'No time set', h('span', { class: 'muted' }, ` ${untimed.length}`)),
|
||||
@@ -460,9 +742,10 @@ function setCalDate(day) {
|
||||
|
||||
// --- Actions ------------------------------------------------------------------
|
||||
|
||||
function setView(view, categoryId = null) {
|
||||
function setView(view, { categoryId = null, projectId = null } = {}) {
|
||||
state.view = view;
|
||||
state.categoryId = categoryId;
|
||||
state.projectId = projectId;
|
||||
savePrefs();
|
||||
document.body.classList.remove('nav-open');
|
||||
render();
|
||||
@@ -482,7 +765,7 @@ function toggleDone(task) {
|
||||
}
|
||||
act(async () => {
|
||||
const res = await api(`api/tasks/${task.id}/complete`, { method: 'POST', body: { today: todayStr() } });
|
||||
const extra = res.next ? ` Next one: ${dueInfo(res.next.due_date).text}.` : '';
|
||||
const extra = res.next ? ` Next one: ${dayName(whenDate(res.next))}.` : '';
|
||||
toast(`Done: ${task.title}.${extra}`, {
|
||||
label: 'Undo',
|
||||
fn: () => act(async () => {
|
||||
@@ -493,13 +776,23 @@ function toggleDone(task) {
|
||||
});
|
||||
}
|
||||
|
||||
// Defaults for a task created from the current view.
|
||||
function viewDefaults() {
|
||||
const d = {};
|
||||
if (state.view === 'cat' && state.categoryId) d.category_id = state.categoryId;
|
||||
if (state.view === 'project') {
|
||||
const p = projectById(state.projectId);
|
||||
d.project_id = p.id;
|
||||
if (p.category_id) d.category_id = p.category_id;
|
||||
}
|
||||
if (['overview', 'today'].includes(state.view)) d.planned_date = todayStr();
|
||||
if (state.view === 'upcoming') d.planned_date = addDays(todayStr(), 1);
|
||||
if (state.view === 'calendar') d.planned_date = state.calDate || todayStr();
|
||||
return d;
|
||||
}
|
||||
|
||||
function quickAdd(title) {
|
||||
const body = { title };
|
||||
if (state.view === 'cat' && state.categoryId) body.category_id = state.categoryId;
|
||||
if (state.view === 'today') body.due_date = todayStr();
|
||||
if (state.view === 'upcoming') body.due_date = addDays(todayStr(), 1);
|
||||
if (state.view === 'calendar') body.due_date = state.calDate || todayStr();
|
||||
act(() => api('api/tasks', { method: 'POST', body }));
|
||||
act(() => api('api/tasks', { method: 'POST', body: { title, ...viewDefaults() } }));
|
||||
}
|
||||
|
||||
function toast(message, action) {
|
||||
@@ -516,12 +809,69 @@ function toast(message, action) {
|
||||
toastTimer = setTimeout(() => { el.hidden = true; }, action ? 6000 : 4000);
|
||||
}
|
||||
|
||||
// --- Postpone / plan sheet ----------------------------------------------------
|
||||
|
||||
const PLAN_OPTIONS = [
|
||||
['today', 'Today'], ['tomorrow', 'Tomorrow'], ['weekend', 'This weekend'],
|
||||
['nextweek', 'Next week'], ['twoweeks', 'In 2 weeks'], ['nextmonth', 'Next month'],
|
||||
];
|
||||
|
||||
function openPlan(task) {
|
||||
// Overdue tasks can't be fixed by planning, so default to moving the deadline for them.
|
||||
const mode = task.due_date && task.due_date < todayStr() ? 'due' : 'plan';
|
||||
planning = { task, mode };
|
||||
$('#p-title').textContent = task.title;
|
||||
renderPlan();
|
||||
$('#plan-dialog').showModal();
|
||||
}
|
||||
|
||||
function renderPlan() {
|
||||
const { task, mode } = planning;
|
||||
const field = mode === 'plan' ? 'planned_date' : 'due_date';
|
||||
$('#p-mode').hidden = !task.due_date;
|
||||
for (const btn of $('#p-mode').querySelectorAll('button')) btn.classList.toggle('active', btn.dataset.mode === mode);
|
||||
const current = task[field];
|
||||
$('#p-current').textContent = mode === 'plan'
|
||||
? (current ? `Planned for ${dayName(current)}. Move it to:` : 'Not planned yet. Do it on:')
|
||||
: `Deadline is ${dayName(current)}. Move it to:`;
|
||||
$('#p-options').replaceChildren(...PLAN_OPTIONS.map(([key, label]) => {
|
||||
const date = namedDate(key);
|
||||
if (key === 'weekend' && [0, 6].includes(parseDate(todayStr()).getDay())) label = 'Next weekend';
|
||||
return h('button', { type: 'button', class: 'plan-option', onclick: () => applyPlan(date) },
|
||||
h('span', {}, label),
|
||||
h('span', { class: 'muted small' }, parseDate(date).toLocaleDateString(undefined, { weekday: 'short', day: 'numeric', month: 'short' })));
|
||||
}));
|
||||
$('#p-date').value = current || '';
|
||||
$('#p-clear').textContent = mode === 'plan' ? 'Remove plan date' : 'Remove deadline';
|
||||
$('#p-clear').hidden = !current;
|
||||
}
|
||||
|
||||
function applyPlan(date) {
|
||||
const { task, mode } = planning;
|
||||
const field = mode === 'plan' ? 'planned_date' : 'due_date';
|
||||
const before = {
|
||||
planned_date: task.planned_date, due_date: task.due_date, start_time: task.start_time, end_time: task.end_time,
|
||||
};
|
||||
const body = { [field]: date };
|
||||
const after = { ...before, ...body };
|
||||
if (!after.planned_date && !after.due_date) { body.start_time = null; body.end_time = null; }
|
||||
$('#plan-dialog').close();
|
||||
act(async () => {
|
||||
await api(`api/tasks/${task.id}`, { method: 'PATCH', body });
|
||||
const what = mode === 'plan' ? 'Planned for' : 'Deadline moved to';
|
||||
toast(date ? `${what} ${dayName(date)}.` : (mode === 'plan' ? 'Plan date removed.' : 'Deadline removed.'), {
|
||||
label: 'Undo',
|
||||
fn: () => act(() => api(`api/tasks/${task.id}`, { method: 'PATCH', body: before })),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// --- Task dialog --------------------------------------------------------------
|
||||
|
||||
function fillCategorySelect(select, selected) {
|
||||
function fillSelect(select, items, selected, noneLabel = '— None —') {
|
||||
select.replaceChildren(
|
||||
h('option', { value: '' }, '— None —'),
|
||||
...state.categories.map((c) => h('option', { value: String(c.id) }, c.name)),
|
||||
h('option', { value: '' }, noneLabel),
|
||||
...items.map((x) => h('option', { value: String(x.id) }, x.name)),
|
||||
);
|
||||
select.value = selected ? String(selected) : '';
|
||||
}
|
||||
@@ -533,13 +883,16 @@ function updateIntervalVisibility() {
|
||||
function openTask(task, defaults = {}) {
|
||||
editing = task;
|
||||
const t = task || {
|
||||
title: '', notes: '', category_id: null, priority: 0, due_date: null,
|
||||
title: '', notes: '', category_id: null, project_id: null, priority: 0, due_date: null, planned_date: null,
|
||||
tags: [], recurrence: 'none', recur_interval: 1, subtasks: [], ...defaults,
|
||||
};
|
||||
$('#task-dialog-title').textContent = task ? 'Edit task' : 'New task';
|
||||
$('#t-title').value = t.title;
|
||||
fillCategorySelect($('#t-category'), t.category_id);
|
||||
fillSelect($('#t-category'), state.categories, t.category_id);
|
||||
// Finished projects are only listed for the task already in one.
|
||||
fillSelect($('#t-project'), sortedProjects().filter((p) => !p.done || p.id === t.project_id), t.project_id);
|
||||
$('#t-priority').value = String(t.priority);
|
||||
$('#t-planned').value = t.planned_date || '';
|
||||
$('#t-due').value = t.due_date || '';
|
||||
$('#t-start').value = t.start_time || '';
|
||||
$('#t-end').value = t.end_time || '';
|
||||
@@ -557,9 +910,9 @@ function openTask(task, defaults = {}) {
|
||||
if (!task) $('#t-title').focus();
|
||||
}
|
||||
|
||||
// Times only make sense with a date; show what else is scheduled that day and flag overlaps.
|
||||
// Times apply to the plan date (or the deadline). Show what else is scheduled that day and flag overlaps.
|
||||
function updateTimeFields() {
|
||||
const day = $('#t-due').value;
|
||||
const day = $('#t-planned').value || $('#t-due').value;
|
||||
for (const id of ['#t-start', '#t-end']) {
|
||||
$(id).disabled = !day;
|
||||
if (!day) $(id).value = '';
|
||||
@@ -582,7 +935,7 @@ function updateTimeFields() {
|
||||
});
|
||||
box.replaceChildren(
|
||||
h('div', { class: 'day-head' + (clashes ? ' clash' : '') },
|
||||
clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dueInfo(day).text.split(' · ')[0]}`),
|
||||
clashes ? `⚠ Overlaps with ${clashes} item${clashes > 1 ? 's' : ''} on this day` : `Already planned on ${dayName(day)}`),
|
||||
h('ul', {}, rows));
|
||||
box.hidden = false;
|
||||
}
|
||||
@@ -595,6 +948,11 @@ function onStartChange() {
|
||||
updateTimeFields();
|
||||
}
|
||||
|
||||
function onProjectChange() {
|
||||
const p = projectById(Number($('#t-project').value));
|
||||
if (p && p.category_id && !$('#t-category').value) $('#t-category').value = String(p.category_id);
|
||||
}
|
||||
|
||||
function renderDraftSubtasks() {
|
||||
$('#t-subtasks').replaceChildren(...draftSubtasks.map((s, i) => h('li', { class: 'subtask' },
|
||||
h('input', { type: 'checkbox', checked: s.done, 'aria-label': 'Done', onchange: (e) => { s.done = e.target.checked; } }),
|
||||
@@ -621,7 +979,9 @@ function saveTask(e) {
|
||||
const body = {
|
||||
title: $('#t-title').value.trim(),
|
||||
category_id: $('#t-category').value ? Number($('#t-category').value) : null,
|
||||
project_id: $('#t-project').value ? Number($('#t-project').value) : null,
|
||||
priority: Number($('#t-priority').value),
|
||||
planned_date: $('#t-planned').value || null,
|
||||
due_date: $('#t-due').value || null,
|
||||
start_time: $('#t-start').value || null,
|
||||
end_time: ($('#t-start').value && $('#t-end').value) || null,
|
||||
@@ -646,6 +1006,58 @@ function deleteTask() {
|
||||
act(() => api(`api/tasks/${task.id}`, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
// --- Project dialog -----------------------------------------------------------
|
||||
|
||||
function openProject(project) {
|
||||
editingProject = project;
|
||||
document.body.classList.remove('nav-open');
|
||||
const p = project || { name: '', notes: '', category_id: null, due_date: null, done: false };
|
||||
$('#pr-dialog-title').textContent = project ? 'Edit project' : 'New project';
|
||||
$('#pr-name').value = p.name;
|
||||
fillSelect($('#pr-category'), state.categories, p.category_id);
|
||||
$('#pr-due').value = p.due_date || '';
|
||||
$('#pr-notes').value = p.notes;
|
||||
$('#pr-done').checked = p.done;
|
||||
$('#pr-done').closest('label').hidden = !project;
|
||||
$('#pr-delete').hidden = !project;
|
||||
$('#project-dialog').showModal();
|
||||
if (!project) $('#pr-name').focus();
|
||||
}
|
||||
|
||||
function saveProject(e) {
|
||||
e.preventDefault();
|
||||
const body = {
|
||||
name: $('#pr-name').value.trim(),
|
||||
category_id: $('#pr-category').value ? Number($('#pr-category').value) : null,
|
||||
due_date: $('#pr-due').value || null,
|
||||
notes: $('#pr-notes').value,
|
||||
done: $('#pr-done').checked,
|
||||
};
|
||||
if (!body.name) return;
|
||||
const project = editingProject;
|
||||
$('#project-dialog').close();
|
||||
act(async () => {
|
||||
if (project) {
|
||||
await api(`api/projects/${project.id}`, { method: 'PATCH', body });
|
||||
} else {
|
||||
const created = await api('api/projects', { method: 'POST', body });
|
||||
state.view = 'project';
|
||||
state.projectId = created.id;
|
||||
savePrefs();
|
||||
toast('Project created. Add its steps as tasks, with deadlines for milestones.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteProject() {
|
||||
const p = editingProject;
|
||||
if (!p) return;
|
||||
const n = projectTasks(p).length;
|
||||
if (!confirm(`Delete project "${p.name}"? Its ${n} task(s) are kept, just no longer in a project.`)) return;
|
||||
$('#project-dialog').close();
|
||||
act(() => api(`api/projects/${p.id}`, { method: 'DELETE' }));
|
||||
}
|
||||
|
||||
// --- Settings dialog ----------------------------------------------------------
|
||||
|
||||
function renderSettingsCategories() {
|
||||
@@ -690,12 +1102,12 @@ async function importFile() {
|
||||
}
|
||||
const n = Array.isArray(data.tasks) ? data.tasks.length : 0;
|
||||
const warning = mode === 'replace'
|
||||
? `REPLACE: this deletes ALL current tasks and categories and loads ${n} tasks from the file. Continue?`
|
||||
? `REPLACE: this deletes ALL current tasks, projects and categories and loads ${n} tasks from the file. Continue?`
|
||||
: `MERGE: add ${n} tasks from the file to your current data. Continue?`;
|
||||
if (!confirm(warning)) return;
|
||||
await act(async () => {
|
||||
const res = await api(`api/import?mode=${mode}`, { method: 'POST', body: data });
|
||||
toast(`Imported ${res.tasks} tasks and ${res.categories} categories (${res.mode}).`);
|
||||
toast(`Imported ${res.tasks} tasks, ${res.projects} projects and ${res.categories} categories (${res.mode}).`);
|
||||
});
|
||||
$('#s-import-file').value = '';
|
||||
renderSettingsCategories();
|
||||
@@ -742,11 +1154,7 @@ function bind() {
|
||||
quickAdd(title);
|
||||
});
|
||||
$('#new-detailed').addEventListener('click', () => {
|
||||
const defaults = {};
|
||||
if (state.view === 'cat' && state.categoryId) defaults.category_id = state.categoryId;
|
||||
if (state.view === 'today') defaults.due_date = todayStr();
|
||||
if (state.view === 'calendar') defaults.due_date = state.calDate || todayStr();
|
||||
defaults.title = $('#quick-title').value.trim();
|
||||
const defaults = { ...viewDefaults(), title: $('#quick-title').value.trim() };
|
||||
$('#quick-title').value = '';
|
||||
openTask(null, defaults);
|
||||
});
|
||||
@@ -761,25 +1169,42 @@ function bind() {
|
||||
$('#t-cancel').addEventListener('click', () => $('#task-dialog').close());
|
||||
$('#t-delete').addEventListener('click', deleteTask);
|
||||
$('#t-repeat').addEventListener('change', updateIntervalVisibility);
|
||||
$('#t-planned').addEventListener('change', updateTimeFields);
|
||||
$('#t-due').addEventListener('change', updateTimeFields);
|
||||
$('#t-start').addEventListener('change', onStartChange);
|
||||
$('#t-end').addEventListener('change', updateTimeFields);
|
||||
|
||||
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
|
||||
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
|
||||
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
|
||||
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
|
||||
$('#t-project').addEventListener('change', onProjectChange);
|
||||
$('#t-subtask-add').addEventListener('click', addDraftSubtask);
|
||||
$('#t-subtask-new').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addDraftSubtask(); }
|
||||
});
|
||||
for (const btn of document.querySelectorAll('.quick-dates .chip-btn')) {
|
||||
btn.addEventListener('click', () => {
|
||||
$('#t-due').value = btn.dataset.days === '' ? '' : addDays(todayStr(), Number(btn.dataset.days));
|
||||
$('#t-planned').value = namedDate(btn.dataset.plan) || '';
|
||||
updateTimeFields();
|
||||
});
|
||||
}
|
||||
|
||||
// Calendar
|
||||
$('#cal-prev').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), -1)));
|
||||
$('#cal-next').addEventListener('click', () => setCalDate(addDays(state.calDate || todayStr(), 1)));
|
||||
$('#cal-today').addEventListener('click', () => setCalDate(todayStr()));
|
||||
$('#cal-date').addEventListener('change', (e) => { if (e.target.value) setCalDate(e.target.value); });
|
||||
|
||||
// Postpone sheet
|
||||
for (const btn of $('#p-mode').querySelectorAll('button')) {
|
||||
btn.addEventListener('click', () => { planning.mode = btn.dataset.mode; renderPlan(); });
|
||||
}
|
||||
$('#p-set').addEventListener('click', () => { if ($('#p-date').value) applyPlan($('#p-date').value); });
|
||||
$('#p-clear').addEventListener('click', () => applyPlan(null));
|
||||
$('#p-cancel').addEventListener('click', () => $('#plan-dialog').close());
|
||||
|
||||
// Projects
|
||||
$('#new-project').addEventListener('click', () => openProject(null));
|
||||
$('#project-form').addEventListener('submit', saveProject);
|
||||
$('#pr-cancel').addEventListener('click', () => $('#project-dialog').close());
|
||||
$('#pr-delete').addEventListener('click', deleteProject);
|
||||
|
||||
// Settings dialog
|
||||
$('#settings-btn').addEventListener('click', () => {
|
||||
document.body.classList.remove('nav-open');
|
||||
|
||||
+98
-27
@@ -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>
|
||||
@@ -65,10 +70,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 +118,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 +133,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 +158,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 +228,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>
|
||||
|
||||
+80
-1
@@ -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;
|
||||
@@ -300,3 +303,79 @@ input:disabled { opacity: 0.5; }
|
||||
.week-strip { gap: 4px; }
|
||||
}
|
||||
.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
@@ -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:
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user