rspace-online/modules/rtrips/mod.ts

721 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Trips module — collaborative trip planner.
*
* Plan trips with destinations, itinerary, bookings, expenses,
* and packing lists. Collaborative with role-based access.
*
* Data layer: Automerge documents via SyncServer.
* One document per trip: {space}:trips:trips:{tripId}
*/
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 {
tripSchema, tripDocId,
type TripDoc, type TripMeta, type Destination, type ItineraryItem,
type Booking, type Expense, type PackingItem,
} from './schemas';
let _syncServer: SyncServer | null = null;
const OSRM_URL = process.env.OSRM_URL || "http://osrm-backend:5000";
// ── Helpers ──
/** Generate a short random ID (collision-safe enough for sub-collections). */
function newId(): string {
return crypto.randomUUID().slice(0, 12);
}
/** Ensure a trip document exists; create it lazily if not. */
function ensureDoc(space: string, tripId: string): TripDoc {
const docId = tripDocId(space, tripId);
let doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<TripDoc>(), 'init', (d) => {
const init = tripSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.trip = init.trip;
d.trip.id = tripId;
d.destinations = {};
d.itinerary = {};
d.bookings = {};
d.expenses = {};
d.packingItems = {};
});
_syncServer!.setDoc(docId, doc);
}
return doc;
}
/** List all trip doc IDs for a given space. */
function listTripDocIds(space: string): string[] {
const prefix = `${space}:trips:trips:`;
return _syncServer!.listDocs().filter((id) => id.startsWith(prefix));
}
const routes = new Hono();
// ── API: Trips ──
// GET /api/trips — list trips
routes.get("/api/trips", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const docIds = listTripDocIds(dataSpace);
const rows = docIds.map((docId) => {
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return null;
const t = doc.trip;
const destinations = Object.values(doc.destinations);
const expenses = Object.values(doc.expenses);
const totalSpent = expenses.reduce((sum, e) => sum + (e.amount || 0), 0);
return {
...t,
destination_count: destinations.length,
expense_count: expenses.length,
total_spent: totalSpent,
};
}).filter(Boolean);
// Sort by createdAt descending (newest first)
rows.sort((a, b) => (b!.createdAt ?? 0) - (a!.createdAt ?? 0));
return c.json(rows);
});
// POST /api/trips — create trip
routes.post("/api/trips", 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_date, end_date, budget_total, budget_currency } = body;
if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = newId();
const slug = title.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const now = Date.now();
const docId = tripDocId(dataSpace, tripId);
let doc = Automerge.change(Automerge.init<TripDoc>(), 'create trip', (d) => {
const init = tripSchema.init();
d.meta = init.meta;
d.meta.spaceSlug = space;
d.meta.createdAt = now;
d.trip = {
id: tripId,
title: title.trim(),
slug,
description: description || '',
startDate: start_date || null,
endDate: end_date || null,
budgetTotal: budget_total ?? null,
budgetCurrency: budget_currency || 'USD',
status: 'planning',
createdBy: claims.sub,
createdAt: now,
updatedAt: now,
};
d.destinations = {};
d.itinerary = {};
d.bookings = {};
d.expenses = {};
d.packingItems = {};
});
_syncServer!.setDoc(docId, doc);
return c.json(doc.trip, 201);
});
// GET /api/trips/:id — trip detail with all sub-resources
routes.get("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Trip not found" }, 404);
const destinations = Object.values(doc.destinations).sort((a, b) => a.sortOrder - b.sortOrder);
const itinerary = Object.values(doc.itinerary).sort((a, b) => {
const dateCmp = (a.date || '').localeCompare(b.date || '');
return dateCmp !== 0 ? dateCmp : a.sortOrder - b.sortOrder;
});
const bookings = Object.values(doc.bookings).sort((a, b) => (a.startDate || '').localeCompare(b.startDate || ''));
const expenses = Object.values(doc.expenses).sort((a, b) => (b.date || '').localeCompare(a.date || ''));
const packing = Object.values(doc.packingItems).sort((a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : a.sortOrder - b.sortOrder;
});
return c.json({ ...doc.trip, destinations, itinerary, bookings, expenses, packing });
});
// PUT /api/trips/:id — update trip
routes.put("/api/trips/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json({ error: "Not found" }, 404);
const body = await c.req.json();
const { title, description, start_date, end_date, budget_total, budget_currency, status } = body;
const hasFields = [title, description, start_date, end_date, budget_total, budget_currency, status]
.some((v) => v !== undefined);
if (!hasFields) return c.json({ error: "No fields" }, 400);
_syncServer!.changeDoc<TripDoc>(docId, 'update trip', (d) => {
if (title !== undefined) d.trip.title = title;
if (description !== undefined) d.trip.description = description;
if (start_date !== undefined) d.trip.startDate = start_date;
if (end_date !== undefined) d.trip.endDate = end_date;
if (budget_total !== undefined) d.trip.budgetTotal = budget_total;
if (budget_currency !== undefined) d.trip.budgetCurrency = budget_currency;
if (status !== undefined) d.trip.status = status;
d.trip.updatedAt = Date.now();
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.trip);
});
// ── API: Destinations ──
routes.post("/api/trips/:id/destinations", 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 dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const destId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add destination', (d) => {
d.destinations[destId] = {
id: destId,
tripId,
name: body.name,
country: body.country || null,
lat: body.lat ?? null,
lng: body.lng ?? null,
arrivalDate: body.arrival_date || null,
departureDate: body.departure_date || null,
notes: body.notes || '',
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.destinations[destId], 201);
});
// ── API: Itinerary ──
routes.post("/api/trips/:id/itinerary", 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 dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const itemId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add itinerary item', (d) => {
d.itinerary[itemId] = {
id: itemId,
tripId,
destinationId: body.destination_id || null,
title: body.title,
category: body.category || 'ACTIVITY',
date: body.date || null,
startTime: body.start_time || null,
endTime: body.end_time || null,
notes: body.notes || '',
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.itinerary[itemId], 201);
});
// ── API: Bookings ──
routes.post("/api/trips/:id/bookings", 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 dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const bookingId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add booking', (d) => {
d.bookings[bookingId] = {
id: bookingId,
tripId,
type: body.type || 'OTHER',
provider: body.provider || null,
confirmationNumber: body.confirmation_number || null,
cost: body.cost ?? null,
currency: body.currency || 'USD',
startDate: body.start_date || null,
endDate: body.end_date || null,
status: null,
notes: body.notes || '',
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.bookings[bookingId], 201);
});
// ── API: Expenses ──
routes.post("/api/trips/:id/expenses", 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 dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const expenseId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add expense', (d) => {
d.expenses[expenseId] = {
id: expenseId,
tripId,
paidBy: null,
description: body.description,
amount: body.amount,
currency: body.currency || 'USD',
category: body.category || 'OTHER',
date: body.date || null,
splitType: body.split_type || 'EQUAL',
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.expenses[expenseId], 201);
});
// ── API: Packing ──
routes.get("/api/trips/:id/packing", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
const docId = tripDocId(dataSpace, tripId);
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc) return c.json([]);
const rows = Object.values(doc.packingItems).sort((a, b) => {
const catCmp = (a.category || '').localeCompare(b.category || '');
return catCmp !== 0 ? catCmp : a.sortOrder - b.sortOrder;
});
return c.json(rows);
});
routes.post("/api/trips/:id/packing", 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 dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const tripId = c.req.param("id");
ensureDoc(dataSpace, tripId);
const docId = tripDocId(dataSpace, tripId);
const body = await c.req.json();
const itemId = newId();
const now = Date.now();
_syncServer!.changeDoc<TripDoc>(docId, 'add packing item', (d) => {
d.packingItems[itemId] = {
id: itemId,
tripId,
addedBy: null,
name: body.name,
category: body.category || 'GENERAL',
packed: false,
quantity: body.quantity || 1,
sortOrder: body.sort_order ?? 0,
createdAt: now,
};
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.packingItems[itemId], 201);
});
routes.patch("/api/packing/:id", async (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
const packingId = c.req.param("id");
// Find the trip doc containing this packing item
const docIds = listTripDocIds(dataSpace);
for (const docId of docIds) {
const doc = _syncServer!.getDoc<TripDoc>(docId);
if (!doc || !doc.packingItems[packingId]) continue;
const body = await c.req.json();
_syncServer!.changeDoc<TripDoc>(docId, 'toggle packing item', (d) => {
d.packingItems[packingId].packed = body.packed ?? false;
});
const updated = _syncServer!.getDoc<TripDoc>(docId);
return c.json(updated!.packingItems[packingId]);
}
return c.json({ error: "Not found" }, 404);
});
// ── OSRM proxy for route planner ──
routes.post("/api/route", async (c) => {
const body = await c.req.json<{ start: { lng: number; lat: number }; end: { lng: number; lat: number } }>();
const { start, end } = body;
if (!start || !end) return c.json({ error: "start and end are required" }, 400);
const url = `${OSRM_URL}/route/v1/driving/${start.lng},${start.lat};${end.lng},${end.lat}?geometries=geojson&overview=full`;
try {
const res = await fetch(url);
const data = await res.json();
return c.json(data, res.status as any);
} catch (e) {
return c.json({ error: "OSRM backend unavailable", detail: String(e) }, 502);
}
});
// ── Route planner page ──
routes.get("/routes", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Route Planner | rTrips`,
moduleId: "rtrips",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rtrips/route-planner.css">`,
body: `<folk-route-planner></folk-route-planner>`,
scripts: `<script type="module" src="/modules/rtrips/folk-route-planner.js"></script>`,
}));
});
// ── Demo dashboard ──
routes.get("/demo", (c) => {
return c.html(renderShell({
title: "rTrips Demo — Live Trip Dashboard | rSpace",
moduleId: "rtrips",
spaceSlug: "demo",
modules: getModuleInfoList(),
theme: "dark",
styles: `<link rel="stylesheet" href="/modules/rtrips/trips.css">`,
body: `
<div class="rd-page">
<div class="rd-hero">
<h1 id="rd-trip-title">Alpine Explorer 2026</h1>
<p id="rd-trip-route" class="rd-hero-route">Loading route…</p>
<div id="rd-trip-meta" class="rd-hero-meta">
<span>📅 Jul 620, 2026</span>
<span>💶 ~€4,000 budget</span>
<span>🏔️ 3 countries</span>
</div>
<div id="rd-avatars" class="rd-hero-avatars"></div>
</div>
<div class="rd-toolbar">
<span class="rd-status">
<span id="rd-hero-dot" class="rd-status-dot"></span>
<span id="rd-hero-label">Connecting…</span>
</span>
<button id="rd-reset-btn" class="rd-btn" disabled>Reset Demo</button>
<a href="/rtrips" class="rd-btn" style="text-decoration:none;margin-left:auto">&larr; About rTrips</a>
</div>
<div class="rd-grid">
<!-- Maps card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">🗺️ Maps</span>
<span id="rd-maps-live" class="rd-card-live">Live</span>
</div>
<div class="rd-card-body">
<svg class="rd-map-svg" viewBox="0 0 800 300">
<rect width="800" height="300" rx="8" fill="#0c1222"/>
<path id="rd-route-path" d="" stroke="#14b8a6" stroke-width="2" fill="none" stroke-dasharray="6 4" opacity="0.5"/>
<g id="rd-map-pins"></g>
</svg>
</div>
</div>
<!-- Notes / Packing card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">📝 Packing List</span>
<span id="rd-notes-live" class="rd-card-live">Live</span>
</div>
<div class="rd-card-body" id="rd-packing-list">
<div class="rd-skeleton">
<div class="rd-skeleton-line" style="width:80%"></div>
<div class="rd-skeleton-line" style="width:60%"></div>
<div class="rd-skeleton-line" style="width:70%"></div>
</div>
</div>
</div>
<!-- Calendar card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">📅 Calendar</span>
<span id="rd-cal-live" class="rd-card-live">Live</span>
<span id="rd-cal-days" style="font-size:0.6875rem;color:#64748b;margin-left:auto"></span>
</div>
<div class="rd-card-body" id="rd-cal-grid">
<div class="rd-skeleton">
<div class="rd-skeleton-line" style="width:100%"></div>
<div class="rd-skeleton-line" style="width:100%"></div>
<div class="rd-skeleton-line" style="width:90%"></div>
</div>
</div>
</div>
<!-- Polls card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">🗳️ Polls</span>
<span id="rd-polls-live" class="rd-card-live">Live</span>
</div>
<div class="rd-card-body" id="rd-polls-body">
<div class="rd-skeleton">
<div class="rd-skeleton-line" style="width:75%"></div>
<div class="rd-skeleton-line" style="width:90%"></div>
<div class="rd-skeleton-line" style="width:60%"></div>
</div>
</div>
</div>
<!-- Funds card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">💶 Funds</span>
<span id="rd-funds-live" class="rd-card-live">Live</span>
<span id="rd-funds-total" style="font-size:0.8125rem;font-weight:600;color:#14b8a6;margin-left:auto">€0</span>
</div>
<div class="rd-card-body">
<div id="rd-funds-skeleton" class="rd-skeleton">
<div class="rd-skeleton-line" style="width:85%"></div>
<div class="rd-skeleton-line" style="width:65%"></div>
<div class="rd-skeleton-line" style="width:70%"></div>
</div>
<div id="rd-funds-expenses" style="display:none"></div>
<div id="rd-funds-balances" style="display:none"></div>
</div>
</div>
<!-- Cart card -->
<div class="rd-card">
<div class="rd-card-header">
<span class="rd-card-title">🛒 Cart</span>
<span id="rd-cart-live" class="rd-card-live">Live</span>
</div>
<div class="rd-card-body">
<div id="rd-cart-skeleton" class="rd-skeleton">
<div class="rd-skeleton-line" style="width:90%"></div>
<div class="rd-skeleton-line" style="width:75%"></div>
<div class="rd-skeleton-line" style="width:80%"></div>
</div>
<div id="rd-cart-content" style="display:none"></div>
</div>
</div>
</div>
<div class="rd-footer">
<a href="/rtrips">&larr; Back to rTrips</a>
</div>
</div>`,
scripts: `<script type="module" src="/modules/rtrips/trips-demo.js"></script>`,
}));
});
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";
const dataSpace = (c.get("effectiveSpace" as any) as string) || space;
return c.html(renderShell({
title: `${space} — Trips | rSpace`,
moduleId: "rtrips",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body: `<folk-trips-planner space="${space}"></folk-trips-planner>`,
scripts: `<script type="module" src="/modules/rtrips/folk-trips-planner.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rtrips/trips.css">`,
}));
});
// ── Seed template data ──
function seedTemplateTrips(space: string) {
if (!_syncServer) return;
// Skip if space already has trips
const prefix = `${space}:trips:trips:`;
const existing = _syncServer.listDocs().filter((id) => id.startsWith(prefix));
if (existing.length > 0) return;
const tripId = crypto.randomUUID();
const docId = tripDocId(space, tripId);
const now = Date.now();
const dest1Id = crypto.randomUUID();
const dest2Id = crypto.randomUUID();
const doc = Automerge.change(Automerge.init<TripDoc>(), 'seed template trip', (d) => {
d.meta = { module: 'trips', collection: 'trips', version: 1, spaceSlug: space, createdAt: now };
d.trip = {
id: tripId, title: 'Mediterranean Design Sprint', slug: 'mediterranean-design-sprint',
description: 'A 5-day design sprint visiting Barcelona and Athens to prototype cosmolocal tools with local communities.',
startDate: '2026-04-14', endDate: '2026-04-18', budgetTotal: 3200, budgetCurrency: 'EUR',
status: 'planning', createdBy: 'did:demo:seed', createdAt: now, updatedAt: now,
};
d.destinations = {};
d.destinations[dest1Id] = {
id: dest1Id, tripId, name: 'Barcelona', country: 'Spain',
lat: 41.3874, lng: 2.1686, arrivalDate: '2026-04-14', departureDate: '2026-04-16',
notes: 'Meet with local maker space — prototype cosmolocal print workflow.', sortOrder: 0, createdAt: now,
};
d.destinations[dest2Id] = {
id: dest2Id, tripId, name: 'Athens', country: 'Greece',
lat: 37.9838, lng: 23.7275, arrivalDate: '2026-04-16', departureDate: '2026-04-18',
notes: 'Commons Fest workshop — present rFlows river visualization.', sortOrder: 1, createdAt: now,
};
d.itinerary = {};
const itin = [
{ destId: dest1Id, title: 'Maker Space Visit', category: 'meeting', date: '2026-04-14', start: '10:00', end: '13:00', notes: 'Tour facilities, discuss print capabilities' },
{ destId: dest1Id, title: 'Prototype Session', category: 'workshop', date: '2026-04-15', start: '09:00', end: '17:00', notes: 'Full-day sprint on cosmolocal order flow' },
{ destId: dest2Id, title: 'Commons Fest Presentation', category: 'conference', date: '2026-04-17', start: '14:00', end: '16:00', notes: 'Present rFlows + rVote governance tools' },
];
for (let i = 0; i < itin.length; i++) {
const iId = crypto.randomUUID();
d.itinerary[iId] = {
id: iId, tripId, destinationId: itin[i].destId, title: itin[i].title,
category: itin[i].category, date: itin[i].date, startTime: itin[i].start,
endTime: itin[i].end, notes: itin[i].notes, sortOrder: i, createdAt: now,
};
}
d.bookings = {};
const book = [
{ type: 'flight', provider: 'Vueling', conf: 'VY-4821', cost: 180, start: '2026-04-14', end: '2026-04-14', notes: 'BCN arrival 09:30' },
{ type: 'hotel', provider: 'Hotel Catalonia', conf: 'HC-9912', cost: 320, start: '2026-04-14', end: '2026-04-16', notes: '2 nights, old town' },
];
for (const b of book) {
const bId = crypto.randomUUID();
d.bookings[bId] = {
id: bId, tripId, type: b.type, provider: b.provider, confirmationNumber: b.conf,
cost: b.cost, currency: 'EUR', startDate: b.start, endDate: b.end,
status: 'confirmed', notes: b.notes, createdAt: now,
};
}
d.expenses = {};
d.packingItems = {};
});
_syncServer.setDoc(docId, doc);
console.log(`[Trips] Template seeded for "${space}": 1 trip, 2 destinations, 3 itinerary, 2 bookings`);
}
export const tripsModule: RSpaceModule = {
id: "rtrips",
name: "rTrips",
icon: "✈️",
description: "Collaborative trip planner with itinerary, bookings, and expense splitting",
scoping: { defaultScope: 'global', userConfigurable: true },
docSchemas: [{ pattern: '{space}:trips:trips:{tripId}', description: 'Trip with destinations and itinerary', init: tripSchema.init }],
routes,
landingPage: renderLanding,
seedTemplate: seedTemplateTrips,
async onInit(ctx) {
_syncServer = ctx.syncServer;
},
standaloneDomain: "rtrips.online",
feeds: [
{
id: "trip-expenses",
name: "Trip Expenses",
kind: "economic",
description: "Expense tracking with split amounts per traveler",
filterable: true,
},
{
id: "itinerary",
name: "Itinerary",
kind: "data",
description: "Destinations, activities, and bookings timeline",
emits: ["folk-itinerary", "folk-destination", "folk-booking"],
},
],
acceptsFeeds: ["economic", "data"],
outputPaths: [
{ path: "itineraries", name: "Itineraries", icon: "🗓️", description: "Trip itineraries with bookings and activities" },
],
subPageInfos: [
{
path: "routes",
title: "Route Planner",
icon: "🗺️",
tagline: "rTrips Tool",
description: "Plan multi-stop routes with distance and duration estimates. Optimize waypoints and export routes for navigation.",
features: [
{ icon: "📍", title: "Multi-Stop Planning", text: "Add waypoints, reorder stops, and calculate the optimal route." },
{ icon: "⏱️", title: "Time & Distance", text: "See real-time estimates for travel time and distances between stops." },
{ icon: "🗺️", title: "Map Visualization", text: "View your full route on an interactive map with turn-by-turn preview." },
],
},
],
};