Files
SneakyScope/app/blueprints/roadmap.py
Phillip Tarrant cd30cde946 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
2025-08-22 15:05:09 -05:00

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