Files
flowdeck/app/routers/collections/_renderers.py
T
bruno ab6ac1e84c
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 3m26s
feat: fondations E2E + 2 bugs trouvés (onglets ?view=, Inter CSP) (v7.36.0)
Ajout — e2e/smoke.spec.js (2 gates verts contre l'instance de test) :
- gate A39 : bascule de vues d'une collection (clic onglet Calendar →
  ?view_type=calendar, grille .calendar + .cal-header rendue ; collection
  créée puis SUPPRIMÉE = répétable)
- gate A20 : palette Ctrl+K (ouverture Alpine .open, recherche GET rend
  .cmd-palette-item, fermeture Échap)
- filet console : 0 erreur JS/CSP (bruit Failed to load resource 401/403
  filtré)
- Service Workers bloqués : /sw.js sert sa page « hors ligne » sur les
  navigations redirigées (redirect:'manual') — pwa_offline.spec.js couvre
  le SW
- bootstrap autonome : login OU création du compte e2e documenté (jamais
  de mot de passe deviné), workspace si absent
- commande : cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js

Fixed — trouvés par les gates :
1. Bascule de vues standalone JAMAIS fonctionnelle : les onglets
   émettaient ?view=… mais la route lit `view_type` (FastAPI) → l'onglet
   restait sur Table quel que soit le clic (bug pré-existant, A28 n'y est
   pour rien). Onglets → ?view_type= ; test_all_view_tabs_present adapté +
   assertion comportementale (GET ?view_type=calendar rend .calendar).
2. Inter bloqué par la CSP depuis v7.27 : app.css importait encore
   Google Fonts (@import raté par le grep de la passe v7.27) → violation
   style-src sur chaque page + police en fallback. Inter auto-hébergé :
   2 faces variables (100-900, latin + latin-ext) dans static/fonts/,
   @import supprimé (8 fichiers dupliqués dédupliqués → 2).

suite **1093/1093** · ruff OK · E2E **2/2** · docs à jour
2026-10-02 10:23:34 -04:00

668 lines
26 KiB
Python

