/** * rCrowdSurf Automerge document schemas. * * Stores collaborative activity proposals ("prompts") with swipe-based * commitment tracking, contribution tagging, and threshold triggers. * * DocId format: {space}:crowdsurf:prompts */ import type { DocSchema } from '../../shared/local-first/document'; // ── Contribution types ── export type ContributionCategory = 'skill' | 'space' | 'equipment' | 'food' | 'other'; export interface Contribution { bringing: string[]; needed: string[]; tags: string[]; value: number; } // ── Swipe record ── export interface PromptSwipe { direction: 'right' | 'left'; timestamp: number; contribution?: Contribution; } // ── Activity prompt ── export interface CrowdSurfPrompt { id: string; text: string; location: string; /** Number of right-swipes needed to trigger */ threshold: number; /** Hours until prompt expires */ duration: number; /** Human-readable activity duration (e.g. "1 hour", "all day") */ activityDuration: string; createdAt: number; createdBy: string | null; triggered: boolean; expired: boolean; /** Keyed by participant DID */ swipes: Record; } // ── Document root ── export interface CrowdSurfDoc { meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number; }; prompts: Record; } // ── Schema registration ── export const crowdsurfSchema: DocSchema = { module: 'crowdsurf', collection: 'prompts', version: 1, init: (): CrowdSurfDoc => ({ meta: { module: 'crowdsurf', collection: 'prompts', version: 1, spaceSlug: '', createdAt: Date.now(), }, prompts: {}, }), migrate: (doc: any, _fromVersion: number) => { if (!doc.prompts) doc.prompts = {}; doc.meta.version = 1; return doc; }, }; // ── Helpers ── export function crowdsurfDocId(space: string) { return `${space}:crowdsurf:prompts` as const; } /** Calculate decay progress (0-1) based on creation time and duration */ export function getDecayProgress(prompt: CrowdSurfPrompt): number { const age = Date.now() - prompt.createdAt; const durationMs = prompt.duration * 60 * 60 * 1000; return Math.min(age / durationMs, 1); } /** Get human-readable time remaining */ export function getTimeRemaining(prompt: CrowdSurfPrompt): string { const remaining = prompt.duration * 60 * 60 * 1000 - (Date.now() - prompt.createdAt); if (remaining <= 0) return 'Expired'; const hours = Math.floor(remaining / (60 * 60 * 1000)); const minutes = Math.floor((remaining % (60 * 60 * 1000)) / (60 * 1000)); if (hours > 0) return `${hours}h ${minutes}m left`; return `${minutes}m left`; } /** Count right-swipes */ export function getRightSwipeCount(prompt: CrowdSurfPrompt): number { return Object.values(prompt.swipes).filter(s => s.direction === 'right').length; } /** Check if prompt has met its threshold */ export function isReadyToTrigger(prompt: CrowdSurfPrompt): boolean { return getRightSwipeCount(prompt) >= prompt.threshold; } /** Get urgency level based on time decay */ export function getUrgency(prompt: CrowdSurfPrompt): 'low' | 'medium' | 'high' { const decay = getDecayProgress(prompt); if (decay > 0.7) return 'high'; if (decay > 0.4) return 'medium'; return 'low'; } /** Parse free-text contribution input into tags and categories */ export function parseContributions(bringing: string, needed: string): Contribution { const parseItems = (text: string): string[] => text.split(/[,\n]/).map(s => s.trim()).filter(s => s.length > 0); const bringingItems = parseItems(bringing); const neededItems = parseItems(needed); const allItems = [...bringingItems, ...neededItems]; const tags = new Set(); const categoryKeywords: Record = { food: ['cook', 'food', 'eat', 'meal', 'kitchen', 'bake', 'grill', 'ingredients'], music: ['music', 'guitar', 'drum', 'sing', 'band', 'dj', 'speaker', 'mic'], learning: ['teach', 'learn', 'skill', 'knowledge', 'workshop', 'lecture'], tech: ['code', 'laptop', 'hack', 'build', 'dev', 'tech', 'wifi'], art: ['art', 'paint', 'draw', 'craft', 'design', 'photo', 'camera'], }; for (const item of allItems) { const lower = item.toLowerCase(); for (const [category, keywords] of Object.entries(categoryKeywords)) { if (keywords.some(kw => lower.includes(kw))) { tags.add(category); } } } // Base value: 5 per item brought, 2 per item needed, +5 bonus for skill keywords const skillWords = ['skill', 'experience', 'professional', 'advanced', 'expert']; const value = bringingItems.reduce((sum, item) => { const hasSkill = skillWords.some(sw => item.toLowerCase().includes(sw)); return sum + (hasSkill ? 10 : 5); }, 0) + neededItems.length * 2; return { bringing: bringingItems, needed: neededItems, tags: Array.from(tags), value, }; }