← All changelog entries
April 29, 2026 · Feature

Interactive live polling — Svelte SSR + Turso, five templates, audit log, end-to-end in one evening

Built the interactive polling stack from empty to shipped in about six hours of focused work — Astro DB schema with five tables (Session, Poll, Vote, PollResult, PollEvent), five question templates (boolean, single-select, multi-select, sliding-scale, multi-string-input), pure validation + aggregation library, two server endpoints, a Svelte orchestrator with six visual states and optimistic submission, and SSR session pages that read the OAuth cookie per request. Whole companies sell this as a SaaS; we shipped one that's tighter, owns its own data, and lives next to the markdown content collections it eventually materializes back into.

Authors
Michael Staton
Augmented with
Claude Code on Opus 4.7
Tags
#Polling#Live-Sessions#Astro-DB#Turso#Svelte-Islands#SSR#Realtime#Audit-Log#Roster-Gating#Markdown-Materialization

Changelog — 2026-04-29 (04)

Interactive live polling, end-to-end

This entry consolidates the polling system that landed across 2282755 (route migration to /sessions), 8e62200 (DB schema + seed), dcb3c87 (API routes + validators + aggregators + sync command), 1b98289 (Svelte component family + SSR session page), f96f1e5 / 08f6933 / 7641b36 / 1f78f67 (test session + tuning), 543c756 (multi-string-input template + bug fix on results rendering), and 79be8fb (Svelte server-error fixes). Six hours of clock time from empty db/config.ts to a polling system live on Turso.

Why Care?

There is an entire SaaS category of companies that exist to do exactly one thing: render an interactive poll during a webinar. Mentimeter, Slido, Poll Everywhere, Pigeonhole. Their pricing pages start at $24/host/month and climb. The rendered surface is a <form> and a bar chart.

The dojo’s launch session needed live polling — the kind that turns a one-way webinar into a working room — and a SaaS embed would have meant: a third-party <iframe> siphoning the audience’s session state, no roster gating against the Kauffman list, no audit trail we own, no way to materialize “what we asked and what people said” back into the content collection that lives next to the session’s markdown notes.

Six hours of focused work, instead. Five templates, five tables, two API endpoints, a Svelte orchestrator, SSR session pages, an audit log, and a sync script. ~2,000 lines of code total. Lives in our repo, joins our auth, ships with the rest of the site.

The polling SaaS pitch is “skip the build.” The reality is that for our audience and our use case, the build is the smaller cost — and the data ownership is the bigger asset. This changelog is the receipts.

What Got Built

Five tables (Astro DB → Turso)

Session     — outer container, kind: 'live' | 'time-bound', status: 'draft' | 'active' | 'archived'
Poll        — single question; belongs to one Session; status: 'draft' | 'scheduled' | 'open' | 'closed'
Vote        — one row per (poll_id, user_id), enforced by unique index
PollResult  — derived projection; recomputed on every vote; never authoritative
PollEvent   — audit log of host actions (open / close / extend / reset / delete)

The integrity contract that matters: (poll_id, user_id) is unique at the index level, with user_id = lowercased_roster_email. A Fellow signing in via GitHub vs LinkedIn shares one Vote row. A Fellow voting twice on the same poll under two providers gets the second vote merged onto the first. One person, one vote, regardless of which OAuth path they took to get here.

PollResult is deliberately a cache, not the source of truth. Recomputed from Vote rows on every write. Drops it any time and the next vote rebuilds it correctly. Means we can change the aggregation function without a migration; the next vote re-derives.

PollEvent exists because live polling is a moment of trust. The host can open, close, extend, reset, or delete a poll — all of those actions happen during a meeting where mistakes (and bad-faith calls) are unauditable without a log. The PR-with-auto-merge audit-trail debate that came up in the Stacks v0.5 work resolved itself here: append to PollEvent on every host transition, full stop.

Five question templates

