rspace-online/modules/rtrips/components/trips-demo.ts

460 lines
16 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.

/**
* rTrips demo — client-side WebSocket controller.
*
* Connects to rSpace (no filter — needs all shape types) and
* populates the 6-card trip dashboard: maps, notes/packing,
* calendar, polls, funds, cart.
*/
import { DemoSync, type DemoShape } from "../../../lib/demo-sync-vanilla";
// ── Helpers ──
function shapesByType(shapes: Record<string, DemoShape>, type: string): DemoShape[] {
return Object.values(shapes).filter((s) => s.type === type);
}
function shapeByType(shapes: Record<string, DemoShape>, type: string): DemoShape | undefined {
return Object.values(shapes).find((s) => s.type === type);
}
function $(id: string): HTMLElement | null {
return document.getElementById(id);
}
function escHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
// ── Constants ──
const MEMBER_COLORS = ["#14b8a6", "#06b6d4", "#3b82f6", "#8b5cf6", "#f59e0b", "#f43f5e"];
const POLL_COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#f43f5e"];
const CATEGORY_COLORS: Record<string, string> = {
travel: "#14b8a6",
hike: "#10b981",
adventure: "#f59e0b",
rest: "#64748b",
culture: "#8b5cf6",
FLIGHT: "#14b8a6",
TRANSPORT: "#06b6d4",
ACCOMMODATION: "#14b8a6",
ACTIVITY: "#10b981",
MEAL: "#f59e0b",
FREE_TIME: "#64748b",
OTHER: "#64748b",
};
// ── DemoSync (no filter — needs all shape types) ──
const sync = new DemoSync();
// ── Render functions ──
function render(shapes: Record<string, DemoShape>) {
const itinerary = shapeByType(shapes, "folk-itinerary");
const destinations = shapesByType(shapes, "folk-destination");
const packingList = shapeByType(shapes, "folk-packing-list");
const pollShapes = shapesByType(shapes, "demo-poll");
const expenseShapes = shapesByType(shapes, "demo-expense");
const cartShapes = shapesByType(shapes, "demo-cart-item");
const budgetShape = shapeByType(shapes, "folk-budget");
const hasShapes = Object.keys(shapes).length > 0;
// Members from itinerary
const travelers = (itinerary?.travelers ?? []) as string[];
const members = travelers.map((name, i) => ({ name, color: MEMBER_COLORS[i % MEMBER_COLORS.length] }));
// Trip header
renderHeader(itinerary, destinations, budgetShape, members, hasShapes);
// Show live badges
for (const card of ["maps", "notes", "cal", "polls", "funds", "cart"]) {
const el = $(`rd-${card}-live`);
if (el) el.style.display = hasShapes ? "inline-flex" : "none";
}
// Cards
renderMap(destinations);
renderNotes(packingList);
renderCalendar(itinerary);
renderPolls(pollShapes);
renderFunds(expenseShapes, members);
renderCart(cartShapes);
}
// ── Header ──
function renderHeader(
itinerary: DemoShape | undefined,
destinations: DemoShape[],
budgetShape: DemoShape | undefined,
members: { name: string; color: string }[],
hasShapes: boolean,
) {
const title = $("rd-trip-title");
const route = $("rd-trip-route");
const meta = $("rd-trip-meta");
const avatars = $("rd-avatars");
if (title && itinerary?.tripTitle) {
title.textContent = itinerary.tripTitle as string;
}
if (route && destinations.length > 0) {
route.textContent = destinations.map((d) => d.destName as string).join(" → ");
}
if (meta && itinerary) {
const start = itinerary.startDate as string;
const end = itinerary.endDate as string;
const budgetTotal = (budgetShape?.budgetTotal as number) || 4000;
const dateRange = start && end
? `${new Date(start).toLocaleDateString("en", { month: "short", day: "numeric" })}${new Date(end).toLocaleDateString("en", { month: "short", day: "numeric" })}, 2026`
: "Jul 620, 2026";
const countries = destinations.length > 0
? new Set(destinations.map((d) => d.country as string)).size
: 3;
meta.innerHTML = `
<span>📅 ${dateRange}</span>
<span>💶 ~€${budgetTotal.toLocaleString()} budget</span>
<span>🏔️ ${countries} countr${countries !== 1 ? "ies" : "y"}</span>
${hasShapes ? `<span style="display:flex;align-items:center;gap:0.25rem;color:#34d399;"><span style="width:6px;height:6px;border-radius:50%;background:#34d399;animation:rd-pulse 2s ease-in-out infinite;"></span> Live data</span>` : ""}
`;
}
if (avatars && members.length > 0) {
avatars.innerHTML = members.map((m) =>
`<div class="rd-avatar" style="background:${m.color}" title="${escHtml(m.name)}">${m.name[0]}</div>`
).join("") + `<span style="font-size:0.75rem;color:#94a3b8;margin-left:0.25rem;">${members.length} explorers</span>`;
}
}
// ── Map card ──
function renderMap(destinations: DemoShape[]) {
if (destinations.length === 0) return;
const pins = destinations.map((d, i) => ({
name: d.destName as string,
cx: 160 + i * 245,
cy: 180 - i * 20,
color: ["#14b8a6", "#06b6d4", "#8b5cf6"][i] || "#94a3b8",
stroke: ["#0d9488", "#0891b2", "#7c3aed"][i] || "#64748b",
dates: d.arrivalDate && d.departureDate
? `${new Date(d.arrivalDate as string).toLocaleDateString("en", { month: "short", day: "numeric" })}${new Date(d.departureDate as string).getUTCDate()}`
: "",
}));
const pinsEl = $("rd-map-pins");
if (pinsEl) {
pinsEl.innerHTML = pins.map((p) => `
<g>
<circle cx="${p.cx}" cy="${p.cy}" r="8" fill="${p.color}" stroke="${p.stroke}" stroke-width="2"/>
<text x="${p.cx}" y="${p.cy + 30}" text-anchor="middle" fill="#94a3b8" font-size="12" font-weight="600">${escHtml(p.name)}</text>
<text x="${p.cx}" y="${p.cy + 44}" text-anchor="middle" fill="#64748b" font-size="10">${p.dates}</text>
</g>
`).join("");
}
if (pins.length >= 3) {
const routeEl = $("rd-route-path");
if (routeEl) {
routeEl.setAttribute("d",
`M${pins[0].cx} ${pins[0].cy} C${pins[0].cx + 90} ${pins[0].cy - 20}, ${pins[1].cx - 80} ${pins[1].cy + 50}, ${pins[1].cx} ${pins[1].cy} C${pins[1].cx + 80} ${pins[1].cy - 50}, ${pins[2].cx - 90} ${pins[2].cy + 20}, ${pins[2].cx} ${pins[2].cy}`
);
}
}
}
// ── Notes card (packing checklist) ──
function renderNotes(packingList: DemoShape | undefined) {
const container = $("rd-packing-list");
if (!container || !packingList) return;
const items = (packingList.items as Array<{ name: string; packed: boolean; category: string }>) || [];
if (items.length === 0) return;
container.innerHTML = `<ul style="list-style:none;margin:0;padding:0;">
${items.map((item, idx) => `
<li class="rd-trips-pack-item" data-pack-idx="${idx}">
<div class="rd-trips-pack-check ${item.packed ? "rd-trips-pack-check--checked" : ""}">
${item.packed ? `<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M5 13l4 4L19 7"/></svg>` : ""}
</div>
<span style="${item.packed ? "color:#64748b;text-decoration:line-through;" : "color:#e2e8f0;"}">${escHtml(item.name)}</span>
</li>
`).join("")}
</ul>`;
}
// ── Calendar card ──
function renderCalendar(itinerary: DemoShape | undefined) {
const grid = $("rd-cal-grid");
if (!grid) return;
const items = (itinerary?.items ?? []) as Array<{ date: string; activity: string; category: string }>;
// Build calendar events map
const calEvents: Record<number, { label: string; color: string }[]> = {};
for (const item of items) {
if (!item.date) continue;
const match = item.date.match(/(\d+)/);
if (!match) continue;
const day = parseInt(match[1], 10);
if (!calEvents[day]) calEvents[day] = [];
calEvents[day].push({
label: item.activity,
color: CATEGORY_COLORS[item.category] || "#64748b",
});
}
const eventDays = Object.keys(calEvents).map(Number);
const tripStart = eventDays.length > 0 ? Math.min(...eventDays) : 6;
const tripEnd = eventDays.length > 0 ? Math.max(...eventDays) : 20;
const daysLabel = $("rd-cal-days");
if (daysLabel) daysLabel.textContent = `${tripEnd - tripStart + 1} days`;
const dayNames = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const offset = 2; // July 1 2026 = Wednesday (0-indexed Mon grid)
const daysInJuly = 31;
let html = `<div class="rd-cal-grid">`;
// Day name headers
for (const d of dayNames) {
html += `<div class="rd-cal-day-name">${d}</div>`;
}
// Empty cells for offset
for (let i = 0; i < offset; i++) {
html += `<div class="rd-cal-cell" style="min-height:3.5rem;"></div>`;
}
// Day cells
for (let day = 1; day <= daysInJuly; day++) {
const isTrip = day >= tripStart && day <= tripEnd;
const events = calEvents[day];
html += `<div class="rd-cal-cell ${isTrip ? "rd-cal-cell--trip" : "rd-cal-cell--empty"}">
<span class="rd-cal-cell-num ${isTrip ? "rd-cal-cell-num--trip" : "rd-cal-cell-num--off"}">${day}</span>`;
if (events) {
for (const ev of events) {
html += `<span class="rd-cal-event" style="background:${ev.color};">${escHtml(ev.label)}</span>`;
}
}
html += `</div>`;
}
html += `</div>`;
grid.innerHTML = html;
}
// ── Polls card ──
function renderPolls(pollShapes: DemoShape[]) {
const body = $("rd-polls-body");
if (!body) return;
if (pollShapes.length === 0) return;
let html = "";
for (const shape of pollShapes) {
const question = shape.question as string;
const options = (shape.options ?? []) as Array<{ label: string; votes: number }>;
const totalVotes = options.reduce((s, o) => s + o.votes, 0);
html += `<div style="margin-bottom:1.25rem;">
<h4 style="font-size:0.8125rem;font-weight:500;color:#e2e8f0;margin:0 0 0.5rem;">${escHtml(question)}</h4>`;
for (let i = 0; i < options.length; i++) {
const opt = options[i];
const pct = totalVotes > 0 ? Math.round((opt.votes / totalVotes) * 100) : 0;
const color = POLL_COLORS[i % POLL_COLORS.length];
html += `<div style="margin-bottom:0.5rem;">
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;margin-bottom:0.25rem;">
<span style="color:#cbd5e1;">${escHtml(opt.label)}</span>
<span style="color:#94a3b8;">${opt.votes} vote${opt.votes !== 1 ? "s" : ""} (${pct}%)</span>
</div>
<div class="rd-trips-poll-bar-bg">
<div class="rd-trips-poll-bar" style="width:${pct}%;background:${color};"></div>
</div>
</div>`;
}
html += `<p style="font-size:0.6875rem;color:#64748b;margin:0.25rem 0 0;">${totalVotes} votes cast</p></div>`;
}
body.innerHTML = html;
}
// ── Funds card ──
function renderFunds(expenseShapes: DemoShape[], members: { name: string; color: string }[]) {
const totalEl = $("rd-funds-total");
const skeleton = $("rd-funds-skeleton");
const expensesEl = $("rd-funds-expenses");
const balancesEl = $("rd-funds-balances");
if (!totalEl || !expensesEl || !balancesEl) return;
if (expenseShapes.length === 0) return;
const expenses = expenseShapes.map((s) => ({
desc: s.description as string,
who: s.paidBy as string,
amount: s.amount as number,
split: members.length || 4,
}));
const totalSpent = expenses.reduce((s, e) => s + e.amount, 0);
totalEl.textContent = `${totalSpent.toLocaleString()}`;
if (skeleton) skeleton.style.display = "none";
expensesEl.style.display = "block";
balancesEl.style.display = "block";
// Recent expenses
let expHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Recent</h4>`;
for (const e of expenses.slice(0, 4)) {
expHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.5rem;">
<div style="min-width:0;">
<p style="color:#e2e8f0;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(e.desc)}</p>
<p style="font-size:0.6875rem;color:#64748b;margin:0;">${escHtml(e.who)} · split ${e.split} ways</p>
</div>
<span style="color:#cbd5e1;font-weight:500;margin-left:0.5rem;flex-shrink:0;">€${e.amount}</span>
</div>`;
}
expensesEl.innerHTML = expHtml;
// Balances
const balances: Record<string, number> = {};
for (const m of members) balances[m.name] = 0;
for (const e of expenses) {
const share = e.amount / e.split;
balances[e.who] = (balances[e.who] || 0) + e.amount;
for (const m of members.slice(0, e.split)) {
balances[m.name] = (balances[m.name] || 0) - share;
}
}
let balHtml = `<h4 class="rd-trips-sub-heading" style="margin-top:0.75rem;">Balances</h4>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.375rem;">`;
for (const m of members) {
const bal = balances[m.name] || 0;
balHtml += `<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.75rem;">
<span style="color:#cbd5e1;">${escHtml(m.name)}</span>
<span style="color:${bal >= 0 ? "#34d399" : "#fb7185"};">${bal >= 0 ? "+" : ""}${Math.round(bal)}</span>
</div>`;
}
balHtml += `</div>`;
balancesEl.innerHTML = balHtml;
}
// ── Cart card ──
function renderCart(cartShapes: DemoShape[]) {
const skeleton = $("rd-cart-skeleton");
const content = $("rd-cart-content");
if (!content) return;
if (cartShapes.length === 0) return;
if (skeleton) skeleton.style.display = "none";
content.style.display = "block";
const items = cartShapes.map((s) => ({
name: s.name as string,
target: s.price as number,
funded: s.funded as number,
status: ((s.funded as number) >= (s.price as number)) ? "Purchased" : "Funding",
}));
const totalFunded = items.reduce((s, i) => s + i.funded, 0);
const totalTarget = items.reduce((s, i) => s + i.target, 0);
const purchased = items.filter((i) => i.status === "Purchased").length;
const overallPct = totalTarget > 0 ? Math.round((totalFunded / totalTarget) * 100) : 0;
let html = `
<div style="display:flex;align-items:center;justify-content:space-between;font-size:0.8125rem;margin-bottom:0.25rem;">
<span style="color:#cbd5e1;">€${totalFunded} / €${totalTarget} funded</span>
<span style="font-size:0.75rem;color:#94a3b8;">${purchased}/${items.length} purchased</span>
</div>
<div style="height:0.5rem;background:rgba(51,65,85,0.7);border-radius:9999px;overflow:hidden;margin-bottom:1rem;">
<div style="height:100%;width:${overallPct}%;background:#14b8a6;border-radius:9999px;transition:width 0.3s;"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.75rem;">`;
for (const item of items) {
const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0;
html += `<div class="rd-trips-cart-item">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:0.375rem;">
<span style="font-size:0.8125rem;color:#e2e8f0;">${escHtml(item.name)}</span>
${item.status === "Purchased"
? `<span style="font-size:0.6875rem;padding:0.125rem 0.5rem;background:rgba(16,185,129,0.2);color:#34d399;border-radius:9999px;">✓ Bought</span>`
: `<span style="font-size:0.6875rem;color:#94a3b8;">€${item.funded}/€${item.target}</span>`
}
</div>
<div class="rd-trips-cart-bar-bg">
<div class="rd-trips-cart-bar" style="width:${pct}%;background:${pct === 100 ? "#10b981" : "#14b8a6"};"></div>
</div>
</div>`;
}
html += `</div>`;
content.innerHTML = html;
}
// ── Event listeners ──
sync.addEventListener("snapshot", ((e: CustomEvent) => {
render(e.detail.shapes);
}) as EventListener);
sync.addEventListener("connected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#10b981";
if (label) label.textContent = "Live — Connected to rSpace";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = false;
});
sync.addEventListener("disconnected", () => {
const dot = $("rd-hero-dot");
const label = $("rd-hero-label");
if (dot) dot.style.background = "#64748b";
if (label) label.textContent = "Reconnecting...";
const resetBtn = $("rd-reset-btn") as HTMLButtonElement | null;
if (resetBtn) resetBtn.disabled = true;
});
// ── Event delegation ──
document.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
// Packing checkbox toggle
const packItem = target.closest<HTMLElement>("[data-pack-idx]");
if (packItem) {
const idx = parseInt(packItem.dataset.packIdx!, 10);
const packingList = shapeByType(sync.shapes, "folk-packing-list");
if (packingList) {
const items = [...(packingList.items as Array<{ name: string; packed: boolean; category: string }>)];
items[idx] = { ...items[idx], packed: !items[idx].packed };
sync.updateShape(packingList.id, { items });
}
return;
}
// Reset button
if (target.closest("#rd-reset-btn")) {
sync.resetDemo().catch((err) => console.error("[Trips] Reset failed:", err));
}
});
// ── Start ──
sync.connect();