/** * 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); } } initDB(); // ── 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: unknown[] = []; 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: unknown[] = []; 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: unknown[] = []; 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: unknown[] = []; 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: "light", 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", };