src/render.js (6569 bytes)
1 // Plain-text views. Kept compact on purpose: every character here may end up in a model's context. 2 3 import { samePath } from './projects.js'; 4 import { baseName, counts, lane, listIdeas } from './store.js'; 5 import { clip } from './text.js'; 6 7 const LANE_TITLES = { 8 doing: 'DOING', 9 do: 'DO (best first)', 10 maybe: 'MAYBE', 11 inbox: 'INBOX (untriaged)', 12 skip: 'SKIP', 13 done: 'DONE', 14 dropped: 'DROPPED', 15 }; 16 17 function projectName(p) { 18 return p ? baseName(p) : ''; 19 } 20 21 export function stamp(iso) { 22 if (!iso) return ''; 23 const d = new Date(iso); 24 const pad = (n) => String(n).padStart(2, '0'); 25 return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; 26 } 27 28 export function ideaLine(idea, { cwd = null } = {}) { 29 const t = idea.triage; 30 const id = `#${idea.id}`.padEnd(5); 31 const meta = t && lane(idea) !== 'inbox' && t.verdict !== 'skip' 32 ? `${t.model.padEnd(6)} ${t.size.toUpperCase().padEnd(2)} ▲${t.impact} ` 33 : ''; 34 const where = idea.project && !samePath(idea.project, cwd) ? ` · ${projectName(idea.project)}` : ''; 35 return ` ${id}${meta}${clip(idea.title, 64)}${where}`; 36 } 37 38 export function summaryLine(db) { 39 const c = counts(db); 40 const parts = ['doing', 'do', 'maybe', 'inbox'].filter((k) => c[k]).map((k) => `${c[k]} ${k}`); 41 return `ideamine: ${c.open} open${parts.length ? ` (${parts.join(' · ')})` : ''}${c.done ? ` · ${c.done} done` : ''}`; 42 } 43 44 /** The board: ideas grouped by lane. */ 45 export function renderBoard(db, { filter = 'open', project = null, query = '', cwd = null, limit = 0, hints = false } = {}) { 46 const ideas = listIdeas(db, { filter, project, query, limit }); 47 const out = [summaryLine(db)]; 48 if (project || query || filter !== 'open') { 49 const scope = [filter !== 'open' && filter, project && `project ${projectName(project)}`, query && `"${query}"`]; 50 out[0] += ` [showing: ${scope.filter(Boolean).join(', ')}]`; 51 } 52 if (!ideas.length) { 53 out.push('', db.ideas.length ? ' (nothing here)' : ' (empty — add one with /idea <text>)'); 54 } 55 let current = null; 56 for (const idea of ideas) { 57 const l = lane(idea); 58 if (l !== current) { 59 current = l; 60 out.push('', LANE_TITLES[l] || l.toUpperCase()); 61 } 62 out.push(ideaLine(idea, { cwd })); 63 } 64 if (hints) { 65 const tips = ['/idea <text> add']; 66 if (counts(db).open) tips.push('/ideas-go build next', '/ideas-all do all that fit here'); 67 if (db.ideas.length) tips.push('/ideas-cat N show', '/ideas-rm N delete'); 68 out.push('', tips.join(' · ')); 69 } 70 return out.join('\n'); 71 } 72 73 /** Search results, best first, with the similarity (by meaning) or the share of words found. */ 74 export function renderFound({ mode, results, note }, query, { cwd = null } = {}) { 75 const how = mode === 'meaning' ? 'by meaning' : `by words, because search by meaning is not available (${note})`; 76 const out = [`ideas like "${clip(query, 60)}", ${how}`]; 77 if (!results.length) out.push(' (no match)'); 78 for (const { idea, score } of results) { 79 out.push(`${String(Math.round(score * 100)).padStart(4)}% ${lane(idea).padEnd(7)}${ideaLine(idea, { cwd }).slice(1)}`); 80 } 81 return out.join('\n'); 82 } 83 84 /** Groups of ideas that are close in meaning, then the ideas in no group. */ 85 export function renderGroups(groups, ideas, { cwd = null, scope = 'open' } = {}) { 86 const out = [`ideamine groups: ${groups.length} group${groups.length === 1 ? '' : 's'} of ${scope} ideas, by meaning`]; 87 const byId = new Map(ideas.map((i) => [i.id, i])); 88 const line = (idea) => ` ${lane(idea).padEnd(7)}${ideaLine(idea, { cwd }).slice(1)}`; 89 for (const g of groups) out.push('', g.label.toUpperCase(), ...g.ids.map((id) => line(byId.get(id)))); 90 const grouped = new Set(groups.flatMap((g) => g.ids)); 91 const rest = ideas.filter((i) => !grouped.has(i.id)); 92 if (rest.length) out.push('', 'IN NO GROUP', ...rest.map(line)); 93 return out.join('\n'); 94 } 95 96 /** One idea in full. */ 97 export function renderIdea(idea) { 98 const t = idea.triage; 99 const out = [`#${idea.id} ${idea.title}`]; 100 const status = [idea.status]; 101 if (t) status.push(t.verdict, t.model, `size ${t.size.toUpperCase()}`, `impact ${t.impact}/5`); 102 out.push(`status ${status.join(' · ')}`); 103 if (idea.project) out.push(`project ${idea.project}`); 104 const added = [`${stamp(idea.created)} via ${idea.source}`]; 105 if (idea.tags?.length) added.push(`tags: ${idea.tags.join(', ')}`); 106 out.push(`added ${added.join(' · ')}`); 107 if (idea.dup_of) out.push(`dup of #${idea.dup_of}`); 108 if (t?.why) out.push(`why ${t.why}`); 109 if (t?.brief) out.push(`brief ${t.brief}`); 110 if (t?.by) out.push(`triaged ${stamp(t.at)} by ${t.by}`); 111 if (idea.text !== idea.title) out.push('', 'text', ...idea.text.split(/\r?\n/).map((l) => ` ${l}`)); 112 if (idea.notes?.length) { 113 out.push('', 'notes'); 114 for (const n of idea.notes) out.push(` ${stamp(n.at)} ${n.text}`); 115 } 116 return out.join('\n'); 117 } 118 119 /** Confirmation after capture, e.g. "💡 Saved #12 · Add dark mode (3 in inbox)". */ 120 export function renderAdded(results, db) { 121 const inbox = counts(db).inbox; 122 const lines = []; 123 if (results.length === 1) { 124 const { idea } = results[0]; 125 lines.push(`💡 Saved #${idea.id} · ${clip(idea.title, 70)} (${inbox} in inbox)`); 126 } else { 127 lines.push(`💡 Saved ${results.length} ideas: ${results.map((r) => `#${r.idea.id}`).join(', ')} (${inbox} in inbox)`); 128 } 129 const seen = new Set(results.map((r) => r.idea.id)); 130 for (const r of results) { 131 for (const s of r.similar) { 132 if (seen.has(s.id)) continue; 133 seen.add(s.id); 134 lines.push(` similar to #${s.id} (${s.status}): ${clip(s.title, 60)}`); 135 } 136 } 137 return lines.join('\n'); 138 } 139 140 /** Markdown export of the whole archive. */ 141 export function renderMarkdown(db) { 142 const out = [`# Ideas`, '', `_${summaryLine(db)} · exported ${stamp(new Date().toISOString())}_`]; 143 let current = null; 144 for (const idea of listIdeas(db, { filter: 'all' })) { 145 const l = lane(idea); 146 if (l !== current) { 147 current = l; 148 out.push('', `## ${LANE_TITLES[l] || l}`, ''); 149 } 150 const t = idea.triage; 151 const meta = t && l !== 'inbox' ? ` — ${t.verdict}, ${t.model}, size ${t.size.toUpperCase()}, impact ${t.impact}/5` : ''; 152 out.push(`- **#${idea.id} ${idea.title}**${meta}${idea.project ? ` · \`${projectName(idea.project)}\`` : ''}`); 153 if (t?.brief) out.push(` - ${t.brief}`); 154 if (idea.text !== idea.title) out.push(` - > ${idea.text.replace(/\r?\n/g, ' ')}`); 155 } 156 return out.join('\n') + '\n'; 157 }