diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..ec56d56
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,7 @@
+node_modules
+.git
+.env
+*.log
+.DS_Store
+coverage
+playwright-report
diff --git a/public/app.js b/public/app.js
index bcb469a..3294a18 100644
--- a/public/app.js
+++ b/public/app.js
@@ -1,259 +1,43 @@
-const state = {
- ideas: [],
- milestones: [],
- activity: [],
- filter: 'all',
- search: '',
- selected: null,
-};
-
-const $ = (sel, root = document) => root.querySelector(sel);
-const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
-const board = $('#board');
-const form = $('#ideaForm');
-const detail = $('#detail');
-const detailForm = $('#detailForm');
-const milestoneSelect = $('#milestoneSelect');
-
-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 scoreOf(idea) { return Number(idea.score || 0).toFixed(1); }
-function escapeHtml(value) {
- return String(value ?? '').replace(/[&<>'"]/g, ch => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[ch]));
-}
-function short(text, len = 126) {
- const value = String(text || '').trim();
- return value.length > len ? `${value.slice(0, len - 1)}…` : value;
-}
-function milestoneFor(id) { return state.milestones.find(m => m.id === id) || state.milestones[0]; }
-function sourceKind(idea) { return /agent|rook|iris|eve|claude|gpt|bot/i.test(`${idea.source} ${idea.sourceName}`) ? 'agent' : 'human'; }
-function toast(message) {
- const el = document.createElement('div');
- el.className = 'toast';
- 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]) => `
${value}${label}
`).join('');
-}
-
-function renderMilestoneOptions() {
- milestoneSelect.innerHTML = state.milestones.map(m => ``).join('');
-}
-
-function renderActivity() {
- $('#activity').innerHTML = state.activity.slice(0, 8).map(item => `${escapeHtml(short(item.message, 52))}`).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('') || 'Drop something here
';
- return `
-
-
${escapeHtml(milestone.name)}
${laneIdeas.length}
-
${escapeHtml(milestone.description || milestone.horizon || '')}
-
- ${cards}
- `;
- }).join('');
- bindDrag();
- bindCards();
-}
-
-function cardHtml(idea) {
- const tags = [`${sourceKind(idea)}`, idea.sourceName, ...(idea.labels || [])].filter(Boolean).slice(0, 5);
- return `
- ${escapeHtml(idea.title)}
${scoreOf(idea)}
- ${idea.description ? `${escapeHtml(short(idea.description))}
` : ''}
- ${tags.map(t => `${escapeHtml(t)}`).join('')}
-
- I ${idea.impact}E ${idea.effort}C ${idea.confidence}U ${idea.urgency}
-
- `;
-}
-
-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 = `Backend is grumpy: ${escapeHtml(error.message)}
`;
- }
-}
-
+const state = { ideas: [], milestones: [], activity: [], activeId: null, selected: null };
+const zones = [
+ { 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' },
+ { 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' },
+ { 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' },
+ { 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' },
+];
+const $ = (sel, root=document) => root.querySelector(sel);
+const $$ = (sel, root=document) => Array.from(root.querySelectorAll(sel));
+const featureDeck = $('#featureDeck'); const sortingGrid = $('#sortingGrid'); const timeline = $('#timeline');
+const detail = $('#detail'); const detailForm = $('#detailForm'); const toastEl = $('#toast');
+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 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 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={}){ 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 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 = `${sorted} / ${total}features sorted
`; }
+function renderDeck(){ const idea = currentActive(); if(!idea){ featureDeck.innerHTML = `No active feature
Add a feature above and it will become the next card in the decision deck.
`; return; } featureDeck.innerHTML = `${escapeHtml(categoryOf(idea))}
${escapeHtml(idea.title)}
${escapeHtml(short(idea.description || 'No brief yet. Open details to add one.'))}
`; $('.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 => ``).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 = `${milestones.map(m=>`
${m.label}`).join('')}${positions.map((p,idx)=>`
`).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}
`; $$('.node', timeline).forEach(n => n.addEventListener('click', () => openDetail(n.dataset.id))); }
+function renderBacklog(){ const items = utilityIdeas(); $('#backlogList').innerHTML = items.length ? items.map(i=>``).join('') : 'Clean utility lanesParked and investigate items will appear here.
'; $$('.mini-card[data-id]').forEach(c => c.addEventListener('click',()=>openDetail(c.dataset.id))); }
+function render(){ 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); }
+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 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 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'); }
+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); } }
+$('#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); } });
+$('#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(); } });
+$$('[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)} });
+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); } });
+$('#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(); });
+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=`Backend is grumpy
${escapeHtml(error.message)}
`; renderGrid(); } }
load();
diff --git a/public/index.html b/public/index.html
index ff1df5f..1977887 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,87 +3,131 @@
-
- Rank — Feature Priorities
-
+
+ Prioritix — Feature Prioritization
+
+
-
-
-
-
-
-
-
-
-
-
-