692 lines
22 KiB
TypeScript
692 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,
|
|
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" },
|
|
],
|
|
};
|