rspace-online/modules/cal/mod.ts

282 lines
10 KiB
TypeScript

/**
* 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";
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 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)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) 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]
);
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 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 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<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 [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: `<link rel="stylesheet" href="/modules/cal/cal.css">`,
body: `<folk-calendar-view space="${space}"></folk-calendar-view>`,
scripts: `<script type="module" src="/modules/cal/folk-calendar-view.js"></script>`,
}));
});
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",
};