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:
2025-08-22 15:05:09 -05:00
parent 9cc2f8183c
commit cd30cde946
7 changed files with 723 additions and 38 deletions

262
app/templates/roadmap.html Normal file
View File

@@ -0,0 +1,262 @@
{% extends "base.html" %}
{% block title %}Roadmap{% endblock %}
{% block content %}
<div class="mx-auto max-w-6xl px-4 py-6">
<!-- Header -->
<div class="mb-6 flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 class="text-2xl font-semibold tracking-tight">SneakyScope Roadmap</h1>
{% if updated %}
<p class="text-sm text-gray-400">Last updated: {{ updated }}</p>
{% endif %}
</div>
<!-- Section switcher -->
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="flex items-center gap-2">
<input type="hidden" name="q" value="{{ q }}">
{% for t in selected_tags %}
<input type="hidden" name="tag" value="{{ t }}">
{% endfor %}
{% if min_priority is not none %}
<input type="hidden" name="min_priority" value="{{ min_priority }}">
{% endif %}
{% if milestone %}
<input type="hidden" name="milestone" value="{{ milestone }}">
{% endif %}
<select name="section" class="rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm">
<option value="roadmap" {{ "selected" if section == "roadmap" else "" }}>Roadmap</option>
<option value="backlog" {{ "selected" if section == "backlog" else "" }}>Backlog</option>
<option value="open_questions" {{ "selected" if section == "open_questions" else "" }}>Open Questions</option>
</select>
<button class="rounded-2xl bg-gray-800 px-4 py-2 text-sm font-medium hover:bg-gray-700">Go</button>
</form>
</div>
<!-- Filters -->
<form method="get" action="{{ url_for('roadmap.roadmap_view') }}" class="mb-6 grid gap-4 sm:grid-cols-12">
<input type="hidden" name="section" value="{{ section }}">
<!-- search -->
<div class="sm:col-span-5">
<label class="mb-1 block text-sm text-gray-300">Search</label>
<input name="q" value="{{ q }}" placeholder="Search title, goal, or ID"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- min priority -->
<div class="sm:col-span-2">
<label class="mb-1 block text-sm text-gray-300">Min Priority</label>
<input name="min_priority" type="number" min="1" max="9" value="{{ min_priority if min_priority is not none }}"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- milestone -->
<div class="sm:col-span-3">
<label class="mb-1 block text-sm text-gray-300">Milestone</label>
<input name="milestone" value="{{ milestone or '' }}" placeholder="e.g., v0.2"
class="w-full rounded-xl border border-gray-700 bg-gray-900 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500">
</div>
<!-- submit -->
<div class="sm:col-span-2 flex items-end">
<button class="w-full rounded-2xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-500">Filter</button>
</div>
<!-- tags -->
<div class="sm:col-span-12">
<label class="mb-2 block text-sm text-gray-300">Tags</label>
<div class="flex flex-wrap gap-2">
{% for t in all_tags %}
<label class="inline-flex cursor-pointer items-center gap-2 rounded-2xl border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm hover:border-gray-600">
<input type="checkbox" name="tag" value="{{ t }}" class="h-4 w-4 accent-blue-600"
{% if t in selected_tags %}checked{% endif %}>
<span class="text-gray-200">{{ t }}</span>
</label>
{% endfor %}
</div>
</div>
</form>
<!-- Empty state -->
{% if not items %}
<div class="rounded-2xl border border-gray-700 bg-gray-900 p-6 text-gray-300">
No items match your filters.
</div>
{% endif %}
<!-- Cards -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
{% for it in items %}
<article class="rounded-2xl border border-gray-700 bg-gray-900 p-4">
<div class="mb-2 flex items-start justify-between gap-3">
<h2 class="text-base font-semibold leading-snug">{{ it.title }}</h2>
{% if it.priority is not none %}
<span class="badge badge-info">
P{{ it.priority }}
</span>
{% endif %}
</div>
<p class="mb-3 text-sm text-gray-300">{{ it.goal }}</p>
<!-- chips -->
<div class="mb-3 flex flex-wrap gap-2">
{% for tag in it.tags %}
<span class="chips">
{{ tag }}
</span>
{% endfor %}
{% if it.milestone %}
<span class="badge badge-success">
milestone: {{ it.milestone }}
</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<code class="text-xs text-gray-400">{{ it.id }}</code>
<!-- Placeholder for future actions (Flowbite buttons/menus) -->
<button
type="button"
class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-xs text-gray-200 hover:bg-gray-700"
data-modal-target="roadmap-modal"
data-modal-toggle="roadmap-modal"
data-item='{{ {
"id": it.id,
"title": it.title,
"goal": it.goal,
"priority": it.priority,
"tags": it.tags,
"milestone": it.milestone,
"details": it.details
} | tojson | forceescape }}'
>
Details
</button>
</div>
</article>
{% endfor %}
</div>
</div>
<!-- Modal -->
<div id="roadmap-modal" tabindex="-1" aria-hidden="true"
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-black/60"></div>
<!-- Make SIZE + LAYOUT CHANGES HERE -->
<div class="relative z-10 w-full sm:max-w-3xl md:max-w-4xl lg:max-w-5xl
max-h-[85vh] overflow-hidden rounded-2xl border border-gray-700 bg-gray-900">
<!-- Header (sticky inside modal) -->
<div class="flex items-center justify-between gap-2 border-b border-gray-800 px-4 py-3">
<h3 id="rm-title" class="text-lg font-semibold text-gray-100">Item</h3>
<button type="button" class="rounded-lg p-2 hover:bg-gray-800" data-modal-hide="roadmap-modal" aria-label="Close"></button>
</div>
<!-- Body (scrolls if long) -->
<div class="px-4 py-4 overflow-y-auto">
<div class="mb-4 space-y-2 text-sm">
<div class="text-gray-300"><span class="font-medium">ID:</span> <code id="rm-id" class="text-gray-400"></code></div>
<div class="text-gray-300"><span class="font-medium">Goal:</span> <span id="rm-goal" class="text-gray-200"></span></div>
<div class="text-gray-300"><span class="font-medium">Priority:</span> <span id="rm-priority"></span></div>
<div class="text-gray-300"><span class="font-medium">Milestone:</span> <span id="rm-milestone"></span></div>
<div class="text-gray-300"><span class="font-medium">Tags:</span>
<span id="rm-tags" class="inline-flex flex-wrap gap-2 align-middle"></span>
</div>
</div>
<div>
<h4 class="mb-2 text-sm font-semibold text-gray-200">Details</h4>
<div id="rm-details" class="prose prose-invert max-w-none text-sm"></div>
</div>
</div>
<!-- Footer -->
<div class="flex justify-end gap-2 border-t border-gray-800 px-4 py-3">
<button type="button" class="rounded-xl border border-gray-700 bg-gray-800 px-3 py-1.5 text-sm text-gray-200 hover:bg-gray-700" data-modal-hide="roadmap-modal">
Close
</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
(function(){
function onReady(fn){
if(document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
onReady(function(){
const modal = document.getElementById('roadmap-modal');
function el(id){
return document.getElementById(id);
}
function pill(text){
const span = document.createElement('span');
span.className = "inline-flex items-center rounded-full border border-gray-700 bg-gray-800 px-2.5 py-0.5 text-xs text-gray-200";
span.textContent = text;
return span;
}
function setText(id, v){ el(id).textContent = (v ?? ""); }
function setTags(tags){
const holder = el("rm-tags"); holder.innerHTML = "";
(tags || []).forEach(t => holder.appendChild(pill(t)));
}
function setDetails(list){
const c = el("rm-details"); c.innerHTML = "";
if(!list || !list.length){
const p = document.createElement('p'); p.className = "text-gray-400"; p.textContent = "No additional details.";
c.appendChild(p); return;
}
list.forEach(part => {
const p = document.createElement('p'); p.className = "text-gray-200"; p.textContent = part;
c.appendChild(p);
});
}
function populate(data){
setText("rm-title", data.title || "Item");
setText("rm-id", data.id || "");
setText("rm-goal", data.goal || "");
setText("rm-priority", (data.priority != null) ? `P${data.priority}` : "");
setText("rm-milestone", data.milestone || "");
setTags(data.tags || []);
setDetails(data.details || []);
}
// Event delegation: works for all current and future buttons
document.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-item]');
if(!btn) return;
try{
const raw = btn.getAttribute('data-item') || "{}";
const data = JSON.parse(raw);
populate(data);
// If not using Flowbite to open, uncomment:
// modal.classList.remove('hidden');
} catch(err){
console.error("Failed to parse data-item JSON", err);
}
});
// If not using Flowbite to close, uncomment:
// document.querySelectorAll('[data-modal-hide="roadmap-modal"]').forEach(b => {
// b.addEventListener('click', () => modal.classList.add('hidden'));
// });
});
})();
</script>
</script>
{% endblock %}