/** * CrowdSurf module — swipe-based community activity coordination. * * Inspired by gospelofchange/Crowdsurfing. Users propose activities with * commitment thresholds; others swipe to join and declare contributions. * When enough people commit, the activity triggers. * * The folk-crowdsurf-dashboard web component lives in components/. * This module provides: * - A dashboard listing active/triggered prompts in the current space * - API to query crowdsurf prompts from the Automerge store * - Canvas shape integration for inline prompt cards */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import type { RSpaceModule } from "../../shared/module"; import { renderLanding } from "./landing"; import { getModuleInfoList } from "../../shared/module"; import { getDocumentData, addShapes } from "../../server/community-store"; import { ELO_DEFAULT, computeElo } from "./schemas"; const routes = new Hono(); // GET /api/crowdsurf — list crowdsurf prompt shapes in the current space routes.get("/api/crowdsurf", async (c) => { const space = c.req.param("space") || c.req.query("space") || "demo"; const docData = getDocumentData(space); if (!docData?.shapes) { return c.json({ prompts: [], total: 0 }); } const promptTypes = ["folk-crowdsurf-prompt"]; const prompts: any[] = []; for (const [id, shape] of Object.entries(docData.shapes as Record)) { if (shape.forgotten) continue; if (promptTypes.includes(shape.type)) { prompts.push({ id, type: shape.type, text: shape.text || "Untitled", location: shape.location || "", threshold: shape.threshold || 3, swipeCount: Object.keys(shape.swipes || {}).length, triggered: shape.triggered || false, createdAt: shape.createdAt, }); } } return c.json({ prompts, total: prompts.length }); }); // GET /api/crowdsurf/pair — get a random pair for pairwise comparison (sortition-weighted) routes.get("/api/crowdsurf/pair", async (c) => { const space = c.req.param("space") || c.req.query("space") || "demo"; const docData = getDocumentData(space); if (!docData?.shapes) return c.json({ error: "No prompts" }, 404); const prompts: any[] = []; for (const [id, shape] of Object.entries(docData.shapes as Record)) { if (shape.forgotten || shape.type !== "folk-crowdsurf-prompt") continue; if (shape.expired || shape.triggered) continue; prompts.push({ id, ...shape, elo: shape.elo ?? ELO_DEFAULT, comparisons: shape.comparisons ?? 0, wins: shape.wins ?? 0 }); } if (prompts.length < 2) return c.json({ error: "Need at least 2 active prompts" }, 404); // Sortition: weight by inverse comparisons (fewer comparisons = more likely to be picked) const maxComp = Math.max(...prompts.map((p: any) => p.comparisons), 1); const weights = prompts.map((p: any) => maxComp - p.comparisons + 1); const totalWeight = weights.reduce((a: number, b: number) => a + b, 0); const pickWeighted = (exclude?: string): any => { let r = Math.random() * (exclude ? totalWeight - (weights[prompts.findIndex((p: any) => p.id === exclude)] || 0) : totalWeight); for (let i = 0; i < prompts.length; i++) { if (prompts[i].id === exclude) continue; r -= weights[i]; if (r <= 0) return prompts[i]; } return prompts[prompts.length - 1]; }; const a = pickWeighted(); const b = pickWeighted(a.id); return c.json({ a, b }); }); // POST /api/crowdsurf/compare — record a pairwise comparison result routes.post("/api/crowdsurf/compare", async (c) => { const space = c.req.param("space") || c.req.query("space") || "demo"; const body = await c.req.json(); const { winnerId, loserId } = body; if (!winnerId || !loserId) return c.json({ error: "winnerId and loserId required" }, 400); const docData = getDocumentData(space); if (!docData?.shapes) return c.json({ error: "No data" }, 404); const winner = (docData.shapes as Record)[winnerId]; const loser = (docData.shapes as Record)[loserId]; if (!winner || !loser) return c.json({ error: "Prompt not found" }, 404); const winnerElo = winner.elo ?? ELO_DEFAULT; const loserElo = loser.elo ?? ELO_DEFAULT; const result = computeElo(winnerElo, loserElo); // Update shapes via addShapes (merge semantics) addShapes(space, [ { id: winnerId, elo: result.winner, comparisons: (winner.comparisons ?? 0) + 1, wins: (winner.wins ?? 0) + 1 }, { id: loserId, elo: result.loser, comparisons: (loser.comparisons ?? 0) + 1 }, ]); return c.json({ ok: true, winner: { id: winnerId, elo: result.winner, delta: result.winner - winnerElo }, loser: { id: loserId, elo: result.loser, delta: result.loser - loserElo }, }); }); // GET / — crowdsurf dashboard page routes.get("/", (c) => { const spaceSlug = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${spaceSlug} — CrowdSurf | rSpace`, moduleId: "crowdsurf", spaceSlug, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ``, })); }); // ── Seed template data ── function seedTemplateCrowdSurf(space: string) { const docData = getDocumentData(space); const promptTypes = ["folk-crowdsurf-prompt"]; if (docData?.shapes) { const existing = Object.values(docData.shapes as Record) .filter((s: any) => !s.forgotten && promptTypes.includes(s.type)); if (existing.length > 0) return; } const now = Date.now(); const shapes: Record[] = [ { id: `tmpl-crowdsurf-1-${now}`, type: 'folk-crowdsurf-prompt', x: 50, y: 2200, width: 420, height: 300, rotation: 0, text: 'Community Garden Planting Day', location: 'Community Center Garden', threshold: 5, duration: 4, activityDuration: '3 hours', swipes: {}, triggered: false, expired: false, createdAt: now, }, { id: `tmpl-crowdsurf-2-${now}`, type: 'folk-crowdsurf-prompt', x: 520, y: 2200, width: 420, height: 300, rotation: 0, text: 'Open Mic & Jam Session', location: 'Local Park Bandstand', threshold: 8, duration: 6, activityDuration: '2 hours', swipes: {}, triggered: false, expired: false, createdAt: now, }, { id: `tmpl-crowdsurf-3-${now}`, type: 'folk-crowdsurf-prompt', x: 990, y: 2200, width: 420, height: 300, rotation: 0, text: 'Repair Cafe — Bring Your Broken Stuff', location: 'Maker Space', threshold: 3, duration: 8, activityDuration: '4 hours', swipes: {}, triggered: false, expired: false, createdAt: now, }, ]; addShapes(space, shapes); console.log(`[CrowdSurf] Template seeded for "${space}": 3 prompt shapes`); } export const crowdsurfModule: RSpaceModule = { id: "crowdsurf", name: "CrowdSurf", icon: "🏄", description: "Swipe-based community activity coordination", scoping: { defaultScope: 'space', userConfigurable: false }, routes, hidden: true, // CrowdSurf is now a sub-tab of rChoices landingPage: renderLanding, seedTemplate: seedTemplateCrowdSurf, feeds: [ { id: "activity-triggers", name: "Activity Triggers", kind: "governance", description: "Activity proposals and triggered events", emits: ["folk-crowdsurf-prompt"], }, ], acceptsFeeds: ["data", "governance"], outputPaths: [ { path: "prompts", name: "Prompts", icon: "🏄", description: "Active activity proposals" }, { path: "triggered", name: "Triggered", icon: "🚀", description: "Activities that reached their threshold" }, { path: "rank", name: "Rank", icon: "🎲", description: "Pairwise Elo ranking of activities" }, ], };