Merge branch 'dev'
CI/CD / deploy (push) Failing after 2m4s Details

This commit is contained in:
Jeff Emmett 2026-04-08 13:28:15 -04:00
commit 282e6a62c6
6 changed files with 52 additions and 121 deletions

View File

@ -106,7 +106,7 @@ import { calendarSchema, calendarDocId, type CalendarDoc } from "../schemas";
import type { DocumentId } from "../../../shared/local-first/document"; import type { DocumentId } from "../../../shared/local-first/document";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { startPresenceHeartbeat } from '../../../shared/collab-presence'; import { startPresenceHeartbeat } from '../../../shared/collab-presence';
import { generateBreadcrumb, CONTINENT_CENTROIDS } from "../geo-hierarchy"; import { generateBreadcrumb } from "../geo-hierarchy";
// ── Component ── // ── Component ──
@ -262,6 +262,7 @@ class FolkCalendarView extends HTMLElement {
source_name: e.sourceName, source_color: e.sourceColor, source_name: e.sourceName, source_color: e.sourceColor,
location_name: e.locationName, location_name: e.locationName,
location_breadcrumb: e.locationBreadcrumb ?? null, location_breadcrumb: e.locationBreadcrumb ?? null,
booking_status: e.bookingStatus ?? null,
location_lat: e.locationLat, location_lng: e.locationLng, location_lat: e.locationLat, location_lng: e.locationLng,
latitude: e.locationLat ?? null, latitude: e.locationLat ?? null,
longitude: e.locationLng ?? null, longitude: e.locationLng ?? null,
@ -479,7 +480,7 @@ class FolkCalendarView extends HTMLElement {
const demoEvents: { const demoEvents: {
start: Date; durationMin: number; title: string; source: number; start: Date; durationMin: number; title: string; source: number;
desc: string; location: string | null; virtual: boolean; desc: string; location: string | null; virtual: boolean;
lat?: number; lng?: number; breadcrumb?: string; likelihood?: number; lat?: number; lng?: number; breadcrumb?: string; likelihood?: number; booked?: boolean;
}[] = [ }[] = [
// ── LAST MONTH ── // ── 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, breadcrumb: "Earth > Europe > Germany > Berlin" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin" },
@ -514,11 +515,11 @@ class FolkCalendarView extends HTMLElement {
{ 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, 10, 15, 30), durationMin: 60, title: "Deploy Prep", source: 0, desc: "Pre-release checklist and staging verification", location: null, virtual: true },
{ start: rel(0, 11, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 11, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 11, 18, 0), durationMin: 120, title: "Workshop: Intro to CRDTs (1/4)", source: 4, desc: "Community workshop series on conflict-free replicated data types", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 11, 18, 0), durationMin: 120, title: "Workshop: Intro to CRDTs (1/4)", source: 4, desc: "Community workshop series on conflict-free replicated data types", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin", booked: true },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam", booked: true },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam", booked: true },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" },
{ 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam" }, { 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, breadcrumb: "Earth > Europe > Netherlands > Amsterdam", booked: true },
{ start: rel(0, 15, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 15, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 16, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
@ -531,11 +532,11 @@ class FolkCalendarView extends HTMLElement {
{ start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 5, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 19, 9, 0), durationMin: 45, title: "Dentist", source: 5, desc: "Regular checkup, Dr. Weber", location: "Torstr. 140, Berlin", virtual: false, lat: 52.5308, lng: 13.3970, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"The Mushroom at the End of the World"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(0, 20, 19, 0), durationMin: 90, title: "Book Club", source: 4, desc: '"The Mushroom at the End of the World"', location: "Shakespeare & Sons, Berlin", virtual: false, lat: 52.4925, lng: 13.4310, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 4, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 21, 14, 0), durationMin: 60, title: "c-base Open Tuesday", source: 4, desc: "Weekly open hackerspace session", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin", booked: true },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { 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, breadcrumb: "Earth > Europe > Portugal > Lisbon", booked: true },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { 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, breadcrumb: "Earth > Europe > Portugal > Lisbon", booked: true },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" },
{ 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, breadcrumb: "Earth > Europe > Portugal > Lisbon" }, { 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, breadcrumb: "Earth > Europe > Portugal > Lisbon", booked: true },
{ start: rel(0, 25, 18, 0), durationMin: 120, title: "Workshop: CRDTs (3/4)", source: 4, desc: "Merging trees and documents", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 25, 18, 0), durationMin: 120, title: "Workshop: CRDTs (3/4)", source: 4, desc: "Merging trees and documents", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 4, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(0, 26, 18, 0), durationMin: 180, title: "Hackathon \u2014 c-base", source: 4, desc: "Local-first data sync hackathon", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(0, 27, 9, 0), durationMin: 30, title: "Team Standup", source: 0, desc: "Post-travel sync", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
@ -545,10 +546,10 @@ class FolkCalendarView extends HTMLElement {
// ── NEXT MONTH (+1) ── // ── NEXT MONTH (+1) ──
{ start: rel(1, 1, 18, 0), durationMin: 120, title: "Workshop: CRDTs (4/4)", source: 4, desc: "Final session: production patterns", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" }, { start: rel(1, 1, 18, 0), durationMin: 120, title: "Workshop: CRDTs (4/4)", source: 4, desc: "Final session: production patterns", location: "c-base, Berlin", virtual: false, lat: 52.5130, lng: 13.4200, breadcrumb: "Earth > Europe > Germany > Berlin > Mitte" },
{ start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" }, { start: rel(1, 2, 10, 0), durationMin: 120, title: "Sprint 25 Planning", source: 0, desc: "Plan next sprint", location: "Factory Berlin", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin", booked: true },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { 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, breadcrumb: "Earth > Europe > Spain > Barcelona", booked: true },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { 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, breadcrumb: "Earth > Europe > Spain > Barcelona", booked: true },
{ 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, breadcrumb: "Earth > Europe > Spain > Barcelona" }, { 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, breadcrumb: "Earth > Europe > Spain > Barcelona", booked: true },
{ start: rel(1, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(1, 6, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ start: rel(1, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" }, { start: rel(1, 8, 7, 30), durationMin: 75, title: "Morning Yoga", source: 5, desc: "Vinyasa flow", location: "Yoga Studio Kreuzberg", virtual: false, breadcrumb: "Earth > Europe > Germany > Berlin > Kreuzberg" },
{ 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, breadcrumb: "Earth > Europe > Germany > Berlin > Tiergarten" }, { 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, breadcrumb: "Earth > Europe > Germany > Berlin > Tiergarten" },
@ -668,6 +669,7 @@ class FolkCalendarView extends HTMLElement {
latitude: e.lat, latitude: e.lat,
longitude: e.lng, longitude: e.lng,
likelihood: e.likelihood ?? null, likelihood: e.likelihood ?? null,
booking_status: e.booked ? "booked" : null,
}; };
}); });
@ -2772,92 +2774,8 @@ class FolkCalendarView extends HTMLElement {
this.mapMarkerLayer.clearLayers(); this.mapMarkerLayer.clearLayers();
const located = this.getVisibleLocatedEvents(); const located = this.getVisibleLocatedEvents();
const spatialLevel = this.getEffectiveSpatialIndex();
if (spatialLevel <= 1) { for (const ev of located) {
// Planet / Continent — group by continent
this.renderAggregateMarkers(located, 1);
} else if (spatialLevel <= 3) {
// Bioregion / Country — group by country
this.renderAggregateMarkers(located, 3);
} else if (spatialLevel <= 5) {
// Region / City — group by city
this.renderAggregateMarkers(located, 5);
} else {
// Neighborhood / Address / Coordinates — individual markers
this.renderIndividualMarkers(located);
}
// Auto-fit map to visible event bounds
if (this.zoomCoupled && located.length > 0) {
if (located.length === 1) {
this.leafletMap?.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
} else {
const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
this.leafletMap?.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
}
}
}
/** Render aggregate cluster markers grouped by spatial label at the given level. */
private renderAggregateMarkers(events: any[], level: number) {
const L = (window as any).L;
if (!L) return;
// Group events by spatial label
const groups = new Map<string, any[]>();
for (const ev of events) {
const label = this.getSpatialLabel(ev.location_breadcrumb, level) || "Other";
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(ev);
}
for (const [label, evs] of groups) {
// Compute centroid — use stable continent centroids at level ≤ 1
let lat: number, lng: number;
if (level <= 1 && CONTINENT_CENTROIDS[label]) {
[lat, lng] = CONTINENT_CENTROIDS[label];
} else {
let sumLat = 0, sumLng = 0;
for (const e of evs) { sumLat += e.latitude; sumLng += e.longitude; }
lat = sumLat / evs.length;
lng = sumLng / evs.length;
}
// Determine dominant source color for border
const colorCounts: Record<string, number> = {};
for (const e of evs) {
const c = e.source_color || "#6366f1";
colorCounts[c] = (colorCounts[c] || 0) + 1;
}
const dominantColor = Object.entries(colorCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || "#6366f1";
const icon = L.divIcon({
className: "",
html: `<div class="map-cluster-label" style="border-color:${dominantColor}"><span>${this.esc(label)}</span><span class="map-cluster-count">${evs.length}</span></div>`,
iconSize: [0, 0],
iconAnchor: [0, 0],
});
const marker = L.marker([lat, lng], { icon });
// Popup listing events in this cluster (first 10)
const popupList = evs.slice(0, 10).map((e: any) =>
`<div style="font-size:11px;padding:2px 0;border-left:2px solid ${e.source_color || "#6366f1"};padding-left:6px">${this.esc(e.title)}<br><span style="color:#888;font-size:10px">${new Date(e.start_time).toLocaleDateString("default", { month: "short", day: "numeric" })}</span></div>`
).join("");
const overflow = evs.length > 10 ? `<div style="color:#888;font-size:10px;margin-top:4px">+${evs.length - 10} more</div>` : "";
marker.bindPopup(`<div style="font-size:13px;color:#1a1a2e"><div style="font-weight:600;margin-bottom:6px">${this.esc(label)} (${evs.length})</div>${popupList}${overflow}</div>`);
this.mapMarkerLayer.addLayer(marker);
}
}
/** Render individual event markers (original behavior). */
private renderIndividualMarkers(events: any[]) {
const L = (window as any).L;
if (!L) return;
for (const ev of events) {
const es = this.getEventStyles(ev); const es = this.getEventStyles(ev);
const marker = L.circleMarker([ev.latitude, ev.longitude], { const marker = L.circleMarker([ev.latitude, ev.longitude], {
radius: 6, color: ev.source_color || "#6366f1", radius: 6, color: ev.source_color || "#6366f1",
@ -2873,6 +2791,16 @@ class FolkCalendarView extends HTMLElement {
</div>`); </div>`);
this.mapMarkerLayer.addLayer(marker); this.mapMarkerLayer.addLayer(marker);
} }
// Auto-fit map to visible event bounds
if (this.zoomCoupled && located.length > 0) {
if (located.length === 1) {
this.leafletMap?.flyTo([located[0].latitude, located[0].longitude], this.getEffectiveLeafletZoom(), { duration: 0.8 });
} else {
const bounds = L.latLngBounds(located.map((e: any) => [e.latitude, e.longitude]));
this.leafletMap?.flyToBounds(bounds, { padding: [40, 40], maxZoom: 16, duration: 0.8 });
}
}
} }
private updateTransitLines() { private updateTransitLines() {
@ -2880,9 +2808,6 @@ class FolkCalendarView extends HTMLElement {
if (!L || !this.transitLineLayer) return; if (!L || !this.transitLineLayer) return;
this.transitLineLayer.clearLayers(); this.transitLineLayer.clearLayers();
// Skip transit lines at aggregate zoom levels (spatial < 6)
if (this.getEffectiveSpatialIndex() < 6) return;
const sorted = this.getVisibleLocatedEvents() const sorted = this.getVisibleLocatedEvents()
.sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime()); .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
@ -2892,14 +2817,16 @@ class FolkCalendarView extends HTMLElement {
const curr = sorted[i], next = sorted[i + 1]; const curr = sorted[i], next = sorted[i + 1];
if (Math.abs(curr.latitude - next.latitude) < 0.01 && Math.abs(curr.longitude - next.longitude) < 0.01) continue; if (Math.abs(curr.latitude - next.latitude) < 0.01 && Math.abs(curr.longitude - next.longitude) < 0.01) continue;
const isTravel = curr.source_name === "Travel" || next.source_name === "Travel"; // Line color based on booking status: green = booked, red = unbooked/unknown
const isBooked = next.booking_status === "booked";
const color = isBooked ? "#22c55e" : "#ef4444";
const label = isBooked ? "Booked" : "Not booked";
const line = L.polyline( const line = L.polyline(
[[curr.latitude, curr.longitude], [next.latitude, next.longitude]], [[curr.latitude, curr.longitude], [next.latitude, next.longitude]],
{ color: isTravel ? "#f97316" : "#94a3b8", weight: isTravel ? 3 : 2, { color, weight: 3, opacity: 0.7, dashArray: isBooked ? undefined : "8, 6" }
opacity: isTravel ? 0.8 : 0.4, dashArray: isTravel ? "8, 8" : "4, 8" }
); );
line.bindTooltip( line.bindTooltip(
`${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)}`, `${this.esc(curr.location_name || curr.title)} \u2192 ${this.esc(next.location_name || next.title)} <span style="color:${color};font-weight:600">[${label}]</span>`,
{ sticky: true } { sticky: true }
); );
this.transitLineLayer.addLayer(line); this.transitLineLayer.addLayer(line);
@ -3008,10 +2935,6 @@ class FolkCalendarView extends HTMLElement {
.map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: var(--rs-shadow-md); transition: all 0.15s; } .map-fab { position: absolute; bottom: 16px; right: 16px; width: 44px; height: 44px; border-radius: 50%; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface); color: var(--rs-text-primary); cursor: pointer; font-size: 20px; display: flex; align-items: center; justify-content: center; z-index: 100; box-shadow: var(--rs-shadow-md); transition: all 0.15s; }
.map-fab:hover { border-color: var(--rs-primary-hover); background: var(--rs-bg-surface-raised); transform: scale(1.1); } .map-fab:hover { border-color: var(--rs-primary-hover); background: var(--rs-bg-surface-raised); transform: scale(1.1); }
/* ── Map Cluster Labels (semantic zoom) ── */
.map-cluster-label { background: var(--rs-bg-surface, #1e1e2e); border: 2px solid var(--rs-primary, #6366f1); border-radius: 16px; padding: 4px 10px; font-size: 12px; font-weight: 600; white-space: nowrap; box-shadow: 0 2px 8px rgba(0,0,0,0.2); color: var(--rs-text-primary, #e0e0e0); display: inline-flex; align-items: center; gap: 4px; transform: translate(-50%, -50%); pointer-events: auto; cursor: pointer; }
.map-cluster-count { font-size: 10px; opacity: 0.7; }
/* ── Synodic (reused in overlay) ── */ /* ── Synodic (reused in overlay) ── */
.synodic-section { margin: 0 0 8px; } .synodic-section { margin: 0 0 8px; }
.synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; } .synodic-labels { display: flex; justify-content: space-between; font-size: 11px; color: var(--rs-text-muted); margin-bottom: 6px; }

View File

@ -70,7 +70,7 @@ export class CalLocalFirstClient {
const docId = calendarDocId(this.#space) as DocumentId; const docId = calendarDocId(this.#space) as DocumentId;
this.#sync.change<CalendarDoc>(docId, `Update event ${eventId}`, (d) => { this.#sync.change<CalendarDoc>(docId, `Update event ${eventId}`, (d) => {
if (!d.events[eventId]) { if (!d.events[eventId]) {
d.events[eventId] = { id: eventId, title: '', description: '', startTime: 0, endTime: 0, allDay: false, timezone: null, rrule: null, status: null, visibility: null, sourceId: null, sourceName: null, sourceType: null, sourceColor: null, locationId: null, locationName: null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, locationBreadcrumb: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes } as CalendarEvent; d.events[eventId] = { id: eventId, title: '', description: '', startTime: 0, endTime: 0, allDay: false, timezone: null, rrule: null, status: null, visibility: null, sourceId: null, sourceName: null, sourceType: null, sourceColor: null, locationId: null, locationName: null, coordinates: null, locationGranularity: null, locationLat: null, locationLng: null, locationBreadcrumb: null, bookingStatus: null, isVirtual: false, virtualUrl: null, virtualPlatform: null, rToolSource: null, rToolEntityId: null, attendees: [], attendeeCount: 0, metadata: null, createdAt: Date.now(), updatedAt: Date.now(), ...changes } as CalendarEvent;
} else { } else {
Object.assign(d.events[eventId], changes); Object.assign(d.events[eventId], changes);
d.events[eventId].updatedAt = Date.now(); d.events[eventId].updatedAt = Date.now();

View File

@ -88,6 +88,7 @@ function eventToRow(ev: CalendarEvent, sources: Record<string, CalendarSource>)
location_lng: ev.locationLng, location_lng: ev.locationLng,
location_granularity: ev.locationGranularity, location_granularity: ev.locationGranularity,
location_breadcrumb: ev.locationBreadcrumb, location_breadcrumb: ev.locationBreadcrumb,
booking_status: ev.bookingStatus,
is_virtual: ev.isVirtual, is_virtual: ev.isVirtual,
virtual_url: ev.virtualUrl, virtual_url: ev.virtualUrl,
virtual_platform: ev.virtualPlatform, virtual_platform: ev.virtualPlatform,
@ -256,6 +257,7 @@ function seedDemoIfEmpty(space: string) {
locationLat: e.locationLat ?? null, locationLat: e.locationLat ?? null,
locationLng: e.locationLng ?? null, locationLng: e.locationLng ?? null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: e.isVirtual || false, isVirtual: e.isVirtual || false,
virtualUrl: e.virtualUrl || null, virtualUrl: e.virtualUrl || null,
virtualPlatform: e.virtualPlatform || null, virtualPlatform: e.virtualPlatform || null,
@ -413,6 +415,7 @@ routes.post("/api/events", async (c) => {
locationLat: null, locationLat: null,
locationLng: null, locationLng: null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: is_virtual || false, isVirtual: is_virtual || false,
virtualUrl: virtual_url || null, virtualUrl: virtual_url || null,
virtualPlatform: virtual_platform || null, virtualPlatform: virtual_platform || null,
@ -544,6 +547,7 @@ routes.post("/api/import-ics", async (c) => {
locationLat: null, locationLat: null,
locationLng: null, locationLng: null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: false, isVirtual: false,
virtualUrl: null, virtualUrl: null,
virtualPlatform: null, virtualPlatform: null,

View File

@ -51,6 +51,7 @@ export interface CalendarEvent {
rToolSource: string | null; rToolSource: string | null;
rToolEntityId: string | null; rToolEntityId: string | null;
locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin" locationBreadcrumb: string | null; // "Earth > Europe > Germany > Berlin"
bookingStatus: string | null; // "booked" | "unbooked" | null (placeholder for booking pipeline)
attendees: unknown[]; attendees: unknown[];
attendeeCount: number; attendeeCount: number;
tags: string[] | null; tags: string[] | null;

View File

@ -260,6 +260,7 @@ async function executeCalendarEvent(
locationLat: null, locationLat: null,
locationLng: null, locationLng: null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: false, isVirtual: false,
virtualUrl: null, virtualUrl: null,
virtualPlatform: null, virtualPlatform: null,
@ -1087,6 +1088,7 @@ function syncReminderToCalendar(reminder: Reminder, space: string): string | nul
locationLat: null, locationLat: null,
locationLng: null, locationLng: null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: false, isVirtual: false,
virtualUrl: null, virtualUrl: null,
virtualPlatform: null, virtualPlatform: null,
@ -1698,6 +1700,7 @@ async function executeWorkflowNode(
locationLat: null, locationLat: null,
locationLng: null, locationLng: null,
locationBreadcrumb: null, locationBreadcrumb: null,
bookingStatus: null,
isVirtual: false, isVirtual: false,
virtualUrl: null, virtualUrl: null,
virtualPlatform: null, virtualPlatform: null,

View File

@ -213,17 +213,17 @@ const CONFIG = {
let smtpTransport: Transporter | null = null; let smtpTransport: Transporter | null = null;
if (CONFIG.smtp.pass) { const isInternalSmtp = CONFIG.smtp.host.includes('mailcow') || CONFIG.smtp.host.includes('postfix');
if (CONFIG.smtp.pass || isInternalSmtp) {
smtpTransport = createTransport({ smtpTransport = createTransport({
host: CONFIG.smtp.host, host: CONFIG.smtp.host,
port: CONFIG.smtp.port, port: isInternalSmtp ? 25 : CONFIG.smtp.port,
secure: CONFIG.smtp.port === 465, secure: !isInternalSmtp && CONFIG.smtp.port === 465,
auth: { ...(isInternalSmtp ? {} : {
user: CONFIG.smtp.user, auth: { user: CONFIG.smtp.user, pass: CONFIG.smtp.pass },
pass: CONFIG.smtp.pass, }),
},
tls: { tls: {
rejectUnauthorized: false, // Internal Mailcow uses self-signed cert rejectUnauthorized: false,
}, },
}); });
@ -232,11 +232,11 @@ if (CONFIG.smtp.pass) {
console.log('EncryptID: SMTP connected to', CONFIG.smtp.host); console.log('EncryptID: SMTP connected to', CONFIG.smtp.host);
}).catch((err) => { }).catch((err) => {
console.error('EncryptID: SMTP connection failed —', err.message); console.error('EncryptID: SMTP connection failed —', err.message);
console.error('EncryptID: Email recovery will not work until SMTP is configured'); console.error('EncryptID: Email delivery will not work until SMTP is configured');
smtpTransport = null; smtpTransport = null;
}); });
} else { } else {
console.warn('EncryptID: SMTP_PASS not set — email recovery disabled (tokens logged to console)'); console.warn('EncryptID: SMTP not configured — email delivery disabled');
} }
async function sendRecoveryEmail(to: string, token: string, username: string): Promise<boolean> { async function sendRecoveryEmail(to: string, token: string, username: string): Promise<boolean> {