fix: side peek des bases repasse en vanilla JS + largeur 1100px standard (v7.49.0)
FlowDeck CI / test (push) Failing after 3h13m58s
FlowDeck CI / lint (push) Successful in 2m12s
FlowDeck CI / docker (push) Skipped

- Panneau peek: les bindings Alpine (x-data absent du conteneur) rendaient
  loovverture et le redimensionnement inoperants -> cblage direct sur le document.
- Helper unique window.fdWirePeekResize (app.js): pointer capture, 300px-90vw,
  clic=fermer, largeur persiste fd_peek_width partagee entre les 4 peeks.
- database-table-container margin:0 (tableau colle a gauche, marge Library).
- .lib-container remonte dans app.css (trash etait pleine largeur), .db-index 1100px.
- ObsiGate verifie sans code: creation .xlsx OK (openpyxl, #186).
This commit is contained in:
2026-10-05 22:47:36 -04:00
parent 894004a1f5
commit 1d1cdbd618
63 changed files with 9473 additions and 393 deletions
+374 -98
View File
@@ -1,147 +1,423 @@
"""FlowDeck — My Tasks router (v1.9.0)."""
"""FlowDeck — My Tasks (tableau de bord de tâches façon Notion).
Contrairement à Notion, où *My Tasks* vit dans l'onglet **Home** sans être
rattaché à une page, cette page est un **tableau de bord transverse** : elle
réunit toutes les tâches qui vous sont assignées, **tous workspaces
confondus**. Le cookie `flowdeck_workspace` ne cadre donc plus cette vue (il
reste utilisé par la sidebar).
Les sources sont opt-in : une base n'alimente My Tasks qu'après conversion
explicite (`collections.is_task` + mapping des trois propriétés requises —
`app/services/task_databases.py`).
Trois vues (tableau / kanban / calendrier), filtres globaux (échéance
aujourd'hui, masquer les terminées), tri (échéance, statut, source), édition
en direct du statut et de l'échéance, et création rapide dans n'importe
quelle base connectée.
"""
from __future__ import annotations
import html
import json
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, Body, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth.session import SessionManager
from app.db import get_conn
from app.services.property_types import apply_auto_properties
from app.services.task_databases import (
MAX_TASK_SOURCES,
collect_tasks,
filter_and_sort,
list_task_sources,
user_today,
)
from app.templating import ENV
logger = logging.getLogger(__name__)
router = APIRouter(tags=["my-tasks"], prefix="/my-tasks")
VIEWS = ("table", "board", "calendar")
DUE_FILTERS = ("all", "today", "overdue", "week")
SORTS = ("due", "status", "source", "created")
# ── Session ──────────────────────────────────────────────────────────────
def _get_current_user(request: Request) -> dict | None:
session = request.cookies.get("flowdeck_session", "")
return SessionManager.decode_session(session)
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "all", days: int = 7):
"""My Tasks — aggregates all pages assigned to the current user across all collections."""
def _require_user(request: Request) -> dict:
"""Session obligatoire, sans repli « admin » (id 1) : ce tableau de bord
est strictement celui de l'appelant."""
user = _get_current_user(request)
user_login = user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
raise HTTPException(status_code=401, detail="Authentication required")
return user
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
grouped: dict[str, list[dict]] = {}
for col in collections:
col_id = col["id"]
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col_id,),
).fetchall()
# ── Données ──────────────────────────────────────────────────────────────
collection_tasks = []
for p in pages:
props = {}
try:
props = json.loads(p["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pass
due_date = props.get("due_date", "")
status = props.get("status", "—")
assignee = props.get("assignee", "")
if view != "all" and assignee and assignee != user_login:
continue
collection_tasks.append({
"id": p["id"],
"title": p["title"] or "Untitled",
"icon": p["icon"] or "📋",
"status": status,
"due_date": due_date,
})
if collection_tasks:
grouped[col["name"]] = collection_tasks
def _workspace_names(conn, user_id: int) -> dict[int, str]:
rows = conn.execute(
"SELECT id, name FROM workspaces WHERE owner_id = ?", (user_id,)
).fetchall()
return {r["id"]: r["name"] for r in rows}
total = sum(len(t) for t in grouped.values())
# Build content HTML
sections = ""
for col_name, tasks in grouped.items():
items = ""
for t in tasks:
due_badge = f"<span class='mt-due'>{t['due_date']}</span>" if t.get("due_date") else ""
status_cls = t['status'].lower().replace(' ', '-') if t.get('status') else 'none'
status_badge = f"<span class='mt-status mt-{status_cls}'>{t.get('status', '—')}</span>"
items += f"<div class='mt-item'><span class='mt-icon'>{t['icon']}</span><span class='mt-title'>{t['title']}</span>{status_badge}{due_badge}</div>"
def _validated(view: str, due: str, sort: str) -> tuple[str, str, str]:
"""Paramètres d'URL bornés aux valeurs réellement implémentées."""
return (
view if view in VIEWS else "table",
due if due in DUE_FILTERS else "all",
sort if sort in SORTS else "due",
)
sections += f"<div class='mt-section'><div class='mt-section-header'>{col_name} <span class='mt-count'>{len(tasks)}</span></div>{items}</div>"
content_html = f"""<div style="max-width:800px;margin:0 auto;padding:40px 24px;">
<h1 style="font-size:24px;margin:0 0 8px;">📋 My Tasks</h1>
<p style="color:var(--text-dim);font-size:14px;margin-bottom:24px;">{total} tasks across {len(grouped)} collections</p>
<div class="view-tabs" style="display:flex;gap:4px;margin-bottom:24px;border-bottom:1px solid var(--border);padding-bottom:12px;">
<a class="tab{' active' if view=='all' else ''}" href="/my-tasks?view=all" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">All</a>
<a class="tab{' active' if view=='today' else ''}" href="/my-tasks?view=today" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Today</a>
<a class="tab{' active' if view=='overdue' else ''}" href="/my-tasks?view=overdue" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Overdue</a>
<a class="tab{' active' if view=='upcoming' else ''}" href="/my-tasks?view=upcoming&days=7" style="padding:6px 14px;border-radius:6px;cursor:pointer;color:var(--text-dim);font-size:13px;text-decoration:none;">Next 7 days</a>
</div>
<style>
.tab.active{{background:var(--accent);color:#fff!important;}}
.tab:hover{{background:var(--bg-hover);color:var(--text);}}
.mt-section{{margin-bottom:24px;}}
.mt-section-header{{font-size:13px;font-weight:600;color:var(--text-dim);margin-bottom:8px;text-transform:uppercase;letter-spacing:.5px;}}
.mt-count{{color:var(--text-dim);font-weight:400;opacity:.5;}}
.mt-item{{display:flex;align-items:center;gap:10px;padding:10px 12px;background:var(--bg-card);border-radius:8px;margin-bottom:3px;border:1px solid var(--border);}}
.mt-item:hover{{border-color:var(--accent);}}
.mt-icon{{font-size:16px;}}
.mt-title{{flex:1;font-size:14px;}}
.mt-status{{font-size:11px;padding:2px 8px;border-radius:4px;}}
.mt-due{{font-size:11px;color:var(--text-dim);}}
.mt-todo,.mt-none{{background:rgba(255,255,255,.05);color:var(--text-dim);}}
.mt-in-progress{{background:rgba(35,131,226,.15);color:#2C8CEB;}}
.mt-done,.mt-completed,.mt-complete{{background:rgba(0,200,100,.15);color:#00CC66;}}
</style>
{sections}
</div>"""
def _public_source(s: dict) -> dict:
return {
"id": s["id"],
"name": s["name"],
"icon": s["icon"],
"workspace_id": s["workspace_id"],
"workspace_name": s.get("workspace_name") or "",
"configured": s["configured"],
"missing_roles": s["missing_roles"],
"status_options": s["status_options"],
}
def _payload(conn, request: Request, user_id: int, *, due: str, sort: str,
hide_done: bool) -> dict:
"""Charge et normalise tout ce dont les trois vues ont besoin."""
user = _get_current_user(request) or {}
tasks = collect_tasks(conn, user_id, mine_only=True,
login=user.get("login", ""))
# Même jour de référence que `due_state` (fuseau de l'utilisateur).
tasks = filter_and_sort(tasks, due=due, hide_done=hide_done, sort=sort,
today=user_today(conn, user_id))
sources = list_task_sources(conn, user_id)
return {
"tasks": tasks,
"total": len(tasks),
"sources": [_public_source(s) for s in sources],
"sources_total": len(sources),
"sources_configured": sum(1 for s in sources if s["configured"]),
"max_sources": MAX_TASK_SOURCES,
"filters": {"due": due, "sort": sort, "hide_done": hide_done},
}
# ── Rendu HTML ───────────────────────────────────────────────────────────
def _render_shell(request: Request, content_html: str, *, app_js: bool = True):
"""Enveloppe le contenu dans le layout commun (sidebar + base.html).
`static/css/my_tasks.css` est lié par `base.html` (le shell, jamais swappé)
: un `<link>` placé dans la zone swappée pouvait être retiré par htmx lors
d'une navigation partielle, et la page revenait sans aucun style. Seul
`static/js/my_tasks.js` reste conditionné à ``app_js`` : sans base
connectée, il n'y a rien à monter côté client.
"""
from app.routers.dashboard import _sidebar_data
env = ENV
sidebar = _sidebar_data(request, [])
block_tpl = env.from_string(
'{% extends "base.html" %}{% block content %}{{ content_html|safe }}{% endblock %}'
# `assets` est rendu par un mini-template : `asset_version` (cache-busting)
# n'est pas substitutionné si on le concatène à la main.
assets = ENV.from_string(
"{% if app_js %}"
'<script src="/static/js/my_tasks.js?v={{ asset_version }}" defer></script>'
"{% endif %}"
).render(app_js=app_js)
block_tpl = ENV.from_string(
'{% extends "base.html" %}'
"{% block content %}{{ assets|safe }}{{ content_html|safe }}{% endblock %}"
)
return block_tpl.render(
**sidebar,
request=request,
content_html=content_html,
assets=assets,
page_title="My Tasks",
title_prefix="My Tasks",
page_icon="📋",
)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "all", days: int = 7):
"""API: return my tasks as JSON."""
def _json_attr(obj) -> str:
"""JSON échappé pour un attribut HTML."""
return html.escape(json.dumps(obj, ensure_ascii=False), quote=True)
def _render_app(data: dict, view: str) -> str:
"""Coquille du tableau de bord : barre d'outils, filtres et zone de vue
sont rendus côté client à partir de la configuration ci-dessous."""
config = {
"view": view,
"filters": data["filters"],
"sources": data["sources"],
"maxSources": data["max_sources"],
"views": list(VIEWS),
}
return ('<div id="my-tasks-app" class="my-tasks-app" '
f'data-config="{_json_attr(config)}"></div>')
def _render_empty(data: dict) -> str:
"""État vide « aucune source » : explique la conversion en base de tâches.
Gabarit centré (`my_tasks-empty*` dans `static/css/my_tasks.css`), sans
style inline : la mise en page suit le thème clair/sombre et reste
modifiable en un seul endroit. Les autres états vides (filtres actifs,
rien à faire) sont rendus par le client, qui connaît l'état des filtres.
"""
return f"""<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucune base n'alimente encore ce tableau de bord. Pour en ajouter une :</p>
<ol class="my-tasks-empty-steps">
<li><span class="n">1</span><span>Ouvrez une base de données depuis
<a href="/db">la liste de vos bases</a>.</span></li>
<li><span class="n">2</span><span>Dans la barre de la base, cliquez sur
<b>« Convertir en base de tâches »</b> — il se trouve à gauche du bouton
<b>New</b>.</span></li>
<li><span class="n">3</span><span>Reliez les colonnes <b>Assigné à</b>,
<b>Statut</b> et <b>Échéance</b>, puis validez.</span></li>
</ol>
<p class="my-tasks-empty-hint">Jusqu'à {data["max_sources"]} bases
peuvent alimenter ce tableau de bord.</p>
<a href="/db" class="my-tasks-empty-btn">Ouvrir mes bases</a>
</div>"""
def _render_no_workspace() -> str:
return """<div class="my-tasks-empty">
<div class="my-tasks-empty-icon">📋</div>
<h2>My Tasks</h2>
<p>Aucun workspace. Créez un workspace, puis une base de tâches : elle
alimentera ce tableau de bord.</p>
<a href="/workspaces" class="my-tasks-empty-btn">Créer un workspace</a>
</div>"""
# ── Routes ───────────────────────────────────────────────────────────────
@router.get("", response_class=HTMLResponse)
def my_tasks_dashboard(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""My Tasks — toutes vos tâches, tous workspaces confondus."""
user = _get_current_user(request)
user.get("login", "admin") if user else "admin"
if not user or not user.get("id"):
return RedirectResponse("/auth/login?provider=local", status_code=302)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
if not _workspace_names(conn, user["id"]):
return _render_shell(request, _render_no_workspace(), app_js=False)
data = _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
# Dès qu'au moins une base alimente le tableau de bord, le rendu est pris
# en charge par le client (3 vues + états vides contextualisés). Sans
# aucune source, le serveur affiche l'invite à convertir une base — le
# fichier JS n'est alors pas chargé.
if data["total"] or data["sources_total"]:
return _render_shell(request, _render_app(data, view))
return _render_shell(request, _render_empty(data), app_js=False)
@router.get("/api")
def my_tasks_api(request: Request, view: str = "table", due: str = "all",
sort: str = "due", hide_done: bool = False):
"""API: tâches normalisées + sources connectées (alimente les 3 vues)."""
user = _require_user(request)
view, due, sort = _validated(view, due, sort)
with get_conn() as conn:
return _payload(conn, request, user["id"], due=due, sort=sort,
hide_done=hide_done)
@router.post("/api/task")
def my_tasks_create(request: Request, body: dict = Body(default={})):
"""Création rapide multi-destinations : la tâche part dans la base choisie.
Elle est **auto-assignée** : sans colonne « Assigné à » renseignée, une
tâche nouvelle n'apparaîtrait pas dans « mes tâches », ce qui serait
illisible pour l'utilisateur qui vient de la créer.
"""
user = _require_user(request)
collection_id = body.get("collection_id")
title = str(body.get("title") or "").strip()
if not collection_id:
raise HTTPException(status_code=400, detail="collection_id is required")
if not title:
raise HTTPException(status_code=400, detail="title is required")
with get_conn() as conn:
collections = conn.execute("SELECT * FROM collections ORDER BY name").fetchall()
result = {}
coll = conn.execute(
"SELECT id, task_assignee_prop, task_due_prop FROM collections "
"WHERE id=? AND is_task=1",
(collection_id,),
).fetchone()
if coll is None:
raise HTTPException(status_code=400,
detail="This database does not feed My Tasks")
for col in collections:
pages = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id=? AND parent_id IS NULL ORDER BY position",
(col["id"],),
props_meta = [
dict(p) for p in conn.execute(
"SELECT id, name, prop_type FROM collection_properties "
"WHERE collection_id=?",
(collection_id,),
).fetchall()
]
tasks = []
for p in pages:
props = json.loads(p["property_values_json"])
tasks.append({
"id": p["id"], "title": p["title"], "icon": p["icon"],
"properties": props,
})
properties: dict = {}
title_prop = next((p for p in props_meta if p["prop_type"] == "title"), None)
if title_prop:
properties[str(title_prop["id"])] = title
if tasks:
result[col["name"]] = tasks
if body.get("due") and coll["task_due_prop"]:
properties[str(coll["task_due_prop"])] = str(body["due"])[:10]
return {"tasks": result, "total": sum(len(t) for t in result.values())}
if coll["task_assignee_prop"]:
member = conn.execute(
"SELECT id, login, full_name, avatar_url, avatar_color "
"FROM users WHERE id=?",
(user["id"],),
).fetchone()
if member:
properties[str(coll["task_assignee_prop"])] = [{
"id": member["id"],
"login": member["login"],
"full_name": member["full_name"],
"avatar_url": member["avatar_url"] or "",
"avatar_color": member["avatar_color"] or "",
}]
apply_auto_properties(props_meta, properties, user, is_create=True)
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 FROM collection_pages "
"WHERE collection_id=?",
(collection_id,),
).fetchone()[0]
cur = conn.execute(
"""INSERT INTO collection_pages
(collection_id, title, icon, position, property_values_json)
VALUES (?, ?, '📄', ?, ?)""",
(collection_id, title, nxt, json.dumps(properties)),
)
page_id = cur.lastrowid
conn.commit()
if title_prop:
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
conn.commit()
logger.info("my-tasks: task %s created in collection %s", page_id, collection_id)
return {"status": "created", "id": page_id, "collection_id": collection_id,
"title": title}
@router.put("/api/task/{page_id}")
def my_tasks_update(request: Request, page_id: int, body: dict = Body(default={})):
"""Édition en direct du statut / de l'échéance / du titre.
L'écriture passe par le **mapping enregistré de la base source** (et non
par une devinette « première colonne de type X »), puis la tâche est relue
et renvoyée normalisée : le front n'a rien à recalculer.
"""
user = _require_user(request)
field = body.get("field")
if field not in ("status", "due", "title", "assignee"):
raise HTTPException(status_code=400, detail="Unsupported field")
with get_conn() as conn:
row = conn.execute(
"SELECT id, collection_id, title, property_values_json "
"FROM collection_pages WHERE id=?",
(page_id,),
).fetchone()
if row is None:
raise HTTPException(status_code=404, detail="Task not found")
coll = conn.execute(
"SELECT id, is_task, task_assignee_prop, task_status_prop, task_due_prop "
"FROM collections WHERE id=?",
(row["collection_id"],),
).fetchone()
if coll is None:
raise HTTPException(status_code=404, detail="Source not found")
if not coll["is_task"]:
raise HTTPException(status_code=400,
detail="This database no longer feeds My Tasks")
column = {"status": coll["task_status_prop"],
"due": coll["task_due_prop"],
"assignee": coll["task_assignee_prop"]}.get(field)
if field != "title" and not column:
raise HTTPException(status_code=400,
detail=f"This database has no {field} column")
try:
pv = json.loads(row["property_values_json"] or "{}")
except (json.JSONDecodeError, TypeError):
pv = {}
if not isinstance(pv, dict):
pv = {}
new_title = None
if field == "title":
new_title = str(body.get("value") or "").strip()
title_prop = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id=? AND prop_type='title' ORDER BY position LIMIT 1",
(coll["id"],),
).fetchone()
if title_prop:
pv[str(title_prop["id"])] = new_title
elif field == "assignee":
# La valeur est une liste de personnes : elle est écrite telle
# quelle (booleenner écraserait la colonne).
pv[str(column)] = body.get("value") or []
elif field == "due":
pv[str(column)] = str(body.get("value") or "")[:10]
else:
pv[str(column)] = str(body.get("value") or "")
if new_title is not None:
conn.execute(
"UPDATE collection_pages SET title=?, property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(new_title, json.dumps(pv), page_id),
)
from app.services.row_pages import sync_row_title_to_page
sync_row_title_to_page(conn, page_id)
else:
conn.execute(
"UPDATE collection_pages SET property_values_json=?, "
"updated_at=CURRENT_TIMESTAMP WHERE id=?",
(json.dumps(pv), page_id),
)
conn.commit()
tasks = collect_tasks(conn, user["id"], mine_only=True,
login=user.get("login", ""))
updated = next((t for t in tasks if t["id"] == page_id), None)
if updated is None:
# La tâche vient de cesser d'être « mienne » (désassignée) : le front
# doit la retirer, pas la re-rendre à l'identique.
return {"status": "updated", "task": None, "left_mytasks": True}
return {"status": "updated", "task": updated, "left_mytasks": False}