TemplateUIVote shapeTally shape
booleanYes / No tiles{ value: boolean }{ true: n, false: m }
single-selectRadio tiles{ option_ids: [string] }Record<option_id, count>
multi-selectCheckbox tiles{ option_ids: string[] }Record<option_id, count>
sliding-scaleRange slider with optional exclude values, labeled min/mid/max{ value: number }{ histogram: Record<bucket, n>, median, iqr: [number, number] }
multi-string-inputFree-text rows, “add another”{ values: string[] }{ total_strings, total_contributors, entries }

sliding-scale carries the most signal. The exclude field forces polarized choices — for a -3..+3 scale where neutral isn’t allowed, set exclude: [0]. Voters can drag the slider through 0; submit is gated client-side and rejected server-side. Histogram skips excluded buckets. Median and inter-quartile range come back in the result, not just a mean.

multi-string-input is the one most polling SaaSes don’t ship. Voters contribute strings to a shared pool (“what’s one tool you wish you had time to evaluate?”). Counts are always public; the content of the strings is gated by results_visibilitylive reveals immediately, on-close reveals when the host closes the poll, host-only keeps strings private to the host until materialized. Split-visibility model in one column.

The pure-logic engine

src/lib/poll-templates.ts — 354 lines, no I/O, no DB, importable from API routes, sync scripts, and tests. Per-template payload validators, per-template aggregators. Adding a new template is: add a case here + add a <PollQuestionTemplate__*.svelte> component. The template type union ('boolean' | 'single-select' | …) drives both the API contract and the renderer dispatch.

export type ValidatedVote =
  | { template: 'boolean';            payload: BooleanVotePayload }
  | { template: 'single-select';      payload: SingleSelectVotePayload }
  | { template: 'multi-select';       payload: MultiSelectVotePayload }
  | { template: 'sliding-scale';      payload: SlidingScaleVotePayload }
  | { template: 'multi-string-input'; payload: MultiStringInputVotePayload };

export function validateVotePayload(
  template: string,
  options: unknown,
  raw: unknown
): ValidationResult<ValidatedVote> { … }

export function aggregateVotes(input: {
  template: string;
  options: unknown;
  votes: VoteRow[];
}): { tallies: unknown; total_votes: number } { … }

The same module’s PollSnapshot interface is the contract between Astro page (server-renders the initial state from astro:db) and PollEmbed.svelte (orchestrator that owns it after mount). One file, one source of truth for what a poll looks like end-to-end.

Two server endpoints

POST /api/polls/[id]/votes — verify session JWT, match against the Kauffman roster, load the poll, reject if not open, validate payload against the template, upsert Vote keyed by (poll_id, user_id), bump Poll.last_vote_at and Session.last_activity_at, recompute and write PollResult. Returns { ok: true, total_votes: n }.

GET /api/polls/[id]/results.json — public read, cached PollResult, respects results_visibility. For host-only and on-close + status !== 'closed': returns shape with tallies: null. For multi-string-input it implements the split-visibility model: total counts are always returned, entries returned only when content visibility is satisfied.

Both endpoints are prerender = false SSR. Total combined LOC: ~190.

PollEmbed.svelte — the orchestrator

367 lines. Receives initialState (server-rendered snapshot), isAuthenticated, displayName, and a variant: 'inline' | 'card' | 'present' | 'archive'. Manages six visual states:

loading · unauthenticated · open-unvoted · open-voted · closed · errored

Tier-1 interval polling: refresh /api/polls/[id]/results.json every 4s while the poll is open AND the tab is visible. Pause on visibilitychange hidden. Stop entirely when status === 'closed'. No websockets — this scales horizontally on Vercel without sticky sessions, and 4s feels live for a meeting where new votes arrive every 5–15s anyway.

Optimistic vote submission: on submit, set hasVoted = true and myVote = payload immediately so the UI flips to the post-vote tally view; in parallel, POST to /api/polls/[id]/votes. On 4xx/5xx, roll back, surface the error in the card’s error banner. The Astro page always provides initialState from the SSR DB read, so there’s no first-paint flash and “loading” only appears between optimistic vote and server confirmation.

