feat: enhance rcal, rtrips, and rmaps demos to match standalone quality

rCal: add Day/Week/Month view switcher with 24-hour timeline, 48 demo
events across 3 months, now indicator, and per-view navigation.

rTrips: add 4 demo trips with varied statuses (Planning/Booked/In
Progress/Completed), destination chains, collaborator avatars, emoji
categories, and grouped itinerary by date.

rMaps: add interactive zoom/pan (mouse wheel + drag + touch), provider
detail panel with descriptions and specialty tags, click-to-zoom on
pins and legend items, and zoom controls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-28 20:04:50 +00:00
parent bf1d126ee5
commit 58af5a304c
3 changed files with 1159 additions and 272 deletions

View File

@ -1,15 +1,16 @@
/**
* <folk-calendar-view> temporal coordination calendar.
*
* Month grid view with event dots, lunar phase overlay,
* event creation, and source filtering.
* Mobile: tapping a day opens a day-detail panel with full event list.
* Three views: Month grid, Week timeline, Day timeline.
* View switcher, event dots, lunar phase overlay,
* event creation, source filtering, and day-detail panels.
*/
class FolkCalendarView extends HTMLElement {
private shadow: ShadowRoot;
private space = "";
private currentDate = new Date();
private viewMode: "month" | "week" | "day" = "month";
private events: any[] = [];
private sources: any[] = [];
private lunarData: Record<string, { phase: string; illumination: number }> = {};
@ -43,44 +44,77 @@ class FolkCalendarView extends HTMLElement {
{ name: "Conferences", color: "#8b5cf6" },
];
const demoEvents: { day: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; startH: number; startM: number; durationMin: number }[] = [
{ day: 3, title: "Team Standup", source: 0, desc: "Daily sync — Berlin engineering team", location: "Factory Berlin", virtual: false, startH: 9, startM: 30, durationMin: 30 },
{ day: 3, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false, startH: 14, startM: 0, durationMin: 60 },
{ day: 5, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false, startH: 10, startM: 0, durationMin: 90 },
{ day: 5, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, startH: 12, startM: 30, durationMin: 60 },
{ day: 7, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 — local-first sync", location: "Factory Berlin, Room 1", virtual: false, startH: 10, startM: 0, durationMin: 120 },
{ day: 7, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false, startH: 15, startM: 0, durationMin: 90 },
{ day: 8, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true, startH: 16, startM: 0, durationMin: 60 },
{ day: 10, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false, startH: 11, startM: 0, durationMin: 45 },
{ day: 10, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true, startH: 15, startM: 30, durationMin: 60 },
{ day: 12, title: "Train Berlin → Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf → Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, startH: 7, startM: 15, durationMin: 390 },
{ day: 12, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, startH: 14, startM: 30, durationMin: 30 },
{ day: 13, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, startH: 10, startM: 0, durationMin: 180 },
{ day: 13, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, startH: 15, startM: 0, durationMin: 120 },
{ day: 14, title: "Return Train", source: 1, desc: "ICE 148 Amsterdam → Berlin", location: "Amsterdam Centraal", virtual: false, startH: 9, startM: 30, durationMin: 390 },
{ day: 15, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Berlin", virtual: false, startH: 19, startM: 30, durationMin: 120 },
{ day: 17, title: "Weekend Hike", source: 2, desc: "Grunewald forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, startH: 8, startM: 0, durationMin: 300 },
{ day: 19, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, startH: 9, startM: 0, durationMin: 45 },
{ day: 20, title: "Yoga Class", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, startH: 7, startM: 30, durationMin: 75 },
{ day: 20, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, startH: 19, startM: 0, durationMin: 90 },
{ day: 21, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false, startH: 10, startM: 0, durationMin: 60 },
{ day: 21, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true, startH: 14, startM: 0, durationMin: 120 },
{ day: 22, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false, startH: 14, startM: 0, durationMin: 90 },
{ day: 24, title: "Flight → Lisbon", source: 1, desc: "TAP TP 571 BER → LIS", location: "BER Airport", virtual: false, startH: 6, startM: 45, durationMin: 195 },
{ day: 25, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 26, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, startH: 9, startM: 0, durationMin: 540 },
{ day: 27, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Pasteis de Belem", location: "Alfama, Lisbon", virtual: false, startH: 10, startM: 0, durationMin: 360 },
{ day: 27, title: "Flight → Berlin", source: 1, desc: "TAP TP 572 LIS → BER", location: "Lisbon Airport", virtual: false, startH: 19, startM: 30, durationMin: 195 },
// Helper to create dates relative to current month
// monthDelta: -1 = last month, 0 = this month, 1 = next month
const rel = (monthDelta: number, day: number, hour: number, min: number) => {
return new Date(year, month + monthDelta, day, hour, min);
};
const demoEvents: { start: Date; durationMin: number; title: string; source: number; desc: string; location: string | null; virtual: boolean; lat?: number; lng?: number }[] = [
// ── LAST MONTH ──
{ start: rel(-1, 18, 10, 0), durationMin: 90, title: "Sprint 22 Review", source: 0, desc: "Sprint review with Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 },
{ start: rel(-1, 19, 14, 0), durationMin: 60, title: "1:1 with Manager", source: 0, desc: "Quarterly check-in", location: "Factory Berlin", virtual: false },
{ start: rel(-1, 20, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 1", source: 3, desc: "Web3 hackathon at Factory Kreuzberg", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 },
{ start: rel(-1, 21, 9, 0), durationMin: 480, title: "EthBerlin Hackathon Day 2", source: 3, desc: "Judging and demos", location: "Factory G\u00f6rlitzer Park", virtual: false, lat: 52.4934, lng: 13.4278 },
{ start: rel(-1, 22, 18, 0), durationMin: 120, title: "Birthday Dinner \u2014 Marco", source: 2, desc: "Italian dinner at La Focacceria", location: "La Focacceria, Kreuzberg", virtual: false, lat: 52.4940, lng: 13.4100 },
{ start: rel(-1, 25, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, lat: 52.4960, lng: 13.4088 },
{ start: rel(-1, 26, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Q4 API integration sync", location: null, virtual: true },
{ start: rel(-1, 28, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Spreewald", source: 2, desc: "Kayak + hike in biosphere reserve", location: "L\u00fcbbenau, Spreewald", virtual: false, lat: 51.8644, lng: 13.7669 },
// ── THIS MONTH ──
{ start: rel(0, 1, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync \u2014 Berlin engineering team", location: "Factory Berlin", virtual: false, lat: 52.5030, lng: 13.3345 },
{ start: rel(0, 1, 14, 0), durationMin: 60, title: "Code Review Session", source: 0, desc: "Review PRs from the weekend batch", location: "Factory Berlin", virtual: false },
{ start: rel(0, 2, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false },
{ start: rel(0, 2, 11, 0), durationMin: 60, title: "Design System Sync", source: 0, desc: "Component library review with design team", location: null, virtual: true },
{ start: rel(0, 3, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Daily sync", location: "Factory Berlin", virtual: false },
{ start: rel(0, 3, 15, 0), durationMin: 90, title: "Architecture Deep-Dive", source: 0, desc: "CRDT merge strategy for offline-first mobile", location: "Factory Berlin", virtual: false },
{ start: rel(0, 4, 10, 0), durationMin: 90, title: "Product Review", source: 0, desc: "Quarterly product roadmap review", location: "Factory Berlin, Room 3", virtual: false },
{ start: rel(0, 4, 12, 30), durationMin: 60, title: "Lunch with Alex", source: 2, desc: "Catch up over Vietnamese food", location: "District Mot, Berlin", virtual: false, lat: 52.5244, lng: 13.4023 },
{ start: rel(0, 5, 10, 0), durationMin: 120, title: "Sprint Planning", source: 0, desc: "Plan sprint 24 \u2014 local-first sync", location: "Factory Berlin, Room 1", virtual: false },
{ start: rel(0, 7, 8, 0), durationMin: 300, title: "Weekend Hike \u2014 Grunewald", source: 2, desc: "Forest loop trail, ~14 km", location: "S-Bahn Grunewald", virtual: false, lat: 52.4730, lng: 13.2260 },
{ start: rel(0, 8, 16, 0), durationMin: 60, title: "Client Call NYC", source: 0, desc: "Sync with NYC partner team on API integration", location: null, virtual: true },
{ start: rel(0, 9, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false },
{ start: rel(0, 10, 11, 0), durationMin: 45, title: "1:1 with Manager", source: 0, desc: "Monthly check-in", location: "Factory Berlin", virtual: false },
{ start: rel(0, 10, 15, 30), durationMin: 60, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true },
{ start: rel(0, 12, 7, 15), durationMin: 390, title: "Train Berlin \u2192 Amsterdam", source: 1, desc: "ICE 643 Berlin Hbf \u2192 Amsterdam Centraal", location: "Berlin Hauptbahnhof", virtual: false, lat: 52.5251, lng: 13.3694 },
{ start: rel(0, 12, 14, 30), durationMin: 30, title: "Hotel Check-in", source: 1, desc: "Hotel V Nesplein, Amsterdam", location: "Hotel V Nesplein", virtual: false, lat: 52.3667, lng: 4.8945 },
{ start: rel(0, 13, 10, 0), durationMin: 180, title: "Partner Meeting", source: 0, desc: "On-site with Amsterdam design team", location: "WeWork Weteringschans", virtual: false, lat: 52.3603, lng: 4.8880 },
{ start: rel(0, 13, 15, 0), durationMin: 120, title: "Canal District Walk", source: 2, desc: "Afternoon along Prinsengracht and Jordaan", location: "Prinsengracht, Amsterdam", virtual: false, lat: 52.3738, lng: 4.8820 },
{ start: rel(0, 14, 9, 30), durationMin: 390, title: "Return Train Amsterdam \u2192 Berlin", source: 1, desc: "ICE 148 Amsterdam \u2192 Berlin", location: "Amsterdam Centraal", virtual: false, lat: 52.3791, lng: 4.9003 },
{ start: rel(0, 15, 19, 30), durationMin: 120, title: "Dinner with Friends", source: 2, desc: "Birthday dinner for Mia", location: "Il Casolare, Kreuzberg", virtual: false, lat: 52.4900, lng: 13.4200 },
{ start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 2, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false },
{ start: rel(0, 17, 10, 0), durationMin: 60, title: "Sprint Retro", source: 0, desc: "Sprint 23 retrospective", location: "Factory Berlin, Room 2", virtual: false },
{ start: rel(0, 17, 14, 0), durationMin: 120, title: "Release Deploy", source: 0, desc: "Push v2.4.0 to production", location: null, virtual: true },
{ start: rel(0, 18, 14, 0), durationMin: 90, title: "Demo Day", source: 0, desc: "Sprint 23 showcase for stakeholders", location: "Factory Berlin, Main Hall", virtual: false },
{ start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 2, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970 },
{ start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 2, desc: "\"The Mushroom at the End of the World\"", location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310 },
{ start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 2, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 },
{ start: rel(0, 22, 6, 45), durationMin: 195, title: "Flight \u2192 Lisbon", source: 1, desc: "TAP TP 571 BER \u2192 LIS", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 },
{ start: rel(0, 23, 9, 0), durationMin: 540, title: "Web Summit Day 1", source: 3, desc: "Opening keynotes, startup pavilion", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 },
{ start: rel(0, 24, 9, 0), durationMin: 540, title: "Web Summit Day 2", source: 3, desc: "Panel: Local-First Software", location: "Altice Arena, Lisbon", virtual: false, lat: 38.7685, lng: -9.0943 },
{ start: rel(0, 25, 10, 0), durationMin: 360, title: "Lisbon City Tour", source: 2, desc: "Alfama, Tram 28, Past\u00e9is de Bel\u00e9m", location: "Alfama, Lisbon", virtual: false, lat: 38.7118, lng: -9.1300 },
{ start: rel(0, 25, 19, 30), durationMin: 195, title: "Flight \u2192 Berlin", source: 1, desc: "TAP TP 572 LIS \u2192 BER", location: "Lisbon Airport", virtual: false, lat: 38.7756, lng: -9.1354 },
{ start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 2, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200 },
{ start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false },
{ start: rel(0, 28, 15, 0), durationMin: 90, title: "Architecture Review", source: 0, desc: "Review local-first sync architecture", location: "Factory Berlin", virtual: false },
// ── NEXT MONTH ──
{ start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false },
{ start: rel(1, 3, 6, 0), durationMin: 180, title: "Flight \u2192 Barcelona", source: 1, desc: "VY 1862 BER \u2192 BCN", location: "BER Airport", virtual: false, lat: 52.3667, lng: 13.5033 },
{ start: rel(1, 3, 14, 0), durationMin: 240, title: "Team Retreat Day 1", source: 0, desc: "Strategy offsite at Barcel\u00f3 Sants", location: "Barcel\u00f3 Sants, Barcelona", virtual: false, lat: 41.3795, lng: 2.1405 },
{ start: rel(1, 4, 9, 0), durationMin: 480, title: "Team Retreat Day 2", source: 0, desc: "Workshops + Sagrada Familia visit", location: "Barcelona", virtual: false, lat: 41.4036, lng: 2.1744 },
{ start: rel(1, 5, 15, 0), durationMin: 180, title: "Flight \u2192 Berlin", source: 1, desc: "VY 1863 BCN \u2192 BER", location: "BCN Airport", virtual: false, lat: 41.2974, lng: 2.0833 },
{ start: rel(1, 8, 19, 0), durationMin: 120, title: "Berlin Philharmonic", source: 2, desc: "Brahms Symphony No. 4", location: "Berliner Philharmonie", virtual: false, lat: 52.5103, lng: 13.3699 },
{ start: rel(1, 12, 10, 0), durationMin: 120, title: "Brussels Workshop", source: 3, desc: "EU Digital Commons Working Group", location: "Brussels", virtual: false, lat: 50.8503, lng: 4.3517 },
];
this.events = demoEvents.map((e, i) => {
const startDate = new Date(year, month, e.day, e.startH, e.startM);
const endDate = new Date(startDate.getTime() + e.durationMin * 60000);
const endDate = new Date(e.start.getTime() + e.durationMin * 60000);
const src = sources[e.source];
return {
id: `demo-${i + 1}`,
title: e.title,
start_time: startDate.toISOString(),
start_time: e.start.toISOString(),
end_time: endDate.toISOString(),
source_color: src.color,
source_name: src.name,
@ -89,15 +123,16 @@ class FolkCalendarView extends HTMLElement {
is_virtual: e.virtual,
virtual_platform: e.virtual ? "Jitsi" : undefined,
virtual_url: e.virtual ? "#" : undefined,
latitude: e.lat,
longitude: e.lng,
};
});
this.sources = sources;
// Compute lunar phases
// Compute lunar phases for all 3 months
const knownNewMoon = new Date(2026, 0, 29).getTime();
const cycle = 29.53;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const phaseNames: [string, number][] = [
["new_moon", 1.85], ["waxing_crescent", 7.38], ["first_quarter", 11.07],
["waxing_gibbous", 14.76], ["full_moon", 16.62], ["waning_gibbous", 22.15],
@ -105,17 +140,22 @@ class FolkCalendarView extends HTMLElement {
];
const lunar: Record<string, { phase: string; illumination: number }> = {};
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dayTime = new Date(year, month, d).getTime();
const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle;
const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew;
let phaseName = "new_moon";
for (const [name, threshold] of phaseNames) {
if (normalizedDays < threshold) { phaseName = name; break; }
for (let m = month - 1; m <= month + 1; m++) {
const actualYear = m < 0 ? year - 1 : (m > 11 ? year + 1 : year);
const actualMonth = ((m % 12) + 12) % 12;
const daysInMonth = new Date(actualYear, actualMonth + 1, 0).getDate();
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${actualYear}-${String(actualMonth + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dayTime = new Date(actualYear, actualMonth, d).getTime();
const daysSinceNew = ((dayTime - knownNewMoon) / 86400000) % cycle;
const normalizedDays = daysSinceNew < 0 ? daysSinceNew + cycle : daysSinceNew;
let phaseName = "new_moon";
for (const [name, threshold] of phaseNames) {
if (normalizedDays < threshold) { phaseName = name; break; }
}
const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100;
lunar[dateStr] = { phase: phaseName, illumination };
}
const illumination = Math.round((1 - Math.cos(2 * Math.PI * normalizedDays / cycle)) / 2 * 100) / 100;
lunar[dateStr] = { phase: phaseName, illumination };
}
this.lunarData = lunar;
this.render();
@ -148,9 +188,15 @@ class FolkCalendarView extends HTMLElement {
}
private navigate(delta: number) {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
if (this.viewMode === "day") {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta);
} else if (this.viewMode === "week") {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), this.currentDate.getDate() + delta * 7);
} else {
this.currentDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + delta, 1);
}
this.expandedDay = "";
this.loadMonth();
if (this.space !== "demo") { this.loadMonth(); } else { this.render(); }
}
private getMoonEmoji(phase: string): string {
@ -167,23 +213,35 @@ class FolkCalendarView extends HTMLElement {
return `${d.getHours()}:${String(d.getMinutes()).padStart(2, "0")}`;
}
private dateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
private getEventsForDate(dateStr: string): any[] {
return this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
}
private render() {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
const monthName = this.currentDate.toLocaleString("default", { month: "long" });
const viewLabel = this.getViewLabel();
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; padding: 0.5rem; }
* { box-sizing: border-box; }
.nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; }
.nav { display: flex; gap: 8px; margin-bottom: 12px; align-items: center; min-height: 36px; flex-wrap: wrap; }
.nav-btn { padding: 6px 12px; border-radius: 6px; border: 1px solid rgba(255,255,255,0.12); background: transparent; color: #94a3b8; cursor: pointer; font-size: 14px; -webkit-tap-highlight-color: transparent; }
.nav-btn:hover { color: #e2e8f0; border-color: rgba(255,255,255,0.2); }
.nav-btn.active { border-color: #6366f1; color: #6366f1; }
.nav-title { font-size: 15px; font-weight: 600; flex: 1; text-align: center; color: #e2e8f0; }
.nav-primary { padding: 6px 14px; border-radius: 6px; border: none; background: #4f46e5; color: #fff; font-weight: 600; cursor: pointer; font-size: 12px; }
/* View switcher */
.view-switch { display: flex; gap: 2px; margin-bottom: 12px; background: #16161e; border-radius: 8px; padding: 3px; border: 1px solid #222; }
.view-switch-btn { flex: 1; padding: 5px 10px; border-radius: 6px; border: none; background: transparent; color: #64748b; cursor: pointer; font-size: 12px; font-weight: 500; transition: all 0.15s; }
.view-switch-btn:hover { color: #94a3b8; }
.view-switch-btn.active { background: #4f46e5; color: #fff; }
.sources { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.src-badge { font-size: 10px; padding: 3px 8px; border-radius: 10px; border: 1px solid #333; }
@ -208,7 +266,7 @@ class FolkCalendarView extends HTMLElement {
.ev-label:hover { background: rgba(255,255,255,0.08); }
.ev-time { color: #666; font-size: 8px; margin-right: 2px; }
/* Day detail panel (shown below grid row on tap, especially for mobile) */
/* Day detail panel */
.day-detail { grid-column: 1 / -1; background: #1a1a2e; border: 1px solid #334155; border-radius: 8px; padding: 12px; }
.dd-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.dd-date { font-size: 14px; font-weight: 600; color: #e2e8f0; }
@ -228,6 +286,50 @@ class FolkCalendarView extends HTMLElement {
.modal-field { font-size: 13px; color: #aaa; margin-bottom: 6px; }
.modal-close { float: right; background: none; border: none; color: #888; font-size: 18px; cursor: pointer; }
/* ── Day View ── */
.day-view { position: relative; }
.day-view-header { font-size: 13px; color: #94a3b8; margin-bottom: 8px; font-weight: 500; }
.day-allday { background: #16161e; border: 1px solid #222; border-radius: 8px; padding: 8px; margin-bottom: 8px; }
.day-allday-label { font-size: 10px; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
.timeline { position: relative; border-left: 1px solid #222; margin-left: 44px; }
.hour-row { display: flex; min-height: 48px; border-bottom: 1px solid rgba(255,255,255,0.04); position: relative; }
.hour-label { position: absolute; left: -48px; top: -7px; width: 40px; text-align: right; font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; }
.hour-content { flex: 1; position: relative; padding-left: 8px; }
.tl-event {
position: absolute; left: 8px; right: 8px; border-radius: 6px;
padding: 4px 8px; font-size: 11px; overflow: hidden; cursor: pointer;
border-left: 3px solid; z-index: 1; transition: opacity 0.15s;
}
.tl-event:hover { opacity: 0.85; }
.tl-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tl-event-time { font-size: 10px; color: #94a3b8; }
.tl-event-loc { font-size: 10px; color: #64748b; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.now-line { position: absolute; left: 0; right: 0; height: 2px; background: #ef4444; z-index: 5; }
.now-dot { position: absolute; left: -5px; top: -3px; width: 8px; height: 8px; border-radius: 50%; background: #ef4444; }
/* ── Week View ── */
.week-view { overflow-x: auto; }
.week-header { display: grid; grid-template-columns: 44px repeat(7, 1fr); gap: 0; margin-bottom: 0; }
.week-day-header {
text-align: center; padding: 8px 4px; font-size: 11px; color: #64748b; font-weight: 600;
border-bottom: 1px solid #222; cursor: pointer;
}
.week-day-header:hover { color: #e2e8f0; }
.week-day-header.today { color: #6366f1; border-bottom-color: #6366f1; }
.week-day-num { font-size: 16px; font-weight: 700; display: block; }
.week-day-name { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.week-grid { display: grid; grid-template-columns: 44px repeat(7, 1fr); }
.week-time-label { font-size: 10px; color: #4a5568; text-align: right; padding-right: 6px; padding-top: 0; font-variant-numeric: tabular-nums; height: 48px; }
.week-cell { border-left: 1px solid rgba(255,255,255,0.04); border-bottom: 1px solid rgba(255,255,255,0.04); min-height: 48px; position: relative; }
.week-cell.today { background: rgba(99,102,241,0.04); }
.week-event {
position: absolute; left: 2px; right: 2px; border-radius: 4px;
padding: 2px 4px; font-size: 10px; overflow: hidden; cursor: pointer;
border-left: 2px solid; z-index: 1;
}
.week-event:hover { opacity: 0.85; }
.week-event-title { font-weight: 600; color: #e2e8f0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Mobile */
@media (max-width: 768px) {
:host { padding: 0.25rem; }
@ -241,6 +343,7 @@ class FolkCalendarView extends HTMLElement {
.sources { gap: 4px; }
.src-badge { font-size: 9px; padding: 2px 6px; }
.wd { font-size: 10px; padding: 3px; }
.week-view { font-size: 10px; }
}
@media (max-width: 480px) {
.day { min-height: 44px; padding: 3px; }
@ -256,48 +359,81 @@ class FolkCalendarView extends HTMLElement {
<div class="nav">
<button class="nav-btn" id="prev">\u2190</button>
<button class="nav-btn" id="today">Today</button>
<span class="nav-title">${monthName} ${year}</span>
<span class="nav-title">${viewLabel}</span>
<button class="nav-btn ${this.showLunar ? "active" : ""}" id="toggle-lunar">\u{1F319}</button>
<button class="nav-btn" id="next">\u2192</button>
</div>
<div class="view-switch">
<button class="view-switch-btn ${this.viewMode === "day" ? "active" : ""}" data-view="day">Day</button>
<button class="view-switch-btn ${this.viewMode === "week" ? "active" : ""}" data-view="week">Week</button>
<button class="view-switch-btn ${this.viewMode === "month" ? "active" : ""}" data-view="month">Month</button>
</div>
${this.sources.length > 0 ? `<div class="sources">
${this.sources.map(s => `<span class="src-badge" style="border-color:${s.color || "#666"};color:${s.color || "#aaa"}">${this.esc(s.name)}</span>`).join("")}
</div>` : ""}
<div class="weekdays">
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>
${this.viewMode === "month" ? this.renderMonth() : ""}
${this.viewMode === "week" ? this.renderWeek() : ""}
${this.viewMode === "day" ? this.renderDay() : ""}
${this.selectedEvent ? this.renderEventModal() : ""}
`;
this.attachListeners();
}
private getViewLabel(): string {
const d = this.currentDate;
if (this.viewMode === "day") {
return d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric", year: "numeric" });
}
if (this.viewMode === "week") {
const weekStart = new Date(d);
weekStart.setDate(d.getDate() - d.getDay());
const weekEnd = new Date(weekStart);
weekEnd.setDate(weekStart.getDate() + 6);
const startLabel = weekStart.toLocaleDateString("default", { month: "short", day: "numeric" });
const endLabel = weekEnd.toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" });
return `${startLabel} \u2013 ${endLabel}`;
}
return d.toLocaleString("default", { month: "long", year: "numeric" });
}
// ── Month View ──
private renderMonth(): string {
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
return `
<div class="weekdays">
${["S", "M", "T", "W", "T", "F", "S"].map(d => `<div class="wd">${d}</div>`).join("")}
</div>
<div class="grid">
${this.renderDays(year, month)}
</div>`;
}
private renderDays(year: number, month: number): string {
const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
const todayStr = this.dateStr(today);
let html = "";
// Previous month padding
const prevDays = new Date(year, month, 0).getDate();
for (let i = firstDay - 1; i >= 0; i--) {
html += `<div class="day other"><div class="day-num">${prevDays - i}</div></div>`;
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = dateStr === todayStr;
const isExpanded = dateStr === this.expandedDay;
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(dateStr));
const lunar = this.lunarData[dateStr];
const ds = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const isToday = ds === todayStr;
const isExpanded = ds === this.expandedDay;
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${dateStr}">
html += `<div class="day ${isToday ? "today" : ""} ${isExpanded ? "expanded" : ""}" data-date="${ds}">
<div class="day-num">
<span>${d}</span>
${this.showLunar && lunar ? `<span class="moon">${this.getMoonEmoji(lunar.phase)}</span>` : ""}
@ -313,48 +449,216 @@ class FolkCalendarView extends HTMLElement {
` : ""}
</div>`;
// Insert day-detail panel after the day's grid row if expanded
if (isExpanded) {
// Calculate how many cells are in this row so far
const cellIndex = firstDay + d - 1; // 0-based
const cellIndex = firstDay + d - 1;
const posInRow = cellIndex % 7;
// We need to fill remaining cells in the row, then insert detail
if (posInRow === 6 || d === daysInMonth) {
// End of row or last day — insert detail panel right here
html += this.renderDayDetail(dateStr, dayEvents);
html += this.renderDayDetail(ds, dayEvents);
}
// Otherwise, we'll insert after the row ends (handled below)
}
}
// Handle expanded day detail that wasn't at row end
if (this.expandedDay) {
const expD = parseInt(this.expandedDay.split("-")[2]);
const cellIndex = firstDay + expD - 1;
const posInRow = cellIndex % 7;
if (posInRow !== 6 && expD <= daysInMonth) {
// Need to render remaining days in the row, then add detail
// Actually this is complex with the linear approach. Let's use a simpler approach:
// Re-render with detail after the full row.
}
if (posInRow !== 6 && expD <= daysInMonth) { /* detail already appended below */ }
}
// Next month padding
const totalCells = firstDay + daysInMonth;
const remaining = (7 - (totalCells % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
html += `<div class="day other"><div class="day-num">${i}</div></div>`;
}
// Append day detail at the very end (below grid) for simplicity on mobile
if (this.expandedDay) {
const dayEvents = this.events.filter(e => e.start_time && e.start_time.startsWith(this.expandedDay));
const dayEvents = this.getEventsForDate(this.expandedDay);
html += this.renderDayDetail(this.expandedDay, dayEvents);
}
return html;
}
// ── Day View ──
private renderDay(): string {
const ds = this.dateStr(this.currentDate);
const dayEvents = this.getEventsForDate(ds);
const lunar = this.lunarData[ds];
const now = new Date();
const isToday = this.dateStr(now) === ds;
const HOUR_HEIGHT = 48;
const START_HOUR = 6;
const END_HOUR = 23;
// Separate all-day vs timed events
const allDay = dayEvents.filter(e => {
const start = new Date(e.start_time);
const end = new Date(e.end_time);
return (end.getTime() - start.getTime()) >= 86400000;
});
const timed = dayEvents.filter(e => {
const start = new Date(e.start_time);
const end = new Date(e.end_time);
return (end.getTime() - start.getTime()) < 86400000;
}).sort((a, b) => a.start_time.localeCompare(b.start_time));
// Hour rows
let hoursHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12 AM" : h < 12 ? `${h} AM` : h === 12 ? "12 PM" : `${h - 12} PM`;
hoursHtml += `<div class="hour-row">
<span class="hour-label">${label}</span>
<div class="hour-content"></div>
</div>`;
}
// Position timed events
let eventsHtml = "";
for (const ev of timed) {
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 30);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 24);
const bgColor = ev.source_color ? `${ev.source_color}18` : "#6366f118";
eventsHtml += `<div class="tl-event" data-event-id="${ev.id}" style="
top: ${topPx}px; height: ${heightPx}px;
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
">
<div class="tl-event-title">${this.esc(ev.title)}</div>
<div class="tl-event-time">${this.formatTime(ev.start_time)} \u2013 ${this.formatTime(ev.end_time)}</div>
${ev.location_name ? `<div class="tl-event-loc">${this.esc(ev.location_name)}</div>` : ""}
</div>`;
}
// Now indicator
let nowHtml = "";
if (isToday) {
const nowMin = now.getHours() * 60 + now.getMinutes();
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
if (nowPx >= 0 && nowPx <= (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT) {
nowHtml = `<div class="now-line" style="top:${nowPx}px"><div class="now-dot"></div></div>`;
}
}
return `
<div class="day-view">
<div class="day-view-header">
${this.showLunar && lunar ? `${this.getMoonEmoji(lunar.phase)} ${Math.round(lunar.illumination * 100)}% illuminated \u00B7 ` : ""}
${dayEvents.length} event${dayEvents.length !== 1 ? "s" : ""}
</div>
${allDay.length > 0 ? `<div class="day-allday">
<div class="day-allday-label">All Day</div>
${allDay.map(e => `<div class="dd-event" data-event-id="${e.id}">
<div class="dd-color" style="background:${e.source_color || "#6366f1"}"></div>
<div class="dd-info"><div class="dd-title">${this.esc(e.title)}</div></div>
</div>`).join("")}
</div>` : ""}
<div class="timeline" style="height:${(END_HOUR - START_HOUR + 1) * HOUR_HEIGHT}px">
${hoursHtml}
${eventsHtml}
${nowHtml}
</div>
</div>`;
}
// ── Week View ──
private renderWeek(): string {
const d = this.currentDate;
const weekStart = new Date(d.getFullYear(), d.getMonth(), d.getDate() - d.getDay());
const today = new Date();
const todayStr = this.dateStr(today);
const HOUR_HEIGHT = 48;
const START_HOUR = 7;
const END_HOUR = 22;
const totalHeight = (END_HOUR - START_HOUR + 1) * HOUR_HEIGHT;
// Header row
let headerHtml = `<div class="week-day-header" style="border-bottom:none"></div>`;
const days: Date[] = [];
for (let i = 0; i < 7; i++) {
const day = new Date(weekStart);
day.setDate(weekStart.getDate() + i);
days.push(day);
const ds = this.dateStr(day);
const isToday = ds === todayStr;
headerHtml += `<div class="week-day-header ${isToday ? "today" : ""}" data-date="${ds}" data-day-click="true">
<span class="week-day-name">${day.toLocaleDateString("default", { weekday: "short" })}</span>
<span class="week-day-num">${day.getDate()}</span>
</div>`;
}
// Time grid
let gridHtml = "";
for (let h = START_HOUR; h <= END_HOUR; h++) {
const label = h === 0 ? "12a" : h < 12 ? `${h}a` : h === 12 ? "12p" : `${h - 12}p`;
gridHtml += `<div class="week-time-label">${label}</div>`;
for (let i = 0; i < 7; i++) {
const ds = this.dateStr(days[i]);
const isToday = ds === todayStr;
gridHtml += `<div class="week-cell ${isToday ? "today" : ""}" data-col="${i}" data-hour="${h}"></div>`;
}
}
// Overlay events onto week grid
let eventsOverlay = "";
for (let i = 0; i < 7; i++) {
const ds = this.dateStr(days[i]);
const dayEvents = this.getEventsForDate(ds);
for (const ev of dayEvents) {
const start = new Date(ev.start_time);
const end = new Date(ev.end_time);
if ((end.getTime() - start.getTime()) >= 86400000) continue; // skip all-day
const startMin = start.getHours() * 60 + start.getMinutes();
const endMin = end.getHours() * 60 + end.getMinutes();
const duration = Math.max(endMin - startMin, 20);
const topPx = ((startMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
const heightPx = Math.max((duration / 60) * HOUR_HEIGHT, 18);
const bgColor = ev.source_color ? `${ev.source_color}20` : "#6366f120";
// Column position: each column is 1/7 of the remaining width after the time label
const colLeft = `calc(44px + ${i} * ((100% - 44px) / 7) + 2px)`;
const colWidth = `calc((100% - 44px) / 7 - 4px)`;
eventsOverlay += `<div class="week-event" data-event-id="${ev.id}" style="
top: ${topPx}px; height: ${heightPx}px; left: ${colLeft}; width: ${colWidth};
background: ${bgColor}; border-left-color: ${ev.source_color || "#6366f1"};
">
<div class="week-event-title">${this.esc(ev.title)}</div>
</div>`;
}
}
// Now indicator for week view
let nowHtml = "";
const nowDay = days.findIndex(day => this.dateStr(day) === todayStr);
if (nowDay >= 0) {
const nowMin = today.getHours() * 60 + today.getMinutes();
const nowPx = ((nowMin - START_HOUR * 60) / 60) * HOUR_HEIGHT;
if (nowPx >= 0 && nowPx <= totalHeight) {
nowHtml = `<div class="now-line" style="top:${nowPx}px;left:44px;right:0"><div class="now-dot"></div></div>`;
}
}
return `
<div class="week-view">
<div class="week-header">${headerHtml}</div>
<div style="position:relative;overflow-y:auto;max-height:600px;">
<div class="week-grid" style="position:relative;height:${totalHeight}px">
${gridHtml}
</div>
${eventsOverlay}
${nowHtml}
</div>
</div>`;
}
private renderDayDetail(dateStr: string, dayEvents: any[]): string {
const d = new Date(dateStr + "T00:00:00");
const label = d.toLocaleDateString("default", { weekday: "long", month: "long", day: "numeric" });
@ -407,7 +711,16 @@ class FolkCalendarView extends HTMLElement {
this.render();
});
// Day cell tap → expand day detail panel
// View switcher
this.shadow.querySelectorAll("[data-view]").forEach(el => {
el.addEventListener("click", () => {
this.viewMode = (el as HTMLElement).dataset.view as any;
this.expandedDay = "";
this.render();
});
});
// Day cell tap → expand day detail panel (month view)
this.shadow.querySelectorAll(".day:not(.other)").forEach(el => {
el.addEventListener("click", () => {
const date = (el as HTMLElement).dataset.date;
@ -417,7 +730,19 @@ class FolkCalendarView extends HTMLElement {
});
});
// Event clicks in day detail or labels → open modal
// Week day header click → switch to day view
this.shadow.querySelectorAll("[data-day-click]").forEach(el => {
el.addEventListener("click", () => {
const ds = (el as HTMLElement).dataset.date;
if (!ds) return;
const parts = ds.split("-");
this.currentDate = new Date(parseInt(parts[0]), parseInt(parts[1]) - 1, parseInt(parts[2]));
this.viewMode = "day";
this.render();
});
});
// Event clicks → open modal
this.shadow.querySelectorAll("[data-event-id]").forEach(el => {
el.addEventListener("click", (e) => {
e.stopPropagation();

View File

@ -4,9 +4,9 @@
* Creates/joins map rooms, shows participant locations on a map,
* and provides location sharing controls.
*
* Demo mode: shows 6 cosmolocal print providers on a world map with
* connection arcs, interactive hover tooltips, and a feature summary
* matching the standalone rMaps capabilities.
* Demo mode: interactive SVG world map with zoom/pan, 6 cosmolocal
* print providers, connection arcs, city-level detail views, tooltips,
* and feature highlights matching standalone rMaps capabilities.
*/
class FolkMapViewer extends HTMLElement {
@ -18,7 +18,20 @@ class FolkMapViewer extends HTMLElement {
private loading = false;
private error = "";
private syncStatus: "disconnected" | "connected" = "disconnected";
private providers: { name: string; city: string; lat: number; lng: number; color: string }[] = [];
private providers: { name: string; city: string; country: string; lat: number; lng: number; color: string; desc: string; specialties: string[] }[] = [];
// Zoom/pan state
private vbX = 0;
private vbY = 0;
private vbW = 900;
private vbH = 460;
private isDragging = false;
private dragStartX = 0;
private dragStartY = 0;
private dragVbX = 0;
private dragVbY = 0;
private zoomLevel = 1;
private selectedProvider = -1;
constructor() {
super();
@ -44,12 +57,42 @@ class FolkMapViewer extends HTMLElement {
this.room = "cosmolocal-providers";
this.syncStatus = "connected";
this.providers = [
{ name: "Radiant Hall Press", city: "Pittsburgh, PA", lat: 40.44, lng: -79.99, color: "#ef4444" },
{ name: "Tiny Splendor", city: "Los Angeles, CA", lat: 34.05, lng: -118.24, color: "#f59e0b" },
{ name: "People's Print Shop", city: "Toronto, ON", lat: 43.65, lng: -79.38, color: "#22c55e" },
{ name: "Colour Code Press", city: "London, UK", lat: 51.51, lng: -0.13, color: "#3b82f6" },
{ name: "Druckwerkstatt Berlin", city: "Berlin, DE", lat: 52.52, lng: 13.40, color: "#8b5cf6" },
{ name: "Kink\u014D Printing Collective", city: "Tokyo, JP", lat: 35.68, lng: 139.69, color: "#ec4899" },
{
name: "Radiant Hall Press", city: "Pittsburgh", country: "USA",
lat: 40.44, lng: -79.99, color: "#ef4444",
desc: "Worker-owned letterpress and risograph studio specializing in art prints and zines.",
specialties: ["Letterpress", "Risograph", "Zines"],
},
{
name: "Tiny Splendor", city: "Los Angeles", country: "USA",
lat: 34.05, lng: -118.24, color: "#f59e0b",
desc: "Artist-run collective creating hand-pulled screen prints and artist books.",
specialties: ["Screen Print", "Artist Books", "Posters"],
},
{
name: "People's Print Shop", city: "Toronto", country: "Canada",
lat: 43.65, lng: -79.38, color: "#22c55e",
desc: "Community print shop offering affordable risograph and offset printing for social movements.",
specialties: ["Risograph", "Offset", "Community"],
},
{
name: "Colour Code Press", city: "London", country: "UK",
lat: 51.51, lng: -0.13, color: "#3b82f6",
desc: "Independent risograph studio in East London, specializing in publications and packaging.",
specialties: ["Risograph", "Publications", "Packaging"],
},
{
name: "Druckwerkstatt Berlin", city: "Berlin", country: "Germany",
lat: 52.52, lng: 13.40, color: "#8b5cf6",
desc: "Open-access printmaking workshop in Kreuzberg with letterpress, screen print, and risograph.",
specialties: ["Letterpress", "Screen Print", "Risograph"],
},
{
name: "Kink\u014D Printing Collective", city: "Tokyo", country: "Japan",
lat: 35.68, lng: 139.69, color: "#ec4899",
desc: "Tokyo-based collective blending traditional Japanese woodblock with modern risograph techniques.",
specialties: ["Woodblock", "Risograph", "Limited Editions"],
},
];
this.renderDemo();
}
@ -61,7 +104,7 @@ class FolkMapViewer extends HTMLElement {
const px = (lng: number) => ((lng + 180) / 360) * W;
const py = (lat: number) => ((90 - lat) / 180) * H;
// Label offsets to avoid overlapping (Pittsburgh/Toronto are close)
// Label offsets to avoid overlapping
const labelOffsets: Record<string, [number, number]> = {
"Radiant Hall Press": [10, -8],
"Tiny Splendor": [-110, 14],
@ -71,14 +114,9 @@ class FolkMapViewer extends HTMLElement {
"Kink\u014D Printing Collective": [-150, -8],
};
// Connection arcs between providers (great-circle style curves)
// Connection arcs between providers
const connections = [
[0, 2], // Pittsburgh -- Toronto
[0, 3], // Pittsburgh -- London
[3, 4], // London -- Berlin
[4, 5], // Berlin -- Tokyo
[1, 5], // LA -- Tokyo (Pacific)
[0, 1], // Pittsburgh -- LA
[0, 2], [0, 3], [3, 4], [4, 5], [1, 5], [0, 1],
];
const arcs = connections.map(([i, j]) => {
@ -86,46 +124,67 @@ class FolkMapViewer extends HTMLElement {
const b = this.providers[j];
const x1 = px(a.lng), y1 = py(a.lat);
const x2 = px(b.lng), y2 = py(b.lat);
// Curved midpoint -- arc above the straight line
const mx = (x1 + x2) / 2;
const my = (y1 + y2) / 2 - Math.abs(x2 - x1) * 0.12;
return `<path d="M${x1},${y1} Q${mx},${my} ${x2},${y2}" fill="none" stroke="rgba(148,163,184,0.15)" stroke-width="1" stroke-dasharray="4,3" />`;
}).join("\n");
// Provider pins (drop-pin style) and labels
// Provider pins
const pins = this.providers.map((p, i) => {
const x = px(p.lng);
const y = py(p.lat);
const [lx, ly] = labelOffsets[p.name] || [10, 4];
const isSelected = this.selectedProvider === i;
return `
<g class="pin-group" data-idx="${i}">
<!-- Pulse ring -->
<g class="pin-group" data-idx="${i}" style="cursor:pointer">
<circle cx="${x}" cy="${y}" r="4" fill="none" stroke="${p.color}" stroke-width="1" opacity="0.5">
<animate attributeName="r" values="4;14;4" dur="3s" repeatCount="indefinite" />
<animate attributeName="opacity" values="0.5;0;0.5" dur="3s" repeatCount="indefinite" />
</circle>
<!-- Drop pin body -->
${isSelected ? `<circle cx="${x}" cy="${y}" r="18" fill="none" stroke="${p.color}" stroke-width="2" opacity="0.6" />` : ""}
<path d="M${x},${y - 2} c0,-7 -6,-12 -6,-16 a6,6 0 1,1 12,0 c0,4 -6,9 -6,16z" fill="${p.color}" stroke="#0f172a" stroke-width="0.8" opacity="0.92" />
<!-- Pin center dot -->
<circle cx="${x}" cy="${y - 14}" r="2.5" fill="#fff" opacity="0.85" />
<!-- Label -->
<text x="${x + lx}" y="${y + ly}" fill="${p.color}" font-size="10" font-weight="600" font-family="system-ui,sans-serif" opacity="0.9">${this.esc(p.name)}</text>
<text x="${x + lx}" y="${y + ly + 12}" fill="#64748b" font-size="8.5" font-family="system-ui,sans-serif">${this.esc(p.city)}</text>
<text x="${x + lx}" y="${y + ly + 12}" fill="#64748b" font-size="8.5" font-family="system-ui,sans-serif">${this.esc(p.city)}, ${this.esc(p.country)}</text>
</g>
`;
}).join("");
// Legend items
const legendItems = this.providers.map((p) => `
<div style="display:flex;align-items:center;gap:8px;padding:5px 0;">
const legendItems = this.providers.map((p, i) => `
<div class="legend-item ${this.selectedProvider === i ? "selected" : ""}" data-legend="${i}">
<div style="width:10px;height:10px;border-radius:50%;background:${p.color};flex-shrink:0;box-shadow:0 0 6px ${p.color}40;"></div>
<div>
<div style="flex:1;min-width:0">
<span style="font-weight:600;font-size:13px;color:#e2e8f0;">${this.esc(p.name)}</span>
<span style="font-size:12px;color:#64748b;margin-left:8px;">${this.esc(p.city)}</span>
<span style="font-size:12px;color:#64748b;margin-left:8px;">${this.esc(p.city)}, ${this.esc(p.country)}</span>
</div>
</div>
`).join("");
// Provider detail panel (shown when selected)
let detailPanel = "";
if (this.selectedProvider >= 0 && this.selectedProvider < this.providers.length) {
const sp = this.providers[this.selectedProvider];
detailPanel = `
<div class="detail-panel">
<div class="detail-header">
<div style="width:14px;height:14px;border-radius:50%;background:${sp.color};flex-shrink:0;box-shadow:0 0 8px ${sp.color}60;"></div>
<div style="flex:1">
<div style="font-size:15px;font-weight:600;color:#e2e8f0;">${this.esc(sp.name)}</div>
<div style="font-size:12px;color:#94a3b8;">${this.esc(sp.city)}, ${this.esc(sp.country)}</div>
</div>
<button class="detail-close" id="detail-close">\u2715</button>
</div>
<p style="font-size:13px;color:#94a3b8;line-height:1.5;margin:10px 0;">${this.esc(sp.desc)}</p>
<div class="detail-tags">
${sp.specialties.map(s => `<span class="detail-tag" style="border-color:${sp.color}40;color:${sp.color}">${this.esc(s)}</span>`).join("")}
</div>
<div style="font-size:11px;color:#4a5568;margin-top:10px;font-family:monospace;">
${sp.lat.toFixed(4)}\u00B0N, ${Math.abs(sp.lng).toFixed(4)}\u00B0${sp.lng >= 0 ? "E" : "W"}
</div>
</div>`;
}
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: #e0e0e0; }
@ -140,11 +199,25 @@ class FolkMapViewer extends HTMLElement {
border: 1px solid rgba(16,185,129,0.2); border-radius: 20px; padding: 3px 10px;
}
.demo-nav__badge .dot { width: 6px; height: 6px; border-radius: 50%; background: #22c55e; }
/* Zoom controls */
.zoom-controls {
display: flex; gap: 4px; align-items: center;
}
.zoom-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #333;
background: #16161e; color: #94a3b8; cursor: pointer; font-size: 16px;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.zoom-btn:hover { border-color: #555; color: #e2e8f0; }
.zoom-label { font-size: 10px; color: #4a5568; font-variant-numeric: tabular-nums; min-width: 32px; text-align: center; }
.map-wrap {
width: 100%; border-radius: 12px; background: #0c1221; border: 1px solid #1e293b;
overflow: hidden; position: relative;
overflow: hidden; position: relative; cursor: grab;
}
.map-svg { display: block; width: 100%; height: auto; }
.map-wrap.dragging { cursor: grabbing; }
.map-svg { display: block; width: 100%; height: auto; user-select: none; }
/* Hover tooltip */
.pin-group { cursor: pointer; }
@ -159,6 +232,7 @@ class FolkMapViewer extends HTMLElement {
.tooltip .city { color: #94a3b8; font-size: 11px; }
.tooltip .coords { color: #64748b; font-size: 10px; font-family: monospace; }
/* Legend */
.legend {
background: rgba(15,23,42,0.6); border: 1px solid #1e293b; border-radius: 10px;
padding: 16px; margin-top: 16px;
@ -167,6 +241,26 @@ class FolkMapViewer extends HTMLElement {
font-size: 12px; font-weight: 600; color: #94a3b8;
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px;
}
.legend-item {
display: flex; align-items: center; gap: 8px; padding: 6px 8px;
border-radius: 6px; cursor: pointer; transition: background 0.15s;
}
.legend-item:hover { background: rgba(255,255,255,0.04); }
.legend-item.selected { background: rgba(99,102,241,0.1); border: 1px solid rgba(99,102,241,0.2); }
/* Detail panel */
.detail-panel {
background: #1a1a2e; border: 1px solid #334155; border-radius: 10px;
padding: 16px; margin-top: 12px;
}
.detail-header { display: flex; align-items: center; gap: 10px; }
.detail-close { background: none; border: none; color: #64748b; font-size: 16px; cursor: pointer; padding: 4px; }
.detail-close:hover { color: #e2e8f0; }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.detail-tag {
font-size: 10px; padding: 3px 8px; border-radius: 10px;
border: 1px solid #333; font-weight: 500;
}
/* Feature highlights row */
.features {
@ -183,17 +277,24 @@ class FolkMapViewer extends HTMLElement {
@media (max-width: 640px) {
.features { grid-template-columns: repeat(2, 1fr); }
.zoom-controls { gap: 2px; }
}
</style>
<div class="demo-nav">
<span class="demo-nav__title">Cosmolocal Print Network</span>
<span class="demo-nav__badge"><span class="dot"></span> 6 providers online</span>
<div class="zoom-controls">
<button class="zoom-btn" id="zoom-out" title="Zoom out">\u2212</button>
<span class="zoom-label">${Math.round(this.zoomLevel * 100)}%</span>
<button class="zoom-btn" id="zoom-in" title="Zoom in">+</button>
<button class="zoom-btn" id="zoom-reset" title="Reset view" style="font-size:12px">\u21BA</button>
</div>
<span class="demo-nav__badge"><span class="dot"></span> ${this.providers.length} providers online</span>
</div>
<div class="map-wrap">
<div class="map-wrap" id="map-wrap">
<div class="tooltip" id="tooltip"></div>
<svg class="map-svg" viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">
<svg class="map-svg" id="map-svg" viewBox="${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ocean" cx="50%" cy="40%" r="70%">
<stop offset="0%" stop-color="#0f1b33" />
@ -202,12 +303,12 @@ class FolkMapViewer extends HTMLElement {
</defs>
<!-- Ocean background -->
<rect width="${W}" height="${H}" fill="url(#ocean)" />
<rect x="-200" y="-200" width="${W + 400}" height="${H + 400}" fill="url(#ocean)" />
<!-- Graticule (lat/lng grid) -->
<!-- Graticule -->
${this.graticule(W, H)}
<!-- Simplified continent fills -->
<!-- Continents -->
${this.continents(W, H)}
<!-- Connection arcs -->
@ -218,8 +319,10 @@ class FolkMapViewer extends HTMLElement {
</svg>
</div>
${detailPanel}
<div class="legend">
<div class="legend-title">Print Providers</div>
<div class="legend-title">Print Providers \u2014 click to explore</div>
${legendItems}
</div>
@ -260,24 +363,46 @@ class FolkMapViewer extends HTMLElement {
this.attachDemoListeners();
}
private zoomTo(lat: number, lng: number, level: number) {
const W = 900, H = 460;
const cx = ((lng + 180) / 360) * W;
const cy = ((90 - lat) / 180) * H;
this.zoomLevel = level;
this.vbW = W / level;
this.vbH = H / level;
this.vbX = cx - this.vbW / 2;
this.vbY = cy - this.vbH / 2;
// Clamp
this.vbX = Math.max(-100, Math.min(W - this.vbW + 100, this.vbX));
this.vbY = Math.max(-100, Math.min(H - this.vbH + 100, this.vbY));
this.renderDemo();
}
private resetZoom() {
this.vbX = 0;
this.vbY = 0;
this.vbW = 900;
this.vbH = 460;
this.zoomLevel = 1;
this.selectedProvider = -1;
this.renderDemo();
}
/** Generate SVG graticule lines */
private graticule(W: number, H: number): string {
const lines: string[] = [];
// Latitude lines every 30 degrees
for (let lat = -60; lat <= 60; lat += 30) {
const y = ((90 - lat) / 180) * H;
lines.push(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
lines.push(`<line x1="-200" y1="${y}" x2="${W + 200}" y2="${y}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
// Longitude lines every 30 degrees
for (let lng = -150; lng <= 180; lng += 30) {
const x = ((lng + 180) / 360) * W;
lines.push(`<line x1="${x}" y1="0" x2="${x}" y2="${H}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
lines.push(`<line x1="${x}" y1="-200" x2="${x}" y2="${H + 200}" stroke="#1a2744" stroke-width="0.5" stroke-dasharray="3,5" />`);
}
// Equator and Prime Meridian slightly brighter
const eq = ((90 - 0) / 180) * H;
const pm = ((0 + 180) / 360) * W;
lines.push(`<line x1="0" y1="${eq}" x2="${W}" y2="${eq}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
lines.push(`<line x1="${pm}" y1="0" x2="${pm}" y2="${H}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
lines.push(`<line x1="-200" y1="${eq}" x2="${W + 200}" y2="${eq}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
lines.push(`<line x1="${pm}" y1="-200" x2="${pm}" y2="${H + 200}" stroke="#1e3050" stroke-width="0.7" stroke-dasharray="4,3" />`);
return lines.join("\n");
}
@ -292,7 +417,6 @@ class FolkMapViewer extends HTMLElement {
const fill = "#162236";
const stroke = "#1e3050";
// Each continent as a polygon path
const continents = [
// North America
`M${p(50, -130)} L${p(60, -130)} L${p(65, -120)} L${p(70, -100)} L${p(72, -80)}
@ -331,7 +455,7 @@ class FolkMapViewer extends HTMLElement {
L${p(10, 0)} L${p(15, -5)} L${p(20, -10)} L${p(25, -15)}
L${p(30, -10)} L${p(35, -5)} Z`,
// Asia (mainland)
// Asia
`M${p(70, 30)} L${p(72, 50)} L${p(72, 80)} L${p(70, 110)}
L${p(68, 140)} L${p(65, 165)} L${p(60, 165)} L${p(55, 140)}
L${p(50, 130)} L${p(45, 135)} L${p(40, 130)} L${p(35, 120)}
@ -355,7 +479,7 @@ class FolkMapViewer extends HTMLElement {
L${p(-34, 125)} L${p(-30, 130)} L${p(-25, 132)}
L${p(-20, 130)} L${p(-16, 128)} L${p(-12, 132)} Z`,
// Japan (simplified)
// Japan
`M${p(35, 133)} L${p(38, 136)} L${p(40, 140)} L${p(42, 142)}
L${p(44, 144)} L${p(45, 142)} L${p(43, 140)} L${p(40, 137)}
L${p(37, 135)} L${p(35, 133)} Z`,
@ -369,7 +493,7 @@ class FolkMapViewer extends HTMLElement {
`M${p(62, -50)} L${p(68, -52)} L${p(75, -45)} L${p(78, -35)}
L${p(76, -20)} L${p(70, -22)} L${p(65, -35)} L${p(62, -45)} Z`,
// Indonesia (simplified)
// Indonesia
`M${p(-2, 100)} L${p(-4, 108)} L${p(-6, 112)} L${p(-8, 115)}
L${p(-7, 118)} L${p(-5, 116)} L${p(-3, 112)} L${p(-1, 106)} L${p(-2, 100)} Z`,
@ -385,36 +509,186 @@ class FolkMapViewer extends HTMLElement {
private attachDemoListeners() {
const tooltip = this.shadow.getElementById("tooltip");
if (!tooltip) return;
const mapWrap = this.shadow.getElementById("map-wrap");
const mapSvg = this.shadow.getElementById("map-svg");
// Tooltip on hover
if (tooltip) {
this.shadow.querySelectorAll(".pin-group").forEach((el) => {
const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10);
const p = this.providers[idx];
el.addEventListener("mouseenter", (e) => {
const rect = mapWrap?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.innerHTML = `<strong>${this.esc(p.name)}</strong><span class="city">${this.esc(p.city)}, ${this.esc(p.country)}</span><span class="coords">${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}</span>`;
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
tooltip.classList.add("visible");
}
});
el.addEventListener("mousemove", (e) => {
const rect = mapWrap?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
}
});
el.addEventListener("mouseleave", () => {
tooltip.classList.remove("visible");
});
});
}
// Click pin to select provider and zoom
this.shadow.querySelectorAll(".pin-group").forEach((el) => {
const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10);
const p = this.providers[idx];
el.addEventListener("mouseenter", (e) => {
const rect = this.shadow.querySelector(".map-wrap")?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.innerHTML = `<strong>${this.esc(p.name)}</strong><span class="city">${this.esc(p.city)}</span><span class="coords">${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}</span>`;
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
tooltip.classList.add("visible");
el.addEventListener("click", (e) => {
e.stopPropagation();
const idx = parseInt((el as HTMLElement).dataset.idx || "0", 10);
if (this.selectedProvider === idx) {
this.resetZoom();
} else {
this.selectedProvider = idx;
const p = this.providers[idx];
this.zoomTo(p.lat, p.lng, 3);
}
});
el.addEventListener("mousemove", (e) => {
const rect = this.shadow.querySelector(".map-wrap")?.getBoundingClientRect();
const me = e as MouseEvent;
if (rect) {
tooltip.style.left = `${me.clientX - rect.left + 12}px`;
tooltip.style.top = `${me.clientY - rect.top - 10}px`;
}
});
el.addEventListener("mouseleave", () => {
tooltip.classList.remove("visible");
});
});
// Click legend item to select/zoom
this.shadow.querySelectorAll("[data-legend]").forEach((el) => {
el.addEventListener("click", () => {
const idx = parseInt((el as HTMLElement).dataset.legend || "0", 10);
if (this.selectedProvider === idx) {
this.resetZoom();
} else {
this.selectedProvider = idx;
const p = this.providers[idx];
this.zoomTo(p.lat, p.lng, 3);
}
});
});
// Close detail panel
this.shadow.getElementById("detail-close")?.addEventListener("click", () => {
this.resetZoom();
});
// Zoom controls
this.shadow.getElementById("zoom-in")?.addEventListener("click", () => {
const newZoom = Math.min(this.zoomLevel * 1.5, 6);
const cx = this.vbX + this.vbW / 2;
const cy = this.vbY + this.vbH / 2;
const lat = 90 - (cy / 460) * 180;
const lng = (cx / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
});
this.shadow.getElementById("zoom-out")?.addEventListener("click", () => {
if (this.zoomLevel <= 1) {
this.resetZoom();
return;
}
const newZoom = Math.max(this.zoomLevel / 1.5, 1);
const cx = this.vbX + this.vbW / 2;
const cy = this.vbY + this.vbH / 2;
const lat = 90 - (cy / 460) * 180;
const lng = (cx / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
});
this.shadow.getElementById("zoom-reset")?.addEventListener("click", () => {
this.resetZoom();
});
// Mouse wheel zoom
mapWrap?.addEventListener("wheel", (e) => {
e.preventDefault();
const we = e as WheelEvent;
const delta = we.deltaY > 0 ? 0.8 : 1.25;
const newZoom = Math.max(1, Math.min(6, this.zoomLevel * delta));
// Zoom toward mouse position
const rect = mapWrap.getBoundingClientRect();
const mouseX = we.clientX - rect.left;
const mouseY = we.clientY - rect.top;
const svgX = this.vbX + (mouseX / rect.width) * this.vbW;
const svgY = this.vbY + (mouseY / rect.height) * this.vbH;
const lat = 90 - (svgY / 460) * 180;
const lng = (svgX / 900) * 360 - 180;
this.zoomTo(lat, lng, newZoom);
}, { passive: false });
// Drag to pan
mapWrap?.addEventListener("mousedown", (e) => {
const me = e as MouseEvent;
// Don't start drag on pins
if ((me.target as Element)?.closest?.(".pin-group")) return;
this.isDragging = true;
this.dragStartX = me.clientX;
this.dragStartY = me.clientY;
this.dragVbX = this.vbX;
this.dragVbY = this.vbY;
mapWrap.classList.add("dragging");
});
const onMouseMove = (e: Event) => {
if (!this.isDragging) return;
const me = e as MouseEvent;
const rect = mapWrap?.getBoundingClientRect();
if (!rect) return;
const dx = (me.clientX - this.dragStartX) / rect.width * this.vbW;
const dy = (me.clientY - this.dragStartY) / rect.height * this.vbH;
this.vbX = this.dragVbX - dx;
this.vbY = this.dragVbY - dy;
// Clamp
this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX));
this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY));
if (mapSvg) {
mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`);
}
};
const onMouseUp = () => {
this.isDragging = false;
mapWrap?.classList.remove("dragging");
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
// Touch support for pan
mapWrap?.addEventListener("touchstart", (e) => {
const te = e as TouchEvent;
if (te.touches.length === 1) {
this.isDragging = true;
this.dragStartX = te.touches[0].clientX;
this.dragStartY = te.touches[0].clientY;
this.dragVbX = this.vbX;
this.dragVbY = this.vbY;
}
}, { passive: true });
mapWrap?.addEventListener("touchmove", (e) => {
if (!this.isDragging) return;
const te = e as TouchEvent;
if (te.touches.length !== 1) return;
const rect = mapWrap.getBoundingClientRect();
const dx = (te.touches[0].clientX - this.dragStartX) / rect.width * this.vbW;
const dy = (te.touches[0].clientY - this.dragStartY) / rect.height * this.vbH;
this.vbX = this.dragVbX - dx;
this.vbY = this.dragVbY - dy;
this.vbX = Math.max(-200, Math.min(900 - this.vbW + 200, this.vbX));
this.vbY = Math.max(-200, Math.min(460 - this.vbH + 200, this.vbY));
if (mapSvg) {
mapSvg.setAttribute("viewBox", `${this.vbX} ${this.vbY} ${this.vbW} ${this.vbH}`);
}
}, { passive: true });
mapWrap?.addEventListener("touchend", () => {
this.isDragging = false;
}, { passive: true });
}
private getApiBase(): string {

View File

@ -3,6 +3,7 @@
*
* Views: trip list trip detail (tabs: overview, destinations,
* itinerary, bookings, expenses, packing).
* Demo: 4 trips with varied statuses and rich destination chains.
*/
class FolkTripsPlanner extends HTMLElement {
@ -28,79 +29,265 @@ class FolkTripsPlanner extends HTMLElement {
private loadDemoData() {
this.trips = [
{ id: "alpine-2026", title: "Alpine Explorer 2026", status: "PLANNING", start_date: "2026-07-06", end_date: "2026-07-20", budget_total: "4500", total_spent: "1203", destination_count: 3, description: "15-day adventure through Chamonix (France) \u2192 Zermatt (Switzerland) \u2192 Dolomites (Italy)" }
{
id: "alpine-2026", title: "Alpine Explorer 2026", status: "PLANNING",
start_date: "2026-07-06", end_date: "2026-07-20", budget_total: "4500", total_spent: "1203",
destination_count: 3, destinations_chain: "Chamonix \u2192 Zermatt \u2192 Dolomites",
description: "15-day adventure through the Alps \u2014 3 countries, 6 explorers, endless peaks.",
collaborator_count: 6
},
{
id: "berlin-dweb-2026", title: "DWeb Camp Berlin", status: "BOOKED",
start_date: "2026-05-15", end_date: "2026-05-18", budget_total: "800", total_spent: "650",
destination_count: 1, destinations_chain: "Berlin",
description: "Decentralized Web camp at c-base \u2014 workshops, hackathons, and local-first demos.",
collaborator_count: 12
},
{
id: "portugal-retreat-2026", title: "Portugal Team Retreat", status: "IN_PROGRESS",
start_date: "2026-02-20", end_date: "2026-02-28", budget_total: "3200", total_spent: "2870",
destination_count: 2, destinations_chain: "Lisbon \u2192 Sintra",
description: "Team offsite \u2014 strategy workshops, co-working from surf hostels, and exploring Sintra's castles.",
collaborator_count: 8
},
{
id: "japan-2025", title: "Japan Autumn 2025", status: "COMPLETED",
start_date: "2025-10-10", end_date: "2025-10-24", budget_total: "5200", total_spent: "4980",
destination_count: 4, destinations_chain: "Tokyo \u2192 Kyoto \u2192 Osaka \u2192 Hiroshima",
description: "14-day autumn foliage tour \u2014 temples, street food, and bullet trains.",
collaborator_count: 4
},
];
this.render();
}
private getDemoTripDetail(id: string): any {
return {
id: "alpine-2026",
title: "Alpine Explorer 2026",
status: "PLANNING",
start_date: "2026-07-06",
end_date: "2026-07-20",
budget_total: "4500",
description: "15-day adventure through Chamonix (France) \u2192 Zermatt (Switzerland) \u2192 Dolomites (Italy). 6 explorers, 3 countries, endless peaks.",
destinations: [
{ id: "d1", name: "Chamonix", country: "France", arrival_date: "2026-07-06" },
{ id: "d2", name: "Zermatt", country: "Switzerland", arrival_date: "2026-07-12" },
{ id: "d3", name: "Dolomites", country: "Italy", arrival_date: "2026-07-17" }
],
itinerary: [
{ id: "i1", title: "Fly Geneva \u2192 Chamonix shuttle", category: "TRANSPORT", date: "2026-07-06", start_time: "10:00" },
{ id: "i2", title: "Acclimatization hike \u2014 Lac Blanc", category: "ACTIVITY", date: "2026-07-07", start_time: "07:00" },
{ id: "i3", title: "Via Ferrata \u2014 Aiguille du Midi", category: "ACTIVITY", date: "2026-07-08", start_time: "08:00" },
{ id: "i4", title: "Rest day / Chamonix town", category: "FREE_TIME", date: "2026-07-09", start_time: "10:00" },
{ id: "i5", title: "Group dinner \u2014 La Cabane", category: "MEAL", date: "2026-07-09", start_time: "19:00" },
{ id: "i6", title: "Mont Blanc viewpoint hike", category: "ACTIVITY", date: "2026-07-10", start_time: "06:30" },
{ id: "i7", title: "Farewell lunch in Chamonix", category: "MEAL", date: "2026-07-11", start_time: "12:00" },
{ id: "i8", title: "Train to Zermatt via Glacier Express", category: "TRANSPORT", date: "2026-07-12", start_time: "08:00" },
{ id: "i9", title: "Gornergrat sunrise hike", category: "ACTIVITY", date: "2026-07-13", start_time: "05:30" },
{ id: "i10", title: "Matterhorn base camp trek", category: "ACTIVITY", date: "2026-07-14", start_time: "07:00" },
{ id: "i11", title: "Paragliding over Zermatt", category: "ACTIVITY", date: "2026-07-15", start_time: "10:00" },
{ id: "i12", title: "Fondue dinner \u2014 Chez Vrony", category: "MEAL", date: "2026-07-15", start_time: "19:30" },
{ id: "i13", title: "Transfer to Dolomites", category: "TRANSPORT", date: "2026-07-16", start_time: "09:00" },
{ id: "i14", title: "Tre Cime di Lavaredo loop", category: "ACTIVITY", date: "2026-07-17", start_time: "07:00" },
{ id: "i15", title: "Lago di Braies kayaking", category: "ACTIVITY", date: "2026-07-18", start_time: "09:00" },
{ id: "i16", title: "Cooking class in Bolzano", category: "ACTIVITY", date: "2026-07-19", start_time: "11:00" },
{ id: "i17", title: "Free day \u2014 shopping & packing", category: "FREE_TIME", date: "2026-07-19", start_time: "14:00" },
{ id: "i18", title: "Fly home from Innsbruck", category: "TRANSPORT", date: "2026-07-20", start_time: "12:00" }
],
bookings: [
{ id: "bk1", type: "FLIGHT", provider: "easyJet \u2014 Geneva", confirmation_number: "EZY-20260706-ALP", cost: "890" },
{ id: "bk2", type: "TRANSPORT", provider: "Glacier Express", confirmation_number: "GEX-445920", cost: "240" },
{ id: "bk3", type: "ACCOMMODATION", provider: "Refuge du Lac Blanc", confirmation_number: "LB2026-234", cost: "320" },
{ id: "bk4", type: "ACCOMMODATION", provider: "Hotel Matterhorn Focus, Zermatt", confirmation_number: "MF-88201", cost: "780" },
{ id: "bk5", type: "ACCOMMODATION", provider: "Rifugio Locatelli, Dolomites", confirmation_number: "TRE2026-089", cost: "280" },
{ id: "bk6", type: "ACTIVITY", provider: "Paragliding Zermatt (tandem x6)", confirmation_number: "PGZ-1120", cost: "1080" }
],
expenses: [
{ id: "e1", category: "TRANSPORT", description: "Geneva \u2192 Chamonix shuttle (6 pax)", amount: "186", date: "2026-07-06" },
{ id: "e2", category: "ACCOMMODATION", description: "Mountain hut reservations (3 nights)", amount: "420", date: "2026-07-07" },
{ id: "e3", category: "ACTIVITY", description: "Via Ferrata gear rental (6 sets)", amount: "216", date: "2026-07-08" },
{ id: "e4", category: "FOOD", description: "Groceries \u2014 Chamonix Carrefour", amount: "93", date: "2026-07-06" },
{ id: "e5", category: "ACTIVITY", description: "Paragliding deposit (4 of 6 booked)", amount: "288", date: "2026-07-13" }
],
packing: [
{ id: "pk1", name: "Hiking boots (broken in)", category: "FOOTWEAR", quantity: 1, packed: true },
{ id: "pk2", name: "Rain jacket", category: "CLOTHING", quantity: 1, packed: true },
{ id: "pk3", name: "Trekking poles", category: "GEAR", quantity: 1, packed: false },
{ id: "pk4", name: "Headlamp + batteries", category: "GEAR", quantity: 1, packed: true },
{ id: "pk5", name: "Sunscreen SPF 50", category: "PERSONAL", quantity: 1, packed: false },
{ id: "pk6", name: "Water filter", category: "GEAR", quantity: 1, packed: false },
{ id: "pk7", name: "First aid kit", category: "SAFETY", quantity: 1, packed: true },
{ id: "pk8", name: "Passport + travel insurance", category: "DOCUMENTS", quantity: 1, packed: true }
],
collaborators: [
{ name: "Alex", role: "organizer" },
{ name: "Sam", role: "photographer" },
{ name: "Jordan", role: "logistics" },
{ name: "Riley", role: "navigator" },
{ name: "Casey", role: "gear lead" },
{ name: "Morgan", role: "safety" }
]
const trips: Record<string, any> = {
"alpine-2026": {
id: "alpine-2026", title: "Alpine Explorer 2026", status: "PLANNING",
start_date: "2026-07-06", end_date: "2026-07-20", budget_total: "4500",
description: "15-day adventure through the Alps \u2014 Chamonix (France) \u2192 Zermatt (Switzerland) \u2192 Dolomites (Italy). 6 explorers, 3 countries, endless peaks.",
destinations: [
{ id: "d1", name: "Chamonix", country: "France", arrival_date: "2026-07-06", lat: 45.9237, lng: 6.8694 },
{ id: "d2", name: "Zermatt", country: "Switzerland", arrival_date: "2026-07-12", lat: 46.0207, lng: 7.7491 },
{ id: "d3", name: "Dolomites", country: "Italy", arrival_date: "2026-07-17", lat: 46.4102, lng: 11.8440 },
],
itinerary: [
{ id: "i1", title: "Fly Geneva \u2192 Chamonix shuttle", category: "TRANSPORT", date: "2026-07-06", start_time: "10:00" },
{ id: "i2", title: "Acclimatization hike \u2014 Lac Blanc", category: "ACTIVITY", date: "2026-07-07", start_time: "07:00" },
{ id: "i3", title: "Via Ferrata \u2014 Aiguille du Midi", category: "ACTIVITY", date: "2026-07-08", start_time: "08:00" },
{ id: "i4", title: "Rest day / Chamonix town", category: "FREE_TIME", date: "2026-07-09", start_time: "10:00" },
{ id: "i5", title: "Group dinner \u2014 La Cabane", category: "MEAL", date: "2026-07-09", start_time: "19:00" },
{ id: "i6", title: "Mont Blanc viewpoint hike", category: "ACTIVITY", date: "2026-07-10", start_time: "06:30" },
{ id: "i7", title: "Farewell lunch in Chamonix", category: "MEAL", date: "2026-07-11", start_time: "12:00" },
{ id: "i8", title: "Train to Zermatt via Glacier Express", category: "TRANSPORT", date: "2026-07-12", start_time: "08:00" },
{ id: "i9", title: "Gornergrat sunrise hike", category: "ACTIVITY", date: "2026-07-13", start_time: "05:30" },
{ id: "i10", title: "Matterhorn base camp trek", category: "ACTIVITY", date: "2026-07-14", start_time: "07:00" },
{ id: "i11", title: "Paragliding over Zermatt", category: "ACTIVITY", date: "2026-07-15", start_time: "10:00" },
{ id: "i12", title: "Fondue dinner \u2014 Chez Vrony", category: "MEAL", date: "2026-07-15", start_time: "19:30" },
{ id: "i13", title: "Transfer to Dolomites", category: "TRANSPORT", date: "2026-07-16", start_time: "09:00" },
{ id: "i14", title: "Tre Cime di Lavaredo loop", category: "ACTIVITY", date: "2026-07-17", start_time: "07:00" },
{ id: "i15", title: "Lago di Braies kayaking", category: "ACTIVITY", date: "2026-07-18", start_time: "09:00" },
{ id: "i16", title: "Cooking class in Bolzano", category: "ACTIVITY", date: "2026-07-19", start_time: "11:00" },
{ id: "i17", title: "Free day \u2014 shopping & packing", category: "FREE_TIME", date: "2026-07-19", start_time: "14:00" },
{ id: "i18", title: "Fly home from Innsbruck", category: "TRANSPORT", date: "2026-07-20", start_time: "12:00" },
],
bookings: [
{ id: "bk1", type: "FLIGHT", provider: "easyJet \u2014 Geneva", confirmation_number: "EZY-20260706-ALP", cost: "890" },
{ id: "bk2", type: "TRANSPORT", provider: "Glacier Express", confirmation_number: "GEX-445920", cost: "240" },
{ id: "bk3", type: "ACCOMMODATION", provider: "Refuge du Lac Blanc", confirmation_number: "LB2026-234", cost: "320" },
{ id: "bk4", type: "ACCOMMODATION", provider: "Hotel Matterhorn Focus, Zermatt", confirmation_number: "MF-88201", cost: "780" },
{ id: "bk5", type: "ACCOMMODATION", provider: "Rifugio Locatelli, Dolomites", confirmation_number: "TRE2026-089", cost: "280" },
{ id: "bk6", type: "ACTIVITY", provider: "Paragliding Zermatt (tandem x6)", confirmation_number: "PGZ-1120", cost: "1080" },
],
expenses: [
{ id: "e1", category: "TRANSPORT", description: "Geneva \u2192 Chamonix shuttle (6 pax)", amount: "186", date: "2026-07-06" },
{ id: "e2", category: "ACCOMMODATION", description: "Mountain hut reservations (3 nights)", amount: "420", date: "2026-07-07" },
{ id: "e3", category: "ACTIVITY", description: "Via Ferrata gear rental (6 sets)", amount: "216", date: "2026-07-08" },
{ id: "e4", category: "FOOD", description: "Groceries \u2014 Chamonix Carrefour", amount: "93", date: "2026-07-06" },
{ id: "e5", category: "ACTIVITY", description: "Paragliding deposit (4 of 6 booked)", amount: "288", date: "2026-07-13" },
],
packing: [
{ id: "pk1", name: "Hiking boots (broken in)", category: "FOOTWEAR", quantity: 1, packed: true },
{ id: "pk2", name: "Rain jacket", category: "CLOTHING", quantity: 1, packed: true },
{ id: "pk3", name: "Trekking poles", category: "GEAR", quantity: 1, packed: false },
{ id: "pk4", name: "Headlamp + batteries", category: "GEAR", quantity: 1, packed: true },
{ id: "pk5", name: "Sunscreen SPF 50", category: "PERSONAL", quantity: 1, packed: false },
{ id: "pk6", name: "Water filter", category: "GEAR", quantity: 1, packed: false },
{ id: "pk7", name: "First aid kit", category: "SAFETY", quantity: 1, packed: true },
{ id: "pk8", name: "Passport + travel insurance", category: "DOCUMENTS", quantity: 1, packed: true },
],
collaborators: [
{ name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" },
{ name: "Sam", role: "photographer", avatar: "\u{1F4F8}" },
{ name: "Jordan", role: "logistics", avatar: "\u{1F5FA}\uFE0F" },
{ name: "Riley", role: "navigator", avatar: "\u{1F9ED}" },
{ name: "Casey", role: "gear lead", avatar: "\u{1F3D4}\uFE0F" },
{ name: "Morgan", role: "safety", avatar: "\u26D1\uFE0F" },
],
},
"berlin-dweb-2026": {
id: "berlin-dweb-2026", title: "DWeb Camp Berlin", status: "BOOKED",
start_date: "2026-05-15", end_date: "2026-05-18", budget_total: "800",
description: "Decentralized Web camp at c-base hackerspace \u2014 3 days of workshops, hackathons, local-first demos, and rStack presentations.",
destinations: [
{ id: "d1", name: "Berlin", country: "Germany", arrival_date: "2026-05-15", lat: 52.5130, lng: 13.4200 },
],
itinerary: [
{ id: "i1", title: "Arrive & check in at c-base", category: "TRANSPORT", date: "2026-05-15", start_time: "14:00" },
{ id: "i2", title: "Welcome session & icebreaker", category: "ACTIVITY", date: "2026-05-15", start_time: "16:00" },
{ id: "i3", title: "Workshop: CRDT Fundamentals", category: "ACTIVITY", date: "2026-05-16", start_time: "09:00" },
{ id: "i4", title: "Workshop: Automerge in Practice", category: "ACTIVITY", date: "2026-05-16", start_time: "14:00" },
{ id: "i5", title: "Group dinner at Markthalle Neun", category: "MEAL", date: "2026-05-16", start_time: "19:30" },
{ id: "i6", title: "Hackathon Day", category: "ACTIVITY", date: "2026-05-17", start_time: "09:00" },
{ id: "i7", title: "Demo presentations", category: "ACTIVITY", date: "2026-05-17", start_time: "16:00" },
{ id: "i8", title: "Wrap-up & departure", category: "FREE_TIME", date: "2026-05-18", start_time: "10:00" },
],
bookings: [
{ id: "bk1", type: "ACCOMMODATION", provider: "Hostel One80\u00B0, Berlin", confirmation_number: "H180-5520", cost: "240" },
{ id: "bk2", type: "ACTIVITY", provider: "c-base space rental", confirmation_number: "CB-DWEB-2026", cost: "350" },
],
expenses: [
{ id: "e1", category: "FOOD", description: "Group catering (3 days)", amount: "180", date: "2026-05-15" },
{ id: "e2", category: "ACTIVITY", description: "Workshop materials & supplies", amount: "120", date: "2026-05-14" },
{ id: "e3", category: "TRANSPORT", description: "BVG group day passes x3", amount: "48", date: "2026-05-15" },
{ id: "e4", category: "FOOD", description: "Markthalle Neun dinner (12 pax)", amount: "302", date: "2026-05-16" },
],
packing: [
{ id: "pk1", name: "Laptop + charger", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk2", name: "USB-C hub", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk3", name: "Notebook & pen", category: "SUPPLIES", quantity: 1, packed: true },
{ id: "pk4", name: "Water bottle", category: "PERSONAL", quantity: 1, packed: false },
],
collaborators: [
{ name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" },
{ name: "Mia", role: "facilitator", avatar: "\u{1F3A4}" },
{ name: "Leo", role: "AV setup", avatar: "\u{1F3AC}" },
],
},
"portugal-retreat-2026": {
id: "portugal-retreat-2026", title: "Portugal Team Retreat", status: "IN_PROGRESS",
start_date: "2026-02-20", end_date: "2026-02-28", budget_total: "3200",
description: "Team offsite in Portugal \u2014 strategy workshops, co-working from surf hostels, exploring Sintra's fairy-tale castles, and Lisbon street food adventures.",
destinations: [
{ id: "d1", name: "Lisbon", country: "Portugal", arrival_date: "2026-02-20", lat: 38.7223, lng: -9.1393 },
{ id: "d2", name: "Sintra", country: "Portugal", arrival_date: "2026-02-25", lat: 38.7979, lng: -9.3906 },
],
itinerary: [
{ id: "i1", title: "Arrive Lisbon \u2014 check in", category: "TRANSPORT", date: "2026-02-20", start_time: "14:00" },
{ id: "i2", title: "Welcome dinner \u2014 Time Out Market", category: "MEAL", date: "2026-02-20", start_time: "19:30" },
{ id: "i3", title: "Strategy workshop: Q2 roadmap", category: "ACTIVITY", date: "2026-02-21", start_time: "09:00" },
{ id: "i4", title: "Co-working at Second Home", category: "ACTIVITY", date: "2026-02-22", start_time: "09:30" },
{ id: "i5", title: "Alfama walking tour", category: "ACTIVITY", date: "2026-02-22", start_time: "15:00" },
{ id: "i6", title: "Surf lesson in Cascais", category: "ACTIVITY", date: "2026-02-23", start_time: "09:00" },
{ id: "i7", title: "Team retrospective", category: "ACTIVITY", date: "2026-02-24", start_time: "10:00" },
{ id: "i8", title: "Train to Sintra", category: "TRANSPORT", date: "2026-02-25", start_time: "09:00" },
{ id: "i9", title: "Pena Palace visit", category: "ACTIVITY", date: "2026-02-25", start_time: "11:00" },
{ id: "i10", title: "Quinta da Regaleira", category: "ACTIVITY", date: "2026-02-26", start_time: "10:00" },
{ id: "i11", title: "Free day \u2014 explore Sintra", category: "FREE_TIME", date: "2026-02-27", start_time: "09:00" },
{ id: "i12", title: "Return to Lisbon & fly home", category: "TRANSPORT", date: "2026-02-28", start_time: "08:00" },
],
bookings: [
{ id: "bk1", type: "FLIGHT", provider: "TAP Air Portugal BER\u2192LIS", confirmation_number: "TAP-2026-8834", cost: "420" },
{ id: "bk2", type: "ACCOMMODATION", provider: "Lisbon Surf House (5 nights)", confirmation_number: "LSH-FEB26", cost: "850" },
{ id: "bk3", type: "ACCOMMODATION", provider: "Sintra B&B (3 nights)", confirmation_number: "SBB-8820", cost: "540" },
{ id: "bk4", type: "ACTIVITY", provider: "Cascais Surf School (8 pax)", confirmation_number: "CSS-G8-0223", cost: "480" },
{ id: "bk5", type: "TRANSPORT", provider: "CP Train Lisbon\u2192Sintra return", confirmation_number: "CP-ROUND-26", cost: "64" },
],
expenses: [
{ id: "e1", category: "FOOD", description: "Time Out Market welcome dinner", amount: "310", date: "2026-02-20" },
{ id: "e2", category: "TRANSPORT", description: "Airport shuttle + Uber rides", amount: "95", date: "2026-02-20" },
{ id: "e3", category: "ACTIVITY", description: "Second Home day passes (8 pax)", amount: "240", date: "2026-02-22" },
{ id: "e4", category: "FOOD", description: "Groceries & coffee (week)", amount: "175", date: "2026-02-21" },
{ id: "e5", category: "ACTIVITY", description: "Pena Palace tickets (8 pax)", amount: "112", date: "2026-02-25" },
{ id: "e6", category: "ACTIVITY", description: "Quinta da Regaleira tickets", amount: "96", date: "2026-02-26" },
{ id: "e7", category: "FOOD", description: "Farewell dinner \u2014 Ramiro", amount: "280", date: "2026-02-27" },
],
packing: [
{ id: "pk1", name: "Laptop + charger", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk2", name: "Swimsuit & towel", category: "CLOTHING", quantity: 1, packed: true },
{ id: "pk3", name: "Sunscreen SPF 50", category: "PERSONAL", quantity: 1, packed: true },
{ id: "pk4", name: "Light jacket", category: "CLOTHING", quantity: 1, packed: true },
{ id: "pk5", name: "Comfortable walking shoes", category: "FOOTWEAR", quantity: 1, packed: true },
{ id: "pk6", name: "Passport", category: "DOCUMENTS", quantity: 1, packed: true },
],
collaborators: [
{ name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" },
{ name: "Sam", role: "co-lead", avatar: "\u{1F91D}" },
{ name: "Jordan", role: "logistics", avatar: "\u{1F5FA}\uFE0F" },
{ name: "Mia", role: "facilitator", avatar: "\u{1F3A4}" },
{ name: "Leo", role: "content", avatar: "\u{1F4DD}" },
{ name: "Kai", role: "surf guide", avatar: "\u{1F3C4}" },
{ name: "Riley", role: "finance", avatar: "\u{1F4B0}" },
{ name: "Morgan", role: "wellbeing", avatar: "\u{1F9D8}" },
],
},
"japan-2025": {
id: "japan-2025", title: "Japan Autumn 2025", status: "COMPLETED",
start_date: "2025-10-10", end_date: "2025-10-24", budget_total: "5200",
description: "14-day autumn foliage tour through Japan \u2014 Tokyo \u2192 Kyoto \u2192 Osaka \u2192 Hiroshima. Temples, street food, bullet trains, and unforgettable views.",
destinations: [
{ id: "d1", name: "Tokyo", country: "Japan", arrival_date: "2025-10-10", lat: 35.6762, lng: 139.6503 },
{ id: "d2", name: "Kyoto", country: "Japan", arrival_date: "2025-10-14", lat: 35.0116, lng: 135.7681 },
{ id: "d3", name: "Osaka", country: "Japan", arrival_date: "2025-10-19", lat: 34.6937, lng: 135.5023 },
{ id: "d4", name: "Hiroshima", country: "Japan", arrival_date: "2025-10-22", lat: 34.3853, lng: 132.4553 },
],
itinerary: [
{ id: "i1", title: "Arrive Tokyo \u2014 Narita Express", category: "TRANSPORT", date: "2025-10-10", start_time: "15:00" },
{ id: "i2", title: "Shibuya & Harajuku exploration", category: "ACTIVITY", date: "2025-10-11", start_time: "10:00" },
{ id: "i3", title: "Tsukiji Outer Market & teamLab", category: "ACTIVITY", date: "2025-10-12", start_time: "08:00" },
{ id: "i4", title: "Day trip to Nikko", category: "ACTIVITY", date: "2025-10-13", start_time: "07:30" },
{ id: "i5", title: "Shinkansen to Kyoto", category: "TRANSPORT", date: "2025-10-14", start_time: "10:00" },
{ id: "i6", title: "Fushimi Inari & Kiyomizu-dera", category: "ACTIVITY", date: "2025-10-15", start_time: "07:00" },
{ id: "i7", title: "Arashiyama bamboo grove", category: "ACTIVITY", date: "2025-10-16", start_time: "08:00" },
{ id: "i8", title: "Tea ceremony in Gion", category: "ACTIVITY", date: "2025-10-17", start_time: "14:00" },
{ id: "i9", title: "Nara day trip (deer park)", category: "ACTIVITY", date: "2025-10-18", start_time: "09:00" },
{ id: "i10", title: "Train to Osaka", category: "TRANSPORT", date: "2025-10-19", start_time: "10:00" },
{ id: "i11", title: "Dotonbori street food crawl", category: "MEAL", date: "2025-10-19", start_time: "17:00" },
{ id: "i12", title: "Osaka Castle & Shinsekai", category: "ACTIVITY", date: "2025-10-20", start_time: "09:00" },
{ id: "i13", title: "Cooking class \u2014 takoyaki", category: "ACTIVITY", date: "2025-10-21", start_time: "11:00" },
{ id: "i14", title: "Shinkansen to Hiroshima", category: "TRANSPORT", date: "2025-10-22", start_time: "09:00" },
{ id: "i15", title: "Peace Memorial & Museum", category: "ACTIVITY", date: "2025-10-22", start_time: "13:00" },
{ id: "i16", title: "Miyajima Island (floating torii)", category: "ACTIVITY", date: "2025-10-23", start_time: "08:00" },
{ id: "i17", title: "Return to Tokyo & fly home", category: "TRANSPORT", date: "2025-10-24", start_time: "08:00" },
],
bookings: [
{ id: "bk1", type: "FLIGHT", provider: "ANA Berlin \u2192 Tokyo Narita", confirmation_number: "ANA-NH204-1010", cost: "1240" },
{ id: "bk2", type: "TRANSPORT", provider: "JR Pass 14-day (4 pax)", confirmation_number: "JRP-4X14-2025", cost: "1680" },
{ id: "bk3", type: "ACCOMMODATION", provider: "Hotel Gracery Shinjuku (4 nights)", confirmation_number: "GRA-TKY-2210", cost: "560" },
{ id: "bk4", type: "ACCOMMODATION", provider: "Kyoto Machiya Guesthouse (5 nights)", confirmation_number: "KMG-KYO-1415", cost: "480" },
{ id: "bk5", type: "ACCOMMODATION", provider: "Hostel 64 Osaka (3 nights)", confirmation_number: "H64-OSA-1920", cost: "195" },
{ id: "bk6", type: "ACCOMMODATION", provider: "Hiroshima Hana Hostel (2 nights)", confirmation_number: "HHH-HIR-2224", cost: "120" },
],
expenses: [
{ id: "e1", category: "TRANSPORT", description: "Narita Express x4", amount: "120", date: "2025-10-10" },
{ id: "e2", category: "FOOD", description: "Ramen, sushi, yakitori (week 1)", amount: "380", date: "2025-10-11" },
{ id: "e3", category: "ACTIVITY", description: "teamLab Borderless tickets x4", amount: "96", date: "2025-10-12" },
{ id: "e4", category: "ACTIVITY", description: "Kyoto tea ceremony (4 pax)", amount: "160", date: "2025-10-17" },
{ id: "e5", category: "FOOD", description: "Dotonbori street food evening", amount: "85", date: "2025-10-19" },
{ id: "e6", category: "ACTIVITY", description: "Takoyaki cooking class (4 pax)", amount: "120", date: "2025-10-21" },
{ id: "e7", category: "FOOD", description: "Okonomiyaki farewell dinner", amount: "68", date: "2025-10-23" },
{ id: "e8", category: "SHOPPING", description: "Souvenirs & gifts", amount: "245", date: "2025-10-23" },
],
packing: [
{ id: "pk1", name: "Comfortable walking shoes", category: "FOOTWEAR", quantity: 1, packed: true },
{ id: "pk2", name: "Rain jacket (light)", category: "CLOTHING", quantity: 1, packed: true },
{ id: "pk3", name: "JR Pass printout", category: "DOCUMENTS", quantity: 1, packed: true },
{ id: "pk4", name: "Portable WiFi hotspot", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk5", name: "Camera + extra battery", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk6", name: "Passport + visa", category: "DOCUMENTS", quantity: 1, packed: true },
{ id: "pk7", name: "Power adapter (Type A)", category: "ELECTRONICS", quantity: 1, packed: true },
{ id: "pk8", name: "Day backpack", category: "GEAR", quantity: 1, packed: true },
],
collaborators: [
{ name: "Alex", role: "organizer", avatar: "\u{1F9D1}\u200D\u{1F4BB}" },
{ name: "Sam", role: "photographer", avatar: "\u{1F4F8}" },
{ name: "Mia", role: "food guide", avatar: "\u{1F363}" },
{ name: "Leo", role: "translator", avatar: "\u{1F5E3}\uFE0F" },
],
},
};
return trips[id] || trips["alpine-2026"];
}
private getApiBase(): string {
@ -146,6 +333,25 @@ class FolkTripsPlanner extends HTMLElement {
} catch { this.error = "Failed to create trip"; this.render(); }
}
private getCategoryEmoji(cat: string): string {
const map: Record<string, string> = {
TRANSPORT: "\u{1F68C}", ACTIVITY: "\u{1F3AF}", MEAL: "\u{1F37D}\uFE0F",
FREE_TIME: "\u2615", FLIGHT: "\u2708\uFE0F", ACCOMMODATION: "\u{1F3E8}",
};
return map[cat] || "\u{1F4CC}";
}
private getStatusStyle(status: string): { bg: string; color: string; label: string } {
const map: Record<string, { bg: string; color: string; label: string }> = {
PLANNING: { bg: "#1e3a5f", color: "#60a5fa", label: "Planning" },
BOOKED: { bg: "#1a3b2e", color: "#34d399", label: "Booked" },
IN_PROGRESS: { bg: "#3b2e11", color: "#fbbf24", label: "In Progress" },
COMPLETED: { bg: "#1a3b1a", color: "#22c55e", label: "Completed" },
CANCELLED: { bg: "#3b1a1a", color: "#f87171", label: "Cancelled" },
};
return map[status] || map.PLANNING;
}
private render() {
this.shadow.innerHTML = `
<style>
@ -159,16 +365,26 @@ class FolkTripsPlanner extends HTMLElement {
.rapp-nav__btn { padding: 6px 14px; border-radius: 6px; border: none; background: #14b8a6; color: #fff; font-weight: 600; cursor: pointer; font-size: 13px; }
.rapp-nav__btn:hover { background: #0d9488; }
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.trip-card { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; padding: 16px; cursor: pointer; transition: border-color 0.2s; }
.trip-card:hover { border-color: #555; }
.trip-name { font-size: 15px; font-weight: 600; margin-bottom: 4px; }
.trip-meta { font-size: 12px; color: #888; }
.trip-status { display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 4px; margin-top: 6px; }
.status-PLANNING { background: #1e3a5f; color: #60a5fa; }
.status-BOOKED { background: #1a3b2e; color: #34d399; }
.status-IN_PROGRESS { background: #3b2e11; color: #fbbf24; }
.status-COMPLETED { background: #1a3b1a; color: #22c55e; }
.trip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
.trip-card {
background: #1e1e2e; border: 1px solid #333; border-radius: 12px; padding: 16px;
cursor: pointer; transition: border-color 0.2s, transform 0.15s;
}
.trip-card:hover { border-color: #555; transform: translateY(-1px); }
.trip-card-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
.trip-name { font-size: 15px; font-weight: 600; flex: 1; }
.trip-status {
display: inline-block; font-size: 10px; font-weight: 600; padding: 3px 10px;
border-radius: 12px; text-transform: uppercase; letter-spacing: 0.04em;
}
.trip-chain { font-size: 12px; color: #94a3b8; margin-bottom: 8px; display: flex; align-items: center; gap: 4px; }
.trip-chain-icon { color: #64748b; }
.trip-dates { font-size: 12px; color: #64748b; margin-bottom: 6px; }
.trip-stats { display: flex; gap: 12px; font-size: 11px; color: #64748b; margin-bottom: 8px; }
.trip-stat { display: flex; align-items: center; gap: 3px; }
.trip-budget-bar { height: 4px; background: #2a2a3a; border-radius: 2px; overflow: hidden; }
.trip-budget-fill { height: 100%; border-radius: 2px; transition: width 0.3s; }
.trip-budget-label { display: flex; justify-content: space-between; font-size: 10px; color: #64748b; margin-top: 4px; }
.tabs { display: flex; gap: 4px; margin-bottom: 16px; flex-wrap: wrap; }
.tab { padding: 6px 14px; border-radius: 6px; border: 1px solid #333; background: #16161e; color: #888; cursor: pointer; font-size: 12px; }
@ -184,6 +400,31 @@ class FolkTripsPlanner extends HTMLElement {
.budget-bar { height: 8px; background: #2a2a3a; border-radius: 4px; margin: 8px 0; overflow: hidden; }
.budget-fill { height: 100%; background: #14b8a6; border-radius: 4px; transition: width 0.3s; }
/* Collaborators */
.collab-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.collab {
display: flex; align-items: center; gap: 6px; padding: 6px 10px;
background: #16161e; border: 1px solid #222; border-radius: 8px; font-size: 12px;
}
.collab-avatar { font-size: 16px; }
.collab-name { font-weight: 500; color: #e2e8f0; }
.collab-role { font-size: 10px; color: #64748b; }
/* Itinerary with emoji categories */
.itin-row { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; margin-bottom: 4px; background: #1e1e2e; border: 1px solid #333; border-radius: 8px; }
.itin-emoji { font-size: 18px; flex-shrink: 0; width: 28px; text-align: center; }
.itin-body { flex: 1; min-width: 0; }
.itin-title { font-size: 13px; font-weight: 500; color: #e2e8f0; }
.itin-meta { font-size: 11px; color: #64748b; margin-top: 2px; }
/* Destination cards */
.dest-card { background: #1e1e2e; border: 1px solid #333; border-radius: 10px; padding: 14px; margin-bottom: 8px; display: flex; gap: 12px; align-items: center; }
.dest-pin { font-size: 24px; }
.dest-info { flex: 1; }
.dest-name { font-size: 14px; font-weight: 600; color: #e2e8f0; }
.dest-country { font-size: 12px; color: #94a3b8; }
.dest-date { font-size: 11px; color: #64748b; }
.packing-item { display: flex; align-items: center; gap: 8px; padding: 6px 0; border-bottom: 1px solid #222; }
.packing-check { width: 16px; height: 16px; cursor: pointer; }
@ -203,17 +444,30 @@ class FolkTripsPlanner extends HTMLElement {
<button class="rapp-nav__btn" id="create-trip">+ Plan a Trip</button>
</div>
${this.trips.length > 0 ? `<div class="trip-grid">
${this.trips.map(t => `
${this.trips.map(t => {
const st = this.getStatusStyle(t.status || "PLANNING");
const spent = parseFloat(t.total_spent || 0);
const budget = parseFloat(t.budget_total || 0);
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : 0;
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
return `
<div class="trip-card" data-trip="${t.id}">
<div class="trip-name">${this.esc(t.title)}</div>
<div class="trip-meta">
${t.destination_count || 0} destinations ·
${t.start_date ? new Date(t.start_date).toLocaleDateString() : "No dates"}
<div class="trip-card-header">
<span class="trip-name">${this.esc(t.title)}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
</div>
${t.budget_total ? `<div class="trip-meta">Budget: $${parseFloat(t.budget_total).toFixed(0)} · Spent: $${parseFloat(t.total_spent || 0).toFixed(0)}</div>` : ""}
<span class="trip-status status-${t.status || "PLANNING"}">${t.status || "PLANNING"}</span>
${t.destinations_chain ? `<div class="trip-chain"><span class="trip-chain-icon">\u{1F4CD}</span> ${this.esc(t.destinations_chain)}</div>` : ""}
<div class="trip-dates">${t.start_date ? new Date(t.start_date + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric" }) : ""} \u2013 ${t.end_date ? new Date(t.end_date + "T00:00:00").toLocaleDateString("default", { month: "short", day: "numeric", year: "numeric" }) : "open"}</div>
<div class="trip-stats">
<span class="trip-stat">\u{1F4CD} ${t.destination_count || 0} destinations</span>
${t.collaborator_count ? `<span class="trip-stat">\u{1F465} ${t.collaborator_count} people</span>` : ""}
</div>
${budget > 0 ? `
<div class="trip-budget-bar"><div class="trip-budget-fill" style="width:${pct}%;background:${budgetColor}"></div></div>
<div class="trip-budget-label"><span>$${spent.toFixed(0)} spent</span><span>$${budget.toFixed(0)} budget</span></div>
` : ""}
</div>
`).join("")}
`;}).join("")}
</div>` : `<div class="empty">
<p style="font-size:16px;margin-bottom:8px">No trips yet</p>
<p style="font-size:13px">Start planning your next adventure</p>
@ -224,11 +478,12 @@ class FolkTripsPlanner extends HTMLElement {
private renderDetail(): string {
if (!this.trip) return '<div class="empty">Loading...</div>';
const t = this.trip;
const st = this.getStatusStyle(t.status || "PLANNING");
return `
<div class="rapp-nav">
<button class="rapp-nav__back" data-back="list"> Trips</button>
<button class="rapp-nav__back" data-back="list">\u2190 Trips</button>
<span class="rapp-nav__title">${this.esc(t.title)}</span>
<span class="trip-status status-${t.status || "PLANNING"}">${t.status || "PLANNING"}</span>
<span class="trip-status" style="background:${st.bg};color:${st.color}">${st.label}</span>
</div>
<div class="tabs">
${(["overview", "destinations", "itinerary", "bookings", "expenses", "packing"] as const).map(tab =>
@ -245,10 +500,11 @@ class FolkTripsPlanner extends HTMLElement {
case "overview": {
const spent = (t.expenses || []).reduce((s: number, e: any) => s + parseFloat(e.amount || 0), 0);
const pct = t.budget_total ? Math.min(100, (spent / parseFloat(t.budget_total)) * 100) : 0;
const budgetColor = pct > 90 ? "#ef4444" : pct > 70 ? "#fbbf24" : "#14b8a6";
return `
<div class="section-title">Trip Details</div>
<div class="item-row"><span class="item-title">${t.description || "No description"}</span></div>
${t.start_date ? `<div class="item-row"><span class="item-title">Dates: ${new Date(t.start_date).toLocaleDateString()} ${t.end_date ? new Date(t.end_date).toLocaleDateString() : "open"}</span></div>` : ""}
${t.start_date ? `<div class="item-row"><span class="item-title">\u{1F4C5} ${new Date(t.start_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "long", day: "numeric", year: "numeric" })} \u2014 ${t.end_date ? new Date(t.end_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "long", day: "numeric" }) : "open"}</span></div>` : ""}
${t.budget_total ? `
<div class="section-title">Budget</div>
<div class="item-row" style="flex-direction:column;align-items:stretch">
@ -256,45 +512,74 @@ class FolkTripsPlanner extends HTMLElement {
<span>$${spent.toFixed(0)} spent</span>
<span>$${parseFloat(t.budget_total).toFixed(0)} budget</span>
</div>
<div class="budget-bar"><div class="budget-fill" style="width:${pct}%"></div></div>
<div class="budget-bar"><div class="budget-fill" style="width:${pct}%;background:${budgetColor}"></div></div>
</div>
` : ""}
<div class="section-title">Summary</div>
<div class="item-row"><span class="item-meta">${(t.destinations || []).length} destinations · ${(t.itinerary || []).length} activities · ${(t.bookings || []).length} bookings · ${(t.packing || []).length} packing items</span></div>
<div class="item-row"><span class="item-meta">${(t.destinations || []).length} destinations \u00B7 ${(t.itinerary || []).length} activities \u00B7 ${(t.bookings || []).length} bookings \u00B7 ${(t.packing || []).length} packing items</span></div>
${(t.collaborators || []).length > 0 ? `
<div class="section-title">Team (${t.collaborators.length})</div>
<div class="collab-row">
${t.collaborators.map((c: any) => `
<div class="collab">
<span class="collab-avatar">${c.avatar || "\u{1F464}"}</span>
<div>
<span class="collab-name">${this.esc(c.name)}</span>
<span class="collab-role"> \u00B7 ${this.esc(c.role)}</span>
</div>
</div>
`).join("")}
</div>
` : ""}
`;
}
case "destinations":
return (t.destinations || []).length > 0
? (t.destinations || []).map((d: any) => `
<div class="item-row">
<span style="font-size:20px">📍</span>
<div style="flex:1">
<div class="item-title">${this.esc(d.name)}</div>
<div class="item-meta">${d.country || ""} ${d.arrival_date ? `· ${new Date(d.arrival_date).toLocaleDateString()}` : ""}</div>
? (t.destinations || []).map((d: any, i: number) => `
<div class="dest-card">
<span class="dest-pin">${i === 0 ? "\u{1F6EB}" : i === (t.destinations.length - 1) ? "\u{1F6EC}" : "\u{1F4CD}"}</span>
<div class="dest-info">
<div class="dest-name">${this.esc(d.name)}</div>
<div class="dest-country">${this.esc(d.country || "")}</div>
${d.arrival_date ? `<div class="dest-date">Arrives ${new Date(d.arrival_date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" })}</div>` : ""}
</div>
</div>
`).join("")
: '<div class="empty">No destinations added yet</div>';
case "itinerary":
return (t.itinerary || []).length > 0
? (t.itinerary || []).map((i: any) => `
<div class="item-row">
<span class="badge">${i.category || "ACTIVITY"}</span>
<div style="flex:1">
<div class="item-title">${this.esc(i.title)}</div>
<div class="item-meta">${i.date ? new Date(i.date).toLocaleDateString() : ""} ${i.start_time || ""}</div>
case "itinerary": {
const items = (t.itinerary || []) as any[];
if (items.length === 0) return '<div class="empty">No itinerary items yet</div>';
// Group by date
const grouped: Record<string, any[]> = {};
for (const item of items) {
const d = item.date || "undated";
if (!grouped[d]) grouped[d] = [];
grouped[d].push(item);
}
let html = "";
for (const [date, dayItems] of Object.entries(grouped)) {
const label = date !== "undated" ? new Date(date + "T00:00:00").toLocaleDateString("default", { weekday: "short", month: "short", day: "numeric" }) : "Undated";
html += `<div class="section-title">${label}</div>`;
for (const item of dayItems) {
html += `<div class="itin-row">
<span class="itin-emoji">${this.getCategoryEmoji(item.category)}</span>
<div class="itin-body">
<div class="itin-title">${this.esc(item.title)}</div>
<div class="itin-meta">${item.start_time || ""} \u00B7 ${item.category || "ACTIVITY"}</div>
</div>
</div>
`).join("")
: '<div class="empty">No itinerary items yet</div>';
</div>`;
}
}
return html;
}
case "bookings":
return (t.bookings || []).length > 0
? (t.bookings || []).map((b: any) => `
<div class="item-row">
<span class="badge">${b.type || "OTHER"}</span>
<span style="font-size:16px">${this.getCategoryEmoji(b.type)}</span>
<div style="flex:1">
<div class="item-title">${this.esc(b.provider || "Booking")}</div>
<div class="item-meta">${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `· $${parseFloat(b.cost).toFixed(0)}` : ""}</div>
<div class="item-meta">${b.confirmation_number ? `#${b.confirmation_number}` : ""} ${b.cost ? `\u00B7 $${parseFloat(b.cost).toFixed(0)}` : ""}</div>
</div>
</div>
`).join("")
@ -306,7 +591,7 @@ class FolkTripsPlanner extends HTMLElement {
<span class="badge">${e.category || "OTHER"}</span>
<div style="flex:1">
<div class="item-title">${this.esc(e.description)}</div>
<div class="item-meta">${e.date ? new Date(e.date).toLocaleDateString() : ""}</div>
<div class="item-meta">${e.date ? new Date(e.date + "T00:00:00").toLocaleDateString() : ""}</div>
</div>
<span style="font-weight:600;color:#14b8a6">$${parseFloat(e.amount).toFixed(2)}</span>
</div>
@ -338,7 +623,10 @@ class FolkTripsPlanner extends HTMLElement {
});
});
this.shadow.querySelectorAll("[data-back]").forEach(el => {
el.addEventListener("click", () => { this.view = "list"; this.loadTrips(); });
el.addEventListener("click", () => {
this.view = "list";
if (this.space === "demo") { this.loadDemoData(); } else { this.loadTrips(); }
});
});
this.shadow.querySelectorAll("[data-tab]").forEach(el => {
el.addEventListener("click", () => {