rspace-online/modules/crowdsurf/schemas.ts

169 lines
4.8 KiB
TypeScript

/**
* 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<string, PromptSwipe>;
}
// ── Document root ──
export interface CrowdSurfDoc {
meta: {
module: string;
collection: string;
version: number;
spaceSlug: string;
createdAt: number;
};
prompts: Record<string, CrowdSurfPrompt>;
}
// ── Schema registration ──
export const crowdsurfSchema: DocSchema<CrowdSurfDoc> = {
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<string>();
const categoryKeywords: Record<string, string[]> = {
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,
};
}