From 383441edf76a8ba4d30960a013b4bdf3a4663c76 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Mon, 16 Mar 2026 17:29:50 -0700 Subject: [PATCH] feat(rchoices): inline CrowdSurf swipe cards with sortition Replace CrowdSurf tab placeholder with working swipe-card interface populated from rChoices session data (or demo fallback). Uses seeded PRNG (mulberry32 + djb2 hash) for deterministic daily sortition per user, preventing position bias. Right-swipe = approve (casts vote via local-first client), left-swipe = skip. Swipe state persists in localStorage across page reloads. Includes summary view with session-grouped approvals and reset functionality. Co-Authored-By: Claude Opus 4.6 --- .../components/folk-choices-dashboard.ts | 411 +++++++++++++++++- 1 file changed, 404 insertions(+), 7 deletions(-) diff --git a/modules/rchoices/components/folk-choices-dashboard.ts b/modules/rchoices/components/folk-choices-dashboard.ts index b7a4e09..8d5ee0b 100644 --- a/modules/rchoices/components/folk-choices-dashboard.ts +++ b/modules/rchoices/components/folk-choices-dashboard.ts @@ -9,6 +9,16 @@ import { TourEngine } from "../../../shared/tour-engine"; import { ChoicesLocalFirstClient } from "../local-first-client"; import type { ChoicesDoc, ChoiceSession, ChoiceVote } from "../schemas"; +// ── CrowdSurf types ── +interface CrowdSurfOption { + optionId: string; + label: string; + color: string; + sessionId: string; + sessionTitle: string; + sessionType: 'vote' | 'rank' | 'score'; +} + // ── Auth helpers ── function getSession(): { accessToken: string; claims: { sub: string; did?: string; username?: string } } | null { try { @@ -47,6 +57,16 @@ class FolkChoicesDashboard extends HTMLElement { private activeSessionId: string | null = null; private sessionVotes: Map = new Map(); + /* CrowdSurf inline state */ + private csOptions: CrowdSurfOption[] = []; + private csCurrentIndex = 0; + private csSwipedMap: Map = new Map(); + private csIsDragging = false; + private csStartX = 0; + private csCurrentX = 0; + private csIsAnimating = false; + private _csTransitionTimer: number | null = null; + // Guided tour private _tour!: TourEngine; private static readonly TOUR_STEPS = [ @@ -83,6 +103,10 @@ class FolkChoicesDashboard extends HTMLElement { clearInterval(this.simTimer); this.simTimer = null; } + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } this._lfcUnsub?.(); this._lfcUnsub = null; this.lfClient?.disconnect(); @@ -573,6 +597,30 @@ class FolkChoicesDashboard extends HTMLElement { .vote-reset:hover { border-color: var(--rs-error); color: #fca5a5; } .vote-status { text-align: center; margin-bottom: 1rem; font-size: 0.8rem; color: var(--rs-text-muted); } + /* CrowdSurf inline */ + .cs-inline { max-width: 420px; margin-inline: auto; } + .cs-progress-header { display: flex; justify-content: space-between; margin-bottom: 0.75rem; } + .cs-card-stack { display: flex; flex-direction: column; align-items: center; min-height: 240px; justify-content: center; } + .cs-card { position: relative; width: 100%; background: linear-gradient(135deg, var(--rs-bg-surface) 0%, var(--rs-bg-surface-raised, var(--rs-bg-surface)) 100%); border: 1px solid var(--rs-border); border-radius: 16px; padding: 1.5rem; cursor: grab; user-select: none; touch-action: pan-y; } + .cs-card:active { cursor: grabbing; } + .cs-card-body { display: flex; flex-direction: column; gap: 0.75rem; } + .cs-type-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; width: fit-content; } + .cs-card-session { font-size: 0.85rem; color: var(--rs-text-secondary); } + .cs-card-option { font-size: 1.3rem; font-weight: 700; color: var(--rs-text-primary); display: flex; align-items: center; gap: 10px; } + .cs-color-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; } + .cs-swipe-indicator { position: absolute; top: 50%; transform: translateY(-50%); font-size: 1.1rem; font-weight: 700; padding: 6px 14px; border-radius: 8px; opacity: 0; transition: opacity 0.15s; pointer-events: none; z-index: 2; } + .cs-swipe-left { left: 12px; color: #ef4444; background: rgba(239,68,68,0.15); } + .cs-swipe-right { right: 12px; color: #22c55e; background: rgba(34,197,94,0.15); } + .cs-swipe-indicator.show { opacity: 1; } + .cs-swipe-buttons { display: flex; justify-content: center; gap: 2rem; margin-top: 1.25rem; } + .cs-btn-skip, .cs-btn-approve { width: 52px; height: 52px; border-radius: 50%; border: 2px solid; font-size: 1.3rem; cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s; background: var(--rs-bg-surface); font-family: inherit; } + .cs-btn-skip { border-color: #ef4444; color: #ef4444; } + .cs-btn-skip:hover { background: rgba(239,68,68,0.15); } + .cs-btn-approve { border-color: #22c55e; color: #22c55e; } + .cs-btn-approve:hover { background: rgba(34,197,94,0.15); } + .cs-btn-reset { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-bg-surface); color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; font-family: inherit; transition: all 0.15s; margin-top: 0.75rem; } + .cs-btn-reset:hover { border-color: var(--rs-primary); color: var(--rs-text-primary); } + @media (max-width: 768px) { .grid { grid-template-columns: 1fr; } } @@ -588,6 +636,9 @@ class FolkChoicesDashboard extends HTMLElement { .rank-name { font-size: 0.875rem; } .vote-option { padding: 0.625rem 0.75rem; } .spider-svg { max-width: 300px; } + .cs-card { padding: 1.25rem; border-radius: 12px; } + .cs-card-option { font-size: 1.1rem; } + .cs-btn-skip, .cs-btn-approve { width: 46px; height: 46px; font-size: 1.1rem; } } @@ -731,17 +782,324 @@ class FolkChoicesDashboard extends HTMLElement { `; } + /* -- CrowdSurf helpers -- */ + + private static mulberry32(seed: number): () => number { + let s = seed | 0; + return () => { + s = (s + 0x6D2B79F5) | 0; + let t = Math.imul(s ^ (s >>> 15), 1 | s); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + } + + private static hashString(str: string): number { + let hash = 5381; + for (let i = 0; i < str.length; i++) { + hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0; + } + return hash >>> 0; + } + + private buildCrowdSurfOptions() { + const myDid = getMyDid() || 'anon'; + const lsKey = `cs_swiped:${this.space}:${myDid}`; + + // Restore persisted swipes + try { + const saved = localStorage.getItem(lsKey); + if (saved) { + const entries: [string, 'right' | 'left'][] = JSON.parse(saved); + this.csSwipedMap = new Map(entries); + } + } catch { /* ignore */ } + + // Build pool from live sessions or demo data + let pool: CrowdSurfOption[] = []; + + // Try live sessions first + const openSessions = this.sessions.filter(s => !s.closed); + for (const session of openSessions) { + const sType: 'vote' | 'rank' | 'score' = session.type === 'rank' ? 'rank' : session.type === 'score' ? 'score' : 'vote'; + for (const opt of session.options) { + pool.push({ + optionId: opt.id, + label: opt.label, + color: opt.color, + sessionId: session.id, + sessionTitle: session.title, + sessionType: sType, + }); + } + } + + // Demo mode fallback + if (this.space === 'demo' && pool.length === 0) { + for (const opt of this.voteOptions) { + pool.push({ + optionId: opt.id, + label: opt.name, + color: opt.color, + sessionId: 'demo-vote', + sessionTitle: 'Movie Night', + sessionType: 'vote', + }); + } + for (const item of this.rankItems) { + pool.push({ + optionId: String(item.id), + label: item.name, + color: '#f59e0b', + sessionId: 'demo-rank', + sessionTitle: 'Lunch Spot', + sessionType: 'rank', + }); + } + } + + // Filter already swiped + pool = pool.filter(o => !this.csSwipedMap.has(`${o.sessionId}:${o.optionId}`)); + + // Seeded shuffle (Fisher-Yates) + const today = new Date().toISOString().slice(0, 10); + const seed = FolkChoicesDashboard.hashString(`${myDid}:${this.space}:${today}`); + const rng = FolkChoicesDashboard.mulberry32(seed); + for (let i = pool.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [pool[i], pool[j]] = [pool[j], pool[i]]; + } + + this.csOptions = pool.slice(0, Math.min(10, pool.length)); + this.csCurrentIndex = 0; + } + private renderCrowdSurf(): string { - return `
-
🏄
-

