-
-
Save shricodev/91b1544958ffdbaa107e43ff8fd30e85 to your computer and use it in GitHub Desktop.
Test 2: Claude Opus 4.5 (Patch file)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| From b4626e127e7fcfcbb0c7711f36b3e5f5cdb7b20c Mon Sep 17 00:00:00 2001 | |
| From: Shrijal Acharya <shrijal.acharya@gmail.com> | |
| Date: Fri, 6 Feb 2026 13:38:55 +0545 | |
| Subject: [PATCH] final composio added as well | |
| --- | |
| .env.example | 49 -- | |
| app/api/active-users/route.ts | 105 ++-- | |
| components/globe/GlobeViewer.tsx | 285 ++++++++-- | |
| components/providers/AnalyticsProvider.tsx | 18 +- | |
| lib/composio.ts | 597 +++++++++++---------- | |
| store/useActiveUsersStore.ts | 26 +- | |
| 6 files changed, 654 insertions(+), 426 deletions(-) | |
| delete mode 100644 .env.example | |
| diff --git a/.env.example b/.env.example | |
| deleted file mode 100644 | |
| index 36cc795..0000000 | |
| --- a/.env.example | |
| +++ /dev/null | |
| @@ -1,49 +0,0 @@ | |
| -# TerraView Environment Variables | |
| - | |
| -# Optional: Cesium Ion Access Token | |
| -# Get a free token at: https://cesium.com/ion/tokens | |
| -# This enables Cesium World Terrain and premium imagery | |
| -# The app works without this, using free OpenStreetMap tiles | |
| -NEXT_PUBLIC_CESIUM_ION_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiI0OTM4NTBkYy1iZmFjLTQ1Y2ItOWE5ZS1kYzM1MjhhNTFkNzIiLCJpZCI6Mzg2NjMzLCJpYXQiOjE3NzAxMzUyMjZ9.YQ6I5xZJ8LV4SoEIYEYUbBHXb2wqUdwlJghJjErkHaI | |
| - | |
| -# Optional: Custom base URL for Cesium assets | |
| -# Defaults to /cesium if not set | |
| -NEXT_PUBLIC_CESIUM_BASE_URL= | |
| - | |
| -# ============================================ | |
| -# PostHog Analytics (via Composio) | |
| -# ============================================ | |
| - | |
| -# PostHog Project API Key (client-side, public) | |
| -# Get from: PostHog Settings > Project > Project API Key | |
| -NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | |
| - | |
| -# PostHog Host (defaults to US cloud) | |
| -NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com | |
| - | |
| -# PostHog Personal API Key (server-side, private) | |
| -# Get from: PostHog Settings > Personal API Keys | |
| -POSTHOG_API_KEY=phx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | |
| - | |
| -# PostHog Project ID (for API queries) | |
| -# Get from: PostHog Settings > Project > Project ID | |
| -POSTHOG_PROJECT_ID=12345 | |
| - | |
| -# Same as NEXT_PUBLIC_POSTHOG_HOST for server-side use | |
| -POSTHOG_HOST=https://us.i.posthog.com | |
| - | |
| -# ============================================ | |
| -# Composio Configuration | |
| -# ============================================ | |
| - | |
| -# Composio API Key | |
| -# Get from: https://platform.composio.dev/settings | |
| -COMPOSIO_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx | |
| - | |
| -# ============================================ | |
| -# OpenAI Configuration (for Composio tool execution) | |
| -# ============================================ | |
| - | |
| -# OpenAI API Key | |
| -# Get from: https://platform.openai.com/api-keys | |
| -OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx | |
| diff --git a/app/api/active-users/route.ts b/app/api/active-users/route.ts | |
| index 7df729c..82d2ade 100644 | |
| --- a/app/api/active-users/route.ts | |
| +++ b/app/api/active-users/route.ts | |
| @@ -1,5 +1,8 @@ | |
| -import { NextResponse } from 'next/server'; | |
| -import { getPostHogComposioService, type ActiveUser } from '@/lib/composio'; | |
| +import { NextResponse } from "next/server"; | |
| +import { | |
| + getPostHogComposioService, | |
| + type ActiveUser, | |
| +} from "@/lib/composio"; | |
| // Refresh interval for fetching users (5 minutes) | |
| const CACHE_DURATION_MS = 5 * 60 * 1000; | |
| @@ -21,35 +24,44 @@ export async function GET() { | |
| }); | |
| } | |
| - // Fetch fresh data from PostHog via Composio | |
| + // Fetch fresh data from PostHog (via Composio or direct fallback) | |
| const service = getPostHogComposioService(); | |
| - const users = await service.getActiveUsers(100); | |
| - | |
| - // Update cache | |
| - cachedUsers = users; | |
| + const posthogUsers = await service.getActiveUsers(100); | |
| + | |
| + // Merge PostHog results with existing cache instead of replacing. | |
| + // Users added via POST (location updates) should persist even if | |
| + // PostHog hasn't indexed them yet. | |
| + const merged = new Map<string, ActiveUser>(); | |
| + for (const u of cachedUsers) { | |
| + merged.set(u.distinctId, u); | |
| + } | |
| + for (const u of posthogUsers) { | |
| + merged.set(u.distinctId, u); | |
| + } | |
| + cachedUsers = Array.from(merged.values()); | |
| lastFetchTime = now; | |
| return NextResponse.json({ | |
| - users, | |
| + users: cachedUsers, | |
| cached: false, | |
| lastFetched: new Date(now).toISOString(), | |
| }); | |
| } catch (error) { | |
| - console.error('Error fetching active users:', error); | |
| + console.error("Error fetching active users:", error); | |
| // Return cached data on error if available | |
| if (cachedUsers.length > 0) { | |
| return NextResponse.json({ | |
| users: cachedUsers, | |
| cached: true, | |
| - error: 'Failed to fetch fresh data, returning cached', | |
| + error: "Failed to fetch fresh data, returning cached", | |
| lastFetched: new Date(lastFetchTime).toISOString(), | |
| }); | |
| } | |
| return NextResponse.json( | |
| - { error: 'Failed to fetch active users', users: [] }, | |
| - { status: 500 } | |
| + { error: "Failed to fetch active users", users: [] }, | |
| + { status: 500 }, | |
| ); | |
| } | |
| } | |
| @@ -58,61 +70,56 @@ export async function GET() { | |
| export async function POST(request: Request) { | |
| try { | |
| const body = await request.json(); | |
| - const { distinctId, latitude, longitude, accuracy } = body; | |
| + const { distinctId, latitude, longitude, accuracy, name, email } = body; | |
| if (!distinctId || latitude === undefined || longitude === undefined) { | |
| return NextResponse.json( | |
| - { error: 'Missing required fields: distinctId, latitude, longitude' }, | |
| - { status: 400 } | |
| + { | |
| + error: | |
| + "Missing required fields: distinctId, latitude, longitude", | |
| + }, | |
| + { status: 400 }, | |
| ); | |
| } | |
| const service = getPostHogComposioService(); | |
| - // Update user location in PostHog | |
| - const success = await service.updateUserLocation( | |
| - distinctId, | |
| - latitude, | |
| - longitude, | |
| - accuracy | |
| - ); | |
| - | |
| - if (!success) { | |
| - return NextResponse.json( | |
| - { error: 'Failed to update location' }, | |
| - { status: 500 } | |
| + // Send location to PostHog (capture event + identify). | |
| + // This is best-effort; we still update the in-memory cache so the UI | |
| + // shows the user immediately even if PostHog ingestion is slow. | |
| + service | |
| + .updateUserLocation(distinctId, latitude, longitude, accuracy) | |
| + .catch((err) => | |
| + console.error("Background location update failed:", err), | |
| ); | |
| - } | |
| - // Update in-memory cache for immediate reflection | |
| - const existingUserIndex = cachedUsers.findIndex( | |
| - (u) => u.distinctId === distinctId | |
| + // Update in-memory cache for immediate UI reflection | |
| + const existingIndex = cachedUsers.findIndex( | |
| + (u) => u.distinctId === distinctId, | |
| ); | |
| - if (existingUserIndex >= 0) { | |
| - cachedUsers[existingUserIndex] = { | |
| - ...cachedUsers[existingUserIndex], | |
| - location: { latitude, longitude }, | |
| - lastSeen: new Date().toISOString(), | |
| - isOnline: true, | |
| - }; | |
| + const userEntry: ActiveUser = { | |
| + id: distinctId, | |
| + distinctId, | |
| + location: { latitude, longitude }, | |
| + lastSeen: new Date().toISOString(), | |
| + isOnline: true, | |
| + email: email || (existingIndex >= 0 ? cachedUsers[existingIndex].email : undefined), | |
| + name: name || (existingIndex >= 0 ? cachedUsers[existingIndex].name : undefined), | |
| + }; | |
| + | |
| + if (existingIndex >= 0) { | |
| + cachedUsers[existingIndex] = userEntry; | |
| } else { | |
| - // Add new user to cache | |
| - cachedUsers.push({ | |
| - id: distinctId, | |
| - distinctId, | |
| - location: { latitude, longitude }, | |
| - lastSeen: new Date().toISOString(), | |
| - isOnline: true, | |
| - }); | |
| + cachedUsers.push(userEntry); | |
| } | |
| return NextResponse.json({ success: true }); | |
| } catch (error) { | |
| - console.error('Error updating user location:', error); | |
| + console.error("Error updating user location:", error); | |
| return NextResponse.json( | |
| - { error: 'Failed to update location' }, | |
| - { status: 500 } | |
| + { error: "Failed to update location" }, | |
| + { status: 500 }, | |
| ); | |
| } | |
| } | |
| diff --git a/components/globe/GlobeViewer.tsx b/components/globe/GlobeViewer.tsx | |
| index 4767e67..d57ea46 100644 | |
| --- a/components/globe/GlobeViewer.tsx | |
| +++ b/components/globe/GlobeViewer.tsx | |
| @@ -13,11 +13,14 @@ import { | |
| Cartesian2, | |
| Entity, | |
| Ion, | |
| + VerticalOrigin, | |
| } from 'cesium'; | |
| +import { X, Mail, MapPin, Clock } from 'lucide-react'; | |
| import { CameraControls } from '@/components/globe/CameraControls'; | |
| import { CoordinateDisplay } from '@/components/globe/CoordinateDisplay'; | |
| import { MeasurementOverlay } from '@/components/globe/MeasurementOverlay'; | |
| +import { formatCoordinate } from '@/lib/formatting'; | |
| import { | |
| useGlobeStore, | |
| useLayersStore, | |
| @@ -44,6 +47,80 @@ if (typeof window !== 'undefined') { | |
| } | |
| } | |
| +// ---------- Pin marker rendering ---------- | |
| + | |
| +const markerImageCache = new Map<string, HTMLCanvasElement>(); | |
| + | |
| +function createPinMarker( | |
| + initial: string, | |
| + color: string, | |
| + isCurrentUser: boolean, | |
| +): HTMLCanvasElement { | |
| + const key = `${initial}-${color}-${isCurrentUser}`; | |
| + const cached = markerImageCache.get(key); | |
| + if (cached) return cached; | |
| + | |
| + const w = isCurrentUser ? 46 : 38; | |
| + const h = isCurrentUser ? 58 : 48; | |
| + const canvas = document.createElement('canvas'); | |
| + canvas.width = w; | |
| + canvas.height = h; | |
| + const ctx = canvas.getContext('2d')!; | |
| + | |
| + const cx = w / 2; | |
| + const r = w / 2 - 4; | |
| + const cy = r + 4; | |
| + const tipY = h - 2; | |
| + const gap = 0.4; | |
| + | |
| + // Drop shadow | |
| + ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; | |
| + ctx.shadowBlur = 5; | |
| + ctx.shadowOffsetY = 2; | |
| + | |
| + // Pin shape: arc (most of the circle) → line to tip → close back | |
| + ctx.beginPath(); | |
| + ctx.arc(cx, cy, r, Math.PI / 2 + gap, Math.PI / 2 - gap, false); | |
| + ctx.lineTo(cx, tipY); | |
| + ctx.closePath(); | |
| + ctx.fillStyle = color; | |
| + ctx.fill(); | |
| + | |
| + // White border | |
| + ctx.shadowColor = 'transparent'; | |
| + ctx.strokeStyle = 'rgba(255, 255, 255, 0.92)'; | |
| + ctx.lineWidth = 2; | |
| + ctx.stroke(); | |
| + | |
| + // Subtle inner highlight | |
| + ctx.beginPath(); | |
| + ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2); | |
| + ctx.fillStyle = 'rgba(255, 255, 255, 0.12)'; | |
| + ctx.fill(); | |
| + | |
| + // Initial letter | |
| + ctx.fillStyle = 'white'; | |
| + ctx.font = `600 ${Math.round(r * 0.88)}px system-ui, -apple-system, sans-serif`; | |
| + ctx.textAlign = 'center'; | |
| + ctx.textBaseline = 'middle'; | |
| + ctx.fillText(initial.toUpperCase(), cx, cy + 1); | |
| + | |
| + markerImageCache.set(key, canvas); | |
| + return canvas; | |
| +} | |
| + | |
| +function getUserMarkerColor( | |
| + userId: string, | |
| + distinctId: string, | |
| + isOnline: boolean, | |
| + currentUserId: string | null, | |
| +): string { | |
| + const isCurrent = userId === currentUserId || distinctId === currentUserId; | |
| + if (isCurrent) return '#22c55e'; | |
| + if (isOnline) return '#a855f7'; | |
| + return '#6b7280'; | |
| +} | |
| + | |
| export function GlobeViewer() { | |
| const containerRef = useRef<HTMLDivElement>(null); | |
| const viewerRef = useRef<Viewer | null>(null); | |
| @@ -63,6 +140,18 @@ export function GlobeViewer() { | |
| const { places } = usePlacesStore(); | |
| const { users: activeUsers, currentUserId } = useActiveUsersStore(); | |
| + // User marker popup | |
| + const [popup, setPopup] = useState<{ | |
| + userId: string; | |
| + screenX: number; | |
| + screenY: number; | |
| + } | null>(null); | |
| + const popupRef = useRef<typeof popup>(null); | |
| + popupRef.current = popup; | |
| + | |
| + const popupUser = popup | |
| + ? activeUsers.find((u) => u.id === popup.userId) ?? null | |
| + : null; | |
| const flyTo = useCallback( | |
| (lat: number, lon: number, height = 50000, duration = CAMERA_SETTINGS.flyToDuration) => { | |
| @@ -235,8 +324,25 @@ export function GlobeViewer() { | |
| } | |
| }, ScreenSpaceEventType.MOUSE_MOVE); | |
| - // Click handler | |
| + // Click handler (with user-marker entity picking) | |
| handler.setInputAction((click: { position: Cartesian2 }) => { | |
| + // Check if a user marker entity was clicked | |
| + const picked = viewer.scene.pick(click.position); | |
| + if (picked?.id instanceof Entity) { | |
| + const entityId = picked.id.id; | |
| + if (entityId && entityId.startsWith('active-user-')) { | |
| + const userId = entityId.replace('active-user-', ''); | |
| + setPopup({ userId, screenX: click.position.x, screenY: click.position.y }); | |
| + return; | |
| + } | |
| + } | |
| + | |
| + // Close popup if one was open | |
| + if (popupRef.current) { | |
| + setPopup(null); | |
| + return; | |
| + } | |
| + | |
| const cartesian = viewer.camera.pickEllipsoid( | |
| click.position, | |
| viewer.scene.globe.ellipsoid | |
| @@ -395,59 +501,50 @@ export function GlobeViewer() { | |
| }); | |
| toRemove.forEach((entity) => viewer.entities.remove(entity)); | |
| - // Add active user markers | |
| + // Add active user pin markers | |
| activeUsers.forEach((user) => { | |
| - // Determine if this is the current user | |
| - const isCurrentUser = user.id === currentUserId || user.distinctId === currentUserId; | |
| + // Skip users without a real location (0,0 = not yet geolocated) | |
| + if (user.location.latitude === 0 && user.location.longitude === 0) return; | |
| - // Use different colors for current user vs other users | |
| - // Current user: green, Online users: purple, Offline users: gray | |
| - let markerColor: string; | |
| - if (isCurrentUser) { | |
| - markerColor = '#22c55e'; // Green for current user | |
| - } else if (user.isOnline) { | |
| - markerColor = '#a855f7'; // Purple for online users | |
| - } else { | |
| - markerColor = '#6b7280'; // Gray for offline users | |
| - } | |
| - | |
| - // Create user label | |
| + const isCurrentUser = user.id === currentUserId || user.distinctId === currentUserId; | |
| + const color = getUserMarkerColor(user.id, user.distinctId, user.isOnline, currentUserId); | |
| const displayName = user.name || user.email?.split('@')[0] || 'Anonymous'; | |
| - const statusText = isCurrentUser ? 'You' : user.isOnline ? 'Online' : 'Offline'; | |
| + const initial = displayName.charAt(0); | |
| viewer.entities.add({ | |
| id: `active-user-${user.id}`, | |
| position: Cartesian3.fromDegrees( | |
| user.location.longitude, | |
| - user.location.latitude | |
| + user.location.latitude, | |
| ), | |
| - point: { | |
| - pixelSize: isCurrentUser ? 14 : 10, | |
| - color: Color.fromCssColorString(markerColor), | |
| - outlineColor: Color.WHITE, | |
| - outlineWidth: 2, | |
| + billboard: { | |
| + image: createPinMarker(initial, color, isCurrentUser), | |
| + verticalOrigin: VerticalOrigin.BOTTOM, | |
| + width: isCurrentUser ? 46 : 38, | |
| + height: isCurrentUser ? 58 : 48, | |
| + disableDepthTestDistance: Number.POSITIVE_INFINITY, | |
| }, | |
| label: { | |
| text: displayName, | |
| - font: '12px sans-serif', | |
| + font: '13px system-ui, -apple-system, sans-serif', | |
| fillColor: Color.WHITE, | |
| outlineColor: Color.BLACK, | |
| - outlineWidth: 2, | |
| + outlineWidth: 3, | |
| style: 2, // FILL_AND_OUTLINE | |
| - verticalOrigin: 1, // BOTTOM | |
| - pixelOffset: new Cartesian2(0, -16), | |
| - show: false, // Will show on hover via billboard picking | |
| + verticalOrigin: VerticalOrigin.BOTTOM, | |
| + pixelOffset: new Cartesian2(0, -(isCurrentUser ? 62 : 52)), | |
| + disableDepthTestDistance: Number.POSITIVE_INFINITY, | |
| }, | |
| name: displayName, | |
| - description: `<div style="padding: 8px;"> | |
| - <strong>${displayName}</strong><br/> | |
| - <span style="color: ${markerColor};">${statusText}</span><br/> | |
| - <small>Last seen: ${new Date(user.lastSeen).toLocaleString()}</small> | |
| - </div>`, | |
| }); | |
| }); | |
| }, [activeUsers, currentUserId, isReady]); | |
| + // Close popup if the selected user no longer exists | |
| + useEffect(() => { | |
| + if (popup && !popupUser) setPopup(null); | |
| + }, [popup, popupUser]); | |
| + | |
| return ( | |
| <div className="fixed inset-0 pt-14"> | |
| <div | |
| @@ -462,6 +559,128 @@ export function GlobeViewer() { | |
| <CoordinateDisplay /> | |
| <CameraControls /> | |
| {isMeasuring && <MeasurementOverlay />} | |
| + | |
| + {/* User marker info popup */} | |
| + {popup && popupUser && (() => { | |
| + const isNearTop = popup.screenY < 220; | |
| + const isCurrent = | |
| + popupUser.id === currentUserId || | |
| + popupUser.distinctId === currentUserId; | |
| + const color = getUserMarkerColor( | |
| + popupUser.id, | |
| + popupUser.distinctId, | |
| + popupUser.isOnline, | |
| + currentUserId, | |
| + ); | |
| + const displayName = | |
| + popupUser.name || | |
| + popupUser.email?.split('@')[0] || | |
| + 'Anonymous'; | |
| + | |
| + return ( | |
| + <div | |
| + className="absolute z-50" | |
| + style={{ | |
| + left: popup.screenX, | |
| + top: popup.screenY, | |
| + transform: isNearTop | |
| + ? 'translate(-50%, 16px)' | |
| + : 'translate(-50%, calc(-100% - 16px))', | |
| + }} | |
| + > | |
| + {/* Caret pointing toward marker (top or bottom) */} | |
| + {isNearTop && ( | |
| + <div className="flex justify-center mb-[-1px]"> | |
| + <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-b-[8px] border-b-[rgba(15,23,42,0.88)]" /> | |
| + </div> | |
| + )} | |
| + | |
| + {/* Card */} | |
| + <div className="w-72 bg-slate-900/[0.88] backdrop-blur-xl rounded-2xl border border-white/[0.08] shadow-2xl overflow-hidden"> | |
| + {/* Header */} | |
| + <div className="flex items-center gap-3 p-4"> | |
| + <div | |
| + className="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold text-sm shrink-0 shadow-lg" | |
| + style={{ backgroundColor: color }} | |
| + > | |
| + {displayName.charAt(0).toUpperCase()} | |
| + </div> | |
| + <div className="flex-1 min-w-0"> | |
| + <p className="text-white font-medium text-sm truncate"> | |
| + {displayName} | |
| + {isCurrent && ( | |
| + <span className="text-green-400 text-xs ml-1.5 font-normal"> | |
| + (You) | |
| + </span> | |
| + )} | |
| + </p> | |
| + <div className="flex items-center gap-1.5 mt-0.5"> | |
| + <span | |
| + className={`w-1.5 h-1.5 rounded-full ${ | |
| + popupUser.isOnline | |
| + ? 'bg-green-400 shadow-[0_0_4px_rgba(74,222,128,0.6)]' | |
| + : 'bg-gray-500' | |
| + }`} | |
| + /> | |
| + <span className="text-[11px] text-white/50"> | |
| + {isCurrent | |
| + ? 'You' | |
| + : popupUser.isOnline | |
| + ? 'Online' | |
| + : 'Offline'} | |
| + </span> | |
| + </div> | |
| + </div> | |
| + <button | |
| + onClick={() => setPopup(null)} | |
| + className="text-white/30 hover:text-white/70 transition-colors p-1.5 rounded-lg hover:bg-white/[0.06]" | |
| + > | |
| + <X className="w-4 h-4" /> | |
| + </button> | |
| + </div> | |
| + | |
| + {/* Divider */} | |
| + <div className="border-t border-white/[0.06] mx-4" /> | |
| + | |
| + {/* Details */} | |
| + <div className="p-4 space-y-2.5"> | |
| + {popupUser.email && ( | |
| + <div className="flex items-center gap-2.5"> | |
| + <Mail className="w-3.5 h-3.5 text-white/30 shrink-0" /> | |
| + <span className="text-xs text-white/60 truncate"> | |
| + {popupUser.email} | |
| + </span> | |
| + </div> | |
| + )} | |
| + <div className="flex items-center gap-2.5"> | |
| + <MapPin className="w-3.5 h-3.5 text-white/30 shrink-0" /> | |
| + <span className="text-xs text-white/60"> | |
| + {formatCoordinate(popupUser.location.latitude, 'lat')} | |
| + ,{' '} | |
| + {formatCoordinate( | |
| + popupUser.location.longitude, | |
| + 'lon', | |
| + )} | |
| + </span> | |
| + </div> | |
| + <div className="flex items-center gap-2.5"> | |
| + <Clock className="w-3.5 h-3.5 text-white/30 shrink-0" /> | |
| + <span className="text-xs text-white/60"> | |
| + {new Date(popupUser.lastSeen).toLocaleString()} | |
| + </span> | |
| + </div> | |
| + </div> | |
| + </div> | |
| + | |
| + {/* Caret at bottom when popup is above marker */} | |
| + {!isNearTop && ( | |
| + <div className="flex justify-center mt-[-1px]"> | |
| + <div className="w-0 h-0 border-l-[8px] border-l-transparent border-r-[8px] border-r-transparent border-t-[8px] border-t-[rgba(15,23,42,0.88)]" /> | |
| + </div> | |
| + )} | |
| + </div> | |
| + ); | |
| + })()} | |
| </> | |
| )} | |
| </div> | |
| diff --git a/components/providers/AnalyticsProvider.tsx b/components/providers/AnalyticsProvider.tsx | |
| index 151b6a4..eb666dc 100644 | |
| --- a/components/providers/AnalyticsProvider.tsx | |
| +++ b/components/providers/AnalyticsProvider.tsx | |
| @@ -72,6 +72,8 @@ export function AnalyticsProvider({ children }: AnalyticsProviderProps) { | |
| latitude: location.coordinates.latitude, | |
| longitude: location.coordinates.longitude, | |
| accuracy: location.accuracy, | |
| + name: user?.name, | |
| + email: user?.email, | |
| }), | |
| }); | |
| @@ -101,9 +103,23 @@ export function AnalyticsProvider({ children }: AnalyticsProviderProps) { | |
| identifyUser(user); | |
| setCurrentUserId(user.id); | |
| - // Get initial location | |
| + // Add user to local store immediately so they appear on the globe | |
| + // even before geolocation resolves. Location will be updated once available. | |
| + addUser({ | |
| + id: user.id, | |
| + distinctId: user.id, | |
| + email: user.email, | |
| + name: user.name, | |
| + location: { latitude: 0, longitude: 0 }, | |
| + lastSeen: new Date().toISOString(), | |
| + isOnline: true, | |
| + }); | |
| + | |
| + // Get initial location and update the user entry | |
| getUserLocation().then((result) => { | |
| if (result.success) { | |
| + // Force the initial update through (bypass rate limiter) | |
| + lastLocationUpdateRef.current = 0; | |
| handleLocationUpdate(result); | |
| } | |
| }); | |
| diff --git a/lib/composio.ts b/lib/composio.ts | |
| index 80dc59e..9357b40 100644 | |
| --- a/lib/composio.ts | |
| +++ b/lib/composio.ts | |
| @@ -1,25 +1,33 @@ | |
| /** | |
| - * Composio Integration for PostHog Analytics | |
| + * Composio + PostHog Integration for TerraView | |
| * | |
| - * This module provides server-side integration with PostHog through Composio. | |
| - * Composio is used to manage the PostHog connection and execute analytics operations. | |
| + * Architecture: | |
| + * - Event capture & user identification → Direct PostHog Capture API | |
| + * (PostHog's capture endpoint is purpose-built for ingestion; Composio's | |
| + * PostHog toolkit wraps the management/query REST API, not the capture API.) | |
| * | |
| - * The integration supports: | |
| - * - Capturing events with user context | |
| - * - Updating user properties | |
| - * - Querying user data for visualization | |
| + * - Querying persons, events, dashboards → Composio PostHog toolkit | |
| + * (Composio manages the PostHog connected account and provides typed tool | |
| + * access to PostHog's REST API for data retrieval.) | |
| + * | |
| + * Key concept: The `COMPOSIO_USER_ID` env var identifies the entity in Composio | |
| + * that owns the connected PostHog account. This is NOT the same as the app user | |
| + * or the PostHog distinct_id. It's the Composio entity (often "default" or an | |
| + * email) that was used when the PostHog connection was set up on the Composio | |
| + * dashboard. | |
| */ | |
| -import { Composio, OpenAIProvider } from '@composio/core'; | |
| -import OpenAI from 'openai'; | |
| -import type { Coordinates } from '@/types'; | |
| +import { Composio, OpenAIProvider } from "@composio/core"; | |
| +import OpenAI from "openai"; | |
| +import type { Coordinates } from "@/types"; | |
| // Environment configuration | |
| -const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || ''; | |
| -const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''; | |
| -const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || ''; | |
| -const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID || ''; | |
| -const POSTHOG_HOST = process.env.POSTHOG_HOST || 'https://us.i.posthog.com'; | |
| +const COMPOSIO_API_KEY = process.env.COMPOSIO_API_KEY || ""; | |
| +const COMPOSIO_USER_ID = process.env.COMPOSIO_USER_ID || "default"; | |
| +const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ""; | |
| +const POSTHOG_API_KEY = process.env.POSTHOG_API_KEY || ""; | |
| +const POSTHOG_PROJECT_ID = process.env.POSTHOG_PROJECT_ID || ""; | |
| +const POSTHOG_HOST = process.env.POSTHOG_HOST || "https://us.i.posthog.com"; | |
| // Active user data structure | |
| export interface ActiveUser { | |
| @@ -35,15 +43,7 @@ export interface ActiveUser { | |
| // PostHog API response types | |
| interface PostHogPerson { | |
| distinct_ids: string[]; | |
| - properties: { | |
| - email?: string; | |
| - name?: string; | |
| - last_known_latitude?: number; | |
| - last_known_longitude?: number; | |
| - location_updated_at?: string; | |
| - $geoip_latitude?: number; | |
| - $geoip_longitude?: number; | |
| - }; | |
| + properties: Record<string, unknown>; | |
| } | |
| interface PostHogPersonsResponse { | |
| @@ -51,15 +51,16 @@ interface PostHogPersonsResponse { | |
| next?: string; | |
| } | |
| +// ---------- Composio Client for PostHog Queries ---------- | |
| + | |
| /** | |
| - * ComposioPostHogClient manages the connection to Composio and provides | |
| - * methods to interact with PostHog through Composio's tool system. | |
| + * Wraps the Composio SDK to query PostHog data via the management API. | |
| + * All calls use COMPOSIO_USER_ID as the entity that owns the connected account. | |
| */ | |
| export class ComposioPostHogClient { | |
| private composio: Composio | null = null; | |
| private openai: OpenAI | null = null; | |
| - private provider: OpenAIProvider | null = null; | |
| - private isInitialized = false; | |
| + private _isInitialized = false; | |
| constructor() { | |
| this.initializeClients(); | |
| @@ -67,392 +68,404 @@ export class ComposioPostHogClient { | |
| private initializeClients() { | |
| if (!COMPOSIO_API_KEY) { | |
| - console.warn('Composio API key not configured. Composio features will be disabled.'); | |
| + console.warn( | |
| + "Composio API key not configured. Composio features disabled.", | |
| + ); | |
| return; | |
| } | |
| - | |
| if (!OPENAI_API_KEY) { | |
| - console.warn('OpenAI API key not configured. AI-powered features will be disabled.'); | |
| + console.warn( | |
| + "OpenAI API key not configured. Composio query features disabled.", | |
| + ); | |
| return; | |
| } | |
| try { | |
| - // Initialize OpenAI client | |
| - this.openai = new OpenAI({ | |
| - apiKey: OPENAI_API_KEY, | |
| - }); | |
| + this.openai = new OpenAI({ apiKey: OPENAI_API_KEY }); | |
| - // Initialize Composio with OpenAI provider from core | |
| - this.provider = new OpenAIProvider(); | |
| + const provider = new OpenAIProvider(); | |
| this.composio = new Composio({ | |
| apiKey: COMPOSIO_API_KEY, | |
| - provider: this.provider, | |
| + provider, | |
| }); | |
| - | |
| - this.isInitialized = true; | |
| + this._isInitialized = true; | |
| } catch (error) { | |
| - console.error('Failed to initialize Composio client:', error); | |
| + console.error("Failed to initialize Composio client:", error); | |
| } | |
| } | |
| + get isInitialized(): boolean { | |
| + return this._isInitialized; | |
| + } | |
| + | |
| /** | |
| - * Get available PostHog tools from Composio | |
| + * Get PostHog tools from Composio for the connected entity. | |
| */ | |
| - async getPostHogTools(userId: string) { | |
| - if (!this.composio || !this.isInitialized) { | |
| - return []; | |
| - } | |
| - | |
| + async getPostHogTools() { | |
| + if (!this.composio) return []; | |
| try { | |
| - const tools = await this.composio.tools.get(userId, { | |
| - toolkits: ['posthog'], | |
| + return await this.composio.tools.get(COMPOSIO_USER_ID, { | |
| + toolkits: ["POSTHOG"], | |
| }); | |
| - return tools; | |
| } catch (error) { | |
| - console.error('Failed to get PostHog tools:', error); | |
| + console.error("Failed to get PostHog tools:", error); | |
| return []; | |
| } | |
| } | |
| /** | |
| - * Execute a PostHog action using Composio and OpenAI | |
| + * Execute a PostHog management action via Composio. | |
| + * Uses OpenAI to decide which tool(s) to call, then executes them through | |
| + * Composio which holds the authenticated PostHog connection. | |
| */ | |
| - async executePostHogAction( | |
| - userId: string, | |
| + async executeAction( | |
| action: string, | |
| - parameters: Record<string, unknown> | |
| + parameters: Record<string, unknown>, | |
| ): Promise<unknown> { | |
| - if (!this.composio || !this.openai || !this.provider || !this.isInitialized) { | |
| - console.warn('Composio not initialized, skipping action:', action); | |
| + if (!this.composio || !this.openai || !this._isInitialized) { | |
| return null; | |
| } | |
| try { | |
| - // Get PostHog tools | |
| - const tools = await this.getPostHogTools(userId); | |
| + const tools = await this.getPostHogTools(); | |
| + if (tools.length === 0) return null; | |
| - if (tools.length === 0) { | |
| - console.warn('No PostHog tools available'); | |
| - return null; | |
| - } | |
| - | |
| - // Create a prompt for the action | |
| const prompt = `Execute the PostHog action "${action}" with these parameters: ${JSON.stringify(parameters)}`; | |
| - // Use OpenAI Chat Completions API with Composio tools | |
| const response = await this.openai.chat.completions.create({ | |
| - model: 'gpt-4o-mini', | |
| + model: "gpt-4o-mini", | |
| messages: [ | |
| { | |
| - role: 'system', | |
| - content: 'You are an analytics assistant. Execute the requested PostHog action using the available tools.', | |
| - }, | |
| - { | |
| - role: 'user', | |
| - content: prompt, | |
| + role: "system", | |
| + content: | |
| + "You are an analytics assistant. Execute the requested PostHog action using the available tools. Always include the project_id parameter when required.", | |
| }, | |
| + { role: "user", content: prompt }, | |
| ], | |
| tools: tools as unknown as OpenAI.ChatCompletionTool[], | |
| - tool_choice: 'auto', | |
| + tool_choice: "auto", | |
| }); | |
| - // Handle tool calls if any | |
| const toolCalls = response.choices[0]?.message?.tool_calls; | |
| - if (toolCalls && toolCalls.length > 0) { | |
| - // Execute each tool call using the composio SDK | |
| - const results = await Promise.all( | |
| - toolCalls.map(async (toolCall) => { | |
| - try { | |
| - // Use composio to execute the tool directly | |
| - const result = await this.composio!.tools.execute(toolCall.function.name, { | |
| - userId, | |
| - arguments: JSON.parse(toolCall.function.arguments), | |
| - dangerouslySkipVersionCheck: true, // For simplicity in this demo | |
| - }); | |
| - return result; | |
| - } catch (err) { | |
| - console.error('Tool execution error:', err); | |
| - return null; | |
| - } | |
| - }) | |
| - ); | |
| - return results; | |
| + if (!toolCalls || toolCalls.length === 0) { | |
| + return response.choices[0]?.message?.content; | |
| } | |
| - return response.choices[0]?.message?.content; | |
| + // Execute each tool call via Composio using the correct entity | |
| + const results = await Promise.all( | |
| + toolCalls.map(async (tc) => { | |
| + try { | |
| + return await this.composio!.tools.execute(tc.function.name, { | |
| + userId: COMPOSIO_USER_ID, | |
| + arguments: JSON.parse(tc.function.arguments), | |
| + dangerouslySkipVersionCheck: true, | |
| + }); | |
| + } catch (err) { | |
| + console.error(`Tool execution error [${tc.function.name}]:`, err); | |
| + return null; | |
| + } | |
| + }), | |
| + ); | |
| + return results; | |
| } catch (error) { | |
| - console.error('Failed to execute PostHog action:', error); | |
| + console.error("Failed to execute Composio action:", error); | |
| return null; | |
| } | |
| } | |
| +} | |
| - /** | |
| - * Check if Composio is properly initialized | |
| - */ | |
| - isReady(): boolean { | |
| - return this.isInitialized; | |
| +// ---------- Direct PostHog Capture API ---------- | |
| + | |
| +/** | |
| + * Capture an event directly to PostHog's ingestion endpoint. | |
| + * This does NOT go through Composio because PostHog's capture API is separate | |
| + * from the management REST API that Composio wraps. | |
| + */ | |
| +async function posthogCapture( | |
| + distinctId: string, | |
| + event: string, | |
| + properties?: Record<string, unknown>, | |
| +): Promise<boolean> { | |
| + if (!POSTHOG_API_KEY) { | |
| + console.warn("PostHog API key not configured"); | |
| + return false; | |
| + } | |
| + | |
| + try { | |
| + const res = await fetch(`${POSTHOG_HOST}/capture/`, { | |
| + method: "POST", | |
| + headers: { "Content-Type": "application/json" }, | |
| + body: JSON.stringify({ | |
| + api_key: POSTHOG_API_KEY, | |
| + distinct_id: distinctId, | |
| + event, | |
| + properties: { | |
| + ...properties, | |
| + $lib: "terraview-server", | |
| + timestamp: new Date().toISOString(), | |
| + }, | |
| + }), | |
| + }); | |
| + return res.ok; | |
| + } catch (error) { | |
| + console.error("PostHog capture failed:", error); | |
| + return false; | |
| } | |
| } | |
| /** | |
| - * PostHogComposioService provides high-level methods for PostHog operations | |
| - * using the Composio client for tool management. | |
| + * Identify / set person properties directly via PostHog's capture endpoint. | |
| + */ | |
| +async function posthogIdentify( | |
| + distinctId: string, | |
| + properties: Record<string, unknown>, | |
| +): Promise<boolean> { | |
| + if (!POSTHOG_API_KEY) return false; | |
| + | |
| + try { | |
| + const res = await fetch(`${POSTHOG_HOST}/capture/`, { | |
| + method: "POST", | |
| + headers: { "Content-Type": "application/json" }, | |
| + body: JSON.stringify({ | |
| + api_key: POSTHOG_API_KEY, | |
| + distinct_id: distinctId, | |
| + event: "$identify", | |
| + properties: { $lib: "terraview-server" }, | |
| + $set: properties, | |
| + }), | |
| + }); | |
| + return res.ok; | |
| + } catch (error) { | |
| + console.error("PostHog identify failed:", error); | |
| + return false; | |
| + } | |
| +} | |
| + | |
| +// ---------- High-Level Service ---------- | |
| + | |
| +/** | |
| + * PostHogComposioService combines: | |
| + * - Direct PostHog capture API for event ingestion | |
| + * - Composio for querying/managing PostHog data | |
| */ | |
| export class PostHogComposioService { | |
| private composioClient: ComposioPostHogClient; | |
| - private posthogApiKey: string; | |
| - private posthogHost: string; | |
| - private projectId: string; | |
| constructor() { | |
| this.composioClient = new ComposioPostHogClient(); | |
| - this.posthogApiKey = POSTHOG_API_KEY; | |
| - this.posthogHost = POSTHOG_HOST; | |
| - this.projectId = POSTHOG_PROJECT_ID; | |
| } | |
| - /** | |
| - * Initialize the service for a specific user | |
| - */ | |
| - async initialize(userId: string): Promise<void> { | |
| - // Composio initializes lazily, nothing to do here | |
| - } | |
| - | |
| - /** | |
| - * Capture an event via Composio/PostHog | |
| - */ | |
| + /** Capture an event for a user. */ | |
| async captureEvent( | |
| distinctId: string, | |
| event: string, | |
| - properties?: Record<string, unknown> | |
| + properties?: Record<string, unknown>, | |
| ): Promise<boolean> { | |
| - // First, try using Composio if available | |
| - if (this.composioClient.isReady()) { | |
| - const result = await this.composioClient.executePostHogAction( | |
| - distinctId, | |
| - 'capture_event', | |
| - { | |
| - distinct_id: distinctId, | |
| - event, | |
| - properties: { | |
| - ...properties, | |
| - $lib: 'composio-terraview', | |
| - timestamp: new Date().toISOString(), | |
| - }, | |
| - } | |
| - ); | |
| - | |
| - if (result !== null) { | |
| - return true; | |
| - } | |
| - } | |
| + return posthogCapture(distinctId, event, properties); | |
| + } | |
| - // Fallback to direct PostHog API | |
| - return this.captureEventDirect(distinctId, event, properties); | |
| + /** Set person properties for a user. */ | |
| + async setUserProperties( | |
| + distinctId: string, | |
| + properties: Record<string, unknown>, | |
| + ): Promise<boolean> { | |
| + return posthogIdentify(distinctId, properties); | |
| } | |
| - /** | |
| - * Direct PostHog API call for event capture (fallback) | |
| - */ | |
| - private async captureEventDirect( | |
| + /** Capture a location_update event and set location as person properties. */ | |
| + async updateUserLocation( | |
| distinctId: string, | |
| - event: string, | |
| - properties?: Record<string, unknown> | |
| + latitude: number, | |
| + longitude: number, | |
| + accuracy?: number, | |
| ): Promise<boolean> { | |
| - if (!this.posthogApiKey) { | |
| - console.warn('PostHog API key not configured'); | |
| - return false; | |
| + const props: Record<string, unknown> = { | |
| + last_known_latitude: latitude, | |
| + last_known_longitude: longitude, | |
| + location_updated_at: new Date().toISOString(), | |
| + }; | |
| + if (accuracy !== undefined) { | |
| + props.location_accuracy_meters = accuracy; | |
| } | |
| - try { | |
| - const response = await fetch(`${this.posthogHost}/capture/`, { | |
| - method: 'POST', | |
| - headers: { | |
| - 'Content-Type': 'application/json', | |
| - }, | |
| - body: JSON.stringify({ | |
| - api_key: this.posthogApiKey, | |
| - distinct_id: distinctId, | |
| - event, | |
| - properties: { | |
| - ...properties, | |
| - $lib: 'composio-terraview', | |
| - timestamp: new Date().toISOString(), | |
| - }, | |
| - }), | |
| - }); | |
| - | |
| - return response.ok; | |
| - } catch (error) { | |
| - console.error('Failed to capture event:', error); | |
| - return false; | |
| - } | |
| + // Fire both in parallel | |
| + const [captureOk, identifyOk] = await Promise.all([ | |
| + posthogCapture(distinctId, "location_update", { | |
| + latitude, | |
| + longitude, | |
| + accuracy, | |
| + source: "browser_geolocation", | |
| + }), | |
| + posthogIdentify(distinctId, props), | |
| + ]); | |
| + | |
| + return captureOk || identifyOk; | |
| } | |
| /** | |
| - * Update user properties via Composio/PostHog | |
| + * Fetch active users with location data. | |
| + * Strategy: | |
| + * 1. Try Composio (queries PostHog management API via connected account) | |
| + * 2. Fallback to direct PostHog REST API with the personal API key | |
| */ | |
| - async setUserProperties( | |
| - distinctId: string, | |
| - properties: Record<string, unknown> | |
| - ): Promise<boolean> { | |
| - // Try Composio if available | |
| - if (this.composioClient.isReady()) { | |
| - const result = await this.composioClient.executePostHogAction( | |
| - distinctId, | |
| - 'identify_user', | |
| - { | |
| - distinct_id: distinctId, | |
| - properties, | |
| - } | |
| - ); | |
| + async getActiveUsers(limit = 100): Promise<ActiveUser[]> { | |
| + // Attempt via Composio first | |
| + if (this.composioClient.isInitialized && POSTHOG_PROJECT_ID) { | |
| + try { | |
| + const result = await this.composioClient.executeAction( | |
| + "list_persons_with_location", | |
| + { | |
| + project_id: POSTHOG_PROJECT_ID, | |
| + properties: JSON.stringify([ | |
| + { | |
| + key: "last_known_latitude", | |
| + type: "person", | |
| + operator: "is_set", | |
| + }, | |
| + ]), | |
| + limit, | |
| + }, | |
| + ); | |
| - if (result !== null) { | |
| - return true; | |
| + // If Composio returned usable data, parse it | |
| + if (result && Array.isArray(result) && result.length > 0) { | |
| + const parsed = this.parsePersonsResult(result); | |
| + if (parsed.length > 0) return parsed; | |
| + } | |
| + } catch (error) { | |
| + console.error( | |
| + "Composio active users query failed, using fallback:", | |
| + error, | |
| + ); | |
| } | |
| } | |
| - // Fallback to direct API | |
| - return this.setUserPropertiesDirect(distinctId, properties); | |
| + // Fallback: direct PostHog REST API | |
| + return this.getActiveUsersDirect(limit); | |
| } | |
| - /** | |
| - * Direct PostHog API call for setting user properties (fallback) | |
| - */ | |
| - private async setUserPropertiesDirect( | |
| - distinctId: string, | |
| - properties: Record<string, unknown> | |
| - ): Promise<boolean> { | |
| - if (!this.posthogApiKey) { | |
| - return false; | |
| - } | |
| + /** Parse Composio tool execution results into ActiveUser[]. */ | |
| + private parsePersonsResult(results: unknown[]): ActiveUser[] { | |
| + const users: ActiveUser[] = []; | |
| - try { | |
| - const response = await fetch(`${this.posthogHost}/capture/`, { | |
| - method: 'POST', | |
| - headers: { | |
| - 'Content-Type': 'application/json', | |
| - }, | |
| - body: JSON.stringify({ | |
| - api_key: this.posthogApiKey, | |
| - distinct_id: distinctId, | |
| - event: '$identify', | |
| - properties, | |
| - $set: properties, | |
| - }), | |
| - }); | |
| + for (const r of results) { | |
| + if (!r || typeof r !== "object") continue; | |
| - return response.ok; | |
| - } catch (error) { | |
| - console.error('Failed to set user properties:', error); | |
| - return false; | |
| - } | |
| - } | |
| + // The result may be nested; try to find a results array | |
| + const data = r as Record<string, unknown>; | |
| + let persons: PostHogPerson[] = []; | |
| - /** | |
| - * Update user location via Composio/PostHog | |
| - */ | |
| - async updateUserLocation( | |
| - distinctId: string, | |
| - latitude: number, | |
| - longitude: number, | |
| - accuracy?: number | |
| - ): Promise<boolean> { | |
| - const locationProperties: Record<string, unknown> = { | |
| - last_known_latitude: latitude, | |
| - last_known_longitude: longitude, | |
| - location_updated_at: new Date().toISOString(), | |
| - }; | |
| + if (Array.isArray(data.results)) { | |
| + persons = data.results as PostHogPerson[]; | |
| + } else if (data.data && typeof data.data === "object") { | |
| + const inner = data.data as Record<string, unknown>; | |
| + if (Array.isArray(inner.results)) { | |
| + persons = inner.results as PostHogPerson[]; | |
| + } | |
| + } | |
| - if (accuracy !== undefined) { | |
| - locationProperties.location_accuracy_meters = accuracy; | |
| + for (const person of persons) { | |
| + const props = person.properties || {}; | |
| + const lat = | |
| + (props.last_known_latitude as number) ?? | |
| + (props.$geoip_latitude as number); | |
| + const lon = | |
| + (props.last_known_longitude as number) ?? | |
| + (props.$geoip_longitude as number); | |
| + | |
| + if (lat === undefined || lon === undefined) continue; | |
| + | |
| + const lastSeen = | |
| + (props.location_updated_at as string) || new Date().toISOString(); | |
| + const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); | |
| + | |
| + users.push({ | |
| + id: person.distinct_ids?.[0] || "unknown", | |
| + distinctId: person.distinct_ids?.[0] || "unknown", | |
| + email: props.email as string | undefined, | |
| + name: props.name as string | undefined, | |
| + location: { latitude: lat, longitude: lon }, | |
| + lastSeen, | |
| + isOnline: lastSeen > hourAgo, | |
| + }); | |
| + } | |
| } | |
| - // Capture location event | |
| - await this.captureEvent(distinctId, 'location_update', { | |
| - latitude, | |
| - longitude, | |
| - accuracy, | |
| - source: 'browser_geolocation', | |
| - }); | |
| - | |
| - // Update user properties | |
| - return this.setUserProperties(distinctId, locationProperties); | |
| + return users; | |
| } | |
| - /** | |
| - * Fetch active users with their locations from PostHog | |
| - * This uses the PostHog API to query persons with recent location data | |
| - */ | |
| - async getActiveUsers(limit = 100): Promise<ActiveUser[]> { | |
| - if (!this.posthogApiKey || !this.projectId) { | |
| - console.warn('PostHog API key or project ID not configured'); | |
| + /** Direct PostHog REST API fallback for fetching persons. */ | |
| + private async getActiveUsersDirect(limit: number): Promise<ActiveUser[]> { | |
| + if (!POSTHOG_API_KEY || !POSTHOG_PROJECT_ID) { | |
| + console.warn("PostHog API key or project ID not configured"); | |
| return []; | |
| } | |
| try { | |
| - // Query persons with location data from PostHog | |
| - const response = await fetch( | |
| - `${this.posthogHost}/api/projects/${this.projectId}/persons/?properties=[{"key":"last_known_latitude","type":"person","operator":"is_set"}]&limit=${limit}`, | |
| - { | |
| - headers: { | |
| - Authorization: `Bearer ${this.posthogApiKey}`, | |
| - }, | |
| - } | |
| + const url = new URL( | |
| + `/api/projects/${POSTHOG_PROJECT_ID}/persons/`, | |
| + POSTHOG_HOST, | |
| + ); | |
| + url.searchParams.set( | |
| + "properties", | |
| + JSON.stringify([ | |
| + { key: "last_known_latitude", type: "person", operator: "is_set" }, | |
| + ]), | |
| ); | |
| + url.searchParams.set("limit", String(limit)); | |
| - if (!response.ok) { | |
| - throw new Error(`Failed to fetch persons: ${response.statusText}`); | |
| + const res = await fetch(url.toString(), { | |
| + headers: { Authorization: `Bearer ${POSTHOG_API_KEY}` }, | |
| + }); | |
| + | |
| + if (!res.ok) { | |
| + throw new Error(`PostHog API error: ${res.status} ${res.statusText}`); | |
| } | |
| - const data: PostHogPersonsResponse = await response.json(); | |
| + const data: PostHogPersonsResponse = await res.json(); | |
| - // Transform to ActiveUser format | |
| - const activeUsers: ActiveUser[] = data.results | |
| - .filter((person) => { | |
| + return data.results | |
| + .filter((p) => { | |
| const lat = | |
| - person.properties.last_known_latitude ?? | |
| - person.properties.$geoip_latitude; | |
| + (p.properties.last_known_latitude as number) ?? | |
| + (p.properties.$geoip_latitude as number); | |
| const lon = | |
| - person.properties.last_known_longitude ?? | |
| - person.properties.$geoip_longitude; | |
| + (p.properties.last_known_longitude as number) ?? | |
| + (p.properties.$geoip_longitude as number); | |
| return lat !== undefined && lon !== undefined; | |
| }) | |
| - .map((person) => { | |
| + .map((p) => { | |
| const lat = | |
| - person.properties.last_known_latitude ?? | |
| - person.properties.$geoip_latitude ?? | |
| + (p.properties.last_known_latitude as number) ?? | |
| + (p.properties.$geoip_latitude as number) ?? | |
| 0; | |
| const lon = | |
| - person.properties.last_known_longitude ?? | |
| - person.properties.$geoip_longitude ?? | |
| + (p.properties.last_known_longitude as number) ?? | |
| + (p.properties.$geoip_longitude as number) ?? | |
| 0; | |
| - const lastSeen = person.properties.location_updated_at || new Date().toISOString(); | |
| + const lastSeen = | |
| + (p.properties.location_updated_at as string) || | |
| + new Date().toISOString(); | |
| const hourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString(); | |
| return { | |
| - id: person.distinct_ids[0], | |
| - distinctId: person.distinct_ids[0], | |
| - email: person.properties.email, | |
| - name: person.properties.name, | |
| - location: { | |
| - latitude: lat, | |
| - longitude: lon, | |
| - }, | |
| + id: p.distinct_ids[0], | |
| + distinctId: p.distinct_ids[0], | |
| + email: p.properties.email as string | undefined, | |
| + name: p.properties.name as string | undefined, | |
| + location: { latitude: lat, longitude: lon }, | |
| lastSeen, | |
| isOnline: lastSeen > hourAgo, | |
| }; | |
| }); | |
| - | |
| - return activeUsers; | |
| } catch (error) { | |
| - console.error('Failed to fetch active users:', error); | |
| + console.error("Failed to fetch active users (direct):", error); | |
| return []; | |
| } | |
| } | |
| } | |
| -// Singleton instance for the service | |
| +// Singleton | |
| let serviceInstance: PostHogComposioService | null = null; | |
| export function getPostHogComposioService(): PostHogComposioService { | |
| diff --git a/store/useActiveUsersStore.ts b/store/useActiveUsersStore.ts | |
| index 1e13e06..8049ac2 100644 | |
| --- a/store/useActiveUsersStore.ts | |
| +++ b/store/useActiveUsersStore.ts | |
| @@ -73,7 +73,7 @@ export const useActiveUsersStore = create<ActiveUsersState>((set, get) => ({ | |
| setError: (error) => set({ error }), | |
| fetchUsers: async () => { | |
| - const { setUsers, setLoading, setError } = get(); | |
| + const { setLoading, setError } = get(); | |
| setLoading(true); | |
| setError(null); | |
| @@ -85,7 +85,29 @@ export const useActiveUsersStore = create<ActiveUsersState>((set, get) => ({ | |
| } | |
| const data = await response.json(); | |
| - setUsers(data.users || []); | |
| + const serverUsers: ActiveUser[] = data.users || []; | |
| + | |
| + // Merge server users with local users instead of replacing. | |
| + // Local users (added via addUser on login/location update) should | |
| + // persist even if the server hasn't indexed them yet. | |
| + set((state) => { | |
| + const merged = new Map<string, ActiveUser>(); | |
| + | |
| + // Start with existing local users | |
| + for (const u of state.users) { | |
| + merged.set(u.id, u); | |
| + } | |
| + | |
| + // Layer server users on top (server is source of truth for data it has) | |
| + for (const u of serverUsers) { | |
| + merged.set(u.id, u); | |
| + } | |
| + | |
| + return { | |
| + users: Array.from(merged.values()), | |
| + lastFetched: new Date().toISOString(), | |
| + }; | |
| + }); | |
| } catch (error) { | |
| console.error('Failed to fetch active users:', error); | |
| setError(error instanceof Error ? error.message : 'Failed to fetch users'); | |
| -- | |
| 2.52.0 | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment