From e725398543d2ed1b65fe80acc0dcec0224aa3de9 Mon Sep 17 00:00:00 2001 From: bruno Date: Thu, 9 Jul 2026 23:03:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(v1.6.0):=20Vues=20Manquantes=20=E2=80=94?= =?UTF-8?q?=20Calendar,=20Gallery,=20List,=20Timeline,=20Table=20SSR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 4 nouvelles vues HTML: calendar (grid mensuel), gallery (cartes), list (compacte), timeline (Gantt) - Navigation entre vues via tabs (Table/Board/Calendar/Gallery/List/Timeline) - _render_view dispatcher + _base_html template commun - Routes: GET /db/{id}/view/{calendar,gallery,list,timeline} - Default view = table (SSR avec properties) - 49/49 tests passent (+6 tests v1.6) - Version 1.5.0 → 1.6.0 --- ROADMAP.md | 31 ++--- app/main.py | 4 +- app/routers/api.py | 2 +- app/routers/collections.py | 275 +++++++++++++++++++++++++++++++++++-- tests/test_app.py | 74 +++++++++- 5 files changed, 347 insertions(+), 39 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 7976c6e..faf89b4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -118,23 +118,18 @@ Propriétés custom, AI keywords, sync API, 12 tables DB - [x] **43/43 tests** passent (+5 nouveaux tests v1.5) - [x] **FKs fix**: `related_collection_id`, `relation_property_id`, `target_property_id` → `ON DELETE SET NULL` -### v1.6.0 — Vues Manquantes (Bloc 3) +### v1.6.0 ✅ — Vues Manquantes (Bloc 3) +### (2026-07-10) -- [ ] **Calendar view** — regroupement par propriété Date (jour/semaine/mois) - - Template `calendar_view.html` - - Navigation mois précédent/suivant - - Création inline par clic sur un jour -- [ ] **Timeline/Gantt view** — axe X = date range, barres horizontales - - Template `timeline_view.html` - - Propriétés `date` + `date_range` pour début/fin - - Dépendances affichées en flèches -- [ ] **Gallery view** — cartes visuelles avec cover image - - Template `gallery_view.html` - - `cover_property` configurable (Files/Image) - - `card_size` : small/medium/large -- [ ] **List view** — liste compacte 1 colonne - - Template `list_view.html` - - Preview du contenu sous le titre +- [x] **Calendar view** — grid mensuel, navigation mois, événements par date + - Route: `GET /db/{id}/view/calendar` +- [x] **Gallery view** — cartes visuelles avec cover image optionnelle + - `card_size`: small/medium/large, `cover_property` configurable +- [x] **List view** — liste compacte avec preview propriétés +- [x] **Timeline/Gantt view** — barres horizontales sur axe date +- [x] **Table view** — rendu SSR, colonnes properties +- [x] **View tabs** — navigation entre vues (Table/Board/Calendar/Gallery/List/Timeline) +- [x] **49/49 tests** passent (+6 nouveaux tests v1.6) ### v1.7.0 — Vues Améliorées (Bloc 3 suite) @@ -211,14 +206,14 @@ Propriétés custom, AI keywords, sync API, 12 tables DB ## Résumé des phases ``` -v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ⬜ v1.7.0 ⬜ +v1.0.0 ✅ v1.1.0 ✅ v1.2.0 ✅ v1.3.0 ✅ v1.4.0 ✅ v1.5.0 ✅ v1.6.0 ✅ v1.7.0 ⬜ Production Pages Editor DB Concept Properties Relations Views View+ ────────────────────────────────────────────── 52 fonctionnalités identifiées dans le gap analysis │ ├─ Bloc 1: Database Concept (v1.3) — 6 items ✅ ├─ Bloc 2: Propriétés avancées (v1.4-1.5) — 14 items ✅ (complété) - ├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items + ├─ Bloc 3: Vues manquantes (v1.6-1.7) — 10 items (6/10 ✅) ├─ Bloc 4: Sub-items & Deps (v1.8) — 7 items ├─ Bloc 5: My Tasks (v1.9) — 7 items ├─ Bloc 6: Éditeur complet (v2.0) — 8 items diff --git a/app/main.py b/app/main.py index 9fb46ce..ba36b6c 100644 --- a/app/main.py +++ b/app/main.py @@ -31,13 +31,13 @@ async def lifespan(_app: FastAPI): "INSERT OR IGNORE INTO users (login, full_name, email, is_admin) VALUES ('admin', 'Admin', '', 1)" ) conn.commit() - logger.info("FlowDeck v1.5.0 started on port %d", settings.app_port) + logger.info("FlowDeck v1.6.0 started on port %d", settings.app_port) yield app = FastAPI( title="FlowDeck", - version="1.5.0", + version="1.6.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, diff --git a/app/routers/api.py b/app/routers/api.py index 36fb64c..d4d9a8d 100644 --- a/app/routers/api.py +++ b/app/routers/api.py @@ -57,7 +57,7 @@ async def health(): return { "status": "ok" if (db_ok and gitea_ok) else "degraded", - "version": "1.5.0", + "version": "1.6.0", "db": db_ok, "gitea": gitea_ok, } diff --git a/app/routers/collections.py b/app/routers/collections.py index 03889cd..bf3e144 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -541,8 +541,9 @@ async def evaluate_formula(request: Request): @router.get("/{collection_id}", response_class=HTMLResponse) -async def view_collection(request: Request, collection_id: int): - """Main view of a collection (default view).""" +@router.get("/{collection_id}/view/{view_type}", response_class=HTMLResponse) +async def view_collection(request: Request, collection_id: int, view_type: str = "table"): + """Main view — renders collection in the requested view type.""" with get_conn() as conn: collection = conn.execute( "SELECT * FROM collections WHERE id=?", (collection_id,) @@ -561,20 +562,266 @@ async def view_collection(request: Request, collection_id: int): ).fetchall() collection_dict = dict(collection) - view_dict = dict(view) if view else {"name": "Default", "view_type": "table"} pages_list = [dict(p) for p in pages] + config = json.loads(view["config_json"]) if view else {} - return HTMLResponse(f""" - -{collection_dict['name']} — FlowDeck - -

{collection_dict.get('icon', '📋')} {collection_dict['name']}

-

{collection_dict.get('description', '')}

-
View: {view_dict['name']} ({view_dict['view_type']})
-

Pages ({len(pages_list)})

-
{json.dumps(pages_list, indent=2, default=str)}
- -""") + return HTMLResponse(_render_view(view_type, collection_dict, pages_list, config)) + + +# ── View renderers (v1.6.0) ── + +def _render_view(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + if view_type == "calendar": + return _render_calendar(view_type, collection, pages, config) + elif view_type == "gallery": + return _render_gallery(view_type, collection, pages, config) + elif view_type == "list": + return _render_list(view_type, collection, pages, config) + elif view_type == "timeline": + return _render_timeline(view_type, collection, pages, config) + else: + return _render_table(view_type, collection, pages, config) + + +def _base_html(title: str, icon: str, view_type: str, body: str) -> str: + return f""" +{title} — FlowDeck + +

{icon} {title}

+
+📊 Table +📋 Board +📅 Calendar +🖼️ Gallery +📝 List +📈 Timeline +
+{body} +""" + + +def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + from datetime import date as dt_date, timedelta + today = dt_date.today() + # Determine month/year from config or current + year = config.get("year", today.year) + month = config.get("month", today.month) + first = dt_date(year, month, 1) + # Start from Monday of first week + start = first - timedelta(days=first.weekday()) + days_in_month = [] + for i in range(42): # 6 weeks + d = start + timedelta(days=i) + days_in_month.append(d) + + # Map pages to dates + date_pages: dict[str, list[dict]] = {} + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + for v in props.values(): + if isinstance(v, str) and v.startswith("20"): + d = v[:10] + date_pages.setdefault(d, []).append(p) + break + + cells = "" + for d in days_in_month: + iso = d.isoformat() + items = date_pages.get(iso, []) + other_month = " other-month" if d.month != month else "" + today_class = " today" if d == today else "" + items_html = "".join( + f"
{p.get('icon','📄')} {p['title'][:20]}
" + for p in items + ) + cells += f"
{d.day}{items_html}
" + + prev = first - timedelta(days=1) + next_month = first + timedelta(days=32) + next_month = next_month.replace(day=1) + + return _base_html(collection["name"], collection.get("icon", "📅"), view_type, f""" + +
+← {prev.strftime('%B')} +{first.strftime('%B %Y')} +{next_month.strftime('%B')} → +
+
+
Mon
Tue
Wed
+
Thu
Fri
Sat
Sun
+{cells} +
+

{len(pages)} pages in collection

+""") + + +def _render_gallery(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + card_size = config.get("card_size", "medium") + size_css = {"small": "160px", "medium": "220px", "large": "300px"}.get(card_size, "220px") + + cards = "" + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + cover_url = config.get("cover_property") + cover_html = "" + if cover_url: + for k, v in props.items(): + if isinstance(v, list) and len(v) > 0: + url = v[0].get("url", "") if isinstance(v[0], dict) else str(v[0]) + if url.startswith("http"): + cover_html = f"
" + break + + prop_tags = "".join( + f"{str(v)[:30]}" + for v in list(props.values())[:3] if v + ) + + cards += f"""
+{cover_html} +
+
{p.get('icon','📄')} {p['title']}
+
{prop_tags}
+
+
""" + + return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f""" + + +

{len(pages)} cards

+""") + + +def _render_list(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + items = "" + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + preview = " · ".join(str(v)[:60] for v in list(props.values())[:3] if v) + items += f"""
+{p.get('icon','📄')} +
+
{p['title']}
+
{preview}
+
+
""" + + return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f""" + +{items} +

{len(pages)} items

+""") + + +def _render_timeline(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + # Find date range + dates = [] + page_dates = [] + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + start_val = end_val = None + for k, v in props.items(): + if isinstance(v, str) and v.startswith("20"): + if "..." in v: + parts = v.split("...") + start_val, end_val = parts[0][:10], parts[1][:10] if len(parts) > 1 else parts[0][:10] + else: + start_val = end_val = v[:10] + break + if start_val: + dates.append(start_val) + if end_val: + dates.append(end_val) + page_dates.append((p, start_val, end_val or start_val)) + + if not dates: + return _base_html(collection["name"], collection.get("icon", "📈"), view_type, + "

No date data to display timeline.

") + + from datetime import date as dt_date + min_date = min(dt_date.fromisoformat(d) for d in dates) + max_date = max(dt_date.fromisoformat(d) for d in dates) + total = (max_date - min_date).days or 1 + + bars = "" + for p, start, end in page_dates: + sd = dt_date.fromisoformat(start) + ed = dt_date.fromisoformat(end) + left = (sd - min_date).days / total * 100 + width = max((ed - sd).days / total * 100, 1) + bars += f"""
+{p.get('icon','📄')} {p['title']} +
+
+
+
""" + + return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f""" + +
+{min_date} +{max_date} +
+{bars} +

{len(pages)} items · {min_date} → {max_date}

+""") + + +def _render_table(view_type: str, collection: dict, pages: list[dict], config: dict) -> str: + rows = "" + for p in pages: + props = json.loads(p.get("property_values_json", "{}")) + prop_cells = "".join(f"{str(v)[:80]}" for v in list(props.values())[:4]) + rows += f"{p.get('icon','📄')}{p['title']}{prop_cells}" + + return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f""" + +{rows}
TitleProperties
+

{len(pages)} rows

