project-bifrost-platform/scripts/seed-demo.js
Jonathan Hvid cde98f9454 feat(pulse): spacing pass + council section header + 7-item roadmap seed
Spacing — explicit per-section margins on /pulse rather than a single
gap. Page is padding: 40px 36px 80px now. Transitions match the spec:

  greeting   ─ 48px ─ below nav
  greeting   ─ 56px ─ hero
  hero       ─ 18px ─ also coming up    (intentionally tight; related)
  also       ─ 72px ─ editorial row
  editorial  ─ 72px ─ roadmap
  roadmap    ─ 72px ─ council

The hero, editorial, roadmap and council transitions all sit at 72px so
the page reads as four distinct registers rather than a slab stack. The
hero → also-coming-up gap stays deliberately tight at 18px because the
two are a pair (the strip is the lighter outro to the indigo card).

Council section restructured to match the roadmap carousel framing:
  - Outer card chrome dropped — no more single white surface wrapping the
    grid. Section is just a header row + a 4-column grid of tiles.
  - Header row: 22px serif 'The council' on the left, 11px terracotta
    tracked uppercase 'See who our council is made up of →' on the right.
    Same pattern as the roadmap header.
  - Tiles: 38px avatar (down from 56), 15px serif name, 11px title,
    10px tracked organisation. No background, no border. 24px grid gap.
  - First 4 members render; if more, a 5th tile replaces the would-be
    fifth member with a right-aligned 'See all N council members →' link.
    With the current 4-member seed this case isn't exercised but the
    branch is in place for when the council grows.
  - 2-up on tablets, 1-up below 520px.

Seed update: roadmap now has 7 items spanning all four statuses (2
shipping / 1 in_beta / 2 exploring / 2 considering) ordered by
display_order 1..7. Traceability layer carries the 'Shaped by Lars'
attribution; Agentic query mode is attributed to Anna; Contextual memory
to Henriette. The rest are unattributed so the attribution trailer's
hidden case is exercised too. With 7 items the carousel arrows engage
and the right-edge fade is visible at start.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:58:34 +02:00

