- 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
174 lines
5.1 KiB
Python
174 lines
5.1 KiB
Python
"""
|
|
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,
|
|
)
|