feat(v4.5.0): Sprints & My Tasks
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped

- 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)
This commit is contained in:
2026-07-21 22:28:30 -04:00
parent 3c5eac3429
commit 44bf469c53
3 changed files with 341 additions and 2 deletions
+26
View File
@@ -536,6 +536,32 @@ def init_db():
""") """)
conn.commit() 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 @contextmanager
def get_conn(): def get_conn():
+175
View File
@@ -5,6 +5,7 @@ import csv
import io import io
import json import json
import logging import logging
import sqlite3
from fastapi import APIRouter, Request, HTTPException from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse, StreamingResponse 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"} 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 ── # ── CSV Import/Export ──
@router.post("/collections/{collection_id}/import/csv") @router.post("/collections/{collection_id}/import/csv")
+139 -1
View File
@@ -812,7 +812,7 @@ def test_my_tasks_page(client):
assert "My Tasks" in resp.text assert "My Tasks" in resp.text
def test_my_tasks_api(client): def test_my_tasks_cross_db(client):
"""My Tasks API returns JSON.""" """My Tasks API returns JSON."""
r = client.post("/db/api", json={"name": "My Project"}) r = client.post("/db/api", json={"name": "My Project"})
cid = r.json()["id"] cid = r.json()["id"]
@@ -2569,3 +2569,141 @@ def test_auto_shift_no_blockers(client):
resp = client.post(f"/db/{coll_id}/pages/{p.json()['id']}/auto-shift/api") resp = client.post(f"/db/{coll_id}/pages/{p.json()['id']}/auto-shift/api")
assert resp.status_code == 200 assert resp.status_code == 200
assert resp.json()["shifted"] is False 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