rspace-online/modules/rcal/mod.ts

693 lines
22 KiB
TypeScript

/**
* 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 } 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<CalendarDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<CalendarDoc>(), '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<string, CalendarSource>) {
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<CalendarDoc>(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: "rFunds 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/rfunds-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 } = 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();
_syncServer!.changeDoc<CalendarDoc>(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 || null,
rToolEntityId: r_tool_entity_id || null,
attendees: [],
attendeeCount: 0,
metadata: null,
createdAt: now,
updatedAt: now,
};
});
const updated = _syncServer!.getDoc<CalendarDoc>(docId)!;
return c.json(eventToRow(updated.events[eventId], updated.sources), 201);
});
// 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<string, keyof CalendarEvent> = {
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',
};
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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<CalendarDoc>(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<string, DerivedLocation>();
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<string, { phase: string; illumination: number }> = {};
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: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/rcal/folk-calendar-view.js?v=4"></script>`,
styles: `<link rel="stylesheet" href="/modules/rcal/cal.css?v=2">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">`,
}));
});
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" },
],
};