Finish Prioritix interaction plan
This commit is contained in:
+72
-19
@@ -1,43 +1,96 @@
|
|||||||
const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null };
|
const profiles = {
|
||||||
const zones = [
|
mvp: {
|
||||||
{ 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' },
|
name: 'MVP (2x2)',
|
||||||
{ 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' },
|
gridLabel: 'Sorting grid (MVP - 2x2)',
|
||||||
{ 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' },
|
zones: [
|
||||||
{ 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' },
|
{ 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', left:13 },
|
||||||
];
|
{ 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', left:47 },
|
||||||
|
{ 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', left:68 },
|
||||||
|
{ 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', left:88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
rice: {
|
||||||
|
name: 'RICE Score',
|
||||||
|
gridLabel: 'Sorting grid (RICE)',
|
||||||
|
zones: [
|
||||||
|
{ id:'must', label:'High RICE / Build Now', copy:'Reach, impact, confidence all justify immediate work.', icon:'R', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
|
||||||
|
{ id:'should', label:'Promising / Validate', copy:'Strong impact but needs confidence or reach proof.', icon:'I', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
|
||||||
|
{ id:'nice', label:'Low Reach / Batch', copy:'Useful, but only after higher-reach work is handled.', icon:'C', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
|
||||||
|
{ id:'stretch', label:'Expensive Bet', copy:'Interesting, but effort or uncertainty is too high right now.', icon:'E', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
name: 'Value vs Effort',
|
||||||
|
gridLabel: 'Sorting grid (Value vs Effort)',
|
||||||
|
zones: [
|
||||||
|
{ id:'must', label:'High Value / Low Effort', copy:'Quick win. Pull into the near-term roadmap.', icon:'↗', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
|
||||||
|
{ id:'should', label:'High Value / High Effort', copy:'Strategic work. Shape before committing.', icon:'◆', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
|
||||||
|
{ id:'nice', label:'Low Value / Low Effort', copy:'Polish work. Batch when context is cheap.', icon:'◇', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
|
||||||
|
{ id:'stretch', label:'Low Value / High Effort', copy:'Avoid unless strategy changes.', icon:'×', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kano: {
|
||||||
|
name: 'Kano Model',
|
||||||
|
gridLabel: 'Sorting grid (Kano)',
|
||||||
|
zones: [
|
||||||
|
{ id:'must', label:'Basic Expectation', copy:'Users will notice if this is missing.', icon:'B', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
|
||||||
|
{ id:'should', label:'Performance Driver', copy:'More of this directly improves satisfaction.', icon:'P', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
|
||||||
|
{ id:'nice', label:'Delighter', copy:'Memorable, but not necessary for usefulness.', icon:'D', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
|
||||||
|
{ id:'stretch', label:'Indifferent / Future', copy:'Weak user pull. Keep it outside the core release.', icon:'?', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
name: 'Custom',
|
||||||
|
gridLabel: 'Sorting grid (Custom)',
|
||||||
|
zones: [
|
||||||
|
{ id:'must', label:'Commit', copy:'This belongs in the next real delivery slice.', icon:'✓', color:'#16A34A', bg:'rgba(22,163,74,.06)', milestoneId:'now', status:'must', left:13 },
|
||||||
|
{ id:'should', label:'Shape', copy:'Worth pursuing after requirements are sharpened.', icon:'✎', color:'#2563EB', bg:'rgba(37,99,235,.055)', milestoneId:'next', status:'should', left:47 },
|
||||||
|
{ id:'nice', label:'Queue', copy:'Keep visible, but do not distract the team now.', icon:'≡', color:'#F59E0B', bg:'rgba(245,158,11,.07)', milestoneId:'later', status:'nice', left:68 },
|
||||||
|
{ id:'stretch', label:'Someday', copy:'A possible future bet, not part of this plan.', icon:'∞', color:'#7C3AED', bg:'rgba(124,58,237,.065)', milestoneId:'later', status:'stretch', left:88 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null, profileId: localStorage.getItem('rank-profile') || 'mvp', undo: null };
|
||||||
const $ = (sel, root=document) => root.querySelector(sel);
|
const $ = (sel, root=document) => root.querySelector(sel);
|
||||||
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
|
||||||
const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
|
const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
|
||||||
const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast');
|
const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast');
|
||||||
|
function profile(){ return profiles[state.profileId] || profiles.mvp; }
|
||||||
|
function zones(){ return profile().zones; }
|
||||||
function escapeHtml(value){ return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[ch])); }
|
function escapeHtml(value){ return String(value ?? '').replace(/[&<>'"]/g, ch => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[ch])); }
|
||||||
function short(text, len=130){ const v=String(text||'').trim(); return v.length>len ? `${v.slice(0,len-1)}…` : v; }
|
function short(text, len=130){ const v=String(text||'').trim(); return v.length>len ? `${v.slice(0,len-1)}…` : v; }
|
||||||
function categoryOf(idea){ return (idea.labels && idea.labels[0]) || idea.sourceName || 'UI / UX'; }
|
function categoryOf(idea){ return (idea.labels && idea.labels[0]) || idea.sourceName || 'UI / UX'; }
|
||||||
function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status || i.status === 'inbox' || i.milestoneId === 'inbox')); }
|
function activeIdeas(){ return state.ideas.filter(i => !i.archived && (!i.status || i.status === 'inbox' || i.milestoneId === 'inbox')); }
|
||||||
function sortedIdeas(){ return state.ideas.filter(i => !i.archived && !activeIdeas().some(a => a.id === i.id)); }
|
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)); }
|
function utilityIdeas(){ return state.ideas.filter(i => ['park','investigate'].includes(i.status)); }
|
||||||
|
function zoneFor(idea){ return zones().find(z=>z.status===idea.status) || profiles.mvp.zones.find(z=>z.status===idea.status) || zones()[0]; }
|
||||||
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; }
|
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; }
|
||||||
function toast(message){ toastEl.textContent = message; toastEl.hidden = false; setTimeout(()=>{toastEl.hidden=true}, 2400); }
|
function toast(message, action){ toastEl.innerHTML = `<span>${escapeHtml(message)}</span>${action ? '<button type="button">Undo</button>' : ''}`; toastEl.hidden = false; const btn = $('button', toastEl); if(btn) btn.addEventListener('click', action, { once:true }); clearTimeout(toastEl._timer); toastEl._timer = setTimeout(()=>{toastEl.hidden=true}, action ? 5200 : 2400); }
|
||||||
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; }
|
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; }
|
||||||
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>`; }
|
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>`; }
|
||||||
|
function renderProfile(){ const p=profile(); $('.profile-card strong').textContent=p.name; $('#gridLabel').textContent=p.gridLabel; $$('#profileMenu [data-profile]').forEach(btn=>{ const active=btn.dataset.profile===state.profileId; btn.classList.toggle('selected', active); btn.textContent = profiles[btn.dataset.profile].name + (active ? ' ✓' : ''); }); }
|
||||||
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 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); }); }); }
|
function renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => `<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">${idx+1}</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); }); }); }
|
||||||
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))); }
|
function renderTimeline(){ const items = sortedIdeas().filter(i => !['park','investigate','remove'].includes(i.status)).sort((a,b)=>(a.rank-b.rank)||(new Date(a.updatedAt)-new Date(b.updatedAt))); 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 = zoneFor(idea); const base = typeof idea.rank === 'number' && idea.rank > 0 ? Math.max(7, Math.min(94, idea.rank / 1000)) : z.left; return {idea,z,left: Math.min(96, base + (idx%3)*1.5)}; }); timeline.innerHTML = `<div class="timeline-line" id="timelineLine">${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" draggable="true" data-id="${escapeHtml(p.idea.id)}" title="Drag to reorder · ${escapeHtml(p.idea.title)}" 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>`; const line=$('#timelineLine'); line.addEventListener('dragover', e => { e.preventDefault(); line.classList.add('drag-over'); }); line.addEventListener('dragleave', () => line.classList.remove('drag-over')); line.addEventListener('drop', e => { e.preventDefault(); line.classList.remove('drag-over'); const id=e.dataTransfer.getData('text/plain'); if(id) reorderOnTimeline(id,e); }); $$('.node', timeline).forEach(n => { n.addEventListener('click', () => openDetail(n.dataset.id)); n.addEventListener('dragstart', e => e.dataTransfer.setData('text/plain', n.dataset.id)); }); }
|
||||||
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))); }
|
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))); }
|
||||||
function render(){ renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); }
|
function render(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); }
|
||||||
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); }
|
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 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); } }
|
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:Math.round(z.left*1000)}); 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}`, () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
|
||||||
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); } }
|
async function undoIdea(previous){ if(!previous?.id) return; try{ const updated=await api(`/api/ideas/${previous.id}`,{method:'PATCH',body:{status:previous.status,milestoneId:previous.milestoneId,rank:previous.rank,archived:previous.archived}}); replaceIdea(updated); state.activeId=updated.status === 'inbox' ? updated.id : state.activeId; render(); toast('Undone'); }catch(error){ toast(error.message); } }
|
||||||
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'); }
|
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', () => undoIdea(previous)); } catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
|
||||||
|
async function reorderOnTimeline(id,e){ const line=e.currentTarget; const idea=state.ideas.find(i=>i.id===id); if(!idea) return; const rect=line.getBoundingClientRect(); const pct=Math.max(6,Math.min(94,((e.clientX-rect.left)/rect.width)*100)); const z=zones().reduce((best,item)=>Math.abs(item.left-pct)<Math.abs(best.left-pct)?item:best,zones()[0]); const previous={...idea}; Object.assign(idea,{status:z.status,milestoneId:z.milestoneId,rank:Math.round(pct*1000)}); render(); try{ const data=await api('/api/reorder',{method:'POST',body:{updates:[{id,rank:idea.rank,status:z.status,milestoneId:z.milestoneId}]}}); (data.changed||[]).forEach(replaceIdea); render(); toast(`Moved to ${z.label}`, () => undoIdea(previous)); }catch(error){ Object.assign(idea, previous); render(); toast(error.message); } }
|
||||||
|
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.labels.value=(idea.labels||[]).join(', '); 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-head .chip').textContent=zoneFor(idea).label; detail.classList.add('open'); detail.setAttribute('aria-hidden','false'); }
|
||||||
function closeDetail(){ detail.classList.remove('open'); detail.setAttribute('aria-hidden','true'); state.selected=null; }
|
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); } }
|
async function archiveIdea(id=state.selected){ if(!id) return; const previous=state.ideas.find(i=>i.id===id); 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', previous ? () => undoIdea({...previous, archived:false}) : null); } catch(error){ toast(error.message); } }
|
||||||
$('#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); } });
|
$('#ideaForm').addEventListener('submit', async e => { e.preventDefault(); const form=e.currentTarget; const fd=new FormData(form); 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; form.reset(); $('#title').value=''; $('#description').value=''; $('#labels').value=''; render(); toast('Captured'); }catch(error){ toast(error.message); } });
|
||||||
$('#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(); });
|
$('#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(); });
|
||||||
$('#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(); } });
|
$('#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)));
|
$$('[data-utility]').forEach(b=>b.addEventListener('click',()=>setUtility(b.dataset.utility)));
|
||||||
$('#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)} });
|
$('#closeDetail').addEventListener('click',closeDetail); $('#archiveIdea').addEventListener('click',()=>archiveIdea()); $('#parkDetail').addEventListener('click',async()=>{ if(!state.selected) return; const id=state.selected; const previous=state.ideas.find(i=>i.id===id); try{ const updated=await api(`/api/ideas/${id}`,{method:'PATCH',body:{status:'park',milestoneId:'later'}}); replaceIdea(updated); closeDetail(); render(); toast('Parked', previous ? () => undoIdea(previous) : null); }catch(error){toast(error.message)} });
|
||||||
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); } });
|
detailForm.addEventListener('submit', async e => { e.preventDefault(); if(!state.selected) return; const payload=Object.fromEntries(new FormData(detailForm).entries()); payload.labels=String(payload.labels||'').split(',').map(s=>s.trim()).filter(Boolean); try{ const idea=await api(`/api/ideas/${state.selected}`,{method:'PATCH',body:payload}); replaceIdea(idea); closeDetail(); render(); toast('Saved'); }catch(error){ toast(error.message); } });
|
||||||
$('#profileToggle').addEventListener('click',()=>{ const m=$('#profileMenu'); m.hidden=!m.hidden; });
|
$('#profileToggle').addEventListener('click',()=>{ const m=$('#profileMenu'); m.hidden=!m.hidden; });
|
||||||
document.addEventListener('keydown', e => { if(e.key==='/' && !['INPUT','TEXTAREA'].includes(document.activeElement.tagName)){ e.preventDefault(); $('#title').focus(); } if(e.key==='Escape') closeDetail(); });
|
$$('#profileMenu [data-profile]').forEach(btn=>btn.addEventListener('click',()=>{ state.profileId=btn.dataset.profile; localStorage.setItem('rank-profile',state.profileId); $('#profileMenu').hidden=true; render(); toast(`Profile: ${profile().name}`); }));
|
||||||
|
document.addEventListener('keydown', e => { const typing=['INPUT','TEXTAREA'].includes(document.activeElement.tagName); if(e.key==='/' && !typing){ e.preventDefault(); $('#title').focus(); return; } if(e.key==='Escape') closeDetail(); if(typing) return; if(['1','2','3','4'].includes(e.key)){ e.preventDefault(); placeActive(zones()[Number(e.key)-1]?.id); } if(e.key.toLowerCase()==='p') setUtility('park'); if(e.key.toLowerCase()==='i') setUtility('investigate'); if(e.key.toLowerCase()==='u' && toastEl.querySelector('button')) toastEl.querySelector('button').click(); });
|
||||||
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(); } }
|
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(); } }
|
||||||
load();
|
load();
|
||||||
|
|||||||
+11
-9
@@ -33,11 +33,11 @@
|
|||||||
<strong>MVP (2x2)</strong>
|
<strong>MVP (2x2)</strong>
|
||||||
<button type="button" id="profileToggle" aria-label="Change sorting profile">⌄</button>
|
<button type="button" id="profileToggle" aria-label="Change sorting profile">⌄</button>
|
||||||
<div class="profile-menu" id="profileMenu" hidden>
|
<div class="profile-menu" id="profileMenu" hidden>
|
||||||
<button type="button" class="selected">MVP (2x2) ✓</button>
|
<button type="button" class="selected" data-profile="mvp">MVP (2x2) ✓</button>
|
||||||
<button type="button">RICE Score</button>
|
<button type="button" data-profile="rice">RICE Score</button>
|
||||||
<button type="button">Value vs Effort</button>
|
<button type="button" data-profile="value">Value vs Effort</button>
|
||||||
<button type="button">Kano Model</button>
|
<button type="button" data-profile="kano">Kano Model</button>
|
||||||
<button type="button">Custom</button>
|
<button type="button" data-profile="custom">Custom</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-card" id="progressCard"></div>
|
<div class="progress-card" id="progressCard"></div>
|
||||||
@@ -74,8 +74,9 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="grid-column" aria-label="Sorting grid">
|
<section class="grid-column" aria-label="Sorting grid">
|
||||||
<div class="section-label">Sorting grid (MVP - 2x2)</div>
|
<div class="section-label" id="gridLabel">Sorting grid (MVP - 2x2)</div>
|
||||||
<div class="sorting-grid" id="sortingGrid"></div>
|
<div class="sorting-grid" id="sortingGrid"></div>
|
||||||
|
<p class="shortcut-hint">Shortcuts: <kbd>1</kbd>-<kbd>4</kbd> place active card · <kbd>P</kbd> park · <kbd>I</kbd> investigate · <kbd>U</kbd> undo · <kbd>/</kbd> capture</p>
|
||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@
|
|||||||
<div class="timeline-head">
|
<div class="timeline-head">
|
||||||
<div>
|
<div>
|
||||||
<h2>Timeline view</h2>
|
<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>
|
<p>Sorted features become nodes. Drag nodes horizontally to reorder the roadmap; click a node to inspect details.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="zoom-controls"><button type="button">−</button><button type="button">+</button><button type="button">⛶</button></div>
|
<div class="zoom-controls"><button type="button">−</button><button type="button">+</button><button type="button">⛶</button></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -112,13 +113,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<input name="title" class="detail-title" />
|
<input name="title" class="detail-title" />
|
||||||
<label>Brief<textarea name="description" rows="4" placeholder="What this feature changes for the product"></textarea></label>
|
<label>Brief<textarea name="description" rows="4" placeholder="What this feature changes for the product"></textarea></label>
|
||||||
|
<label>Categories / chips<input name="labels" placeholder="UI / UX, Dependencies, Growth" /></label>
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<label>User value<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, links, dependencies…"></textarea></label>
|
<label>Links, dependencies, notes<textarea name="notes" rows="5" placeholder="Decision notes, source links, dependency risks, acceptance details…"></textarea></label>
|
||||||
<div class="detail-actions">
|
<div class="detail-actions">
|
||||||
<button type="button" id="parkDetail">Park</button>
|
<button type="button" id="parkDetail">Park</button>
|
||||||
<button type="submit">Save</button>
|
<button type="submit">Save</button>
|
||||||
@@ -128,6 +130,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
<script src="/app.js?v=prioritix-20260522-1" type="module"></script>
|
<script src="/app.js?v=prioritix-20260522-3" type="module"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user