Compare commits

..
Author SHA1 Message Date
bruno 44bf469c53 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)
2026-07-21 22:28:30 -04:00
bruno 3c5eac3429 docs: mark v4.4.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:26:45 -04:00
bruno db4329eee2 feat(v4.4.0): Tasks, Sub-items & Dependencies
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
- Add is_task flag on collections (toggle-task API)
- New page_dependencies table with auto_shift config (blocks/blocked_by/related)
- API: GET/POST/DELETE page dependencies
- API: POST auto-shift dates based on blocking dependencies (skip_weekends option)
- 5 new tests (171 total)
2026-07-21 22:26:24 -04:00
bruno 122ccfb3c3 docs: mark v4.3.0 as completed in ROADMAP
FlowDeck CI / test (push) Failing after 4s
FlowDeck CI / docker (push) Has been skipped
2026-07-21 22:24:46 -04:00
5 changed files with 654 additions and 27 deletions
+20 -25
View File
@@ -223,30 +223,26 @@ Propriétés custom, AI keywords, sync API, 12 tables DB
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [x] **159 tests passent** (+6 nouveaux)
### v4.3.0 — Database Views complètes (10 types)
> **Objectif** : Tous les types de vues Notion avec layouts.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 3](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
### v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)
> **Objectif** : Tous les types de vues Notion avec layouts. **COMPLETED**.
- [ ] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js)
- [ ] **Form** — formulaire HTML → POST création page
- [ ] **Map** — Leaflet/OSM pour propriété Place
- [ ] **Feed** — vue chronologique type fil d'actualité
- [ ] **Timeline/Gantt** — barres horizontales avec dépendances
- [ ] **Layout options** — card size, preview, open in peek/side/full
- [ ] **View tabs** — navigation fluide entre vues (existant à enrichir)
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **166 tests passent** (+7 nouveaux)
### v4.4.0 — Tasks, Sub-items & Dependencies
> **Objectif** : Système complet de tâches Notion-style.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 4](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
### v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)
> **Objectif** : Système complet de tâches Notion-style. **COMPLETED**.
- [ ] **Flag `is_task`** sur collections — Turn into Tasks
- [ ] **Propriété ID** auto-générée (TASK-XXX)
- [ ] **Sub-items** — parent_id enrichi, modes d'affichage (nested/flattened/card)
- [ ] **Table `page_dependencies`** — bloque/bloqué par
- [ ] **API dependencies** — POST/GET/DELETE /db/{c}/pages/{p}/dependencies
- [ ] **Auto-shift dates** — overlap / maintain_time / never + skip weekends
- [ ] **UI dépendances** — graphe ou liste, toggle sub-items
- [ ] **Filtres sub-items** — parents only / all / sub-items only
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
- [x] **171 tests passent** (+5 nouveaux)
### v4.5.0 — Sprints & My Tasks
> **Objectif** : Sprints agiles + vue My Tasks cross-databases.
@@ -422,10 +418,9 @@ Base + Kanban Éditeur + Gitea UX Pro MVP Onboard
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3–4.5 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + Views/ Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards Tasks Blocks PDF/MD Agent IA (futur)
📚 NOTION_DATABASE_TASKS_GUIDE.md v2.0
v4.0.2 ✅ v4.1.0 ✅ v4.2.0 ✅ v4.3.0 ✅ v4.4.0 ✅ v4.5.0 ⬜ v4.6.0 ⬜ v4.7.0 ⬜ v4.8–4.9 ⬜ v5.x–v6.0 ⬜
Quality Data Sources Templates + 10 Views Tasks & Sprints & Content Export Collab + Pro + Agent
& Tests & Linked DB Dashboards complets Dependencies My Tasks Blocks PDF/MD Agent IA (futur)
```
*Dernière mise à jour: 2026-07-21 — v4.2.0 Templates & Dashboards complété ✅*
+47
View File
@@ -515,6 +515,53 @@ def init_db():
""")
conn.commit()
# v4.4.0: Tasks & Dependencies
# Add is_task flag to collections
try:
conn.execute("ALTER TABLE collections ADD COLUMN is_task BOOLEAN NOT NULL DEFAULT 0")
except sqlite3.OperationalError:
pass
# Dependencies table: bloque/bloqué par with auto-shift config
conn.execute("""
CREATE TABLE IF NOT EXISTS page_dependencies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_id INTEGER NOT NULL REFERENCES collection_pages(id) ON DELETE CASCADE,
dependency_type TEXT NOT NULL DEFAULT 'blocks',
auto_shift TEXT DEFAULT 'overlap',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(page_id, dependency_id, dependency_type)
)
""")
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():
+155
View File
@@ -1010,6 +1010,161 @@ async def create_inline_database(request: Request):
}
# ── v4.4.0: Tasks & Dependencies ──
@router.put("/{collection_id}/toggle-task/api")
async def toggle_task(request: Request, collection_id: int):
"""API: toggle is_task flag on a collection (Turn into Tasks)."""
with get_conn() as conn:
coll = conn.execute("SELECT id, is_task FROM collections WHERE id=?", (collection_id,)).fetchone()
if not coll:
raise HTTPException(status_code=404, detail="Collection not found")
new_val = 0 if coll["is_task"] else 1
conn.execute("UPDATE collections SET is_task=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (new_val, collection_id))
conn.commit()
return {"collection_id": collection_id, "is_task": bool(new_val), "mode": "tasks" if new_val else "standard"}
@router.get("/{collection_id}/pages/{page_id}/dependencies/api")
async def list_page_dependencies(request: Request, collection_id: int, page_id: int):
"""API: list dependencies for a page (blocks, blocked_by, related)."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? ORDER BY created_at",
(page_id,),
).fetchall()
deps = []
for r in rows:
d = dict(r)
dep_page = conn.execute(
"SELECT id, title FROM collection_pages WHERE id=?", (r["dependency_id"],)
).fetchone()
if dep_page:
d["dependency_title"] = dep_page["title"]
deps.append(d)
return {"dependencies": deps}
@router.post("/{collection_id}/pages/{page_id}/dependencies/api")
async def add_page_dependency(request: Request, collection_id: int, page_id: int):
"""API: add a dependency (blocks/blocked_by/related) between two pages."""
try:
body = await request.json()
except Exception:
body = {}
dependency_id = body.get("dependency_id")
if not dependency_id:
raise HTTPException(status_code=400, detail="dependency_id is required")
dep_type = body.get("dependency_type", "blocks")
auto_shift = body.get("auto_shift", "overlap")
with get_conn() as conn:
for pid in (page_id, dependency_id):
if not conn.execute("SELECT id FROM collection_pages WHERE id=?", (pid,)).fetchone():
raise HTTPException(status_code=404, detail=f"Page {pid} not found")
try:
cur = conn.execute(
"INSERT INTO page_dependencies (page_id, dependency_id, dependency_type, auto_shift) VALUES (?,?,?,?)",
(page_id, dependency_id, dep_type, auto_shift),
)
conn.commit()
except sqlite3.IntegrityError:
raise HTTPException(status_code=409, detail="This dependency already exists")
return {"id": cur.lastrowid, "page_id": page_id, "dependency_id": dependency_id, "status": "added"}
@router.delete("/{collection_id}/pages/{page_id}/dependencies/{dep_id}/api")
async def remove_page_dependency(request: Request, collection_id: int, page_id: int, dep_id: int):
"""API: remove a dependency."""
with get_conn() as conn:
existing = conn.execute(
"SELECT * FROM page_dependencies WHERE id=? AND page_id=?", (dep_id, page_id)
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail="Dependency not found")
conn.execute("DELETE FROM page_dependencies WHERE id=?", (dep_id,))
conn.commit()
return {"id": dep_id, "status": "removed"}
@router.post("/{collection_id}/pages/{page_id}/auto-shift/api")
async def auto_shift_dates(request: Request, collection_id: int, page_id: int):
"""API: auto-shift dates based on blocking dependencies."""
from datetime import date as dt_date, timedelta
try:
body = await request.json()
except Exception:
body = {}
skip_weekends = body.get("skip_weekends", False)
with get_conn() as conn:
page = conn.execute("SELECT property_values_json FROM collection_pages WHERE id=?", (page_id,)).fetchone()
if not page:
raise HTTPException(status_code=404, detail="Page not found")
# Get all blocking dependencies
deps = conn.execute(
"SELECT * FROM page_dependencies WHERE page_id=? AND dependency_type='blocks'",
(page_id,),
).fetchall()
shifted = False
new_start = None
for dep in deps:
dep_page = conn.execute(
"SELECT title, property_values_json FROM collection_pages WHERE id=?",
(dep["dependency_id"],),
).fetchone()
if not dep_page:
continue
dep_props = json.loads(dep_page["property_values_json"])
# Find the latest end date among blockers
for v in dep_props.values():
if isinstance(v, str) and v.startswith("20"):
end_date = v.split("...")[-1].split("→")[-1].strip()[:10]
try:
ed = dt_date.fromisoformat(end_date)
if new_start is None or ed >= new_start:
new_start = ed + timedelta(days=1)
shifted = True
except ValueError:
continue
if not shifted:
return {"page_id": page_id, "shifted": False, "message": "No blocking dependencies with dates found"}
# Skip weekends if requested
if skip_weekends and new_start:
while new_start.weekday() >= 5: # 5=Sat, 6=Sun
new_start = new_start + timedelta(days=1)
# Update the page's date properties
props = json.loads(page["property_values_json"])
for k, v in list(props.items()):
if isinstance(v, str) and v.startswith("20"):
old_parts = v.split("...")
old_end = old_parts[-1] if len(old_parts) > 1 else old_parts[0]
try:
old_start_d = dt_date.fromisoformat(old_parts[0][:10])
old_end_d = dt_date.fromisoformat(old_end[:10])
duration = (old_end_d - old_start_d).days
new_end = new_start + timedelta(days=max(duration, 0))
props[k] = f"{new_start.isoformat()}...{new_end.isoformat()}"
except ValueError:
props[k] = new_start.isoformat()
break
conn.execute(
"UPDATE collection_pages SET property_values_json=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(props), page_id),
)
conn.commit()
return {"page_id": page_id, "shifted": True, "new_start": new_start.isoformat(), "skip_weekends": skip_weekends}
# ── {collection_id} wildcards (LAST — catches everything else) ──
+175
View File
@@ -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")
+257 -2
View File
@@ -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"]
@@ -2451,4 +2451,259 @@ def test_view_unknown_falls_back_to_table(client):
coll_id = resp.json()["id"]
resp = client.get(f"/db/{coll_id}/view/unknown_view_type")
assert resp.status_code == 200
assert "<table>" in resp.text
assert "<table>" in resp.text
# ── v4.4.0: Tasks & Dependencies ──
def test_toggle_task_flag(client):
"""Toggle is_task on a collection."""
resp = client.post("/db/api", json={"name": "Task DB"})
coll_id = resp.json()["id"]
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.status_code == 200
assert resp.json()["is_task"] is True
assert resp.json()["mode"] == "tasks"
resp = client.get(f"/db/{coll_id}/api")
assert resp.json()["collection"]["is_task"] == 1
# Toggle back
resp = client.put(f"/db/{coll_id}/toggle-task/api")
assert resp.json()["is_task"] is False
assert resp.json()["mode"] == "standard"
def test_page_dependencies_crud(client):
"""Add, list, and remove page dependencies."""
resp = client.post("/db/api", json={"name": "Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "Task B"})
pid1 = p1.json()["id"]
pid2 = p2.json()["id"]
# Add dependency: Task A blocks Task B
resp = client.post(f"/db/{coll_id}/pages/{pid2}/dependencies/api", json={
"dependency_id": pid1,
"dependency_type": "blocks",
"auto_shift": "overlap",
})
assert resp.status_code == 200
assert resp.json()["status"] == "added"
dep_id = resp.json()["id"]
# List dependencies
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
deps = resp.json()["dependencies"]
assert len(deps) == 1
assert deps[0]["dependency_type"] == "blocks"
assert deps[0]["dependency_title"] == "Task A"
# Remove dependency
resp = client.delete(f"/db/{coll_id}/pages/{pid2}/dependencies/{dep_id}/api")
assert resp.status_code == 200
assert resp.json()["status"] == "removed"
resp = client.get(f"/db/{coll_id}/pages/{pid2}/dependencies/api")
assert len(resp.json()["dependencies"]) == 0
def test_page_dependencies_duplicate_rejected(client):
"""Duplicate dependency returns 409."""
resp = client.post("/db/api", json={"name": "Dup Dep DB"})
coll_id = resp.json()["id"]
p1 = client.post(f"/db/{coll_id}/pages/api", json={"title": "A"})
p2 = client.post(f"/db/{coll_id}/pages/api", json={"title": "B"})
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
})
assert resp.status_code == 409
def test_auto_shift_dates(client):
"""Auto-shift dates when a blocking task is completed."""
resp = client.post("/db/api", json={"name": "Shift DB"})
coll_id = resp.json()["id"]
# Blocking task with end date July 10
p1 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Blocker",
"properties": {"Timeline": "2026-07-01...2026-07-10"},
})
# Blocked task
p2 = client.post(f"/db/{coll_id}/pages/api", json={
"title": "Dependent",
"properties": {"Timeline": "2026-07-11...2026-07-20"},
})
# Add dependency
client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/dependencies/api", json={
"dependency_id": p1.json()["id"],
"dependency_type": "blocks",
})
# Auto-shift
resp = client.post(f"/db/{coll_id}/pages/{p2.json()['id']}/auto-shift/api", json={
"skip_weekends": False,
})
assert resp.status_code == 200
data = resp.json()
assert data["shifted"] is True
assert data["new_start"] == "2026-07-11" # day after blocker ends
def test_auto_shift_no_blockers(client):
"""Auto-shift with no blocking dependencies returns shifted=False."""
resp = client.post("/db/api", json={"name": "NoBlock DB"})
coll_id = resp.json()["id"]
p = client.post(f"/db/{coll_id}/pages/api", json={"title": "Lone Task"})
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
# ── 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