Five PollQuestionTemplate__*.svelte components hang off the orchestrator, each implementing question UI + tally visualization + reduced-motion support. PollEmbed does dispatch by snapshot.template — the orchestrator doesn’t know how a sliding-scale renders a histogram; that lives in the template component, which knows nothing about the network layer.

SSR session pages

/sessions/[id] lost getStaticPaths and went SSR. Reasons:

  1. The session page needs to read the OAuth cookie per request to know whether to render the form or the “Sign in to vote” placeholder. Static pages can’t.
  2. The page renders the initial poll snapshots from astro:db so there’s no first-paint flash. That requires server-time DB access.
  3. Recorded-session pages still benefit — Vercel’s edge caches SSR responses by URL, so they perform identically to SSG once the post-session traffic pattern stabilizes.

Per-page boot: verify session cookie → match roster → look up DB Session by slug → load polls → fetch all PollResults in parallel → build PollSnapshot[] → render <PollEmbed client:load /> per poll. ~50 lines of data assembly above the existing markdown body.

Markdown materialization

pnpm sync:session reads a closed session’s polls + votes + results and writes back to the session’s markdown frontmatter so the canonical content collection holds the receipts. DB rows are the runtime substrate; markdown is the archive. After a session ends, the session detail page can render from markdown alone — no DB query needed for historical surfaces. Refuses to re-sync without FORCE=true so the archive doesn’t drift quietly.

This is the hinge that lets us treat Turso as ephemeral and markdown as durable. A future Turso outage doesn’t lose past sessions; a future content-collection-only deploy doesn’t lose past polls.

Test session + helper scripts

scripts/seed-test-session.ts and scripts/seed-production.ts separate the two — local dev seeds a 2026-04-29_polls-qa session with unlisted: true (hidden from listings) where votes are real but disposable, alongside the actual launch session in draft status. scripts/set-poll-status.ts is the host-side tool to flip a poll between draft / scheduled / open / closed during a live meeting until the proper host UI ships.

Architectural Decisions

  • Cache, don’t compute on read. PollResult is materialized; results endpoints read the cache. Aggregation runs on write because writes are bursty during a live session and reads are constant. Recomputing on read would have made the polling-every-4s fan-out an O(n) cost per voter.
  • One vote per person, not per provider. (poll_id, user_id) unique index plus user_id = lowercased_roster_email. Reusing the User-table identity model from changelog 01 means votes don’t fork when a Fellow links a second provider.
  • Optimistic + interval, not WebSockets. WebSockets buy ~3s of latency over a 4s interval and cost stateful connections, sticky sessions, and a different scaling story on Vercel. Not worth it for a meeting cadence. Revisit when we’re driving polls from a presenter overlay where every second matters.
  • SSR for session pages, static everywhere else. SSR is reserved for routes that need per-request auth or live DB reads. The session page is the only public route that does both.
  • No charting library. Bar charts are CSS gradients; histogram cells are CSS color-mix; sliding-scale axes are pure SVG. Saves the bundle, lets the visualization track theme tokens (light / dark / vibrant) without a re-skin pass.
  • PollEvent audit log is required, not optional. Host actions during a live meeting are unauditable without it. We chose to never make this a flag.
  • DB → markdown materialization is one-way. The DB doesn’t read from materialized markdown. Writers are the only source of mutation; the markdown archive is read-only from the DB’s perspective. Avoids the round-trip drift that bites every sync system.

