diff --git a/.dockerignore b/.dockerignore index ec56d56..41caa73 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ node_modules .git +npm-debug.log +.DS_Store .env *.log -.DS_Store -coverage -playwright-report diff --git a/public/app.js b/public/app.js index 00a0b07..e28771b 100644 --- a/public/app.js +++ b/public/app.js @@ -74,6 +74,89 @@ function renderGrid(){ sortingGrid.innerHTML = zones().map((z,idx) => ``).join('')}${positions.slice(0,1).map(p=>`
${escapeHtml(short(p.idea.title,32))}${escapeHtml(categoryOf(p.idea))} · ${escapeHtml(p.z.label)}
`).join('')}`; 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=>``).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(){ renderProfile(); renderProgress(); renderDeck(); renderGrid(); renderTimeline(); renderBacklog(); } +function sortedSetupFeatures(){ + const ordered = state.ideas + .filter(i => !i.archived) + .slice() + .sort((a,b) => { + const aActive = activeIdeas().some(i => i.id === a.id) ? 0 : 1; + const bActive = activeIdeas().some(i => i.id === b.id) ? 0 : 1; + return (aActive - bActive) || ((a.rank || 0) - (b.rank || 0)) || ((b.score || 0) - (a.score || 0)) || String(a.title).localeCompare(String(b.title)); + }); + return ordered.map((idea, index) => { + const z = zoneFor(idea); + return { + title: idea.title, + description: idea.description || '', + labels: idea.labels || [], + impact: idea.impact ?? 5, + effort: idea.effort ?? 5, + confidence: idea.confidence ?? 5, + urgency: idea.urgency ?? 5, + score: idea.score ?? 0, + status: idea.status || 'inbox', + milestoneId: idea.milestoneId || 'inbox', + rank: idea.rank ?? 0, + notes: idea.notes || '', + sortedSetup: { + order: index + 1, + profile: state.profileId, + lane: activeIdeas().some(i => i.id === idea.id) ? 'Inbox / unsorted' : z.label, + milestoneId: idea.milestoneId || 'inbox', + status: idea.status || 'inbox' + } + }; + }); +} +function featureSetPayload(){ + return { + format: 'prioritix-feature-set-v1', + exportedAt: new Date().toISOString(), + sortingProfile: state.profileId, + scoring: '((impact×2.4)+(confidence×1.2)+(urgency×1.4))/effort', + features: sortedSetupFeatures() + }; +} +function parseFeatureSetText(text){ + let parsed; + try { parsed = JSON.parse(text); } catch { throw new Error('Paste valid Prioritix Feature Set v1 JSON. Required shape: { "format":"prioritix-feature-set-v1", "features":[{"title":"..."}] }.'); } + const features = Array.isArray(parsed) ? parsed : parsed.features; + if(!Array.isArray(features) || !features.length) throw new Error('Feature set must contain a non-empty features array.'); + const cleaned = features.map((feature, index) => { + const title = String(feature?.title || feature?.name || '').trim(); + if(!title) throw new Error(`Feature ${index + 1} is missing title.`); + return { + title, + description: feature.description || feature.brief || '', + labels: Array.isArray(feature.labels) ? feature.labels : String(feature.labels || feature.category || '').split(',').map(s=>s.trim()).filter(Boolean), + impact: feature.impact ?? 5, + effort: feature.effort ?? 5, + confidence: feature.confidence ?? 6, + urgency: feature.urgency ?? 5, + status: feature.status || 'inbox', + milestoneId: feature.milestoneId || feature.milestone || 'inbox', + rank: feature.rank ?? 0, + notes: feature.notes || '' + }; + }); + return { name: parsed.name || 'Pasted feature set', format: parsed.format || 'prioritix-feature-set-v1', features: cleaned }; +} +function downloadJson(filename, payload){ + const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); + URL.revokeObjectURL(url); +} +async function importFeatureSet(){ + const input = $('#featureSetInput'); + const payload = parseFeatureSetText(input.value.trim()); + const data = await api('/api/feature-set/import', { method:'POST', body: payload }); + (data.created || []).forEach(replaceIdea); + state.activeId = data.created?.[0]?.id || state.activeId; + render(); + toast(data.errors?.length ? `Imported ${data.imported}; ${data.errors.length} skipped` : `Imported ${data.imported} feature${data.imported === 1 ? '' : 's'}`); +} 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: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); } } @@ -83,6 +166,21 @@ async function reorderOnTimeline(id,e){ const line=e.currentTarget; const idea=s 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; } 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); } } +$('#featureSetFile')?.addEventListener('change', async e => { + const file = e.currentTarget.files?.[0]; + if(!file) return; + try { $('#featureSetInput').value = await file.text(); toast(`Loaded ${file.name}`); } + catch(error){ toast(error.message || 'Could not read file'); } +}); +$('#importFeatureSet')?.addEventListener('click', async () => { + try { await importFeatureSet(); } catch(error){ toast(error.message); } +}); +$('#exportFeatureSet')?.addEventListener('click', () => { + const payload = featureSetPayload(); + $('#featureSetInput').value = JSON.stringify(payload, null, 2); + downloadJson(`prioritix-feature-set-${new Date().toISOString().slice(0,10)}.json`, payload); + toast(`Exported ${payload.features.length} feature${payload.features.length === 1 ? '' : 's'}`); +}); $('#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(); }); $('#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(); } }); diff --git a/public/index.html b/public/index.html index 3e37c28..c20de4a 100644 --- a/public/index.html +++ b/public/index.html @@ -6,7 +6,7 @@ Prioritix — Feature Prioritization - +
@@ -63,6 +63,28 @@ +
+
+ +

Upload or paste a whole feature set.

+

Use Prioritix Feature Set v1: JSON with a top-level features array. Required: title. Optional: description, labels, impact, effort, confidence, urgency, status, milestoneId, rank, notes. Export uses the same format, including the sorted setup.

+
+
+ +
+ + + +
+
+
+
@@ -130,6 +152,6 @@ - + diff --git a/public/styles.css b/public/styles.css index ceacf38..f6d8b97 100644 --- a/public/styles.css +++ b/public/styles.css @@ -18,3 +18,5 @@ button,input,textarea{font:inherit} button{cursor:pointer} a{color:inherit;text- @media(max-width:1050px){.app-shell{grid-template-columns:1fr}.sidebar{display:none}.workspace{padding:14px}.topbar{grid-template-columns:auto 1fr}.profile-card,.progress-card{grid-column:1/-1}.sorting-layout{grid-template-columns:1fr}.capture-strip form{grid-template-columns:1fr 1fr}.capture-strip button{grid-column:1/-1}.utility-row{grid-template-columns:1fr}.utility-row button{border-right:0;border-bottom:1px solid var(--border)}.utility-row button:last-child{border-bottom:0}} .shortcut-hint{margin:10px 2px 0;color:var(--slate-600);font-size:12px}.shortcut-hint kbd{display:inline-grid;place-items:center;min-width:22px;height:20px;padding:0 5px;border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;background:#fff;color:var(--navy-950);font-weight:800}.timeline-line.drag-over{outline:2px dashed var(--blue-600);outline-offset:8px;background:linear-gradient(90deg,rgba(22,163,74,.06),rgba(37,99,235,.06),rgba(245,158,11,.06),rgba(124,58,237,.06))}.node[draggable="true"]{cursor:grab}.node[draggable="true"]:active{cursor:grabbing}.toast{display:flex;align-items:center;gap:12px}.toast button{border:1px solid rgba(255,255,255,.35);background:rgba(255,255,255,.12);color:#fff;border-radius:4px;padding:6px 9px;font-weight:800}.detail label input[name="labels"]{width:100%;border:1px solid var(--border);border-radius:6px;padding:11px 12px;background:#F8FAFC;color:var(--slate-900);margin-top:6px}.profile-menu button.selected{background:#F0E9FF;color:var(--purple-600);font-weight:900} @media(max-width:680px){.workspace{padding:10px}.topbar{padding:12px;gap:12px}.project-title h1{font-size:21px}.sorting-grid{grid-template-columns:1fr 1fr;gap:9px}.zone{min-height:142px;padding:12px}.zone-icon{font-size:30px}.zone strong{font-size:14px}.zone span{font-size:12px}.capture-strip form,.deck-actions,.detail-grid,.detail-actions{grid-template-columns:1fr}.timeline{overflow-x:auto}.timeline-line{min-width:760px}.feature-card{min-height:160px}.feature-card::before,.feature-card::after{display:none}} + +.feature-set-panel{display:grid;grid-template-columns:minmax(280px,.9fr) minmax(420px,1.4fr);gap:16px;background:linear-gradient(135deg,rgba(6,27,51,.96),rgba(9,38,75,.92));color:white;border:1px solid rgba(255,255,255,.12);border-radius:var(--radius);box-shadow:var(--shadow);padding:16px;margin-bottom:18px;overflow:hidden;position:relative}.feature-set-panel::before{content:"";position:absolute;inset:auto -80px -120px auto;width:260px;height:260px;background:radial-gradient(circle,rgba(69,184,255,.25),transparent 65%);pointer-events:none}.feature-set-copy{position:relative;z-index:1}.feature-set-copy .section-label{color:#A7D8FF}.feature-set-copy h2{margin:7px 0 8px;font-size:22px;letter-spacing:-.03em}.feature-set-copy p{margin:0;color:#D9E8FF;line-height:1.5;font-size:13px}.feature-set-copy code{background:rgba(255,255,255,.10);border:1px solid rgba(255,255,255,.10);border-radius:4px;padding:1px 5px;color:#fff}.feature-set-actions{position:relative;z-index:1;display:grid;gap:10px}.feature-set-actions textarea{width:100%;min-height:178px;resize:vertical;border:1px solid rgba(255,255,255,.16);border-radius:6px;background:rgba(2,8,23,.64);color:#EAF6FF;padding:12px;font-family:"JetBrains Mono","SFMono-Regular",ui-monospace,monospace;font-size:12px;line-height:1.45;outline:none}.feature-set-actions textarea:focus{border-color:#45B8FF;box-shadow:0 0 0 3px rgba(69,184,255,.16)}.feature-set-toolbar{display:flex;gap:10px;flex-wrap:wrap;align-items:center}.feature-set-toolbar button,.file-pick{border:1px solid rgba(255,255,255,.20);border-radius:6px;background:rgba(255,255,255,.10);color:white;padding:10px 12px;font-weight:850;box-shadow:0 10px 24px rgba(0,0,0,.12)}.feature-set-toolbar button:hover,.file-pick:hover{background:rgba(255,255,255,.16)}.file-pick input{position:absolute;inline-size:1px;block-size:1px;opacity:0;pointer-events:none}@media(max-width:1050px){.feature-set-panel{grid-template-columns:1fr}.feature-set-actions textarea{min-height:220px}} diff --git a/server.js b/server.js index 01e5c39..23f75b3 100644 --- a/server.js +++ b/server.js @@ -56,6 +56,29 @@ function scoreIdea({ impact, effort, confidence, urgency }) { return Number((((i * 2.4) + (c * 1.2) + (u * 1.4)) / Math.max(1, e)).toFixed(2)); } +function ideaDataFromInput(input = {}, defaults = {}) { + const title = cleanText(input.title || input.name, 180); + if (!title) throw Object.assign(new Error('Every feature needs a title.'), { code: 400 }); + const data = { + title, + description: cleanMultiline(input.description || input.brief || '', 5000), + source: cleanText(input.source || defaults.source || 'import', 40), + sourceName: cleanText(input.sourceName || input.owner || defaults.sourceName || 'Prioritix feature set', 80), + status: cleanText(input.status || defaults.status || 'inbox', 40), + milestoneId: cleanText(input.milestoneId || input.milestone || defaults.milestoneId || 'inbox', 64), + impact: clampInt(input.impact, defaults.impact ?? 5), + effort: clampInt(input.effort, defaults.effort ?? 5, 1, 10), + confidence: clampInt(input.confidence, defaults.confidence ?? 6), + urgency: clampInt(input.urgency, defaults.urgency ?? 5), + rank: clampInt(input.rank, defaults.rank ?? 0, -100000, 100000), + labels: encodeList(input.labels || input.category || input.categories || []), + notes: cleanMultiline(input.notes || '', 4000), + archived: Boolean(input.archived ?? false), + }; + data.score = scoreIdea(data); + return data; +} + function publicIdea(row) { return { id: row.$id, @@ -174,30 +197,31 @@ app.get('/api/bootstrap', async (_req, res) => { }); app.post('/api/ideas', requireAgent, async (req, res) => { - const title = cleanText(req.body.title, 180); - if (!title) return res.status(400).json({ error: 'title is required' }); - const data = { - title, - description: cleanMultiline(req.body.description, 5000), - source: cleanText(req.body.source || 'human', 40), - sourceName: cleanText(req.body.sourceName || req.body.agent || '', 80), - status: cleanText(req.body.status || 'inbox', 40), - milestoneId: cleanText(req.body.milestoneId || 'inbox', 64), - impact: clampInt(req.body.impact, 5), - effort: clampInt(req.body.effort, 5, 1, 10), - confidence: clampInt(req.body.confidence, 6), - urgency: clampInt(req.body.urgency, 5), - rank: clampInt(req.body.rank, 0, -100000, 100000), - labels: encodeList(req.body.labels), - notes: cleanMultiline(req.body.notes, 4000), - archived: false, - }; - data.score = scoreIdea(data); + const data = ideaDataFromInput(req.body, { source: 'human', status: 'inbox', milestoneId: 'inbox' }); const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); - await logActivity('idea.created', `Captured “${title}”`, row.$id, data.source); + await logActivity('idea.created', `Captured “${data.title}”`, row.$id, data.source); res.status(201).json(publicIdea(row)); }); +app.post('/api/feature-set/import', requireAgent, async (req, res) => { + const features = Array.isArray(req.body.features) ? req.body.features.slice(0, 100) : []; + if (!features.length) return res.status(400).json({ error: 'Feature set must include a non-empty features array.' }); + const defaults = req.body.defaults && typeof req.body.defaults === 'object' ? req.body.defaults : {}; + const created = []; + const errors = []; + for (const [index, feature] of features.entries()) { + try { + const data = ideaDataFromInput(feature, { source: 'import', sourceName: req.body.name || 'Prioritix feature set', ...defaults }); + const row = assertRow(await tables.createRow({ databaseId, tableId: ideasTableId, rowId: ID.unique(), data })); + created.push(publicIdea(row)); + } catch (error) { + errors.push({ index, title: cleanText(feature?.title || feature?.name || '', 180), error: error.message }); + } + } + if (created.length) await logActivity('feature_set.imported', `Imported ${created.length} feature${created.length === 1 ? '' : 's'}`, '', req.body.name || 'feature-set-v1'); + res.status(created.length ? 201 : 400).json({ ok: errors.length === 0, imported: created.length, created, errors }); +}); + app.patch('/api/ideas/:id', requireAgent, async (req, res) => { const allowed = ['title', 'description', 'source', 'sourceName', 'status', 'milestoneId', 'impact', 'effort', 'confidence', 'urgency', 'rank', 'labels', 'notes', 'archived']; const data = {};