feat(roadmap): YAML-driven roadmap + Tailwind UI w/ filters & details modal
- Convert roadmap to YAML: - Add data structure: id, priority, title, goal, tags, milestone - Add `details` field (supports list or block string); populated initial content - Quote scalars and use explicit nulls to avoid YAML parse edge cases - Update `updated` date to 2025-08-22 - Flask blueprint + loader: - New /roadmap view with section switching (roadmap | backlog | open_questions) - Filters: q (search), tag (multi, AND), min_priority, milestone - Dataclasses: RoadmapData/RoadmapItem; include `details` - `_normalize_details()` to accept string or list, normalize to list[str] - Configurable path via `ROADMAP_FILE` (env or defaults) - Remove cache layer for simplicity - UI (Tailwind): - `templates/roadmap.html` with responsive cards, tag chips, and filter form - Details modal (larger max width, scrollable body) showing ID/goal/priority/tags/milestone - Safe JSON payload to modal via `|tojson|forceescape` - JS: - DOM-ready, event-delegated handler for `data-item` buttons - Populate modal fields and render multi-paragraph details - Fixes & polish: - Resolved YAML `ScannerError` by quoting strings with `:` and `#` - Ensured `details` is passed through route to template and included in button payload - Minor styling tweaks for consistency with Tailwind setup Usage: - Set `ROADMAP_FILE` if not using default path - Visit /roadmap and filter via q/tag/min_priority/milestone
This commit is contained in:
173
app/blueprints/roadmap.py
Normal file
173
app/blueprints/roadmap.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Roadmap view: loads data/roadmap.yaml, sorts and renders with filters.
|
||||
|
||||
Query params (all optional):
|
||||
- q=... (substring search over title/goal/id)
|
||||
- tag=tag1&tag=tag2 (multi; include if item has ALL selected tags)
|
||||
- min_priority=1..9 (int; keep items with priority >= this)
|
||||
- milestone=v0.2 (string; exact match on milestone)
|
||||
- section=roadmap|backlog|open_questions (default=roadmap)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import time
|
||||
import yaml
|
||||
from flask import Blueprint, render_template, request, abort, current_app
|
||||
|
||||
from app.logging_setup import get_app_logger
|
||||
logger = get_app_logger()
|
||||
|
||||
bp = Blueprint("roadmap", __name__)
|
||||
|
||||
@dataclass
|
||||
class RoadmapItem:
|
||||
id: str
|
||||
title: str
|
||||
goal: str
|
||||
tags: List[str] = field(default_factory=list)
|
||||
priority: Optional[int] = None
|
||||
milestone: Optional[str] = None
|
||||
details: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoadmapData:
|
||||
updated: Optional[str]
|
||||
roadmap: List[RoadmapItem]
|
||||
backlog: List[RoadmapItem]
|
||||
open_questions: List[RoadmapItem]
|
||||
|
||||
def _normalize_details(val) -> List[str]:
|
||||
# Accept string (block scalar) or list of strings; normalize to list[str]
|
||||
if not val:
|
||||
return []
|
||||
if isinstance(val, str):
|
||||
# split on blank lines while preserving paragraphs
|
||||
parts = [p.strip() for p in val.strip().split("\n\n")]
|
||||
return [p for p in parts if p]
|
||||
if isinstance(val, list):
|
||||
return [str(x) for x in val if str(x).strip()]
|
||||
# Fallback: stringify unknown types
|
||||
return [str(val)]
|
||||
|
||||
def _to_items(raw: List[Dict[str, Any]]) -> List[RoadmapItem]:
|
||||
items: List[RoadmapItem] = []
|
||||
for obj in raw or []:
|
||||
items.append(
|
||||
RoadmapItem(
|
||||
id=str(obj.get("id", "")),
|
||||
title=str(obj.get("title", "")),
|
||||
goal=str(obj.get("goal", "")),
|
||||
tags=list(obj.get("tags", []) or []),
|
||||
priority=obj.get("priority"),
|
||||
milestone=obj.get("milestone"),
|
||||
details=_normalize_details(obj.get("details")),
|
||||
)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def load_roadmap() -> RoadmapData:
|
||||
"""Load YAML and return structured RoadmapData (no caching)."""
|
||||
path = Path(current_app.config.get("ROADMAP_FILE"))
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
return RoadmapData(
|
||||
updated=raw.get("updated"),
|
||||
roadmap=_to_items(raw.get("roadmap", [])),
|
||||
backlog=_to_items(raw.get("backlog", [])),
|
||||
open_questions=_to_items(raw.get("open_questions", [])),
|
||||
)
|
||||
|
||||
|
||||
def _apply_filters(
|
||||
items: List[RoadmapItem],
|
||||
query: str,
|
||||
tags: List[str],
|
||||
min_priority: Optional[int],
|
||||
milestone: Optional[str],
|
||||
) -> List[RoadmapItem]:
|
||||
def matches(item: RoadmapItem) -> bool:
|
||||
# text search over id/title/goal
|
||||
if query:
|
||||
hay = f"{item.id} {item.title} {item.goal}".lower()
|
||||
if query not in hay:
|
||||
return False
|
||||
|
||||
# tag filter (AND)
|
||||
if tags:
|
||||
if not set(tags).issubset(set(item.tags)):
|
||||
return False
|
||||
|
||||
# min priority
|
||||
if min_priority is not None and item.priority is not None:
|
||||
if item.priority < min_priority:
|
||||
return False
|
||||
|
||||
# milestone
|
||||
if milestone:
|
||||
if (item.milestone or "").strip() != milestone.strip():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
# sort: priority asc (None last), then title
|
||||
def sort_key(i: RoadmapItem) -> Tuple[int, str]:
|
||||
pri = i.priority if i.priority is not None else 9999
|
||||
return (pri, i.title.lower())
|
||||
|
||||
return sorted([i for i in items if matches(i)], key=sort_key)
|
||||
|
||||
|
||||
def _collect_all_tags(data: RoadmapData) -> List[str]:
|
||||
seen = set()
|
||||
for col in (data.roadmap, data.backlog, data.open_questions):
|
||||
for i in col:
|
||||
for t in i.tags:
|
||||
seen.add(t)
|
||||
return sorted(seen)
|
||||
|
||||
|
||||
@bp.route("/roadmap")
|
||||
def roadmap_view():
|
||||
data = load_roadmap()
|
||||
|
||||
# which column?
|
||||
section = request.args.get("section", "roadmap")
|
||||
if section not in {"roadmap", "backlog", "open_questions"}:
|
||||
abort(400, "invalid section")
|
||||
|
||||
# filters
|
||||
q = (request.args.get("q") or "").strip().lower()
|
||||
tags = request.args.getlist("tag")
|
||||
min_priority = request.args.get("min_priority")
|
||||
milestone = request.args.get("milestone") or None
|
||||
try:
|
||||
min_priority_val = int(min_priority) if min_priority else None
|
||||
except ValueError:
|
||||
min_priority_val = None
|
||||
|
||||
# pick list + filter
|
||||
source = getattr(data, section)
|
||||
items = _apply_filters(source, q, tags, min_priority_val, milestone)
|
||||
|
||||
# tag universe for sidebar chips
|
||||
all_tags = _collect_all_tags(data)
|
||||
|
||||
return render_template(
|
||||
"roadmap.html",
|
||||
updated=data.updated,
|
||||
section=section,
|
||||
items=items,
|
||||
all_tags=all_tags,
|
||||
q=q,
|
||||
selected_tags=tags,
|
||||
min_priority=min_priority_val,
|
||||
milestone=milestone,
|
||||
)
|
||||
Reference in New Issue
Block a user