Behavior

  • Submit a vote → the orchestrator flips hasVoted = true immediately, the chosen template re-renders in “voted” state with the running tally → server-confirmed in <250ms over the same roundtrip → next 4s tick refreshes the tally with anyone else’s vote that landed during.
  • Tab goes hidden → polling pauses → tab returns to foreground → polling resumes from the last-known snapshot.
  • Host flips a poll’s status from open to closed → next interval tick reads status: 'closed', the orchestrator stops polling, and the template component re-renders in its closed state with full results (or host-only if visibility is locked).
  • Vote on the same poll under both GitHub and LinkedIn sessions → second submission updates the first vote in place; one row in Vote; total count increments by 1, not 2.
  • Recorded-session page (status: 'archived' or no DB Session matching the slug) → no <PollEmbed /> islands rendered; the markdown body renders alone with materialized poll archives if the sync command has run.

What’s Deferred to v0.0.2+

The blueprint (context-v/blueprints/Maintain-an-Interactive-Polling-System--v2.md) calls these out explicitly as next-sprint scope:

  • Grace-period auto-close (15-min poll-level, 45-min session-level inactivity timers).
  • Host UI in-page for open / close / extend / reset (currently flipped via pnpm scripts/set-poll-status.ts).
  • Live presenter mode — full-screen poll renderer with no card chrome, optimized for screen-share.
  • GSAP polish on tally bar growth + result reveal.
  • Vitest harness for lib/poll-templates.ts (the validators + aggregators are pure, testable units; we just didn’t write them yet).
  • Matrix and area-board templates (spec’d in §16, gated by the same template column when they ship).
  • A /design-system catalog entry per poll template + the orchestrator.

Migration Note

Production DB schema is on Turso. The build command runs astro db push --remote so deploys carry the schema forward. New tables added today (the Proposal table from changelog 03) push as part of the same pnpm build pipeline.

Verification

  • pnpm dev — local libSQL at .astro/content.db seeded from db/seed.ts with one launch Session (draft) and one QA session (active, unlisted: true).
  • pnpm dev --remote — same UI against Turso. Production-like data without leaving local.
  • Smoke test: /sessions/2026-04-29_agentic-vc-dojo-launch SSRs the launch session, three poll cards render in closed state pre-meeting, host flips status to open, refresh, the same three cards render in open-unvoted state with the form active.
  • Vote on each template through both auth providers → one row per poll per voter in Vote regardless of provider; tallies recompute correctly; sliding-scale histogram + IQR + median populated; multi-string-input split-visibility honored.
  • pnpm sync:session 2026-04-29_polls-qa — closed test session’s polls + results materialized into the session’s markdown frontmatter. Re-running without FORCE=true refuses; with FORCE=true overwrites cleanly.
  • Browser test: tab → background → foreground → polling resumes with the right delta. Page reloaded mid-vote → optimistic state recovers from server-confirmed Vote row on the next render.
Files modified (18)
  • sites/fullstack-vc/db/config.ts
  • sites/fullstack-vc/db/seed.ts
  • sites/fullstack-vc/src/config/polling.ts
  • sites/fullstack-vc/src/lib/poll-templates.ts
  • sites/fullstack-vc/src/pages/api/polls/[id]/votes.ts
  • sites/fullstack-vc/src/pages/api/polls/[id]/results.json.ts
  • sites/fullstack-vc/src/components/polls/PollEmbed.svelte
  • sites/fullstack-vc/src/components/polls/PollQuestionTemplate__Boolean.svelte
  • sites/fullstack-vc/src/components/polls/PollQuestionTemplate__SingleSelect.svelte
  • sites/fullstack-vc/src/components/polls/PollQuestionTemplate__MultiSelect.svelte
  • sites/fullstack-vc/src/components/polls/PollQuestionTemplate__SlidingScale.svelte
  • sites/fullstack-vc/src/components/polls/PollQuestionTemplate__MultiStringInput.svelte
  • sites/fullstack-vc/src/pages/sessions/[id].astro
  • sites/fullstack-vc/scripts/sync-session.ts
  • sites/fullstack-vc/scripts/seed-production.ts
  • sites/fullstack-vc/scripts/seed-test-session.ts
  • sites/fullstack-vc/scripts/set-poll-status.ts
  • sites/fullstack-vc/astro.config.mjs