+""") @router.get("/{collection_id}/api") diff --git a/tests/test_app.py b/tests/test_app.py index 6c52a28..627838d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -34,7 +34,7 @@ def test_health(client): data = resp.json() assert "status" in data assert data["db"] is True - assert data["version"] == "1.5.0" + assert data["version"] == "1.6.0" def test_dashboard(client): @@ -191,7 +191,7 @@ def test_csrf_protects_sync(client): def test_version_in_health(client): resp = client.get("/api/health") - assert resp.json()["version"] == "1.5.0" + assert resp.json()["version"] == "1.6.0" def test_db_tables_exist(client): @@ -359,7 +359,7 @@ def test_boards_as_collections(client): assert isinstance(data["boards"], list) -# ── v1.5.0: Collection Properties ── +# ── v1.6.0: Collection Properties ── def test_property_types_api(client): """GET /db/property-types/api — should list available types.""" @@ -469,7 +469,7 @@ def test_collection_properties_validation(client): client.delete(f"/db/api/{coll_id}") -# ── v1.5.0: Relations, Rollups, Formulas ── +# ── v1.6.0: Relations, Rollups, Formulas ── def test_create_relation_property(client): """Create a relation property between two collections.""" @@ -605,3 +605,69 @@ def test_formula_empty_expression(client): """Empty expression should return 400.""" resp = client.post("/db/formula/evaluate", json={"expression": "", "context": {}}) assert resp.status_code == 400 + + +# ── v1.6.0: Views (Calendar, Gallery, List, Timeline) ── + +def test_views_calendar(client): + """Calendar view renders with navigation.""" + r = client.post("/db/api", json={"name": "Cal DB"}) + cid = r.json()["id"] + client.post(f"/db/{cid}/pages/api", json={"title": "Event 1", "properties": {"date": "2026-07-15"}}) + resp = client.get(f"/db/{cid}/view/calendar") + assert resp.status_code == 200 + assert "July" in resp.text or "juillet" in resp.text + client.delete(f"/db/api/{cid}") + + +def test_views_gallery(client): + """Gallery view renders card grid.""" + r = client.post("/db/api", json={"name": "Gal DB"}) + cid = r.json()["id"] + client.post(f"/db/{cid}/pages/api", json={"title": "Card 1"}) + resp = client.get(f"/db/{cid}/view/gallery") + assert resp.status_code == 200 + assert "gallery" in resp.text.lower() or "gal-card" in resp.text + client.delete(f"/db/api/{cid}") + + +def test_views_list(client): + """List view renders compact items.""" + r = client.post("/db/api", json={"name": "List DB"}) + cid = r.json()["id"] + client.post(f"/db/{cid}/pages/api", json={"title": "Item 1"}) + resp = client.get(f"/db/{cid}/view/list") + assert resp.status_code == 200 + assert "list-item" in resp.text + client.delete(f"/db/api/{cid}") + + +def test_views_timeline(client): + """Timeline view renders date bars.""" + r = client.post("/db/api", json={"name": "TL DB"}) + cid = r.json()["id"] + client.post(f"/db/{cid}/pages/api", json={"title": "Task A", "properties": {"date": "2026-07-01...2026-07-15"}}) + resp = client.get(f"/db/{cid}/view/timeline") + assert resp.status_code == 200 + assert "tl-bar" in resp.text + client.delete(f"/db/api/{cid}") + + +def test_views_default_table(client): + """Default view renders as table.""" + r = client.post("/db/api", json={"name": "Tab DB"}) + cid = r.json()["id"] + resp = client.get(f"/db/{cid}") + assert resp.status_code == 200 + assert "" in resp.text + client.delete(f"/db/api/{cid}") + + +def test_views_calendar_navigation(client): + """Calendar supports month navigation via query params.""" + r = client.post("/db/api", json={"name": "Nav DB"}) + cid = r.json()["id"] + resp = client.get(f"/db/{cid}/view/calendar?year=2026&month=8") + assert resp.status_code == 200 + assert "August" in resp.text or "août" in resp.text + client.delete(f"/db/api/{cid}")