260 lines
9.9 KiB
JavaScript
260 lines
9.9 KiB
JavaScript
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]) => `<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();
|