chore(demo): seed-demo.js + utc fix for last_seen_at

scripts/seed-demo.js — one open Pulse with realistic context, marks
"Traceability layer" as shipping with shipped_at -2 days and attributes
it to the cab user, two events (dinner in 5 weeks, office hours in 2
weeks), six hand-crafted activity rows mixing all 5 activity kinds so
the ticker has something to scroll on first load. Idempotent: skips if
any pulses exist. Backdates Lars's cab_joined_date so the greeting
renders "2 years, 4 months". Wired to db:setup and db:seed:demo.

Also fixes a parse bug on /pulse: SQL stores last_seen_at as
'YYYY-MM-DD HH:MM:SS' UTC, but new Date(string) parses that as local
time — on a non-UTC server the freshness check was wrong by the server's
offset. Coerce to UTC ISO before parsing. Manual smoke as Lars now shows
two member chips in "online now"; admin tabs all render.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan Hvid 2026-05-11 15:04:11 +02:00
parent f6e7337c5e
commit fe27811d16
3 changed files with 147 additions and 3 deletions

View file

@ -13,7 +13,8 @@
"db:migrate": "node scripts/migrate.js",
"db:seed": "node scripts/seed.js",
"db:seed:roadmap": "node scripts/seed-roadmap.js",
"db:setup": "node scripts/migrate.js && node scripts/seed.js && node scripts/seed-roadmap.js"
"db:seed:demo": "node scripts/seed-demo.js",
"db:setup": "node scripts/migrate.js && node scripts/seed.js && node scripts/seed-roadmap.js && node scripts/seed-demo.js"
},
"dependencies": {
"@astrojs/node": "^8.3.0",

138
scripts/seed-demo.js Normal file
View file

@ -0,0 +1,138 @@
#!/usr/bin/env node
// Demo seed for first-load credibility: one open pulse, one shipped roadmap
// item attributed to the cab user, one dinner + one office hours event, and
// a handful of hand-crafted activity rows so the ticker has something to
// scroll on a fresh demo.
//
// Idempotent: skips if a pulse already exists. Run AFTER scripts/seed.js
// and scripts/seed-roadmap.js (or via `pnpm db:setup`).
import Database from 'better-sqlite3';
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');
const existing = db.prepare('SELECT COUNT(*) AS n FROM pulses').get().n;
if (existing > 0) {
console.log(` demo data already present (${existing} pulse(s)) — skipping.`);
db.close();
process.exit(0);
}
const users = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all();
const byRole = (r) => users.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);
}
// Backdate Lars's cab membership to give realistic tenure on /pulse
db.prepare(`UPDATE users SET cab_joined_date = date('now', '-2 years', '-4 months') WHERE id = ?`).run(lars.id);
// Mark all three as recently seen so the "online now" chip strip has content
// (current viewer is excluded from "others online" — see /pulse)
db.prepare(`UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?, ?)`)
.run(lars.id, mette.id, jon.id);
const nowIso = (offsetSeconds = 0) => {
const d = new Date(Date.now() + offsetSeconds * 1000);
return d.toISOString().replace('T', ' ').slice(0, 19);
};
// ── Pulse: open now, closes in 5 days ────────────────────────────────
const opensAt = nowIso(-3600); // opened an hour ago
const closesAt = nowIso(5 * 24 * 3600); // closes in 5 days
const options = [
'Locking down on-prem deployment first',
'Pushing the traceability layer to GA',
'Going wide on document ingestion',
'Building the agentic query loop',
];
const pulseId = 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(options),
opensAt,
closesAt,
'open',
jon.id,
).lastInsertRowid;
// Lars votes for the traceability option
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, lars.id, 1, nowIso(-2 * 3600));
// ── Roadmap: mark "Traceability layer" as shipping, attribute to Lars ──
const traceability = db.prepare("SELECT id FROM roadmap_items WHERE title LIKE 'Traceability%'").get();
if (traceability) {
db.prepare(`UPDATE roadmap_items SET status = 'shipping', shipped_at = datetime('now', '-2 days'), target = 'Live now' WHERE id = ?`).run(traceability.id);
db.prepare('INSERT OR IGNORE INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)').run(traceability.id, lars.id);
}
// ── Events ────────────────────────────────────────────────────────────
const dinnerStart = nowIso(38 * 24 * 3600); // ~5.5 weeks out
db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, capacity, created_by)
VALUES (?,?,?,?,?,?,?,?)
`).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',
dinnerStart,
12,
jon.id,
);
const dinnerId = db.prepare("SELECT id FROM events WHERE slug = 'kickoff-dinner-2026-06'").get().id;
const officeHoursStart = nowIso(14 * 24 * 3600); // 2 weeks out
db.prepare(`
INSERT INTO events (slug, title, kind, description, location, starts_at, created_by)
VALUES (?,?,?,?,?,?,?)
`).run(
'office-hours-2026-05',
'Office hours with the founder',
'office_hours',
'30-minute one-on-one slots. Open agenda. Book one or just drop by.',
'Virtual (link sent after RSVP)',
officeHoursStart,
jon.id,
);
const officeHoursId = db.prepare("SELECT id FROM events WHERE slug = 'office-hours-2026-05'").get().id;
// ── Activity rows ─────────────────────────────────────────────────────
// Mix of real (Lars's vote, Jonathan's publish, Jonathan's ship) and
// hand-crafted demo rows so the ticker has six items to scroll.
const insertActivity = db.prepare(`
INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at)
VALUES (?,?,?,?,?)
`);
insertActivity.run(jon.id, 'pulse_opened', 'pulse', pulseId, nowIso(-3600));
insertActivity.run(lars.id, 'voted', 'pulse', pulseId, nowIso(-2 * 3600));
if (traceability) {
insertActivity.run(jon.id, 'roadmap_shipped', 'roadmap', traceability.id, nowIso(-2 * 24 * 3600));
}
insertActivity.run(lars.id, 'rsvped', 'event', dinnerId, nowIso(-8 * 3600));
insertActivity.run(mette.id,'rsvped', 'event', officeHoursId, nowIso(-30 * 60));
insertActivity.run(jon.id, 'booked_office_hours', 'event', officeHoursId, nowIso(-1 * 24 * 3600));
console.log(' demo data seeded:');
console.log(` pulse #${pulseId} (open, closes in 5 days)`);
if (traceability) console.log(` roadmap #${traceability.id} → shipping, attributed to ${lars.name}`);
console.log(` events: kickoff-dinner-2026-06, office-hours-2026-05`);
console.log(` activity: 6 rows`);
db.close();

View file

@ -119,10 +119,15 @@ const shippedCount = countShippedAttributions(user.id);
// ── Members in the room ────────────────────────────────────────────
const allMembers = getAllUsersPublic();
// SQL stores 'YYYY-MM-DD HH:MM:SS' UTC; new Date() would parse as local — coerce to UTC ISO first.
function sqlToUtcDate(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
return new Date(s.replace(' ', 'T') + 'Z');
}
const onlineOthers = allMembers.filter(u =>
u.id !== user.id
&& u.last_seen_at
&& (Date.now() - new Date(u.last_seen_at).getTime()) < 5 * 60_000
&& (Date.now() - sqlToUtcDate(u.last_seen_at).getTime()) < 5 * 60_000
);
const visibleChips = onlineOthers.slice(0, 4);
const overflowCount = Math.max(0, onlineOthers.length - visibleChips.length);
@ -154,7 +159,7 @@ function formatEventDate(iso: string): string {
<span class="greeting-italic">{greeting}</span>
</h1>
<p class="greeting-sub body-md">
You've been a council member for <em>{tenure}</em>. The team is reading every note you leave.
You've been a member for <em>{tenure}</em>. The team is reading every note you leave.
</p>
</section>