/** * Cal module — temporal coordination calendar. * * Group calendars with lunar/solar/seasonal time systems, * location-aware events, and temporal-spatial zoom coupling. */ import { Hono } from "hono"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { sql } from "../../shared/db/pool"; import { renderShell } from "../../server/shell"; import { getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { verifyEncryptIDToken, extractToken } from "@encryptid/sdk/server"; const routes = new Hono(); // ── DB initialization ── const SCHEMA_SQL = readFileSync(resolve(import.meta.dir, "db/schema.sql"), "utf-8"); async function initDB() { try { await sql.unsafe(SCHEMA_SQL); console.log("[Cal] DB schema initialized"); } catch (e) { console.error("[Cal] DB init error:", e); } } async function seedDemoIfEmpty() { try { const count = await sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"); if (parseInt(count[0].cnt) > 0) return; // Create calendar sources const community = await sql.unsafe( `INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible) VALUES ('Community Events', 'MANUAL', '#6366f1', true, true) RETURNING id` ); const sprints = await sql.unsafe( `INSERT INTO rcal.calendar_sources (name, source_type, color, is_active, is_visible) VALUES ('Development Sprints', 'MANUAL', '#f59e0b', true, true) RETURNING id` ); const communityId = community[0].id; const sprintsId = sprints[0].id; // Create location hierarchy const world = await sql.unsafe( `INSERT INTO rcal.locations (name, granularity) VALUES ('Earth', 1) RETURNING id` ); const europe = await sql.unsafe( `INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Europe', 2, $1, 48.8566, 2.3522) RETURNING id`, [world[0].id] ); const berlin = await sql.unsafe( `INSERT INTO rcal.locations (name, granularity, parent_id, lat, lng) VALUES ('Berlin', 4, $1, 52.52, 13.405) RETURNING id`, [europe[0].id] ); // Seed events — past, current week, and future const now = new Date(); const events = [ { 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, virtual: 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, virtual: 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, virtual: 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: berlin[0].id, locationName: "Druckwerkstatt Berlin", }, { 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, virtual: true, virtualUrl: "https://meet.jit.si/rspace-retro", virtualPlatform: "Jitsi", }, ]; for (const e of events) { await sql.unsafe( `INSERT INTO rcal.events (title, description, start_time, end_time, all_day, source_id, location_id, location_name, is_virtual, virtual_url, virtual_platform) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`, [e.title, e.desc, e.start.toISOString(), e.end.toISOString(), e.allDay || false, e.sourceId, e.locationId || null, e.locationName || null, e.virtual || false, e.virtualUrl || null, e.virtualPlatform || null] ); } console.log("[Cal] Demo data seeded: 2 sources, 3 locations, 7 events"); } catch (e) { console.error("[Cal] Seed error:", e); } } 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; } initDB().then(seedDemoIfEmpty); // ── API: Events ── // GET /api/events — query events with filters routes.get("/api/events", async (c) => { const { start, end, source, search, rTool, rEntityId, upcoming } = c.req.query(); let where = "WHERE 1=1"; const params: any[] = []; let idx = 1; if (start) { where += ` AND e.start_time >= $${idx}`; params.push(start); idx++; } if (end) { where += ` AND e.start_time <= ($${idx}::date + interval '1 day')`; params.push(end); idx++; } if (source) { where += ` AND e.source_id = $${idx}`; params.push(source); idx++; } if (search) { where += ` AND (e.title ILIKE $${idx} OR e.description ILIKE $${idx})`; params.push(`%${search}%`); idx++; } if (rTool) { where += ` AND e.r_tool_source = $${idx}`; params.push(rTool); idx++; } if (rEntityId) { where += ` AND e.r_tool_entity_id = $${idx}`; params.push(rEntityId); idx++; } if (upcoming) { where += ` AND e.start_time >= NOW() AND e.start_time <= NOW() + ($${idx} || ' days')::interval`; params.push(upcoming); idx++; } const rows = await sql.unsafe( `SELECT e.*, cs.name as source_name, cs.color as source_color, l.name as location_label FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id LEFT JOIN rcal.locations l ON l.id = e.location_id ${where} ORDER BY e.start_time ASC LIMIT 500`, params ); 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 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 rows = await sql.unsafe( `INSERT INTO rcal.events (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, created_by) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`, [title.trim(), description || null, start_time, end_time || null, all_day || false, timezone || "UTC", source_id || null, location_id || null, location_name || null, is_virtual || false, virtual_url || null, virtual_platform || null, r_tool_source || null, r_tool_entity_id || null, claims.sub] ); return c.json(rows[0], 201); }); // GET /api/events/:id routes.get("/api/events/:id", async (c) => { const rows = await sql.unsafe( `SELECT e.*, cs.name as source_name, cs.color as source_color FROM rcal.events e LEFT JOIN rcal.calendar_sources cs ON cs.id = e.source_id WHERE e.id = $1`, [c.req.param("id")] ); if (rows.length === 0) return c.json({ error: "Event not found" }, 404); return c.json(rows[0]); }); // 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 id = c.req.param("id"); const body = await c.req.json(); const fields: string[] = []; const params: any[] = []; let idx = 1; const allowed = ["title", "description", "start_time", "end_time", "all_day", "timezone", "status", "visibility", "location_name", "is_virtual", "virtual_url"]; for (const key of allowed) { if (body[key] !== undefined) { fields.push(`${key} = $${idx}`); params.push(body[key]); idx++; } } if (fields.length === 0) return c.json({ error: "No fields" }, 400); fields.push("updated_at = NOW()"); params.push(id); const rows = await sql.unsafe( `UPDATE rcal.events SET ${fields.join(", ")} WHERE id = $${idx} RETURNING *`, params ); if (rows.length === 0) return c.json({ error: "Not found" }, 404); return c.json(rows[0]); }); // DELETE /api/events/:id routes.delete("/api/events/:id", async (c) => { const result = await sql.unsafe("DELETE FROM rcal.events WHERE id = $1 RETURNING id", [c.req.param("id")]); if (result.length === 0) return c.json({ error: "Not found" }, 404); return c.json({ ok: true }); }); // ── API: Sources ── routes.get("/api/sources", async (c) => { const { is_active, is_visible, source_type } = c.req.query(); let where = "WHERE 1=1"; const params: any[] = []; let idx = 1; if (is_active !== undefined) { where += ` AND is_active = $${idx}`; params.push(is_active === "true"); idx++; } if (is_visible !== undefined) { where += ` AND is_visible = $${idx}`; params.push(is_visible === "true"); idx++; } if (source_type) { where += ` AND source_type = $${idx}`; params.push(source_type); idx++; } const rows = await sql.unsafe(`SELECT * FROM rcal.calendar_sources ${where} ORDER BY name`, params); 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 body = await c.req.json(); const rows = await sql.unsafe( `INSERT INTO rcal.calendar_sources (name, source_type, url, color, is_active, is_visible) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`, [body.name, body.source_type || "MANUAL", body.url || null, body.color || "#6366f1", body.is_active ?? true, body.is_visible ?? true] ); return c.json(rows[0], 201); }); // ── API: Locations ── routes.get("/api/locations", async (c) => { const { granularity, parent, search, root } = c.req.query(); let where = "WHERE 1=1"; const params: any[] = []; let idx = 1; if (root === "true") { where += " AND parent_id IS NULL"; } if (granularity) { where += ` AND granularity = $${idx}`; params.push(parseInt(granularity)); idx++; } if (parent) { where += ` AND parent_id = $${idx}`; params.push(parent); idx++; } if (search) { where += ` AND name ILIKE $${idx}`; params.push(`%${search}%`); idx++; } const rows = await sql.unsafe(`SELECT * FROM rcal.locations ${where} ORDER BY name`, params); return c.json(rows); }); routes.get("/api/locations/tree", async (c) => { const rows = await sql.unsafe( `WITH RECURSIVE tree AS ( SELECT id, name, granularity, parent_id, 0 as depth FROM rcal.locations WHERE parent_id IS NULL UNION ALL SELECT l.id, l.name, l.granularity, l.parent_id, t.depth + 1 FROM rcal.locations l JOIN tree t ON l.parent_id = t.id ) SELECT * FROM tree ORDER BY depth, name` ); return c.json(rows); }); // ── 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 [eventCount, sourceCount, locationCount] = await Promise.all([ sql.unsafe("SELECT count(*)::int as cnt FROM rcal.events"), sql.unsafe("SELECT count(*)::int as cnt FROM rcal.calendar_sources WHERE is_active = true"), sql.unsafe("SELECT count(*)::int as cnt FROM rcal.locations"), ]); return c.json({ events: eventCount[0]?.cnt || 0, sources: sourceCount[0]?.cnt || 0, locations: locationCount[0]?.cnt || 0, }); }); // ── API: Context (r* tool bridge) ── routes.get("/api/context/:tool", async (c) => { const tool = c.req.param("tool"); const entityId = c.req.query("entityId"); if (!entityId) return c.json({ error: "entityId required" }, 400); const rows = await sql.unsafe( "SELECT * FROM rcal.events WHERE r_tool_source = $1 AND r_tool_entity_id = $2 ORDER BY start_time", [tool, entityId] ); 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: "cal", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", styles: ``, body: ``, scripts: ``, })); }); export const calModule: RSpaceModule = { id: "cal", name: "rCal", icon: "\u{1F4C5}", description: "Temporal coordination calendar with lunar, solar, and seasonal systems", routes, standaloneDomain: "rcal.online", };