"""FlowDeck — Collections : rendus HTML des vues (A28).
Les 15 helpers de rendu de l'ancien collections.py (_render_view,
_render_chart, …) + CHART_MAX_GROUPS — ré-exportés pour les tests.
"""
from __future__ import annotations
import json
from app.db import get_conn
from app.templating import CSP_NONCE
CHART_MAX_GROUPS = 200
# ── 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)
elif view_type == "chart":
return _render_chart(view_type, collection, pages, config)
elif view_type == "form":
return _render_form(view_type, collection, pages, config)
elif view_type == "map":
return _render_map(view_type, collection, pages, config)
elif view_type == "feed":
return _render_feed(view_type, collection, pages, config)
elif view_type == "gantt":
return _render_gantt(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"""<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>{title} — FlowDeck</title>
<style>
body{{font-family:system-ui,sans-serif;background:#191919;color:#fff;margin:0;padding:20px}}
h1{{font-size:24px;margin:0 0 8px}} p.desc{{color:#A0A0A0;margin:0 0 20px}}
.view-tabs{{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid #333;padding-bottom:8px}}
.tab{{padding:6px 14px;border-radius:6px;cursor:pointer;color:#A0A0A0;font-size:13px;background:none;border:none}}
.tab:hover,.tab.active{{background:#333;color:#fff}}
</style></head><body>
<h1>{icon} {title}</h1>
<div class="view-tabs">
<a class="tab{' active' if view_type=='table' else ''}" href="?view_type=table">📊 Table</a>
<a class="tab{' active' if view_type=='board' else ''}" href="?view_type=board">📋 Board</a>
<a class="tab{' active' if view_type=='calendar' else ''}" href="?view_type=calendar">📅 Calendar</a>
<a class="tab{' active' if view_type=='gallery' else ''}" href="?view_type=gallery">🖼️ Gallery</a>
<a class="tab{' active' if view_type=='list' else ''}" href="?view_type=list">📝 List</a>
<a class="tab{' active' if view_type=='timeline' else ''}" href="?view_type=timeline">📈 Timeline</a>
<a class="tab{' active' if view_type=='gantt' else ''}" href="?view_type=gantt">📊 Gantt</a>
<a class="tab{' active' if view_type=='chart' else ''}" href="?view_type=chart">📉 Chart</a>
<a class="tab{' active' if view_type=='form' else ''}" href="?view_type=form">📋 Form</a>
<a class="tab{' active' if view_type=='map' else ''}" href="?view_type=map">🗺️ Map</a>
<a class="tab{' active' if view_type=='feed' else ''}" href="?view_type=feed">📰 Feed</a>
</div>
{body}
</body></html>"""
def _render_calendar(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
from datetime import date as dt_date
from datetime import 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"<div class='cal-item' title='{p['title']}'>{p.get('icon','📄')} {p['title'][:20]}</div>"
for p in items
)
cells += f"<div class='cal-day{other_month}{today_class}'><span class='cal-num'>{d.day}</span>{items_html}</div>"
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"""
<style>
.calendar{{display:grid;grid-template-columns:repeat(7,1fr);gap:1px;background:#333;border-radius:8px;overflow:hidden}}
.cal-header{{background:#222;padding:8px;text-align:center;font-size:11px;color:#A0A0A0;text-transform:uppercase}}
.cal-day{{background:#1a1a1a;min-height:80px;padding:4px}}
.cal-day.other-month{{opacity:.35}}
.cal-day.today{{background:#1a2744}}
.cal-num{{font-size:12px;color:#A0A0A0;display:block;margin-bottom:2px}}
.cal-item{{font-size:11px;padding:2px 4px;margin:1px 0;background:#333;border-radius:3px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis}}
.cal-nav{{display:flex;gap:8px;align-items:center;margin-bottom:12px}}
.cal-nav a{{color:#3366CC;text-decoration:none;font-size:14px}}
.cal-nav span{{font-size:16px;font-weight:600}}
</style>
<div class="cal-nav">
<a href="?view_type=calendar&year={prev.year}&month={prev.month}">← {prev.strftime('%B')}</a>
<span>{first.strftime('%B %Y')}</span>
<a href="?view_type=calendar&year={next_month.year}&month={next_month.month}">{next_month.strftime('%B')} →</a>
</div>
<div class="calendar">
<div class="cal-header">Mon</div><div class="cal-header">Tue</div><div class="cal-header">Wed</div>
<div class="cal-header">Thu</div><div class="cal-header">Fri</div><div class="cal-header">Sat</div><div class="cal-header">Sun</div>
{cells}
</div>
<p class="desc">{len(pages)} pages in collection</p>
""")
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"<div class='gal-cover' style='background-image:url({url})'></div>"
break
prop_tags = "".join(
f"<span class='gal-prop'>{str(v)[:30]}</span>"
for v in list(props.values())[:3] if v
)
cards += f"""<div class='gal-card'>
{cover_html}
<div class='gal-body'>
<div class='gal-title'>{p.get('icon','📄')} {p['title']}</div>
<div class='gal-props'>{prop_tags}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "🖼️"), view_type, f"""
<style>
.gallery{{display:grid;grid-template-columns:repeat(auto-fill,minmax({size_css},1fr));gap:12px}}
.gal-card{{background:#1a1a1a;border-radius:8px;overflow:hidden;border:1px solid #333}}
.gal-card:hover{{border-color:#555}}
.gal-cover{{height:120px;background:#222;background-size:cover;background-position:center}}
.gal-body{{padding:12px}}
.gal-title{{font-size:14px;font-weight:500;margin-bottom:6px}}
.gal-props{{display:flex;flex-wrap:wrap;gap:4px}}
.gal-prop{{font-size:11px;padding:2px 6px;background:#333;border-radius:4px;color:#A0A0A0}}
</style>
<div class="gallery">{cards}</div>
<p class="desc">{len(pages)} cards</p>
""")
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"""<div class='list-item'>
<span class='list-icon'>{p.get('icon','📄')}</span>
<div class='list-content'>
<div class='list-title'>{p['title']}</div>
<div class='list-preview'>{preview}</div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.list-item{{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;background:#1a1a1a;border-radius:6px;margin-bottom:4px;border:1px solid #222}}
.list-item:hover{{border-color:#444}}
.list-icon{{font-size:18px;margin-top:1px}}
.list-content{{flex:1;min-width:0}}
.list-title{{font-size:14px;font-weight:500}}
.list-preview{{font-size:12px;color:#A0A0A0;margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
</style>
{items}
<p class="desc">{len(pages)} items</p>
""")
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,
"<p class='desc'>No date data to display timeline.</p>")
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"""<div class='tl-row'>
<span class='tl-label'>{p.get('icon','📄')} {p['title']}</span>
<div class='tl-track'>
<div class='tl-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{start} → {end}'></div>
</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📈"), view_type, f"""
<style>
.tl-row{{display:flex;align-items:center;margin-bottom:8px;gap:12px}}
.tl-label{{width:160px;font-size:13px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.tl-track{{flex:1;height:28px;background:#222;border-radius:4px;position:relative}}
.tl-bar{{position:absolute;top:4px;height:20px;background:#3366CC;border-radius:4px;min-width:4px}}
</style>
<div class="tl-header" style="display:flex;margin-bottom:16px;padding-left:172px">
<span style="flex:1;font-size:11px;color:#A0A0A0">{min_date}</span>
<span style="font-size:11px;color:#A0A0A0">{max_date}</span>
</div>
{bars}
<p class="desc">{len(pages)} items · {min_date} → {max_date}</p>
""")
# ── v4.3.0: New view types ──
# Multi-collection dashboards and chart aggregations cap the number of
# input rows/groups at 200 (keeps the rendered HTML and export reasonable).
def _chart_values(pages: list[dict], chart_property: str) -> list[float]:
"""Numeric values of ``chart_property`` across ``pages`` (cap 200)."""
values: list[float] = []
for p in pages[:CHART_MAX_GROUPS]:
props = json.loads(p.get("property_values_json", "{}") or "{}")
v = props.get(chart_property)
if v is None or v == "":
continue
try:
values.append(float(v))
except (ValueError, TypeError):
continue
return values
def _chart_aggregate(pages: list[dict], chart_property: str, aggregate: str) -> float:
"""Compute count|sum|avg|min|max over a property (or row count)."""
values = _chart_values(pages, chart_property)
if aggregate == "count":
return float(len(pages[:CHART_MAX_GROUPS]))
if not values:
return 0.0
if aggregate == "sum":
return float(sum(values))
if aggregate == "avg":
return float(sum(values) / len(values))
if aggregate == "min":
return float(min(values))
if aggregate == "max":
return float(max(values))
return 0.0
def _fmt_number(value: float) -> str:
if abs(value) >= 1e9:
return f"{value / 1e9:.2f}B"
if abs(value) >= 1e6:
return f"{value / 1e6:.2f}M"
if abs(value) >= 1e3:
return f"{value / 1e3:.1f}K"
if value == int(value):
return str(int(value))
return f"{value:.2f}"
def _render_chart(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Chart view — bar, line, pie, doughnut, scatter via Chart.js CDN, plus
the v7.3.0 ``number`` KPI widget (count|sum|avg|min|max aggregate)."""
chart_type = config.get("chart_type", "bar")
chart_property = config.get("chart_property", "")
if chart_type == "number":
aggregate = config.get("aggregate", "sum" if chart_property else "count")
if aggregate not in ("count", "sum", "avg", "min", "max"):
aggregate = "sum" if chart_property else "count"
num = _chart_aggregate(pages, chart_property, aggregate)
label = config.get("title") or chart_property or collection["name"]
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.kpi{{max-width:420px;margin:60px auto;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.12);border-radius:14px;padding:36px;text-align:center}}
.kpi-label{{font-size:13px;text-transform:uppercase;letter-spacing:1.2px;opacity:.6;margin-bottom:10px}}
.kpi-value{{font-size:64px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}}
.kpi-agg{{font-size:12px;color:var(--text-dim);margin-top:12px}}
</style>
<div class="kpi">
<div class="kpi-label">{label}</div>
<div class="kpi-value">{_fmt_number(num)}</div>
<div class="kpi-agg">{aggregate} · {len(pages[:CHART_MAX_GROUPS])} row(s)
{' of ' + chart_property if chart_property else ''}</div>
</div>
""")
labels = []
values = []
for p in pages[:CHART_MAX_GROUPS]:
labels.append(str(p.get("title") or "")[:30])
props = json.loads(p.get("property_values_json", "{}") or "{}")
val = 0.0
if chart_property:
v_raw = props.get(chart_property, 0)
try:
val = float(v_raw) if v_raw else 0.0
except (ValueError, TypeError):
val = 0.0
values.append(val)
labels_json = json.dumps(labels)
values_json = json.dumps(values)
subtitle = (f"{len(pages[:CHART_MAX_GROUPS])} entries"
+ (f" (truncated at {CHART_MAX_GROUPS})" if len(pages) > CHART_MAX_GROUPS else ""))
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.chart-container{{max-width:800px;margin:0 auto}}
canvas{{max-height:400px}}
</style>
<div class="chart-container"><canvas id="chartCanvas"></canvas></div>
<script src="/static/js/vendor/chart.umd.js"></script>
<script nonce="{CSP_NONCE.get()}">
new Chart(document.getElementById('chartCanvas'), {{
type: '{chart_type}',
data: {{
labels: {labels_json},
datasets: [{{
label: '{config.get("title") or collection["name"]}',
data: {values_json},
backgroundColor: ['#3366CC','#DC3912','#FF9900','#109618','#990099','#0099C6','#DD4477','#66AA00'],
}}]
}},
options: {{ responsive: true }}
}});
</script>
<p class="desc">{subtitle}</p>
""")
def _render_form(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Form view — generates an HTML form that creates new pages in the collection."""
properties = []
with get_conn() as conn:
props = conn.execute(
"SELECT name, prop_type, options_json FROM collection_properties WHERE collection_id=? AND prop_type!='formula' ORDER BY position",
(collection["id"],),
).fetchall()
for p in props:
prop_dict = dict(p)
prop_dict["options"] = json.loads(prop_dict.get("options_json", "[]"))
properties.append(prop_dict)
fields = ""
for prop in properties:
name = prop["name"]
ptype = prop["prop_type"]
if ptype in ("text", "email", "url", "phone", "number"):
fields += f"""<div class='form-field'><label>{name}</label><input type='{"number" if ptype=="number" else "text"}' name='prop_{name}' placeholder='{name}'></div>"""
elif ptype in ("select", "status"):
options = "".join(f"<option value='{o}'>{o}</option>" for o in prop.get("options", []))
fields += f"""<div class='form-field'><label>{name}</label><select name='prop_{name}'>{options}</select></div>"""
elif ptype == "checkbox":
fields += f"""<div class='form-field'><label><input type='checkbox' name='prop_{name}' value='1'> {name}</label></div>"""
elif ptype == "date":
fields += f"""<div class='form-field'><label>{name}</label><input type='date' name='prop_{name}'></div>"""
return _base_html(collection["name"], collection.get("icon", "📝"), view_type, f"""
<style>
.form-field{{margin-bottom:12px}}
.form-field label{{display:block;font-size:13px;color:#A0A0A0;margin-bottom:4px}}
.form-field input,.form-field select{{width:100%;max-width:400px;padding:8px;background:#333;border:1px solid #555;border-radius:6px;color:#fff;font-size:14px}}
.form-submit{{padding:8px 20px;background:#3366CC;color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:14px;margin-top:8px}}
.form-submit:hover{{background:#254E99}}
</style>
<div class="form-container">
<h3>New entry in {collection['name']}</h3>
<form id="collectionForm" onsubmit="submitForm(event)">
{fields}
<div class='form-field'><label>Title</label><input type='text' name='title' placeholder='Page title' required></div>
<button type='submit' class='form-submit'>Submit</button>
</form>
<div id="formResult" style="margin-top:12px;color:#4CAF50;display:none">✓ Created successfully!</div>
</div>
<script nonce="{CSP_NONCE.get()}">
async function submitForm(e) {{
e.preventDefault();
const form = document.getElementById('collectionForm');
const fd = new FormData(form);
const properties = {{}};
const title = fd.get('title') || 'New entry';
fd.forEach((v,k) => {{ if(k.startsWith('prop_')) properties[k.slice(5)] = v; }});
const resp = await fetch('/db/{collection["id"]}/pages/api', {{
method:'POST', headers:{{'Content-Type':'application/json'}},
body: JSON.stringify({{title, properties}})
}});
if(resp.ok) {{
document.getElementById('formResult').style.display='block';
form.reset();
}}
}}
</script>
""")
def _render_map(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Map view — displays pages with location data on Leaflet map."""
markers = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
lat, lng = None, None
for _k, v in props.items():
if isinstance(v, str) and "," in v:
parts = v.split(",")
try:
lat, lng = float(parts[0].strip()), float(parts[1].strip())
except ValueError:
continue
elif isinstance(v, dict):
lat = v.get("lat")
lng = v.get("lng")
if lat and lng:
markers.append({"title": p["title"], "lat": lat, "lng": lng})
markers_json = json.dumps(markers)
center_lat = markers[0]["lat"] if markers else 45.5
center_lng = markers[0]["lng"] if markers else -73.5
return _base_html(collection["name"], collection.get("icon", "🗺️"), view_type, f"""
<style>
#map{{height:400px;border-radius:8px}}
</style>
<link rel="stylesheet" href="/static/js/vendor/leaflet.css" />
<div id="map"></div>
<script src="/static/js/vendor/leaflet.js"></script>
<script nonce="{CSP_NONCE.get()}">
const map = L.map('map').setView([{center_lat}, {center_lng}], 6);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{attribution:'© OSM'}}).addTo(map);
const markers = {markers_json};
markers.forEach(m => L.marker([m.lat, m.lng]).addTo(map).bindPopup(m.title));
if(markers.length===0) L.marker([{center_lat},{center_lng}]).addTo(map).bindPopup('Default');
</script>
<p class="desc">{len(markers)} location(s) mapped</p>
""")
def _render_feed(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Feed view — chronological feed of pages, newest first."""
sorted_pages = sorted(pages, key=lambda p: p.get("created_at", ""), reverse=True)
items = ""
for p in sorted_pages:
created = p.get("created_at", "")[:10] if p.get("created_at") else ""
items += f"""<div class='feed-item'>
<div class='feed-meta'>{created}</div>
<div class='feed-title'>{p.get('icon','📄')} {p['title']}</div>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📰"), view_type, f"""
<style>
.feed-item{{padding:12px 16px;border-left:2px solid #333;margin-bottom:8px;background:#1a1a1a;border-radius:0 8px 8px 0}}
.feed-item:hover{{border-left-color:#3366CC}}
.feed-meta{{font-size:11px;color:#A0A0A0;margin-bottom:4px}}
.feed-title{{font-size:14px;font-weight:500}}
</style>
{items}
<p class="desc">{len(sorted_pages)} entries</p>
""")
def _render_gantt(view_type: str, collection: dict, pages: list[dict], config: dict) -> str:
"""Gantt view — timeline with dependencies and group_by support."""
group_by = config.get("group_by", "")
gantt_data = []
for p in pages:
props = json.loads(p.get("property_values_json", "{}"))
group = None
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].strip()[:10], parts[1].strip()[:10] if len(parts) > 1 else parts[0].strip()[:10]
elif "..." 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 group_by:
group = str(props.get(group_by, props.get("Status", "")))[:20]
if start_val:
gantt_data.append({"title": p["title"], "start": start_val, "end": end_val or start_val, "group": group or ""})
if not gantt_data:
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, "<p class='desc'>No date data for Gantt chart.</p>")
from datetime import date as dt_date_2
all_dates = [d["start"] for d in gantt_data] + [d["end"] for d in gantt_data]
min_date = min(dt_date_2.fromisoformat(d) for d in all_dates)
max_date = max(dt_date_2.fromisoformat(d) for d in all_dates)
total_days = max((max_date - min_date).days, 1)
groups = {}
for d in gantt_data:
groups.setdefault(d["group"], []).append(d)
if not groups or all(k == "" for k in groups):
groups = {"": gantt_data}
rows = ""
for group_name, items in sorted(groups.items()):
if group_name:
rows += f"<div class='gantt-group'>{group_name} ({len(items)})</div>"
for item in items:
sd = dt_date_2.fromisoformat(item["start"])
ed = dt_date_2.fromisoformat(item["end"])
left = max((sd - min_date).days / total_days * 100, 0)
width = max((ed - sd).days / total_days * 100, 1)
rows += f"""<div class='gantt-row'>
<span class='gantt-label'>{item['title'][:30]}</span>
<div class='gantt-track'><div class='gantt-bar' style='left:{left:.1f}%;width:{width:.1f}%' title='{item["start"]} → {item["end"]}'></div></div>
<span class='gantt-dates'>{item['start']} → {item['end']}</span>
</div>"""
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
.gantt-group{{padding:8px 12px;background:#222;font-size:13px;font-weight:600;margin:8px 0 4px;border-radius:4px}}
.gantt-row{{display:flex;align-items:center;margin-bottom:6px;gap:8px}}
.gantt-label{{width:180px;font-size:12px;text-align:right;flex-shrink:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
.gantt-track{{flex:1;height:24px;background:#222;border-radius:4px;position:relative}}
.gantt-bar{{position:absolute;top:3px;height:18px;background:linear-gradient(90deg,#3366CC,#5599EE);border-radius:4px;min-width:4px}}
.gantt-dates{{font-size:10px;color:#A0A0A0;flex-shrink:0;min-width:140px}}
</style>
<div class="gantt-header" style="display:flex;margin-bottom:8px;padding-left:188px">
<span style="flex:1;font-size:10px;color:#A0A0A0">{min_date}</span>
<span style="font-size:10px;color:#A0A0A0">{max_date}</span>
</div>
{rows}
<p class="desc">{len(gantt_data)} items · {min_date} → {max_date}</p>
""")
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"<td>{str(v)[:80]}</td>" for v in list(props.values())[:4])
rows += f"<tr><td>{p.get('icon','📄')}</td><td>{p['title']}</td>{prop_cells}</tr>"
return _base_html(collection["name"], collection.get("icon", "📊"), view_type, f"""
<style>
table{{width:100%;border-collapse:collapse}}
th,td{{padding:8px 12px;text-align:left;font-size:13px;border-bottom:1px solid #333}}
th{{color:#A0A0A0;font-weight:500;background:#1a1a1a;position:sticky;top:0}}
tr:hover td{{background:#222}}
</style>
<table><thead><tr><th></th><th>Title</th><th>Properties</th></tr></thead><tbody>{rows}</tbody></table>
<p class="desc">{len(pages)} rows</p>
""")