From ed2c272d3a9a3c3fcc2b3425bf6099e22bdda4ff Mon Sep 17 00:00:00 2001 From: Jonathan Hvid Date: Mon, 11 May 2026 16:16:24 +0200 Subject: [PATCH] chore: Studio hours rename + Phase 2 demo seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Studio hours rename pass (one commit, grep-driven): - /pulse right-card eyebrow: 'Office hours' → 'Studio hours' - scripts/seed-demo.js: event title + description match the spec ('Studio hours with Jonathan' · '30-minute slots. Open agenda. Drop in when you've got something to talk through.') - No code-level enum changes — the kind value office_hours is preserved for back-compat; display labels switch wherever surfaced (admin form select, /pulse eyebrow, /events list and meta) scripts/seed-demo.js — Phase 2 demo state. Destructive in scope (wipes the data tables it owns then re-inserts), idempotent on re-run: - 4 cab members: Lars (existing) + Anna Kjær / Søren Vedel / Henriette Rask. cab_joined_date staggered 24/6/4/2 weeks ago so tenures vary. title, pull_quote, focus_tags populated per spec. member_number backfilled via the same SQL pattern as migration 0004 (deterministic). - 1 active pulse with 2 of 4 council members voted. Vote count on /pulse now reads '2 of 4 council members have weighed in.' — the line voted test was designed to lock down. - 4 roadmap items: Traceability layer (shipping, attributed to Lars), Document ingestion (beta, attributed to Anna + Søren), Contextual memory (exploring, attributed to Henriette), Agentic query mode (exploring, unattributed). - 3 contributions, most recent ('inline annotations' idea by Søren) has 3 reactions — populates the RecentlyFromTheCouncil card. - 4 published dispatches at 2/5/9/12 days ago covering all four kinds (decision / behind_the_scenes / update / note). Real-ish prose so the excerpt cutter has actual sentence boundaries to find. - Events: hero dinner 5w out, Studio hours 2w out, a working_session 3w out (exercising the new kind), April roundtable 3w ago with a notes_url, March launch dinner 7.5w ago without notes (exercises both past-card thumb modes). Hero dinner has 1 confirmed RSVP (Lars) to drive the avatar pile at small scale. - Activity rows for the (now-hidden but still-written) feed so admin's Activity tab has something to display. Smoke (curl as Lars): /pulse renders 'Good afternoon, Lars.' · COUNCIL · 001 · '2 of 4' · Latest from the studio · Recently from the council · Studio hours. /members shows all four members with pull quotes + focus pills. /events shows the dinner hero, 'Save your seat →', Studio hours + working session in 'also coming up', April and March in 'past gatherings'. /dispatches lists all four; /dispatches/{slug} renders body + adjacent prev/next. Co-Authored-By: Claude Opus 4.7 (1M context) --- scripts/seed-demo.js | 360 +++++++++++++++++++++++++++++++++--------- src/pages/pulse.astro | 2 +- 2 files changed, 283 insertions(+), 79 deletions(-) diff --git a/scripts/seed-demo.js b/scripts/seed-demo.js index 4c33c68..8b9733b 100644 --- a/scripts/seed-demo.js +++ b/scripts/seed-demo.js @@ -1,13 +1,16 @@ #!/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. +// 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. // -// Idempotent: skips if a pulse already exists. Run AFTER scripts/seed.js -// and scripts/seed-roadmap.js (or via `pnpm db:setup`). +// 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'; @@ -16,15 +19,24 @@ 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); -} +// ── 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; +`); -const users = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all(); -const byRole = (r) => users.find(u => u.role === r); +// ── 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'); @@ -34,105 +46,297 @@ if (!mette || !lars || !jon) { 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); +// ── 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@kommune.dk', org: 'Kbh Kommune' }, + { name: 'Søren Vedel', email: 'soren@energinet.dk', org: 'Energinet' }, + { name: 'Henriette Rask',email: 'henriette@dnv.dk', org: 'Dansk Nationalbank' }, +]; + +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@rigspolitiet.dk': 24, 'anna@kommune.dk': 6, 'soren@energinet.dk': 4, 'henriette@dnv.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@rigspolitiet.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@kommune.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@energinet.dk': { + title: 'Head of Data Engineering', + pull_quote: 'Make it boring to deploy and surprising to query.', + focus_tags: ['Infrastructure', 'Telemetry'], + }, + 'henriette@dnv.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); }; -// ── 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 = [ +// ── Pulse: open now, closes in 5 days, 2 of 4 voted ──────────────── +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 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 July–September. Read the roadmap before voting.', - JSON.stringify(options), - opensAt, - closesAt, - 'open', - jon.id, + JSON.stringify(pulseOptions), + nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, ).lastInsertRowid; -// Lars votes for the traceability option +// 2 votes from cabs[0] and cabs[1] db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') - .run(pulseId, lars.id, 1, nowIso(-2 * 3600)); + .run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600)); +db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') + .run(pulseId, cabs[1].id, 1, nowIso(-30 * 60)); -// ── 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); +// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ─────── +const roadmap = [ + { title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] }, + { title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] }, + { title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] }, + { title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, 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); } -// ── 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', +// ── 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) + 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; + insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when); +} + +// ── 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', - dinnerStart, - 12, + nowIso(38 * 24 * 3600), null, 12, null, + 'Members only', null, null, null, jon.id, ); -const dinnerId = db.prepare("SELECT id FROM events WHERE slug = 'kickoff-dinner-2026-06'").get().id; +const dinnerSlug = 'kickoff-dinner-2026-06'; -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.', +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)', - officeHoursStart, + nowIso(14 * 24 * 3600), null, null, null, + 'Council members', '30 minutes', null, null, 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. +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', pulseId, nowIso(-3600)); +insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600)); +insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, nowIso(-30 * 60)); +insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); -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`); - +console.log(' pulse #' + pulseId + ' open, 2 of 4 voted'); +console.log(' roadmap: 1 shipping / 1 beta / 2 exploring'); +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(); diff --git a/src/pages/pulse.astro b/src/pages/pulse.astro index 4add8ef..43a1dfe 100644 --- a/src/pages/pulse.astro +++ b/src/pages/pulse.astro @@ -226,7 +226,7 @@ function formatEventDate(iso: string): string { {nextOfficeHours && (

- Office hours · {formatEventDate(nextOfficeHours.starts_at)} + Studio hours · {formatEventDate(nextOfficeHours.starts_at)}

{nextOfficeHours.title}

{nextOfficeHours.description}