// Release notes + roadmap (Marty, 2026-09-14): what shipped and what is coming, written in Admin > Releases, // shown on the public /whats-new page and in a "What's new" card on every member's Overview. // Storage: DATA_DIR/releases.json { notes: [{id, date, title, body, tags[]}], roadmap: [{id, title, note, status, eta}] } const fs = require('fs'); const path = require('path'); let FILE = null; const SITE = 'https://linkspin-test.saasy.top'; const TAGS = ['new', 'improved', 'fixed']; const STATUSES = ['planned', 'building', 'done']; const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); function init(opts) { FILE = path.join(opts.dataDir, 'releases.json'); } function load() { try { return JSON.parse(fs.readFileSync(FILE, 'utf8')); } catch (e) { return { notes: [], roadmap: [] }; } } function save(d) { fs.writeFileSync(FILE, JSON.stringify(d, null, 1)); } const newId = () => Date.now().toString(36) + Math.random().toString(36).slice(2, 6); // body text: plain lines; a line starting with "- " becomes a bullet; blank line = paragraph break function bodyHtml(text) { const lines = String(text || '').split(/\r?\n/); let out = '', list = false, para = []; const flush = () => { if (para.length) { out += '

' + esc(para.join(' ')) + '

'; para = []; } }; for (const l of lines) { if (/^\s*-\s+/.test(l)) { flush(); if (!list) { out += ''; list = false; } if (!l.trim()) { flush(); continue; } para.push(l.trim()); } if (list) out += ''; flush(); return out; } function notes() { return load().notes.slice().sort((a, b) => (b.date || '').localeCompare(a.date || '') || (b.ts || 0) - (a.ts || 0)); } function roadmap() { const order = { building: 0, planned: 1, done: 2 }; return load().roadmap.slice().sort((a, b) => (order[a.status] - order[b.status]) || (a.order || 0) - (b.order || 0)); } function saveNote(input) { const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the note a title.' }; const date = /^\d{4}-\d{2}-\d{2}$/.test(String(input.date || '')) ? input.date : new Date().toISOString().slice(0, 10); const tags = String(input.tags || '').split(',').map(t => t.trim().toLowerCase()).filter(t => TAGS.includes(t)); const n = { id: input.id && d.notes.find(x => x.id === input.id) ? input.id : newId(), date, title, body: String(input.body || '').slice(0, 4000), tags: tags.length ? tags : ['new'], ts: Date.now() }; const i = d.notes.findIndex(x => x.id === n.id); if (i >= 0) d.notes[i] = Object.assign({}, d.notes[i], n); else d.notes.push(n); save(d); return { ok: true, note: n }; } function saveRoadmap(input) { const d = load(); const title = String(input.title || '').trim().slice(0, 120); if (!title) return { error: 'Give the item a title.' }; const status = STATUSES.includes(input.status) ? input.status : 'planned'; const r = { id: input.id && d.roadmap.find(x => x.id === input.id) ? input.id : newId(), title, note: String(input.note || '').slice(0, 600), status, eta: String(input.eta || '').slice(0, 40), order: Number(input.order) || d.roadmap.length + 1, ts: Date.now() }; const i = d.roadmap.findIndex(x => x.id === r.id); if (i >= 0) d.roadmap[i] = Object.assign({}, d.roadmap[i], r); else d.roadmap.push(r); save(d); return { ok: true, item: r }; } function remove(kind, id) { const d = load(); const k = kind === 'roadmap' ? 'roadmap' : 'notes'; d[k] = d[k].filter(x => x.id !== id); save(d); return { ok: true }; } function publicView() { return { notes: notes().slice(0, 30), roadmap: roadmap().filter(r => r.status !== 'done').slice(0, 12), latest: (notes()[0] || {}).date || null }; } function fmt(d) { try { return new Date(d + 'T12:00:00Z').toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }); } catch (e) { return d; } } const tagChip = t => '' + t + ''; function renderPage() { const v = publicView(); const desc = 'What changed on LinkSpin and what is coming next: release notes and the roadmap, updated as things ship.'; let h = 'What\'s new | LinkSpin' + '' + '' + '
' + '

Release notes and roadmap

What\'s new, and what\'s next.

' + esc(desc) + '

'; h += '

On the roadmap

'; h += v.roadmap.length ? '
' + v.roadmap.map(r => '
' + r.status + (r.eta ? ' ยท ' + esc(r.eta) : '') + '
' + esc(r.title) + '' + (r.note ? '' + esc(r.note) + '' : '') + '
').join('') + '
' : '

Nothing queued right now.

'; h += '

Release notes

'; h += v.notes.length ? v.notes.map(n => '

' + esc(n.title) + n.tags.map(tagChip).join('') + '

' + fmt(n.date) + '

' + bodyHtml(n.body) + '
').join('') : '

No notes yet.

'; h += '

Suggestions and bug reports: the chat bubble on any page, or the LinkSpin Telegram group.

'; h += '
'; return h; } module.exports = { init, notes, roadmap, saveNote, saveRoadmap, remove, publicView, renderPage, bodyHtml, TAGS, STATUSES };