Apply Prioritix design to Rank
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
coverage
|
||||||
|
playwright-report
|
||||||
+42
-258
@@ -1,259 +1,43 @@
|
|||||||
const state = {
|
const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null };
|
||||||
ideas: [],
|
const zones = [
|
||||||
milestones: [],
|
{ id:'must', label:'Required / Must Have', copy:'Critical for the solution. Necessary for MVP.', icon:'☆', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must' },
|
||||||
activity: [],
|
{ id:'should', label:'Should Have', copy:'Important but not critical. Adds significant value.', icon:'○', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should' },
|
||||||
filter: 'all',
|
{ id:'nice', label:'Nice to Have', copy:'Good to include if possible. Not essential.', icon:'♡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice' },
|
||||||
search: '',
|
{ id:'stretch', label:'Stretch Goal', copy:'Low priority ideas. Consider for the future.', icon:'ϟ', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch' },
|
||||||
selected: null,
|
];
|
||||||
};
|
const $ = (sel, root=document) => root.querySelector(sel);
|
||||||
|
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
||||||
const $ = (sel, root = document) => root.querySelector(sel);
|
const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
|
||||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast');
|
||||||
const board = $('#board');
|
function escapeHtml(value){ return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[ch])); }
|
||||||
const form = $('#ideaForm');
|
function short(text, len=130){ const v=String(text||'').trim(); return v.length>len ? `${v.slice(0,len-1)}…` : v; }
|
||||||
const detail = $('#detail');
|
function categoryOf(idea){ return (idea.labels && idea.labels[0]) || idea.sourceName || 'UI / UX'; }
|
||||||
const detailForm = $('#detailForm');
|
function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status || i.status === 'inbox' || i.milestoneId === 'inbox')); }
|
||||||
const milestoneSelect = $('#milestoneSelect');
|
function sortedIdeas(){ return state.ideas.filter(i => !i.archived && !activeIdeas().some(a => a.id === i.id)); }
|
||||||
|
function utilityIdeas(){ return state.ideas.filter(i => ['park','investigate'].includes(i.status)); }
|
||||||
async function api(path, options = {}) {
|
async function api(path, options={}){ const res = await fetch(path,{ headers:{'Content-Type':'application/json',...(options.headers||{})}, ...options, body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body }); const text=await res.text(); const data=text?JSON.parse(text):null; if(!res.ok) throw new Error(data?.error||res.statusText); return data; }
|
||||||
const res = await fetch(path, {
|
function toast(message){ toastEl.textContent = message; toastEl.hidden = false; setTimeout(()=>{toastEl.hidden=true}, 2400); }
|
||||||
headers: { 'Content-Type': 'application/json', ...(options.headers || {}) },
|
function currentActive(){ const queue = activeIdeas().sort((a,b)=>(a.rank-b.rank)||(new Date(a.createdAt)-new Date(b.createdAt))); if(!state.activeId || !queue.some(i=>i.id===state.activeId)) state.activeId = queue[0]?.id || null; return queue.find(i=>i.id===state.activeId) || null; }
|
||||||
...options,
|
function renderProgress(){ const total = state.ideas.filter(i=>!i.archived).length; const sorted = sortedIdeas().length; const pct = total ? Math.round((sorted/total)*100) : 0; $('#progressCard').innerHTML = `<strong>${sorted} / ${total}</strong><span>features sorted</span><div class="progressbar"><i style="width:${pct}%"></i></div>`; }
|
||||||
body: options.body && typeof options.body !== 'string' ? JSON.stringify(options.body) : options.body,
|
function renderDeck(){ const idea = currentActive(); if(!idea){ featureDeck.innerHTML = `<div class="empty-deck"><strong>No active feature</strong><br>Add a feature above and it will become the next card in the decision deck.</div>`; return; } featureDeck.innerHTML = `<article class="feature-card" draggable="true" data-id="${escapeHtml(idea.id)}"><div class="feature-meta"><span class="category-dot"></span>${escapeHtml(categoryOf(idea))}</div><h2>${escapeHtml(idea.title)}</h2><p>${escapeHtml(short(idea.description || 'No brief yet. Open details to add one.'))}</p><button type="button" class="open-details" aria-label="Open details">↗</button></article>`; $('.feature-card').addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', idea.id)); $('.open-details').addEventListener('click', () => openDetail(idea.id)); $('.feature-card').addEventListener('dblclick', () => openDetail(idea.id)); }
|
||||||
});
|
function renderGrid(){ sortingGrid.innerHTML = zones.map(z => `<button type="button" class="zone" data-zone="${z.id}" style="--zone-color:${z.color};--zone-bg:${z.bg}"><div><div class="zone-icon">${z.icon}</div><strong>${z.label}</strong><span>${z.copy}</span><div class="drop-hint">⇧</div></div></button>`).join(''); $$('.zone').forEach(el => { el.addEventListener('click', () => placeActive(el.dataset.zone)); el.addEventListener('dragover', e => { e.preventDefault(); el.classList.add('drag-over'); }); el.addEventListener('dragleave', () => el.classList.remove('drag-over')); el.addEventListener('drop', e => { e.preventDefault(); el.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain') || state.activeId; placeFeature(id, el.dataset.zone); }); }); }
|
||||||
const text = await res.text();
|
function renderTimeline(){ const items = sortedIdeas().filter(i => !['park','investigate','remove'].includes(i.status)); const milestones = [{label:'MVP',left:8,color:'#16A34A'},{label:'Beta',left:42,color:'#2563EB'},{label:'1.0',left:66,color:'#DC2626'},{label:'Stretch Goal',left:88,color:'#7C3AED'}]; const positions = items.map((idea, idx) => { const z = zones.find(z=>z.status===idea.status) || zones[idx % zones.length]; const base = {must:13,should:47,nice:68,stretch:88}[z.status] || 20; return {idea,z,left: Math.min(96, base + (idx%5)*4)}; }); timeline.innerHTML = `<div class="timeline-line">${milestones.map(m=>`<span class="milestone-label" style="left:${m.left}%;--milestone-color:${m.color}">${m.label}</span><span class="milestone-tick" style="left:${m.left}%;--milestone-color:${m.color}"></span>`).join('')}${positions.map((p,idx)=>`<button class="node" data-id="${escapeHtml(p.idea.id)}" style="left:${p.left}%;--node-color:${p.z.color}">${idx+1}</button>`).join('')}${positions.slice(0,1).map(p=>`<div class="node-card" style="left:${p.left}%"><strong>${escapeHtml(short(p.idea.title,32))}</strong><span>${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}</span></div>`).join('')}</div>`; $$('.node', timeline).forEach(n => n.addEventListener('click', () => openDetail(n.dataset.id))); }
|
||||||
const data = text ? JSON.parse(text) : null;
|
function renderBacklog(){ const items = utilityIdeas(); $('#backlogList').innerHTML = items.length ? items.map(i=>`<button type="button" class="mini-card" data-id="${escapeHtml(i.id)}"><strong>${escapeHtml(i.title)}</strong><span>${escapeHtml(i.status)} · ${escapeHtml(short(i.description,70))}</span></button>`).join('') : '<div class="mini-card"><strong>Clean utility lanes</strong><span>Parked and investigate items will appear here.</span></div>'; $$('.mini-card[data-id]').forEach(c => c.addEventListener('click',()=>openDetail(c.dataset.id))); }
|
||||||
if (!res.ok) throw new Error(data?.error || res.statusText);
|
function render(){ renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); }
|
||||||
return data;
|
function replaceIdea(idea){ const idx=state.ideas.findIndex(i=>i.id===idea.id); if(idx>=0) state.ideas[idx]=idea; else state.ideas.unshift(idea); }
|
||||||
}
|
async function placeActive(zoneId){ const idea = currentActive(); if(!idea) return toast('Capture a feature first'); return placeFeature(idea.id, zoneId); }
|
||||||
|
async function placeFeature(id, zoneId){ const z = zones.find(z=>z.id===zoneId); const idea = state.ideas.find(i=>i.id===id); if(!z || !idea) return; const previous = {...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Date.now()%100000}); render(); try{ const updated = await api(`/api/ideas/${id}`, {method:'PATCH', body:{status:z.status,milestoneId:z.milestoneId,rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(`Placed in ${z.label}`); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
|
||||||
function scoreOf(idea) { return Number(idea.score || 0).toFixed(1); }
|
async function setUtility(action){ const idea=currentActive(); if(!idea) return toast('No active feature'); if(action==='remove') return archiveIdea(idea.id); const previous={...idea}; Object.assign(idea,{status:action,milestoneId:'later',rank:Date.now()%100000}); render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{status:action,milestoneId:'later',rank:idea.rank}}); replaceIdea(updated); state.activeId=null; render(); toast(action === 'park' ? 'Parked' : 'Sent to investigate'); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
|
||||||
function escapeHtml(value) {
|
function openDetail(id){ const idea=state.ideas.find(i=>i.id===id); if(!idea) return; state.selected=id; detailForm.title.value=idea.title||''; detailForm.description.value=idea.description||''; detailForm.impact.value=idea.impact??5; detailForm.effort.value=idea.effort??5; detailForm.confidence.value=idea.confidence??5; detailForm.urgency.value=idea.urgency??5; detailForm.notes.value=idea.notes||''; $('#detailCategory').textContent=categoryOf(idea); detail.classList.add('open'); detail.setAttribute('aria-hidden','false'); }
|
||||||
return String(value ?? '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch]));
|
function closeDetail(){ detail.classList.remove('open'); detail.setAttribute('aria-hidden','true'); state.selected=null; }
|
||||||
}
|
async function archiveIdea(id=state.selected){ if(!id) return; try{ await api(`/api/ideas/${id}`,{method:'PATCH',body:{archived:true,status:'remove'}}); state.ideas = state.ideas.filter(i=>i.id!==id); closeDetail(); render(); toast('Removed'); } catch(error){ toast(error.message); } }
|
||||||
function short(text, len = 126) {
|
$('#ideaForm').addEventListener('submit', async e => { e.preventDefault(); const fd=new FormData(e.currentTarget); const payload=Object.fromEntries(fd.entries()); payload.labels=String(payload.labels||'UI / UX').split(',').map(s=>s.trim()).filter(Boolean); payload.source='human'; payload.status='inbox'; try{ const idea=await api('/api/ideas',{method:'POST',body:payload}); replaceIdea(idea); state.activeId=idea.id; e.currentTarget.reset(); $('#title').value=''; $('#description').value=''; $('#labels').value=''; render(); toast('Captured'); }catch(error){ toast(error.message); } });
|
||||||
const value = String(text || '').trim();
|
$('#skipFeature').addEventListener('click',()=>{ const q=activeIdeas(); if(q.length<2) return toast('No next feature'); const idx=q.findIndex(i=>i.id===state.activeId); state.activeId=q[(idx+1)%q.length].id; renderDeck(); });
|
||||||
return value.length > len ? `${value.slice(0, len - 1)}…` : value;
|
$('#postponeFeature').addEventListener('click',async()=>{ const idea=currentActive(); if(!idea) return; idea.rank=Date.now()%100000; state.activeId=null; render(); try{ const updated=await api(`/api/ideas/${idea.id}`,{method:'PATCH',body:{rank:idea.rank}}); replaceIdea(updated); render(); toast('Postponed'); }catch(error){ toast(error.message); await load(); } });
|
||||||
}
|
$$('[data-utility]').forEach(b=>b.addEventListener('click',()=>setUtility(b.dataset.utility)));
|
||||||
function milestoneFor(id) { return state.milestones.find(m => m.id === id) || state.milestones[0]; }
|
$('#closeDetail').addEventListener('click',closeDetail); $('#archiveIdea').addEventListener('click',()=>archiveIdea()); $('#parkDetail').addEventListener('click',async()=>{ if(!state.selected) return; const id=state.selected; try{ const updated=await api(`/api/ideas/${id}`,{method:'PATCH',body:{status:'park',milestoneId:'later'}}); replaceIdea(updated); closeDetail(); render(); toast('Parked'); }catch(error){toast(error.message)} });
|
||||||
function sourceKind(idea) { return /agent|rook|iris|eve|claude|gpt|bot/i.test(`${idea.source} ${idea.sourceName}`) ? 'agent' : 'human'; }
|
detailForm.addEventListener('submit', async e => { e.preventDefault(); if(!state.selected) return; const payload=Object.fromEntries(new FormData(detailForm).entries()); try{ const idea=await api(`/api/ideas/${state.selected}`,{method:'PATCH',body:payload}); replaceIdea(idea); closeDetail(); render(); toast('Saved'); }catch(error){ toast(error.message); } });
|
||||||
function toast(message) {
|
$('#profileToggle').addEventListener('click',()=>{ const m=$('#profileMenu'); m.hidden=!m.hidden; });
|
||||||
const el = document.createElement('div');
|
document.addEventListener('keydown', e => { if(e.key==='/' && !['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){ e.preventDefault(); $('#title').focus(); } if(e.key==='Escape') closeDetail(); });
|
||||||
el.className = 'toast';
|
async function load(){ try{ const data=await api('/api/bootstrap'); state.ideas=data.ideas||[]; state.milestones=data.milestones||[]; state.activity=data.activity||[]; render(); } catch(error){ featureDeck.innerHTML=`<div class="empty-deck"><strong>Backend is grumpy</strong><br>${escapeHtml(error.message)}</div>`; renderGrid(); } }
|
||||||
el.textContent = message;
|
|
||||||
document.body.append(el);
|
|
||||||
setTimeout(() => el.remove(), 2200);
|
|
||||||
}
|
|
||||||
|
|
||||||
function filteredIdeas() {
|
|
||||||
const q = state.search.toLowerCase();
|
|
||||||
return state.ideas.filter(idea => {
|
|
||||||
if (state.filter === 'human' && sourceKind(idea) !== 'human') return false;
|
|
||||||
if (state.filter === 'agent' && sourceKind(idea) !== 'agent') return false;
|
|
||||||
if (state.filter === 'high' && Number(idea.score) < 4) return false;
|
|
||||||
if (!q) return true;
|
|
||||||
return [idea.title, idea.description, idea.sourceName, ...(idea.labels || [])].join(' ').toLowerCase().includes(q);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStats() {
|
|
||||||
const ideas = state.ideas;
|
|
||||||
const now = ideas.filter(i => i.milestoneId === 'now').length;
|
|
||||||
const agent = ideas.filter(i => sourceKind(i) === 'agent').length;
|
|
||||||
const top = ideas.slice().sort((a, b) => b.score - a.score)[0];
|
|
||||||
$('#stats').innerHTML = [
|
|
||||||
['Ideas', ideas.length],
|
|
||||||
['Now', now],
|
|
||||||
['Agent drops', agent],
|
|
||||||
['Top score', top ? scoreOf(top) : '—'],
|
|
||||||
].map(([label, value]) => `<div class="stat"><strong>${value}</strong><span>${label}</span></div>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMilestoneOptions() {
|
|
||||||
milestoneSelect.innerHTML = state.milestones.map(m => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.name)}</option>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActivity() {
|
|
||||||
$('#activity').innerHTML = state.activity.slice(0, 8).map(item => `<span class="activity-item">${escapeHtml(short(item.message, 52))}</span>`).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderBoard() {
|
|
||||||
renderStats();
|
|
||||||
renderMilestoneOptions();
|
|
||||||
renderActivity();
|
|
||||||
const ideas = filteredIdeas();
|
|
||||||
board.innerHTML = state.milestones.map(milestone => {
|
|
||||||
const laneIdeas = ideas
|
|
||||||
.filter(idea => (idea.milestoneId || 'inbox') === milestone.id)
|
|
||||||
.sort((a, b) => (a.rank - b.rank) || (b.score - a.score));
|
|
||||||
const cards = laneIdeas.map(cardHtml).join('') || '<div class="empty">Drop something here</div>';
|
|
||||||
return `<article class="lane" data-milestone="${escapeHtml(milestone.id)}" style="--lane-color:${escapeHtml(milestone.color)}">
|
|
||||||
<div class="lane-head">
|
|
||||||
<div class="lane-title"><h2>${escapeHtml(milestone.name)}</h2><strong>${laneIdeas.length}</strong></div>
|
|
||||||
<p>${escapeHtml(milestone.description || milestone.horizon || '')}</p>
|
|
||||||
</div>
|
|
||||||
<div class="cards">${cards}</div>
|
|
||||||
</article>`;
|
|
||||||
}).join('');
|
|
||||||
bindDrag();
|
|
||||||
bindCards();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardHtml(idea) {
|
|
||||||
const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5);
|
|
||||||
return `<article class="card" draggable="true" data-id="${escapeHtml(idea.id)}">
|
|
||||||
<div class="card-top"><h3>${escapeHtml(idea.title)}</h3><div class="score">${scoreOf(idea)}</div></div>
|
|
||||||
${idea.description ? `<p>${escapeHtml(short(idea.description))}</p>` : ''}
|
|
||||||
<div class="meta">${tags.map(t => `<span class="pill">${escapeHtml(t)}</span>`).join('')}</div>
|
|
||||||
<div class="metrics">
|
|
||||||
<span>I <b>${idea.impact}</b></span><span>E <b>${idea.effort}</b></span><span>C <b>${idea.confidence}</b></span><span>U <b>${idea.urgency}</b></span>
|
|
||||||
</div>
|
|
||||||
</article>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindCards() {
|
|
||||||
$$('.card').forEach(card => card.addEventListener('click', () => openDetail(card.dataset.id)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindDrag() {
|
|
||||||
$$('.card').forEach(card => {
|
|
||||||
card.addEventListener('dragstart', event => {
|
|
||||||
card.classList.add('dragging');
|
|
||||||
event.dataTransfer.setData('text/plain', card.dataset.id);
|
|
||||||
});
|
|
||||||
card.addEventListener('dragend', () => card.classList.remove('dragging'));
|
|
||||||
});
|
|
||||||
$$('.lane').forEach(lane => {
|
|
||||||
lane.addEventListener('dragover', event => { event.preventDefault(); lane.classList.add('drag-over'); });
|
|
||||||
lane.addEventListener('dragleave', () => lane.classList.remove('drag-over'));
|
|
||||||
lane.addEventListener('drop', async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
lane.classList.remove('drag-over');
|
|
||||||
const id = event.dataTransfer.getData('text/plain');
|
|
||||||
const idea = state.ideas.find(i => i.id === id);
|
|
||||||
const milestoneId = lane.dataset.milestone;
|
|
||||||
if (!idea || idea.milestoneId === milestoneId) return;
|
|
||||||
idea.milestoneId = milestoneId;
|
|
||||||
idea.rank = Date.now() % 100000;
|
|
||||||
renderBoard();
|
|
||||||
try {
|
|
||||||
const updated = await api(`/api/ideas/${id}`, { method: 'PATCH', body: { milestoneId, rank: idea.rank } });
|
|
||||||
replaceIdea(updated);
|
|
||||||
toast(`Moved to ${milestoneFor(milestoneId).name}`);
|
|
||||||
} catch (error) { toast(error.message); await load(); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceIdea(idea) {
|
|
||||||
const idx = state.ideas.findIndex(i => i.id === idea.id);
|
|
||||||
if (idx >= 0) state.ideas[idx] = idea;
|
|
||||||
else state.ideas.unshift(idea);
|
|
||||||
state.ideas.sort((a, b) => b.score - a.score);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(id) {
|
|
||||||
const idea = state.ideas.find(i => i.id === id);
|
|
||||||
if (!idea) return;
|
|
||||||
state.selected = idea.id;
|
|
||||||
detailForm.title.value = idea.title;
|
|
||||||
detailForm.description.value = idea.description || '';
|
|
||||||
detailForm.impact.value = idea.impact;
|
|
||||||
detailForm.effort.value = idea.effort;
|
|
||||||
detailForm.confidence.value = idea.confidence;
|
|
||||||
detailForm.urgency.value = idea.urgency;
|
|
||||||
detailForm.notes.value = idea.notes || '';
|
|
||||||
detail.classList.add('open');
|
|
||||||
detail.setAttribute('aria-hidden', 'false');
|
|
||||||
}
|
|
||||||
function closeDetail() {
|
|
||||||
detail.classList.remove('open');
|
|
||||||
detail.setAttribute('aria-hidden', 'true');
|
|
||||||
state.selected = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
form.addEventListener('submit', async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
const fd = new FormData(form);
|
|
||||||
const payload = Object.fromEntries(fd.entries());
|
|
||||||
payload.labels = String(payload.labels || '').split(',').map(s => s.trim()).filter(Boolean);
|
|
||||||
payload.source = payload.sourceName ? sourceKind({ sourceName: payload.sourceName }) : 'human';
|
|
||||||
payload.status = payload.milestoneId === 'inbox' ? 'inbox' : 'planned';
|
|
||||||
try {
|
|
||||||
const idea = await api('/api/ideas', { method: 'POST', body: payload });
|
|
||||||
replaceIdea(idea);
|
|
||||||
form.reset();
|
|
||||||
form.impact.value = 7; form.effort.value = 4; form.confidence.value = 6; form.urgency.value = 5;
|
|
||||||
renderBoard();
|
|
||||||
toast('Captured');
|
|
||||||
} catch (error) { toast(error.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
detailForm.addEventListener('submit', async event => {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!state.selected) return;
|
|
||||||
const payload = Object.fromEntries(new FormData(detailForm).entries());
|
|
||||||
try {
|
|
||||||
const idea = await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: payload });
|
|
||||||
replaceIdea(idea);
|
|
||||||
closeDetail();
|
|
||||||
renderBoard();
|
|
||||||
toast('Saved');
|
|
||||||
} catch (error) { toast(error.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#archiveIdea').addEventListener('click', async () => {
|
|
||||||
if (!state.selected) return;
|
|
||||||
try {
|
|
||||||
await api(`/api/ideas/${state.selected}`, { method: 'PATCH', body: { archived: true } });
|
|
||||||
state.ideas = state.ideas.filter(i => i.id !== state.selected);
|
|
||||||
closeDetail();
|
|
||||||
renderBoard();
|
|
||||||
toast('Archived');
|
|
||||||
} catch (error) { toast(error.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
$('#closeDetail').addEventListener('click', closeDetail);
|
|
||||||
$('#refresh').addEventListener('click', load);
|
|
||||||
$('#search').addEventListener('input', event => { state.search = event.target.value; renderBoard(); });
|
|
||||||
$('#filters').addEventListener('click', event => {
|
|
||||||
const button = event.target.closest('button[data-filter]');
|
|
||||||
if (!button) return;
|
|
||||||
state.filter = button.dataset.filter;
|
|
||||||
$$('#filters button').forEach(b => b.classList.toggle('active', b === button));
|
|
||||||
renderBoard();
|
|
||||||
});
|
|
||||||
$('#addMilestone').addEventListener('click', async () => {
|
|
||||||
const name = prompt('Milestone name');
|
|
||||||
if (!name) return;
|
|
||||||
const horizon = prompt('Horizon / timing', 'Custom') || '';
|
|
||||||
const colors = ['#8cf7ff', '#f8ff73', '#a78bfa', '#6ee7b7', '#ff5e7a'];
|
|
||||||
try {
|
|
||||||
const milestone = await api('/api/milestones', { method: 'POST', body: { name, horizon, color: colors[state.milestones.length % colors.length], position: state.milestones.length * 10 } });
|
|
||||||
state.milestones.push(milestone);
|
|
||||||
renderBoard();
|
|
||||||
toast('Milestone added');
|
|
||||||
} catch (error) { toast(error.message); }
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', event => {
|
|
||||||
if (event.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) {
|
|
||||||
event.preventDefault();
|
|
||||||
$('#title').focus();
|
|
||||||
}
|
|
||||||
if (event.key === 'Escape') closeDetail();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
try {
|
|
||||||
const data = await api('/api/bootstrap');
|
|
||||||
state.ideas = data.ideas || [];
|
|
||||||
state.milestones = data.milestones || [];
|
|
||||||
state.activity = data.activity || [];
|
|
||||||
renderBoard();
|
|
||||||
} catch (error) {
|
|
||||||
board.innerHTML = `<div class="empty">Backend is grumpy: ${escapeHtml(error.message)}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
load();
|
||||||
|
|||||||
+100
-56
@@ -3,87 +3,131 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#050712" />
|
<meta name="theme-color" content="#061B33" />
|
||||||
<title>Rank — Feature Priorities</title>
|
<title>Prioritix — Feature Prioritization</title>
|
||||||
<link rel="stylesheet" href="/styles.css?v=rank-1" />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link rel="stylesheet" href="/styles.css?v=prioritix-20260522-1" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="noise"></div>
|
<div class="app-shell">
|
||||||
<main class="shell">
|
<aside class="sidebar" aria-label="Main navigation">
|
||||||
<header class="hero">
|
<div class="brand-mark" aria-label="Prioritix">P</div>
|
||||||
<div>
|
<nav>
|
||||||
<p class="eyebrow">rank.friborg.uk · feature triage</p>
|
<a class="active" href="#prioritize"><span>▦</span><b>Prioritize</b></a>
|
||||||
<h1>Drop ideas. Score fast. Drag into reality.</h1>
|
<a href="#backlog"><span>☰</span><b>Backlog</b></a>
|
||||||
<p class="subcopy">A sharp prioritization board for humans and agents. No ceremony, no rounded-corner startup soup.</p>
|
<a href="#roadmap"><span>◇</span><b>Roadmap</b></a>
|
||||||
|
<a href="#reports"><span>↗</span><b>Reports</b></a>
|
||||||
|
</nav>
|
||||||
|
<button class="collapse" type="button" title="Collapse sidebar">←</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="workspace">
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="menu-button" type="button" aria-label="Menu">☰</button>
|
||||||
|
<div class="project-title">
|
||||||
|
<h1>Rank Prioritization Studio</h1>
|
||||||
|
<p><strong>Project goal:</strong> capture, sort, and turn rough feature ideas into a visible roadmap.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats" id="stats"></div>
|
<div class="profile-card">
|
||||||
|
<span>Sorting profile</span>
|
||||||
|
<strong>MVP (2x2)</strong>
|
||||||
|
<button type="button" id="profileToggle" aria-label="Change sorting profile">⌄</button>
|
||||||
|
<div class="profile-menu" id="profileMenu" hidden>
|
||||||
|
<button type="button" class="selected">MVP (2x2) ✓</button>
|
||||||
|
<button type="button">RICE Score</button>
|
||||||
|
<button type="button">Value vs Effort</button>
|
||||||
|
<button type="button">Kano Model</button>
|
||||||
|
<button type="button">Custom</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-card" id="progressCard"></div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="capture-panel" aria-label="Quick capture">
|
<section class="capture-strip" aria-label="Quick capture">
|
||||||
<form id="ideaForm" autocomplete="off">
|
<form id="ideaForm" autocomplete="off">
|
||||||
<div class="capture-main">
|
<label class="capture-title">New feature
|
||||||
<label for="title">Idea</label>
|
<input id="title" name="title" maxlength="180" placeholder="Smart requirement sorting, mobile dark mode, dependency warnings…" required />
|
||||||
<input id="title" name="title" maxlength="180" placeholder="Press / then type the feature that keeps nagging at you" required />
|
</label>
|
||||||
</div>
|
<label>Brief
|
||||||
<div class="capture-grid">
|
<input id="description" name="description" maxlength="500" placeholder="One sentence. Keep sorting focused." />
|
||||||
<label>Description<textarea id="description" name="description" rows="2" placeholder="Optional: what it does, why it matters, ugly constraints…"></textarea></label>
|
</label>
|
||||||
<label>Labels<input id="labels" name="labels" placeholder="scattermind, revenue, ux" /></label>
|
<label>Category
|
||||||
<label>Source<input id="sourceName" name="sourceName" placeholder="Jimmi, Rook, Iris…" /></label>
|
<input id="labels" name="labels" placeholder="UI / UX" />
|
||||||
<label>Milestone<select id="milestoneSelect" name="milestoneId"></select></label>
|
</label>
|
||||||
</div>
|
<input type="hidden" name="milestoneId" value="inbox" />
|
||||||
<div class="score-row">
|
<input type="hidden" name="impact" value="7" />
|
||||||
<label>Impact <input type="range" min="0" max="10" value="7" name="impact" /></label>
|
<input type="hidden" name="effort" value="4" />
|
||||||
<label>Effort <input type="range" min="1" max="10" value="4" name="effort" /></label>
|
<input type="hidden" name="confidence" value="6" />
|
||||||
<label>Confidence <input type="range" min="0" max="10" value="6" name="confidence" /></label>
|
<input type="hidden" name="urgency" value="5" />
|
||||||
<label>Urgency <input type="range" min="0" max="10" value="5" name="urgency" /></label>
|
|
||||||
<button type="submit">Capture ↵</button>
|
<button type="submit">Capture ↵</button>
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="toolbar">
|
<section class="sorting-layout" id="prioritize">
|
||||||
<div class="tabs" id="filters">
|
<section class="active-column" aria-label="Active feature">
|
||||||
<button data-filter="all" class="active">All</button>
|
<div class="section-label">Active feature</div>
|
||||||
<button data-filter="human">Human</button>
|
<div class="feature-deck" id="featureDeck"></div>
|
||||||
<button data-filter="agent">Agent</button>
|
<div class="deck-actions">
|
||||||
<button data-filter="high">High score</button>
|
<button type="button" id="skipFeature">▷ Skip</button>
|
||||||
</div>
|
<button type="button" id="postponeFeature">↓ Postpone</button>
|
||||||
<div class="tools">
|
|
||||||
<input id="search" placeholder="Filter ideas" />
|
|
||||||
<button id="addMilestone">+ Milestone</button>
|
|
||||||
<button id="refresh">Refresh</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="board" class="board" aria-label="Priority board"></section>
|
<section class="grid-column" aria-label="Sorting grid">
|
||||||
|
<div class="section-label">Sorting grid (MVP - 2x2)</div>
|
||||||
|
<div class="sorting-grid" id="sortingGrid"></div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="utility-row" aria-label="Feature card actions">
|
||||||
|
<button type="button" data-utility="park"><span>⚑</span><strong>Park</strong><small>Valid idea, not relevant now. Revisit later.</small></button>
|
||||||
|
<button type="button" data-utility="investigate"><span>⌕</span><strong>Investigate</strong><small>Needs more research or validation.</small></button>
|
||||||
|
<button type="button" data-utility="remove"><span>⌫</span><strong>Remove</strong><small>Not a good fit. Remove from project.</small></button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="timeline-panel" id="roadmap" aria-label="Timeline view">
|
||||||
|
<div class="timeline-head">
|
||||||
|
<div>
|
||||||
|
<h2>Timeline view</h2>
|
||||||
|
<p>Sorted features become nodes. Click a node to inspect; drag-and-drop can come later without breaking the model.</p>
|
||||||
|
</div>
|
||||||
|
<div class="zoom-controls"><button type="button">−</button><button type="button">+</button><button type="button">⛶</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="timeline" id="timeline"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="backlog-panel" id="backlog">
|
||||||
|
<div class="section-label">Backlog / utility states</div>
|
||||||
|
<div id="backlogList" class="backlog-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<aside class="detail" id="detail" aria-hidden="true">
|
<aside class="detail" id="detail" aria-hidden="true">
|
||||||
<form id="detailForm">
|
<form id="detailForm">
|
||||||
<div class="detail-head">
|
<div class="detail-head">
|
||||||
<p class="eyebrow">selected idea</p>
|
<div><span class="category-dot"></span><span id="detailCategory">UI / UX</span></div>
|
||||||
<button type="button" id="closeDetail">×</button>
|
<span class="chip">MVP</span>
|
||||||
|
<button type="button" id="closeDetail" aria-label="Close">×</button>
|
||||||
</div>
|
</div>
|
||||||
<input name="title" class="detail-title" />
|
<input name="title" class="detail-title" />
|
||||||
<textarea name="description" rows="8" placeholder="Description"></textarea>
|
<label>Brief<textarea name="description" rows="4" placeholder="What this feature changes for the product"></textarea></label>
|
||||||
<div class="detail-sliders">
|
<div class="detail-grid">
|
||||||
<label>Impact <input type="number" min="0" max="10" name="impact" /></label>
|
<label>User value<input type="number" min="0" max="10" name="impact" /></label>
|
||||||
<label>Effort <input type="number" min="1" max="10" name="effort" /></label>
|
<label>Effort<input type="number" min="1" max="10" name="effort" /></label>
|
||||||
<label>Confidence <input type="number" min="0" max="10" name="confidence" /></label>
|
<label>Confidence<input type="number" min="0" max="10" name="confidence" /></label>
|
||||||
<label>Urgency <input type="number" min="0" max="10" name="urgency" /></label>
|
<label>Urgency<input type="number" min="0" max="10" name="urgency" /></label>
|
||||||
</div>
|
</div>
|
||||||
<label>Notes<textarea name="notes" rows="5" placeholder="Decision notes, objections, cut lines…"></textarea></label>
|
<label>Notes<textarea name="notes" rows="5" placeholder="Decision notes, links, dependencies…"></textarea></label>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
|
<button type="button" id="parkDetail">Park</button>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
<button type="button" id="archiveIdea">Archive</button>
|
<button type="button" id="archiveIdea" class="danger">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<footer>
|
<div class="toast" id="toast" hidden></div>
|
||||||
<div id="activity"></div>
|
<script src="/app.js?v=prioritix-20260522-1" type="module"></script>
|
||||||
<p>Keyboard: <kbd>/</kbd> capture · <kbd>Esc</kbd> close · drag cards between milestones.</p>
|
|
||||||
</footer>
|
|
||||||
</main>
|
|
||||||
<script src="/app.js?v=rank-1" type="module"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+18
-107
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user