/** * Cal module — temporal coordination calendar. * * Group calendars with lunar/solar/seasonal time systems, * location-aware events, and temporal-spatial zoom coupling. * * All persistence uses Automerge documents via SyncServer — * no PostgreSQL dependency. */ import { Hono } from "hono"; import * as Automerge from "@automerge/automerge"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; import { renderLanding } from "./landing"; import type { SyncServer } from '../../server/local-first/sync-server'; import { calendarSchema, calendarDocId } from './schemas'; import type { CalendarDoc, CalendarEvent, CalendarSource, ScheduledItemMetadata } from './schemas'; let _syncServer: SyncServer | null = null; const routes = new Hono(); // ── Local-first helpers ── /** * Lazily create the calendar Automerge doc if it doesn't exist yet. * Returns the current (immutable) doc snapshot. */ function ensureDoc(space: string): CalendarDoc { const docId = calendarDocId(space); let doc = _syncServer!.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init calendar', (d) => { const init = calendarSchema.init(); d.meta = init.meta; d.meta.spaceSlug = space; d.sources = {}; d.events = {}; }); _syncServer!.setDoc(docId, doc); } return doc; } function daysFromNow(days: number, hours: number, minutes: number): Date { const d = new Date(); d.setDate(d.getDate() + days); d.setHours(hours, minutes, 0, 0); return d; } /** * Build an event row object suitable for JSON responses. * Maps camelCase schema fields to the snake_case format the API previously returned. */ function eventToRow(ev: CalendarEvent, sources: Record) { const src = ev.sourceId ? sources[ev.sourceId] : undefined; return { id: ev.id, title: ev.title, description: ev.description, start_time: ev.startTime ? new Date(ev.startTime).toISOString() : null, end_time: ev.endTime ? new Date(ev.endTime).toISOString() : null, all_day: ev.allDay, timezone: ev.timezone, rrule: ev.rrule, status: ev.status, visibility: ev.visibility, source_id: ev.sourceId, source_name: src?.name ?? ev.sourceName ?? null, source_color: src?.color ?? ev.sourceColor ?? null, source_type: src?.sourceType ?? ev.sourceType ?? null, location_id: ev.locationId, location_name: ev.locationName, location_label: ev.locationName, location_lat: ev.locationLat, location_lng: ev.locationLng, location_granularity: ev.locationGranularity, is_virtual: ev.isVirtual, virtual_url: ev.virtualUrl, virtual_platform: ev.virtualPlatform, r_tool_source: ev.rToolSource, r_tool_entity_id: ev.rToolEntityId, attendees: ev.attendees, attendee_count: ev.attendeeCount, metadata: ev.metadata, created_at: ev.createdAt ? new Date(ev.createdAt).toISOString() : null, updated_at: ev.updatedAt ? new Date(ev.updatedAt).toISOString() : null, }; } /** * Build a source row object for JSON responses. */ function sourceToRow(src: CalendarSource) { return { id: src.id, name: src.name, source_type: src.sourceType, url: src.url, color: src.color, is_active: src.isActive, is_visible: src.isVisible, sync_interval_minutes: src.syncIntervalMinutes, last_synced_at: src.lastSyncedAt ? new Date(src.lastSyncedAt).toISOString() : null, owner_id: src.ownerId, created_at: src.createdAt ? new Date(src.createdAt).toISOString() : null, }; } /** * Seed demo data if the doc has no events yet. */ function seedDemoIfEmpty(space: string) { const docId = calendarDocId(space); const doc = ensureDoc(space); if (Object.keys(doc.events).length > 0) return; _syncServer!.changeDoc(docId, 'seed demo data', (d) => { const now = Date.now(); // Create calendar sources const communityId = crypto.randomUUID(); const sprintsId = crypto.randomUUID(); d.sources[communityId] = { id: communityId, name: 'Community Events', sourceType: 'MANUAL', url: null, color: '#6366f1', isActive: true, isVisible: true, syncIntervalMinutes: null, lastSyncedAt: 0, ownerId: null, createdAt: now, }; d.sources[sprintsId] = { id: sprintsId, name: 'Development Sprints', sourceType: 'MANUAL', url: null, color: '#f59e0b', isActive: true, isVisible: true, syncIntervalMinutes: null, lastSyncedAt: 0, ownerId: null, createdAt: now, }; // Location IDs (embedded on events, no separate locations table) const berlinLocId = crypto.randomUUID(); // Seed events const seedEvents: Array<{ title: string; desc: string; start: Date; end: Date; sourceId: string; allDay?: boolean; locationId?: string; locationName?: string; locationLat?: number; locationLng?: number; locationGranularity?: string; isVirtual?: boolean; virtualUrl?: string; virtualPlatform?: string; }> = [ { title: "rSpace Launch Party", desc: "Celebrating the launch of the unified rSpace platform with all 22 modules live.", start: daysFromNow(-21, 18, 0), end: daysFromNow(-21, 22, 0), sourceId: communityId, locationName: "Radiant Hall, Pittsburgh", }, { title: "Provider Onboarding Workshop", desc: "Hands-on session for print providers joining the cosmolocal network.", start: daysFromNow(-12, 14, 0), end: daysFromNow(-12, 17, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-providers", virtualPlatform: "Jitsi", }, { title: "Weekly Community Standup", desc: "Open standup — share what you're working on, ask for help, coordinate.", start: daysFromNow(0, 16, 0), end: daysFromNow(0, 16, 45), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-standup", virtualPlatform: "Jitsi", }, { title: "Sprint: Module Seeding & Polish", desc: "Focus sprint on populating demo data and improving UX across all modules.", start: daysFromNow(0, 9, 0), end: daysFromNow(5, 18, 0), sourceId: sprintsId, allDay: true, }, { title: "rFlows Budget Review", desc: "Quarterly review of treasury flows, enoughness thresholds, and overflow routing.", start: daysFromNow(6, 15, 0), end: daysFromNow(6, 17, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rflows-review", virtualPlatform: "Jitsi", }, { title: "Cosmolocal Design Sprint", desc: "Two-day design sprint on the next generation of cosmolocal tooling.", start: daysFromNow(11, 9, 0), end: daysFromNow(12, 18, 0), sourceId: sprintsId, locationId: berlinLocId, locationName: "Druckwerkstatt Berlin", locationLat: 52.52, locationLng: 13.405, locationGranularity: "city", }, { title: "Q1 Retrospective", desc: "Looking back at what we built, what worked, and what to improve.", start: daysFromNow(21, 16, 0), end: daysFromNow(21, 18, 0), sourceId: communityId, isVirtual: true, virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi", }, ]; for (const e of seedEvents) { const eventId = crypto.randomUUID(); d.events[eventId] = { id: eventId, title: e.title, description: e.desc, startTime: e.start.getTime(), endTime: e.end.getTime(), allDay: e.allDay || false, timezone: 'UTC', rrule: null, status: null, visibility: null, sourceId: e.sourceId, sourceName: null, sourceType: null, sourceColor: null, locationId: e.locationId || null, locationName: e.locationName || null, coordinates: null, locationGranularity: e.locationGranularity || null, locationLat: e.locationLat ?? null, locationLng: e.locationLng ?? null, isVirtual: e.isVirtual || false, virtualUrl: e.virtualUrl || null, virtualPlatform: e.virtualPlatform || null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: now, updatedAt: now, }; } }); console.log("[Cal] Demo data seeded: 2 sources, 7 events"); } // ── API: Events ── // GET /api/events — query events with filters routes.get("/api/events", async (c) => { const space = c.req.param("space") || "demo"; const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query(); const doc = ensureDoc(space); let events = Object.values(doc.events); // Apply filters if (start) { const startMs = new Date(start).getTime(); events = events.filter((e) => e.startTime >= startMs); } if (end) { const endMs = new Date(end).getTime() + 86400000; // +1 day events = events.filter((e) => e.startTime <= endMs); } if (source) { events = events.filter((e) => e.sourceId === source); } if (search) { const term = search.toLowerCase(); events = events.filter((e) => e.title.toLowerCase().includes(term) || (e.description && e.description.toLowerCase().includes(term)) ); } if (rTool) { events = events.filter((e) => e.rToolSource === rTool); } if (rEntityId) { events = events.filter((e) => e.rToolEntityId === rEntityId); } if (upcoming) { const nowMs = Date.now(); const futureMs = nowMs + parseInt(upcoming) * 86400000; events = events.filter((e) => e.startTime >= nowMs && e.startTime <= futureMs); } // Sort by start time, limit to 500 events.sort((a, b) => a.startTime - b.startTime); events = events.slice(0, 500); const rows = events.map((e) => eventToRow(e, doc.sources)); return c.json({ count: rows.length, results: rows }); }); // POST /api/events — create event routes.post("/api/events", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const body = await c.req.json(); const { title, description, start_time, end_time, all_day, timezone, source_id, location_id, location_name, is_virtual, virtual_url, virtual_platform, r_tool_source, r_tool_entity_id, is_scheduled_item, provenance, item_preview } = body; if (!title?.trim() || !start_time) return c.json({ error: "Title and start_time required" }, 400); const docId = calendarDocId(space); ensureDoc(space); const eventId = crypto.randomUUID(); const now = Date.now(); // Build metadata for scheduled knowledge items let metadata: ScheduledItemMetadata | null = null; if (is_scheduled_item && provenance) { metadata = { isScheduledItem: true, provenance: { rid: provenance.rid || '', contentHash: provenance.content_hash ?? provenance.contentHash ?? null, sourceType: provenance.source_type ?? provenance.sourceType ?? '', sourceSpace: provenance.source_space ?? provenance.sourceSpace ?? space, lifecycleEvent: provenance.lifecycle_event ?? provenance.lifecycleEvent ?? 'NEW', originalCreatedAt: provenance.original_created_at ?? provenance.originalCreatedAt ?? now, scheduledBy: provenance.scheduled_by ?? provenance.scheduledBy ?? null, scheduledAt: now, }, itemPreview: item_preview ? { textPreview: item_preview.text_preview ?? item_preview.textPreview ?? '', mimeType: item_preview.mime_type ?? item_preview.mimeType ?? 'text/plain', thumbnailUrl: item_preview.thumbnail_url ?? item_preview.thumbnailUrl, canvasUrl: item_preview.canvas_url ?? item_preview.canvasUrl, shapeSnapshot: item_preview.shape_snapshot ?? item_preview.shapeSnapshot, } : { textPreview: description || title, mimeType: 'text/plain', }, reminderSent: false, reminderSentAt: null, }; } _syncServer!.changeDoc(docId, `create event ${eventId}`, (d) => { d.events[eventId] = { id: eventId, title: title.trim(), description: description || '', startTime: new Date(start_time).getTime(), endTime: end_time ? new Date(end_time).getTime() : 0, allDay: all_day || false, timezone: timezone || 'UTC', rrule: null, status: null, visibility: null, sourceId: source_id || null, sourceName: null, sourceType: null, sourceColor: null, locationId: location_id || null, locationName: location_name || null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, isVirtual: is_virtual || false, virtualUrl: virtual_url || null, virtualPlatform: virtual_platform || null, rToolSource: r_tool_source || 'rSchedule', rToolEntityId: r_tool_entity_id || null, attendees: [], attendeeCount: 0, metadata: metadata, createdAt: now, updatedAt: now, }; }); const updated = _syncServer!.getDoc(docId)!; return c.json(eventToRow(updated.events[eventId], updated.sources), 201); }); // GET /api/events/scheduled — query only scheduled knowledge items routes.get("/api/events/scheduled", async (c) => { const space = c.req.param("space") || "demo"; const { date, upcoming, pending_only } = c.req.query(); const doc = ensureDoc(space); let events = Object.values(doc.events).filter((e) => { const meta = e.metadata as ScheduledItemMetadata | null; return meta?.isScheduledItem === true; }); if (date) { const dayStart = new Date(date).setHours(0, 0, 0, 0); const dayEnd = dayStart + 86400000; events = events.filter((e) => e.startTime >= dayStart && e.startTime < dayEnd); } if (upcoming) { const nowMs = Date.now(); const futureMs = nowMs + parseInt(upcoming) * 86400000; events = events.filter((e) => e.startTime >= nowMs && e.startTime <= futureMs); } if (pending_only === "true") { events = events.filter((e) => { const meta = e.metadata as ScheduledItemMetadata; return !meta.reminderSent; }); } events.sort((a, b) => a.startTime - b.startTime); const rows = events.map((e) => eventToRow(e, doc.sources)); return c.json({ count: rows.length, results: rows }); }); // GET /api/events/:id routes.get("/api/events/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const doc = ensureDoc(space); const ev = doc.events[id]; if (!ev) return c.json({ error: "Event not found" }, 404); return c.json(eventToRow(ev, doc.sources)); }); // PATCH /api/events/:id routes.patch("/api/events/:id", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); let claims; try { claims = await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const body = await c.req.json(); const docId = calendarDocId(space); const doc = ensureDoc(space); if (!doc.events[id]) return c.json({ error: "Not found" }, 404); // Map of allowed body keys to CalendarEvent fields const fieldMap: Record = { title: 'title', description: 'description', start_time: 'startTime', end_time: 'endTime', all_day: 'allDay', timezone: 'timezone', status: 'status', visibility: 'visibility', location_name: 'locationName', is_virtual: 'isVirtual', virtual_url: 'virtualUrl', metadata: 'metadata', }; const updates: Array<{ field: keyof CalendarEvent; value: any }> = []; for (const [bodyKey, docField] of Object.entries(fieldMap)) { if (body[bodyKey] !== undefined) { let value = body[bodyKey]; // Convert time strings to epoch ms if (bodyKey === 'start_time' || bodyKey === 'end_time') { value = new Date(value).getTime(); } updates.push({ field: docField, value }); } } if (updates.length === 0) return c.json({ error: "No fields" }, 400); _syncServer!.changeDoc(docId, `update event ${id}`, (d) => { const ev = d.events[id]; for (const { field, value } of updates) { (ev as any)[field] = value; } ev.updatedAt = Date.now(); }); const updated = _syncServer!.getDoc(docId)!; return c.json(eventToRow(updated.events[id], updated.sources)); }); // DELETE /api/events/:id routes.delete("/api/events/:id", async (c) => { const space = c.req.param("space") || "demo"; const id = c.req.param("id"); const docId = calendarDocId(space); const doc = ensureDoc(space); if (!doc.events[id]) return c.json({ error: "Not found" }, 404); _syncServer!.changeDoc(docId, `delete event ${id}`, (d) => { delete d.events[id]; }); return c.json({ ok: true }); }); // ── API: Sources ── routes.get("/api/sources", async (c) => { const space = c.req.param("space") || "demo"; const { is_active, is_visible, source_type } = c.req.query(); const doc = ensureDoc(space); let sources = Object.values(doc.sources); if (is_active !== undefined) { const active = is_active === "true"; sources = sources.filter((s) => s.isActive === active); } if (is_visible !== undefined) { const visible = is_visible === "true"; sources = sources.filter((s) => s.isVisible === visible); } if (source_type) { sources = sources.filter((s) => s.sourceType === source_type); } sources.sort((a, b) => a.name.localeCompare(b.name)); const rows = sources.map(sourceToRow); return c.json({ count: rows.length, results: rows }); }); routes.post("/api/sources", async (c) => { const token = extractToken(c.req.raw.headers); if (!token) return c.json({ error: "Authentication required" }, 401); try { await verifyEncryptIDToken(token); } catch { return c.json({ error: "Invalid token" }, 401); } const space = c.req.param("space") || "demo"; const body = await c.req.json(); const docId = calendarDocId(space); ensureDoc(space); const sourceId = crypto.randomUUID(); const now = Date.now(); _syncServer!.changeDoc(docId, `create source ${sourceId}`, (d) => { d.sources[sourceId] = { id: sourceId, name: body.name, sourceType: body.source_type || 'MANUAL', url: body.url || null, color: body.color || '#6366f1', isActive: body.is_active ?? true, isVisible: body.is_visible ?? true, syncIntervalMinutes: null, lastSyncedAt: 0, ownerId: null, createdAt: now, }; }); const updated = _syncServer!.getDoc(docId)!; return c.json(sourceToRow(updated.sources[sourceId]), 201); }); // ── API: Locations ── // Locations are now derived from event data (no separate table). // Each unique locationId/locationName combination is extracted from events. interface DerivedLocation { id: string; name: string; granularity: number | null; parent_id: string | null; lat: number | null; lng: number | null; } function deriveLocations(doc: CalendarDoc): DerivedLocation[] { const seen = new Map(); for (const ev of Object.values(doc.events)) { const key = ev.locationId || ev.locationName; if (!key) continue; if (seen.has(key)) continue; seen.set(key, { id: ev.locationId || key, name: ev.locationName || key, granularity: ev.locationGranularity ? parseInt(ev.locationGranularity) || null : null, parent_id: null, lat: ev.locationLat, lng: ev.locationLng, }); } return Array.from(seen.values()); } routes.get("/api/locations", async (c) => { const space = c.req.param("space") || "demo"; const { granularity, parent, search, root } = c.req.query(); const doc = ensureDoc(space); let locations = deriveLocations(doc); if (root === "true") { locations = locations.filter((l) => l.parent_id === null); } if (granularity) { const g = parseInt(granularity); locations = locations.filter((l) => l.granularity === g); } if (parent) { locations = locations.filter((l) => l.parent_id === parent); } if (search) { const term = search.toLowerCase(); locations = locations.filter((l) => l.name.toLowerCase().includes(term)); } locations.sort((a, b) => a.name.localeCompare(b.name)); return c.json(locations); }); routes.get("/api/locations/tree", async (c) => { const space = c.req.param("space") || "demo"; const doc = ensureDoc(space); // Flat list with depth=0 since hierarchical parent_id data is not stored in Automerge const locations = deriveLocations(doc).map((l) => ({ ...l, depth: 0 })); locations.sort((a, b) => a.name.localeCompare(b.name)); return c.json(locations); }); // ── API: Lunar data (computed, not stored) ── routes.get("/api/lunar", async (c) => { const { start, end } = c.req.query(); if (!start || !end) return c.json({ error: "start and end required" }, 400); // Simple lunar phase approximation based on synodic month const SYNODIC_MONTH = 29.53059; const KNOWN_NEW_MOON = new Date("2024-01-11T11:57:00Z").getTime(); const phases: Record = {}; const startDate = new Date(start); const endDate = new Date(end); const current = new Date(startDate); while (current <= endDate) { const daysSinceNewMoon = (current.getTime() - KNOWN_NEW_MOON) / (1000 * 60 * 60 * 24); const lunation = ((daysSinceNewMoon % SYNODIC_MONTH) + SYNODIC_MONTH) % SYNODIC_MONTH; const fraction = lunation / SYNODIC_MONTH; const illumination = 0.5 * (1 - Math.cos(2 * Math.PI * fraction)); let phase = "waxing_crescent"; if (fraction < 0.0625) phase = "new_moon"; else if (fraction < 0.1875) phase = "waxing_crescent"; else if (fraction < 0.3125) phase = "first_quarter"; else if (fraction < 0.4375) phase = "waxing_gibbous"; else if (fraction < 0.5625) phase = "full_moon"; else if (fraction < 0.6875) phase = "waning_gibbous"; else if (fraction < 0.8125) phase = "last_quarter"; else if (fraction < 0.9375) phase = "waning_crescent"; else phase = "new_moon"; phases[current.toISOString().split("T")[0]] = { phase, illumination: Math.round(illumination * 100) / 100 }; current.setDate(current.getDate() + 1); } return c.json(phases); }); // ── API: Stats ── routes.get("/api/stats", async (c) => { const space = c.req.param("space") || "demo"; const doc = ensureDoc(space); const events = Object.values(doc.events).length; const sources = Object.values(doc.sources).filter((s) => s.isActive).length; const locations = deriveLocations(doc).length; return c.json({ events, sources, locations }); }); // ── API: Context (r* tool bridge) ── routes.get("/api/context/:tool", async (c) => { const space = c.req.param("space") || "demo"; const tool = c.req.param("tool"); const entityId = c.req.query("entityId"); if (!entityId) return c.json({ error: "entityId required" }, 400); const doc = ensureDoc(space); const matching = Object.values(doc.events) .filter((e) => e.rToolSource === tool && e.rToolEntityId === entityId) .sort((a, b) => a.startTime - b.startTime); const rows = matching.map((e) => eventToRow(e, doc.sources)); return c.json({ count: rows.length, results: rows }); }); // ── Page route ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderShell({ title: `${space} — Calendar | rSpace`, moduleId: "rcal", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body: ``, scripts: ``, styles: ` `, })); }); export const calModule: RSpaceModule = { id: "rcal", name: "rCal", icon: "📅", description: "Temporal coordination calendar with lunar, solar, and seasonal systems", scoping: { defaultScope: 'global', userConfigurable: true }, docSchemas: [{ pattern: '{space}:cal:events', description: 'Calendar events and sources', init: calendarSchema.init }], routes, standaloneDomain: "rcal.online", landingPage: renderLanding, seedTemplate: seedDemoIfEmpty, async onInit(ctx) { _syncServer = ctx.syncServer; // Seed demo data for the default space seedDemoIfEmpty("demo"); }, feeds: [ { id: "events", name: "Events", kind: "data", description: "Calendar events with times, locations, and virtual meeting links", filterable: true, }, { id: "availability", name: "Availability", kind: "data", description: "Free/busy windows and scheduling availability across calendars", }, ], acceptsFeeds: ["data", "governance"], outputPaths: [ { path: "saved-views", name: "Saved Views", icon: "👁️", description: "Custom calendar views and filters" }, { path: "events", name: "Events", icon: "📅", description: "Calendar events across all systems" }, ], };