From 58af5a304c927302ed9a17a653732db889228d80 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 28 Feb 2026 20:04:50 +0000 Subject: [PATCH] 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 --- modules/cal/components/folk-calendar-view.ts | 497 ++++++++++++++--- modules/maps/components/folk-map-viewer.ts | 424 ++++++++++++--- .../trips/components/folk-trips-planner.ts | 510 ++++++++++++++---- 3 files changed, 1159 insertions(+), 272 deletions(-) diff --git a/modules/cal/components/folk-calendar-view.ts b/modules/cal/components/folk-calendar-view.ts index 4de2e84..69f1f0d 100644 --- a/modules/cal/components/folk-calendar-view.ts +++ b/modules/cal/components/folk-calendar-view.ts @@ -1,15 +1,16 @@ /** * — 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 = {}; @@ -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 = {}; - 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 = `
Cosmolocal Print Network - 6 providers online +
+ + ${Math.round(this.zoomLevel * 100)}% + + +
+ ${this.providers.length} providers online
-
+
- + @@ -202,12 +303,12 @@ class FolkMapViewer extends HTMLElement { - + - + ${this.graticule(W, H)} - + ${this.continents(W, H)} @@ -218,8 +319,10 @@ class FolkMapViewer extends HTMLElement {
+ ${detailPanel} +
-
Print Providers
+
Print Providers \u2014 click to explore
${legendItems}
@@ -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(``); + lines.push(``); } - // Longitude lines every 30 degrees for (let lng = -150; lng <= 180; lng += 30) { const x = ((lng + 180) / 360) * W; - lines.push(``); + lines.push(``); } - // Equator and Prime Meridian slightly brighter const eq = ((90 - 0) / 180) * H; const pm = ((0 + 180) / 360) * W; - lines.push(``); - lines.push(``); + lines.push(``); + lines.push(``); 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 = `${this.esc(p.name)}${this.esc(p.city)}, ${this.esc(p.country)}${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}`; + 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 = `${this.esc(p.name)}${this.esc(p.city)}${p.lat.toFixed(2)}, ${p.lng.toFixed(2)}`; - 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 { diff --git a/modules/trips/components/folk-trips-planner.ts b/modules/trips/components/folk-trips-planner.ts index fdcf34c..44df68f 100644 --- a/modules/trips/components/folk-trips-planner.ts +++ b/modules/trips/components/folk-trips-planner.ts @@ -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 = { + "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 = { + 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 = { + 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 = `