/** * 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, type: string): DemoShape[] { return Object.values(shapes).filter((s) => s.type === type); } function shapeByType(shapes: Record, 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, "&").replace(//g, ">").replace(/"/g, """); } // ── Constants ── const MEMBER_COLORS = ["#14b8a6", "#06b6d4", "#3b82f6", "#8b5cf6", "#f59e0b", "#f43f5e"]; const POLL_COLORS = ["#f59e0b", "#3b82f6", "#10b981", "#f43f5e"]; const CATEGORY_COLORS: Record = { 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) { 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 6–20, 2026"; const countries = destinations.length > 0 ? new Set(destinations.map((d) => d.country as string)).size : 3; meta.innerHTML = ` 📅 ${dateRange} 💶 ~€${budgetTotal.toLocaleString()} budget 🏔️ ${countries} countr${countries !== 1 ? "ies" : "y"} ${hasShapes ? ` Live data` : ""} `; } if (avatars && members.length > 0) { avatars.innerHTML = members.map((m) => `
${m.name[0]}
` ).join("") + `${members.length} explorers`; } } // ── 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) => ` ${escHtml(p.name)} ${p.dates} `).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 = `
    ${items.map((item, idx) => `
  • ${item.packed ? `` : ""}
    ${escHtml(item.name)}
  • `).join("")}
`; } // ── 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 = {}; 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 = `
`; // Day name headers for (const d of dayNames) { html += `
${d}
`; } // Empty cells for offset for (let i = 0; i < offset; i++) { html += `
`; } // Day cells for (let day = 1; day <= daysInJuly; day++) { const isTrip = day >= tripStart && day <= tripEnd; const events = calEvents[day]; html += `
${day}`; if (events) { for (const ev of events) { html += `${escHtml(ev.label)}`; } } html += `
`; } html += `
`; 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 += `

${escHtml(question)}

`; 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 += `
${escHtml(opt.label)} ${opt.votes} vote${opt.votes !== 1 ? "s" : ""} (${pct}%)
`; } html += `

${totalVotes} votes cast

`; } 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 = `

Recent

`; for (const e of expenses.slice(0, 4)) { expHtml += `

${escHtml(e.desc)}

${escHtml(e.who)} · split ${e.split} ways

€${e.amount}
`; } expensesEl.innerHTML = expHtml; // Balances const balances: Record = {}; 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 = `

Balances

`; for (const m of members) { const bal = balances[m.name] || 0; balHtml += `
${escHtml(m.name)} ${bal >= 0 ? "+" : ""}€${Math.round(bal)}
`; } balHtml += `
`; 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 = `
€${totalFunded} / €${totalTarget} funded ${purchased}/${items.length} purchased
`; for (const item of items) { const pct = item.target > 0 ? Math.round((item.funded / item.target) * 100) : 0; html += `
${escHtml(item.name)} ${item.status === "Purchased" ? `✓ Bought` : `€${item.funded}/€${item.target}` }
`; } html += `
`; 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("[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();