351 lines
17 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
// Phase 2 demo seed — produces the visual state described in SPEC §Phase 2:
// 4 cab members with title/pull_quote/focus_tags/member_number, 1 active
// pulse (2 of 4 voted), 4 roadmap items (1 shipping / 1 beta / 2 exploring),
// 3 contributions with reactions, 4 dispatches at staggered ages, 1 hero
// event + 1 studio hours + 1 working session + 2 past events.
//
// Destructive in scope: wipes the data tables it owns then re-inserts.
// Users (created by seed.js) are kept; new ones are added with INSERT OR
// IGNORE. Idempotent: re-running produces the same demo state.
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db');
const db = new Database(dbPath);
db.pragma('foreign_keys = ON');
// ── Wipe the tables this seed owns ─────────────────────────────────
db.exec(`
DELETE FROM activity;
DELETE FROM attendance;
DELETE FROM reactions;
DELETE FROM replies;
DELETE FROM contributions;
DELETE FROM roadmap_attributions;
DELETE FROM roadmap_items;
DELETE FROM votes;
DELETE FROM pulses;
DELETE FROM dispatches;
DELETE FROM events;
`);
// ── Locate canonical users from seed.js ────────────────────────────
const allUsers = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
const byRole = (r) => allUsers.find(u => u.role === r);
const mette = byRole('pilot');
const lars = byRole('cab');
const jon = byRole('fenja');
if (!mette || !lars || !jon) {
console.error(' seed.js users not found — run `pnpm db:seed` first.');
process.exit(1);
}
// ── Add 3 additional CAB members, then populate metadata on all 4 ──
const ROUNDS = 10;
const hash = bcrypt.hashSync('cab123', ROUNDS);
function kebab(s) {
return s.toLowerCase()
.normalize('NFKD').replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
const newCabs = [
{ name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' },
{ name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' },
{ name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' },
];
const insertUser = db.prepare(`
INSERT OR IGNORE INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date)
VALUES (?, ?, ?, ?, 'cab', ?, NULL)
`);
for (const c of newCabs) {
insertUser.run(c.email, hash, c.name, c.org, kebab(c.name));
}
// Allocate member_numbers in member-since order, tiebreak id asc.
// We backdate cab_joined_date first, then let allocateMemberNumber pick it up.
// Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others.
const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all();
const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 };
const setCabMeta = db.prepare(`
UPDATE users
SET cab_joined_date = date('now', ?),
title = ?,
pull_quote = ?,
focus_tags = ?,
last_seen_at = datetime('now', '-2 minutes')
WHERE id = ?
`);
const cabMeta = {
'lars@virk2.dk': {
title: 'Senior Adviser, Operational Risk',
pull_quote: 'A model is only as auditable as the chain of evidence behind it. That chain is the work.',
focus_tags: ['Risk', 'Audit trail', 'GDPR'],
},
'anna@virk3.dk': {
title: 'Director of Digital Services',
pull_quote: 'Municipalities can\'t outsource sovereignty. We need tools that assume that.',
focus_tags: ['Public sector', 'Sovereignty'],
},
'soren@virk4.dk': {
title: 'Head of Data Engineering',
pull_quote: 'Make it boring to deploy and surprising to query.',
focus_tags: ['Infrastructure', 'Telemetry'],
},
'henriette@virk5.dk': {
title: 'Lead Counsel, Compliance',
pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.',
focus_tags: ['Legal', 'Policy', 'EU AI Act'],
},
};
for (const u of cabRows) {
const m = cabMeta[u.email] ?? null;
if (!m) continue;
const weeks = tenureWeeks[u.email] ?? 0;
setCabMeta.run(`-${weeks} weeks`, m.title, m.pull_quote, JSON.stringify(m.focus_tags), u.id);
}
// Allocate member_numbers using the SQL backfill from migration 0004,
// but only for cab rows that still lack one (idempotent).
db.exec(`
WITH ranked AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY COALESCE(cab_joined_date, created_at) ASC, id ASC) AS rn
FROM users WHERE role = 'cab'
)
UPDATE users
SET member_number = (SELECT rn FROM ranked WHERE ranked.id = users.id)
WHERE role = 'cab' AND member_number IS NULL;
`);
// Mark Mette + Jonathan recently seen too (for the avatar pile on /events)
db.prepare("UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?)").run(mette.id, jon.id);
// Re-fetch cab rows ordered by member_number
const cabs = db.prepare("SELECT id, name, email, member_number FROM users WHERE role = 'cab' AND active = 1 ORDER BY member_number ASC").all();
console.log(` cab members: ${cabs.map(c => `${c.name} #${c.member_number}`).join(', ')}`);
// ── Helper: SQL datetime string at offset seconds from now ─────────
const nowIso = (offsetSeconds = 0) => {
const d = new Date(Date.now() + offsetSeconds * 1000);
return d.toISOString().replace('T', ' ').slice(0, 19);
};
// ── Poll attached to a dispatch (the decision) — open, 2/4 voted ──
// Polls are no longer standalone; they attach to a dispatch via pulse_id.
// We create the pulse first, capture its id, and stamp it on the dispatch
// when we INSERT it further down.
const pulseOptions = [
'Locking down on-prem deployment first',
'Pushing the traceability layer to GA',
'Going wide on document ingestion',
'Building the agentic query loop',
];
const decisionPulseId = db.prepare(`
INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by)
VALUES (?,?,?,?,?,?,?)
`).run(
'Which milestone should we anchor Q3 around?',
'Council input on this directly shapes what the team works on in JulySeptember. Read the roadmap before voting.',
JSON.stringify(pulseOptions),
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).lastInsertRowid;
// 2 votes — Lars and Anna
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 7 items spanning shipping → considering, admin-ordered ──
const roadmap = [
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 1, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Audit log export', description: 'One-click export of every model call, source, and reviewer action.', status: 'shipping', target: 'Next week', display_order: 2, shipped_at: nowIso(-1 * 24 * 3600), attributed: [] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'in_beta', target: 'July', display_order: 3, shipped_at: null, attributed: [cabs[1].id] },
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 4, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Multi-tenant isolation', description: 'Strict per-organisation data boundaries for shared deployments.', status: 'exploring', target: 'Q4 2026', display_order: 5, shipped_at: null, attributed: [] },
{ title: 'Federated learning hooks', description: 'Train shared models across council members without moving data.', status: 'considering', target: '2027', display_order: 6, shipped_at: null, attributed: [] },
{ title: 'Open evaluation framework', description: 'A public benchmark suite for sovereign AI deployments.', status: 'considering', target: '2027', display_order: 7, shipped_at: null, attributed: [] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at)
VALUES (?,?,?,?,?,?)
`);
const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)');
for (const r of roadmap) {
const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid);
for (const uid of r.attributed) insertAttr.run(id, uid);
}
// ── Contributions: 3 entries; most recent gets 3 reactions ─────────
const contribs = [
{ user_id: cabs[2].id, type: 'idea',
body_md: 'What if we let council members write **inline annotations** on the roadmap items they shaped? A trail of "here\'s what I pushed for."',
when: nowIso(-2 * 60 * 60), reactors: [cabs[0].id, cabs[1].id, jon.id] },
{ user_id: cabs[1].id, type: 'question',
body_md: 'How will the traceability layer handle documents with conflicting metadata across versions?',
when: nowIso(-2 * 24 * 60 * 60) },
{ user_id: cabs[0].id, type: 'inspiration',
body_md: 'A piece on **institutional memory** in regulated industries — it lines up almost exactly with what we\'re trying to build.',
when: nowIso(-9 * 24 * 60 * 60) },
];
const insertContrib = db.prepare(`
INSERT INTO contributions (user_id, type, body_md, created_at) VALUES (?,?,?,?)
`);
const insertReact = db.prepare('INSERT INTO reactions (user_id, contribution_id) VALUES (?,?)');
for (const c of contribs) {
const id = Number(insertContrib.run(c.user_id, c.type, c.body_md, c.when).lastInsertRowid);
for (const uid of c.reactors ?? []) insertReact.run(uid, id);
}
// ── Dispatches: 4 published at staggered ages ──────────────────────
const dispatchSeed = [
{ kind: 'decision', ageDays: 2,
title: 'We are deprioritising public-cloud parity for Q3',
excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.',
body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3.
The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction.
So for Q3 the platform supports two deployment targets only — on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki. Everything else is parked. The agentic query work moves up a quarter to fill the gap.
We'll revisit public cloud in Q4 once the on-prem story is boring.`,
},
{ kind: 'behind_the_scenes', ageDays: 5,
title: 'A morning at the council kickoff',
excerpt: 'Four members in the room, two on video. The whiteboard ended up with three lists: must-have, would-be-nice, do-not-build.',
body: `Four council members in the room, two on video. The kickoff meeting was meant to be 90 minutes. It went four hours.
The whiteboard ended up with three lists: must-have, would-be-nice, and — the most interesting one — do-not-build.
Henriette pushed back hard on the "agent that emails on your behalf" pattern. "I don't want a system speaking on my legal team's behalf. Ever." That note alone reshaped a whole feature.
Photos to come, with permission.`,
},
{ kind: 'update', ageDays: 9,
title: 'Document ingestion is now feature-complete in beta',
excerpt: 'PDF, Word, plain text. Chunking, metadata, deduplication, basic OCR. Three pilots have run it against their corpora.',
body: `Document ingestion is feature-complete in beta. PDF, Word, plain text. Chunking, metadata extraction, deduplication, and basic OCR for scanned PDFs.
Three pilots have now run it against their internal corpora — biggest was 47,000 documents, smallest was 380. Both worked. The 47k run took 8 hours and surfaced some neat edge cases (mostly around tables that span pages).
Next week we open it to the full pilot group. We'll need notes.`,
},
{ kind: 'note', ageDays: 12,
title: 'Welcome to the council',
excerpt: 'A short note to mark the start. This page will fill up with decisions, half-built ideas, and things we have changed our minds about.',
body: `This page will fill up with decisions, half-built ideas, and things we have changed our minds about.
It is not a blog. It is the studio talking to the room — short, dated, signed.`,
},
];
const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all();
const insertDispatch = db.prepare(`
INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id)
VALUES (?,?,?,?,?,'published',?,?,?,?)
`);
for (let i = 0; i < dispatchSeed.length; i += 1) {
const d = dispatchSeed[i];
const when = nowIso(-d.ageDays * 24 * 60 * 60);
const authorId = fenjas[i % fenjas.length].id;
// Attach the decision-pulse to the decision dispatch — this is the demo
// case for polls-as-articles. Other dispatches stay poll-free.
const attachedPulse = d.kind === 'decision' ? decisionPulseId : null;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse);
}
// ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past
const insertEvent = db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
`);
insertEvent.run(
'kickoff-dinner-2026-06', 'Council kickoff dinner', 'dinner',
'A private dinner at the studio. Conversation about what we ship next, no slides.',
'Studio, Refshalevej · Copenhagen',
nowIso(38 * 24 * 3600), null, 12, null,
'Members only', null, null, null,
jon.id,
);
const dinnerSlug = 'kickoff-dinner-2026-06';
insertEvent.run(
'studio-hours-2026-05', 'Studio hours with Jonathan', 'office_hours',
'30-minute slots. Open agenda. Drop in when you\'ve got something to talk through.',
'Virtual (link sent after RSVP)',
nowIso(14 * 24 * 3600), null, null, null,
'Council members', '30 minutes', null, null,
jon.id,
);
insertEvent.run(
'working-session-2026-06', 'Working session: traceability UX', 'working_session',
'Three-person session to walk through the new traceability UI before it lands in beta.',
'Studio, Refshalevej · Copenhagen',
nowIso(21 * 24 * 3600), null, 6, null,
'Council + Fenja team', '90 minutes', 'RSVP →', null,
jon.id,
);
insertEvent.run(
'apr-roundtable-2026', 'April roundtable', 'summit',
'A half-day session anchored around the EU AI Act compliance roadmap.',
'Studio, Refshalevej · Copenhagen',
nowIso(-21 * 24 * 3600), null, 10, null,
'Members only', null, null, 'https://example.invalid/notes/april',
jon.id,
);
const aprilSlug = 'apr-roundtable-2026';
insertEvent.run(
'march-launch-dinner', 'Launch dinner', 'dinner',
'The first dinner. Where the council was formally introduced.',
'Aamanns Etablissement · Copenhagen',
nowIso(-52 * 24 * 3600), null, 10, null,
'Members only', null, null, null,
jon.id,
);
const marchSlug = 'march-launch-dinner';
// Past-event RSVPs (drives the "attended_count" on past cards)
const insertRsvp = db.prepare(`
INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at)
VALUES (?, ?, 'event', 'yes', datetime('now'))
`);
for (const c of cabs) insertRsvp.run(c.id, aprilSlug);
for (const c of cabs.slice(0, 3)) insertRsvp.run(c.id, marchSlug);
// Hero dinner: 1 confirmed so far (Lars) — keeps the avatar pile small at first
insertRsvp.run(cabs[0].id, dinnerSlug);
// ── Activity rows for the (now hidden but still-written) feed ──────
const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?)
`);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60));
insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600));
console.log(' pulse #' + decisionPulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 7 items (2 shipping / 1 in_beta / 2 exploring / 2 considering)');
console.log(' contributions: 3 (most recent has 3 reactions)');
console.log(' dispatches: 4 published (2/5/9/12 days ago)');
console.log(' events: dinner + studio hours + working session, 2 past');
db.close();