CrowdSurf

-

- Swipe-based community coordination. Propose activities, set commitment thresholds, and watch them trigger when enough people join. -

- Open CrowdSurf + // Build options if empty or all consumed + if (this.csOptions.length === 0 || this.csCurrentIndex >= this.csOptions.length) { + this.buildCrowdSurfOptions(); + } + + // No options at all + if (this.csOptions.length === 0) { + return `
+
+
🏄
+

No open polls to surf yet.

+

Create a poll in the Voting tab, then come back to swipe!

+ ${this.csSwipedMap.size > 0 ? `` : ''} +
+
`; + } + + // All swiped — show summary + if (this.csCurrentIndex >= this.csOptions.length) { + return this.renderCrowdSurfSummary(); + } + + // Active card + const opt = this.csOptions[this.csCurrentIndex]; + const approved = Array.from(this.csSwipedMap.values()).filter(v => v === 'right').length; + const typeBadgeColors: Record = { vote: '#3b82f6', rank: '#f59e0b', score: '#10b981' }; + const badgeColor = typeBadgeColors[opt.sessionType] || '#3b82f6'; + + return `
+
+ ${this.csCurrentIndex + 1} of ${this.csOptions.length} + ${approved} approved +
+
+
+
✗ Skip
+
✓ Approve
+
+
${opt.sessionType}
+
${this.esc(opt.sessionTitle)}
+
+ + ${this.esc(opt.label)} +
+
+
+
+
+ + +
`; } + private renderCrowdSurfSummary(): string { + const approved: CrowdSurfOption[] = []; + this.csSwipedMap.forEach((dir, key) => { + if (dir !== 'right') return; + const [sessionId, optionId] = key.split(':'); + const opt = this.csOptions.find(o => o.sessionId === sessionId && o.optionId === optionId); + if (opt) approved.push(opt); + }); + + // Group by session + const grouped = new Map(); + for (const opt of approved) { + const list = grouped.get(opt.sessionId) || []; + list.push(opt); + grouped.set(opt.sessionId, list); + } + + let groupHtml = ''; + grouped.forEach((opts) => { + const title = opts[0].sessionTitle; + const items = opts.map(o => + `
+ + ${this.esc(o.label)} +
` + ).join(''); + groupHtml += `
+
${this.esc(title)}
+ ${items} +
`; + }); + + return `
+
+
+

