169 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|