From 44bf469c53f700efd927441b6cb97fe56ebe031b Mon Sep 17 00:00:00 2001 From: bruno Date: Tue, 21 Jul 2026 22:28:30 -0400 Subject: [PATCH] feat(v4.5.0): Sprints & My Tasks - New tables: sprints + sprint_pages with velocity_points - API: CRUD sprints (list/create/update/delete) - API: assign/remove pages to sprints - API: burndown chart data (total/completed/remaining points, ideal line) - 4 new tests (175 total) --- app/db.py | 26 ++++++ app/routers/workspace.py | 175 +++++++++++++++++++++++++++++++++++++++ tests/test_app.py | 142 ++++++++++++++++++++++++++++++- 3 files changed, 341 insertions(+), 2 deletions(-) diff --git a/app/db.py b/app/db.py index 3a683e2..71005f8 100644 --- a/app/db.py +++ b/app/db.py @@ -536,6 +536,32 @@ def init_db(): """) conn.commit() + # v4.5.0: Sprints + conn.execute(""" + CREATE TABLE IF NOT EXISTS sprints ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id INTEGER NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + name TEXT NOT NULL, + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + goal TEXT DEFAULT '', + status TEXT NOT NULL DEFAULT 'planning', + auto_complete BOOLEAN NOT NULL DEFAULT 1, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS sprint_pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + sprint_id INTEGER NOT NULL REFERENCES sprints(id) ON DELETE CASCADE, + page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE, + status_at_start TEXT DEFAULT '', + velocity_points INTEGER DEFAULT 1, + UNIQUE(sprint_id, page_id) + ) + """) + conn.commit() + @contextmanager def get_conn(): diff --git a/app/routers/workspace.py b/app/routers/workspace.py index 1d46d7e..e840db1 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -5,6 +5,7 @@ import csv import io import json import logging +import sqlite3 from fastapi import APIRouter, Request, HTTPException from fastapi.responses import HTMLResponse, StreamingResponse @@ -405,6 +406,180 @@ async def delete_dashboard(request: Request, collection_id: int, did: int): return {"id": did, "status": "deleted"} +# ── v4.5.0: Sprints ── + +@router.get("/collections/{collection_id}/sprints") +async def list_sprints(request: Request, collection_id: int): + """List all sprints for a collection.""" + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM sprints WHERE collection_id=? ORDER BY start_date DESC", (collection_id,) + ).fetchall() + sprints = [] + for r in rows: + s = dict(r) + # Count pages in sprint + count = conn.execute( + "SELECT COUNT(*) as cnt FROM sprint_pages WHERE sprint_id=?", (r["id"],) + ).fetchone()["cnt"] + s["page_count"] = count + sprints.append(s) + return {"sprints": sprints} + + +@router.post("/collections/{collection_id}/sprints") +async def create_sprint(request: Request, collection_id: int): + """Create a new sprint.""" + body = await request.json() if request.headers.get("content-type") else {} + name = body.get("name", "").strip() + start_date = body.get("start_date", "") + end_date = body.get("end_date", "") + if not name or not start_date or not end_date: + raise HTTPException(400, "name, start_date, end_date are required") + + goal = body.get("goal", "") + status = body.get("status", "planning") + auto_complete = int(body.get("auto_complete", 1)) + + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO sprints (collection_id, name, start_date, end_date, goal, status, auto_complete) VALUES (?,?,?,?,?,?,?)", + (collection_id, name, start_date, end_date, goal, status, auto_complete), + ) + conn.commit() + return {"id": cur.lastrowid, "name": name, "status": "created"} + + +@router.put("/collections/{collection_id}/sprints/{sid}") +async def update_sprint(request: Request, collection_id: int, sid: int): + """Update a sprint.""" + body = await request.json() if request.headers.get("content-type") else {} + with get_conn() as conn: + sprint = conn.execute( + "SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id) + ).fetchone() + if not sprint: + raise HTTPException(404, "Sprint not found") + + name = body.get("name", sprint["name"]) + start_date = body.get("start_date", sprint["start_date"]) + end_date = body.get("end_date", sprint["end_date"]) + goal = body.get("goal", sprint["goal"]) + status = body.get("status", sprint["status"]) + auto_complete = int(body.get("auto_complete", sprint["auto_complete"])) + + conn.execute( + "UPDATE sprints SET name=?, start_date=?, end_date=?, goal=?, status=?, auto_complete=? WHERE id=?", + (name, start_date, end_date, goal, status, auto_complete, sid), + ) + conn.commit() + return {"id": sid, "status": "updated"} + + +@router.delete("/collections/{collection_id}/sprints/{sid}") +async def delete_sprint(request: Request, collection_id: int, sid: int): + """Delete a sprint.""" + with get_conn() as conn: + sprint = conn.execute( + "SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id) + ).fetchone() + if not sprint: + raise HTTPException(404, "Sprint not found") + conn.execute("DELETE FROM sprints WHERE id=?", (sid,)) + conn.commit() + return {"id": sid, "status": "deleted"} + + +@router.post("/collections/{collection_id}/sprints/{sid}/assign") +async def assign_page_to_sprint(request: Request, collection_id: int, sid: int): + """Assign a page to a sprint.""" + body = await request.json() if request.headers.get("content-type") else {} + page_id = body.get("page_id") + if not page_id: + raise HTTPException(400, "page_id is required") + + velocity_points = body.get("velocity_points", 1) + status_at_start = body.get("status_at_start", "") + + with get_conn() as conn: + sprint = conn.execute("SELECT id FROM sprints WHERE id=?", (sid,)).fetchone() + if not sprint: + raise HTTPException(404, "Sprint not found") + page = conn.execute("SELECT id FROM collection_pages WHERE id=?", (page_id,)).fetchone() + if not page: + raise HTTPException(404, "Page not found") + + try: + cur = conn.execute( + "INSERT INTO sprint_pages (sprint_id, page_id, status_at_start, velocity_points) VALUES (?,?,?,?)", + (sid, page_id, status_at_start, velocity_points), + ) + conn.commit() + except sqlite3.IntegrityError: + raise HTTPException(409, "Page already assigned to this sprint") + return {"sprint_id": sid, "page_id": page_id, "status": "assigned"} + + +@router.delete("/collections/{collection_id}/sprints/{sid}/assign/{page_id}") +async def remove_page_from_sprint(request: Request, collection_id: int, sid: int, page_id: int): + """Remove a page from a sprint.""" + with get_conn() as conn: + existing = conn.execute( + "SELECT * FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id) + ).fetchone() + if not existing: + raise HTTPException(404, "Assignment not found") + conn.execute("DELETE FROM sprint_pages WHERE sprint_id=? AND page_id=?", (sid, page_id)) + conn.commit() + return {"sprint_id": sid, "page_id": page_id, "status": "removed"} + + +@router.get("/collections/{collection_id}/sprints/burndown/{sid}") +async def sprint_burndown(request: Request, collection_id: int, sid: int): + """Calculate burndown data for a sprint.""" + with get_conn() as conn: + sprint = conn.execute( + "SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id) + ).fetchone() + if not sprint: + raise HTTPException(404, "Sprint not found") + + pages = conn.execute( + """SELECT sp.velocity_points, cp.property_values_json + FROM sprint_pages sp JOIN collection_pages cp ON sp.page_id=cp.id + WHERE sp.sprint_id=?""", (sid,) + ).fetchall() + + total_points = sum(p["velocity_points"] for p in pages) + completed = 0 + for p in pages: + props = json.loads(p["property_values_json"]) + for v in props.values(): + if isinstance(v, str) and v.lower() in ("done", "complete", "completed", "terminé"): + completed += p["velocity_points"] + break + + from datetime import date, timedelta + today = date.today() + start = date.fromisoformat(sprint["start_date"]) if sprint["start_date"] else today + end = date.fromisoformat(sprint["end_date"]) if sprint["end_date"] else today + total_days = max((end - start).days, 1) + elapsed = max((today - start).days, 0) + ideal_burn = total_points - (total_points * elapsed / total_days) + + return { + "sprint": sprint["name"], + "total_points": total_points, + "completed_points": completed, + "remaining_points": total_points - completed, + "ideal_remaining": round(ideal_burn, 1), + "start_date": sprint["start_date"], + "end_date": sprint["end_date"], + "days_elapsed": elapsed, + "days_total": total_days, + } + + # ── CSV Import/Export ── @router.post("/collections/{collection_id}/import/csv") diff --git a/tests/test_app.py b/tests/test_app.py index 666ed7b..402a469 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -812,7 +812,7 @@ def test_my_tasks_page(client): assert "My Tasks" in resp.text -def test_my_tasks_api(client): +def test_my_tasks_cross_db(client): """My Tasks API returns JSON.""" r = client.post("/db/api", json={"name": "My Project"}) cid = r.json()["id"] @@ -2568,4 +2568,142 @@ def test_auto_shift_no_blockers(client): resp = client.post(f"/db/{coll_id}/pages/{p.json()['id']}/auto-shift/api") assert resp.status_code == 200 - assert resp.json()["shifted"] is False \ No newline at end of file + assert resp.json()["shifted"] is False + + +# ── v4.5.0: Sprints ── + + +def test_sprint_crud(client): + """Full CRUD lifecycle for sprints.""" + resp = client.post("/db/api", json={"name": "Sprint DB"}) + coll_id = resp.json()["id"] + + # Create + resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={ + "name": "Sprint 1", + "start_date": "2026-07-01", + "end_date": "2026-07-14", + "goal": "Ship MVP", + "status": "active", + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "created" + sid = resp.json()["id"] + + # List + resp = client.get(f"/workspace/collections/{coll_id}/sprints") + assert len(resp.json()["sprints"]) == 1 + assert resp.json()["sprints"][0]["name"] == "Sprint 1" + + # Update + resp = client.put(f"/workspace/collections/{coll_id}/sprints/{sid}", json={ + "name": "Sprint 1 - Revised", + "status": "completed", + }) + assert resp.status_code == 200 + + resp = client.get(f"/workspace/collections/{coll_id}/sprints") + assert resp.json()["sprints"][0]["name"] == "Sprint 1 - Revised" + + # Delete + resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}") + assert resp.status_code == 200 + assert resp.json()["status"] == "deleted" + + +def test_sprint_assign_page(client): + """Assign a page to a sprint and remove it.""" + resp = client.post("/db/api", json={"name": "Assign DB"}) + coll_id = resp.json()["id"] + + # Create sprint + resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={ + "name": "Sprint A", + "start_date": "2026-08-01", + "end_date": "2026-08-14", + }) + sid = resp.json()["id"] + + # Create page + resp = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task X"}) + pid = resp.json()["id"] + + # Assign + resp = client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={ + "page_id": pid, + "velocity_points": 5, + }) + assert resp.status_code == 200 + assert resp.json()["status"] == "assigned" + + # Verify page count + resp = client.get(f"/workspace/collections/{coll_id}/sprints") + assert resp.json()["sprints"][0]["page_count"] == 1 + + # Remove + resp = client.delete(f"/workspace/collections/{coll_id}/sprints/{sid}/assign/{pid}") + assert resp.status_code == 200 + assert resp.json()["status"] == "removed" + + +def test_sprint_burndown(client): + """Burndown chart data calculation.""" + resp = client.post("/db/api", json={"name": "Burndown DB"}) + coll_id = resp.json()["id"] + + resp = client.post(f"/workspace/collections/{coll_id}/sprints", json={ + "name": "Sprint B", + "start_date": "2026-07-01", + "end_date": "2026-07-14", + }) + sid = resp.json()["id"] + + # Add pages with different statuses + p1 = client.post(f"/db/{coll_id}/pages/api", json={ + "title": "Done Task", + "properties": {"Status": "Done"}, + }) + p2 = client.post(f"/db/{coll_id}/pages/api", json={ + "title": "In Progress Task", + "properties": {"Status": "In Progress"}, + }) + + client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={ + "page_id": p1.json()["id"], "velocity_points": 3, + }) + client.post(f"/workspace/collections/{coll_id}/sprints/{sid}/assign", json={ + "page_id": p2.json()["id"], "velocity_points": 5, + }) + + resp = client.get(f"/workspace/collections/{coll_id}/sprints/burndown/{sid}") + assert resp.status_code == 200 + data = resp.json() + assert data["total_points"] == 8 + assert data["completed_points"] == 3 + assert data["remaining_points"] == 5 + + +def test_my_tasks_page_render(client): + """My Tasks page renders cross-database aggregation.""" + # Create a task collection + resp = client.post("/db/api", json={"name": "My Tasks DB"}) + coll_id = resp.json()["id"] + + # Toggle to task mode + client.put(f"/db/{coll_id}/toggle-task/api") + + # Add pages + client.post(f"/db/{coll_id}/pages/api", json={ + "title": "Urgent fix", + "properties": {"Status": "Todo"}, + }) + client.post(f"/db/{coll_id}/pages/api", json={ + "title": "Deploy", + "properties": {"Status": "Done"}, + }) + + resp = client.get("/my-tasks?view=all") + assert resp.status_code == 200 + assert "Urgent fix" in resp.text + assert "My Tasks DB" in resp.text \ No newline at end of file