All done!

+

+ You approved ${approved.length} of ${this.csSwipedMap.size} options +

+ ${groupHtml || `

No approvals this round.

`} + +
+
`; + } + + private setupCrowdSurfSwipe() { + const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; + if (!card) return; + + // Clear any pending transition timer from previous card + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } + + const handleStart = (clientX: number) => { + if (this.csIsAnimating) return; + // Clear any lingering transition + if (this._csTransitionTimer !== null) { + clearTimeout(this._csTransitionTimer); + this._csTransitionTimer = null; + } + card.style.transition = ''; + this.csStartX = clientX; + this.csCurrentX = clientX; + this.csIsDragging = true; + }; + + const handleMove = (clientX: number) => { + if (!this.csIsDragging || this.csIsAnimating) return; + this.csCurrentX = clientX; + const diffX = this.csCurrentX - this.csStartX; + const rotation = diffX * 0.1; + card.style.transform = `translateX(${diffX}px) rotate(${rotation}deg)`; + + const leftInd = card.querySelector('.cs-swipe-left') as HTMLElement; + const rightInd = card.querySelector('.cs-swipe-right') as HTMLElement; + + if (diffX < -50) { + leftInd?.classList.add('show'); + rightInd?.classList.remove('show'); + } else if (diffX > 50) { + rightInd?.classList.add('show'); + leftInd?.classList.remove('show'); + } else { + leftInd?.classList.remove('show'); + rightInd?.classList.remove('show'); + } + }; + + const handleEnd = () => { + if (!this.csIsDragging || this.csIsAnimating) return; + this.csIsDragging = false; + const diffX = this.csCurrentX - this.csStartX; + + card.querySelector('.cs-swipe-left')?.classList.remove('show'); + card.querySelector('.cs-swipe-right')?.classList.remove('show'); + + if (Math.abs(diffX) > 100) { + const direction = diffX > 0 ? 1 : -1; + this.csIsAnimating = true; + card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; + card.style.transform = `translateX(${direction * 500}px) rotate(${direction * 30}deg)`; + card.style.opacity = '0'; + this._csTransitionTimer = window.setTimeout(() => { + card.style.transform = ''; + card.style.opacity = ''; + card.style.transition = ''; + this._csTransitionTimer = null; + this.handleCrowdSurfSwipe(diffX > 0 ? 'right' : 'left'); + }, 300); + } else { + card.style.transition = 'transform 0.2s ease-out'; + card.style.transform = ''; + this._csTransitionTimer = window.setTimeout(() => { + card.style.transition = ''; + this._csTransitionTimer = null; + }, 200); + } + }; + + card.addEventListener('pointerdown', (e: PointerEvent) => { + if (e.button !== 0) return; + card.setPointerCapture(e.pointerId); + card.style.touchAction = 'none'; + handleStart(e.clientX); + }); + card.addEventListener('pointermove', (e: PointerEvent) => { + e.preventDefault(); + handleMove(e.clientX); + }); + card.addEventListener('pointerup', () => handleEnd()); + card.addEventListener('pointercancel', () => { + this.csIsDragging = false; + card.style.transform = ''; + card.style.transition = ''; + card.style.opacity = ''; + card.style.touchAction = ''; + }); + } + + private handleCrowdSurfSwipe(direction: 'right' | 'left') { + this.csIsAnimating = false; + if (this.csCurrentIndex >= this.csOptions.length) return; + + const opt = this.csOptions[this.csCurrentIndex]; + const swipeKey = `${opt.sessionId}:${opt.optionId}`; + this.csSwipedMap.set(swipeKey, direction); + + // Persist to localStorage + const myDid = getMyDid() || 'anon'; + const lsKey = `cs_swiped:${this.space}:${myDid}`; + try { + localStorage.setItem(lsKey, JSON.stringify(Array.from(this.csSwipedMap.entries()))); + } catch { /* quota */ } + + // Cast vote on right swipe (live mode) + if (direction === 'right' && this.lfClient && opt.sessionId !== 'demo-vote' && opt.sessionId !== 'demo-rank') { + const did = getMyDid(); + if (did) { + const existing = this.lfClient.getMyVote(opt.sessionId, did); + const newChoices = { ...(existing?.choices || {}), [opt.optionId]: 1 }; + this.lfClient.castVote(opt.sessionId, did, newChoices); + } + } + + this.csCurrentIndex++; + this.renderDemo(); + this.bindDemoEvents(); + } + /* -- Demo event binding -- */ private bindDemoEvents() { @@ -861,6 +1219,45 @@ class FolkChoicesDashboard extends HTMLElement { this.renderDemo(); }); } + + // CrowdSurf swipe + buttons + this.setupCrowdSurfSwipe(); + this.shadow.querySelector('[data-cs-action="skip"]')?.addEventListener('click', () => { + if (this.csIsAnimating) return; + const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; + if (card) { + this.csIsAnimating = true; + card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; + card.style.transform = 'translateX(-500px) rotate(-30deg)'; + card.style.opacity = '0'; + setTimeout(() => this.handleCrowdSurfSwipe('left'), 300); + } else { + this.handleCrowdSurfSwipe('left'); + } + }); + this.shadow.querySelector('[data-cs-action="approve"]')?.addEventListener('click', () => { + if (this.csIsAnimating) return; + const card = this.shadow.getElementById('cs-inline-card') as HTMLElement | null; + if (card) { + this.csIsAnimating = true; + card.style.transition = 'transform 0.3s ease-out, opacity 0.3s ease-out'; + card.style.transform = 'translateX(500px) rotate(30deg)'; + card.style.opacity = '0'; + setTimeout(() => this.handleCrowdSurfSwipe('right'), 300); + } else { + this.handleCrowdSurfSwipe('right'); + } + }); + this.shadow.querySelector('[data-cs-action="reset"]')?.addEventListener('click', () => { + const myDid = getMyDid() || 'anon'; + const lsKey = `cs_swiped:${this.space}:${myDid}`; + localStorage.removeItem(lsKey); + this.csSwipedMap.clear(); + this.csOptions = []; + this.csCurrentIndex = 0; + this.renderDemo(); + this.bindDemoEvents(); + }); } private esc(s: string): string {