Compare commits
5 Commits
509b1a1320
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 119b8a0205 | |||
| 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).
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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),)
|
||||||
)
|
)
|
||||||
|
|||||||
+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"
|
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)
|
||||||
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"]]
|
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).
|
||||||
|
|||||||
+707
-132
File diff suppressed because it is too large
Load Diff
+104
-28
@@ -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
@@ -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
@@ -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:
|
||||||
@@ -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()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user