""" 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, )