diff --git a/packages/ui/src/modules/places/CompetitorsGridView.tsx b/packages/ui/src/modules/places/CompetitorsGridView.tsx index 015dde00..d4a2864e 100644 --- a/packages/ui/src/modules/places/CompetitorsGridView.tsx +++ b/packages/ui/src/modules/places/CompetitorsGridView.tsx @@ -41,7 +41,17 @@ export const CompetitorsGridView: React.FC = ({ compet const [sortModel, setSortModel] = useState(() => paramsToSortModel(searchParams)); const [columnVisibilityModel, setColumnVisibilityModel] = useState(() => paramsToVisibilityModel(searchParams)); - const filteredCompetitors = competitors; + // Deduplicate and filter rows that have no usable ID (livesearch streams may have placeId instead of place_id, or missing IDs) + const filteredCompetitors = React.useMemo(() => { + const seen = new Set(); + return competitors.filter(c => { + const id = c.place_id || (c as any).placeId || (c as any).id; + if (!id) return false; + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + }, [competitors]); // Column Widths state const [columnWidths, setColumnWidths] = useState>(() => { try { @@ -220,7 +230,7 @@ export const CompetitorsGridView: React.FC = ({ compet row.place_id} + getRowId={(row) => row.place_id || row.placeId || row.id} loading={loading} filterModel={filterModel} onFilterModelChange={handleFilterModelChange} diff --git a/packages/ui/src/modules/places/CompetitorsMapView.tsx b/packages/ui/src/modules/places/CompetitorsMapView.tsx index fe0cb86b..f41777b7 100644 --- a/packages/ui/src/modules/places/CompetitorsMapView.tsx +++ b/packages/ui/src/modules/places/CompetitorsMapView.tsx @@ -603,9 +603,9 @@ export const CompetitorsMapView: React.FC = ({ competit 0 ? null : simulatorData} + simulatorPath={liveAreas.length > 0 ? null : simulatorPath} + simulatorScanner={liveScanner || simulatorScanner} /> { if (!map) return; - // Add source if not present - if (!map.getSource('live-areas')) { - map.addSource('live-areas', { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - }); - map.addLayer({ - id: 'live-areas-fill', - type: 'fill', - source: 'live-areas', - paint: { - 'fill-color': '#eab308', // yellow-500 - 'fill-opacity': 0.15 - } - }); - map.addLayer({ - id: 'live-areas-line', - type: 'line', - source: 'live-areas', - paint: { - 'line-color': '#eab308', - 'line-width': 2, - 'line-dasharray': [2, 2] - } - }); - } - - if (!map.getSource('live-radii')) { - map.addSource('live-radii', { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - }); - map.addLayer({ - id: 'live-radii-fill', - type: 'fill', - source: 'live-radii', - paint: { - 'fill-color': '#3b82f6', // blue-500 - 'fill-opacity': 0.1 - } - }); - map.addLayer({ - id: 'live-radii-line', - type: 'line', - source: 'live-radii', - paint: { - 'line-color': '#3b82f6', - 'line-width': 2 - } - }); - } - - if (!map.getSource('live-nodes')) { - map.addSource('live-nodes', { - type: 'geojson', - data: { type: 'FeatureCollection', features: [] } - }); - map.addLayer({ - id: 'live-nodes-circle', - type: 'circle', - source: 'live-nodes', - paint: { - 'circle-radius': 4, - 'circle-color': '#f43f5e', // rose-500 - 'circle-stroke-width': 1, - 'circle-stroke-color': '#ffffff' - } - }); + const initSources = () => { + if (!map.getSource('live-areas')) { + map.addSource('live-areas', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + map.addLayer({ + id: 'live-areas-fill', + type: 'fill', + source: 'live-areas', + paint: { + 'fill-color': '#eab308', + 'fill-opacity': 0.15 + } + }); + map.addLayer({ + id: 'live-areas-line', + type: 'line', + source: 'live-areas', + paint: { + 'line-color': '#eab308', + 'line-width': 2, + 'line-dasharray': [2, 2] + } + }); + } + + if (!map.getSource('live-nodes')) { + map.addSource('live-nodes', { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] } + }); + map.addLayer({ + id: 'live-nodes-circle', + type: 'circle', + source: 'live-nodes', + paint: { + 'circle-radius': 4, + 'circle-color': '#f43f5e', + 'circle-stroke-width': 1, + 'circle-stroke-color': '#ffffff' + } + }); + } + }; + + if (map.isStyleLoaded()) { + initSources(); + } else { + map.once('style.load', initSources); } + return () => { + map.off('style.load', initSources); + }; }, [map]); useEffect(() => { @@ -99,62 +84,32 @@ export function LiveSearchLayers({ map, liveAreas, liveRadii, liveNodes, isDarkS }); }, [map, liveAreas]); - useEffect(() => { - if (!map || !map.getSource('live-radii')) return; - - // Radii usually come as center + radius - // Let's create a rough circle polygon using basic math if there is no boundary - const features = liveRadii.map(r => { - const center = r.location?.location || r.center || r; - const radiusInKm = r.radius || r.radius_km; - if (center && radiusInKm) { - const lng = center.lng || center.lon; - if (lng === undefined || center.lat === undefined) return null; - - // Return a circle polygon - const points = 64; - const coords = []; - const distanceX = radiusInKm / (111.320 * Math.cos((center.lat * Math.PI) / 180)); - const distanceY = radiusInKm / 110.574; - - for (let i = 0; i < points; i++) { - const theta = (i / points) * (2 * Math.PI); - const x = distanceX * Math.cos(theta); - const y = distanceY * Math.sin(theta); - coords.push([lng + x, center.lat + y]); - } - coords.push(coords[0]); // close polygon - - return { - type: 'Feature', - properties: { name: r.name || 'Radius' }, - geometry: { - type: 'Polygon', - coordinates: [coords] - } - }; - } - return null; - }).filter(Boolean); - - (map.getSource('live-radii') as maplibregl.GeoJSONSource).setData({ - type: 'FeatureCollection', - features: features as any - }); - }, [map, liveRadii]); - useEffect(() => { if (!map || !map.getSource('live-nodes')) return; const features = liveNodes.map(n => { - const loc = n.location || n; - if (loc && typeof loc.lat === 'number' && typeof loc.lng === 'number') { + // Resolve coordinates from multiple possible formats + let lat: number | undefined; + let lng: number | undefined; + + if (n.gps_coordinates) { + lat = n.gps_coordinates.latitude; + lng = n.gps_coordinates.longitude; + } else if (n.location && typeof n.location.lat === 'number') { + lat = n.location.lat; + lng = n.location.lng || n.location.lon; + } else if (typeof n.lat === 'number') { + lat = n.lat; + lng = n.lng || n.lon; + } + + if (lat !== undefined && lng !== undefined && isFinite(lat) && isFinite(lng)) { return { type: 'Feature', - properties: { name: n.name || 'Node' }, + properties: { name: n.title || n.name || 'Node', type: n.type }, geometry: { type: 'Point', - coordinates: [loc.lng, loc.lat] + coordinates: [lng, lat] } }; } diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx index 51f21c9c..937e4458 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchResults.tsx @@ -69,6 +69,16 @@ export function GridSearchResults({ jobId, competitors, excludedTypes, updateExc
{competitors.length} results + {(() => { + const emailCount = competitors.filter(c => { + const e = c as any; + const emails = e.emails || e.email; + return emails && (Array.isArray(emails) ? emails.length > 0 : !!emails); + }).length; + return emailCount > 0 ? ( + 📧 {emailCount} + ) : null; + })()}
diff --git a/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx b/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx index a4d0a327..874c9c0c 100644 --- a/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx +++ b/packages/ui/src/modules/places/gridsearch/GridSearchStreamContext.tsx @@ -2,6 +2,8 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from import { getPlacesGridSearchStreamUrl } from '../client-gridsearch'; import { supabase } from '@/integrations/supabase/client'; +export type StreamPhase = 'idle' | 'grid' | 'searching' | 'enriching' | 'complete' | 'failed'; + export interface GridSearchStreamState { streaming: boolean; connected: boolean; @@ -12,11 +14,15 @@ export interface GridSearchStreamState { liveNodes: any[]; stats: { completedWaypoints: number; + totalWaypoints: number; totalResults: number; apiCalls: number; totalLocationsEnriched: number; + errors: number; + phase: StreamPhase; }; liveScanner: any; + jobResult: any; } const initialState: GridSearchStreamState = { @@ -29,26 +35,65 @@ const initialState: GridSearchStreamState = { liveNodes: [], stats: { completedWaypoints: 0, + totalWaypoints: 0, totalResults: 0, apiCalls: 0, totalLocationsEnriched: 0, + errors: 0, + phase: 'idle', }, - liveScanner: null + liveScanner: null, + jobResult: null, }; // ── pure helpers ── -function buildScannerGeoJSON(data: any): any | null { - const loc = data.location?.location || data.center || data; - const lng = loc.lng || loc.lon; - if (!loc.lat || !lng) return null; +function extractCoords(data: any): { lat: number; lng: number } | null { + // waypoint-start: data.location.location or data.center + const loc = data.location?.location || data.center; + if (loc) { + const lng = loc.lng || loc.lon; + if (loc.lat && lng) return { lat: loc.lat, lng }; + } + // node event: gps_coordinates from places table + if (data.gps_coordinates) { + return { lat: data.gps_coordinates.latitude, lng: data.gps_coordinates.longitude }; + } + // fallback + if (typeof data.lat === 'number') { + return { lat: data.lat, lng: data.lng || data.lon }; + } + return null; +} + +function bearingBetween(lat1: number, lng1: number, lat2: number, lng2: number): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const toDeg = (r: number) => (r * 180) / Math.PI; + const dLng = toRad(lng2 - lng1); + const y = Math.sin(dLng) * Math.cos(toRad(lat2)); + const x = Math.cos(toRad(lat1)) * Math.sin(toRad(lat2)) - + Math.sin(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.cos(dLng); + return (toDeg(Math.atan2(y, x)) + 360) % 360; +} + +function buildScannerGeoJSON( + lat: number, lng: number, + prevLat?: number, prevLng?: number, + mouthOpen = true +): any { + const bearing = (prevLat !== undefined && prevLng !== undefined) + ? bearingBetween(prevLat, prevLng, lat, lng) + : 0; return { type: 'FeatureCollection', features: [{ type: 'Feature', - properties: { icon_state: 'pacman-open', bearing: 0, radius: data.radius_km || 0.5 }, - geometry: { type: 'Point', coordinates: [lng, loc.lat] } + properties: { + icon_state: mouthOpen ? 'pacman-open' : 'pacman-closed', + bearing, + }, + geometry: { type: 'Point', coordinates: [lng, lat] } }] }; } @@ -81,16 +126,46 @@ function applyEventToState( const stats = parsed.stats || data?.stats; if (stats) next.stats = { ...next.stats, ...stats }; + console.log(`SSE ${type}`, parsed); switch (type) { + case 'grid-ready': + next.stats = { ...next.stats, totalWaypoints: data?.totalWaypoints || data?.waypoints?.length || 0, phase: 'searching' }; + next.statusMessage = `Grid ready — ${next.stats.totalWaypoints} waypoints`; + break; + case 'area-start': next.liveAreas = [...next.liveAreas, data]; next.statusMessage = `Searching area: ${data?.name || '...'}`; break; + case 'area-finish': + next.statusMessage = `Finished area: ${data?.area_gid || '...'}`; + break; + case 'radius-start': - case 'waypoint-start': - next.liveRadii = [...next.liveRadii, data]; - next.liveScanner = buildScannerGeoJSON(data) ?? next.liveScanner; + case 'waypoint-start': { + // No longer accumulate liveRadii (blue circles removed) + const wCoords = extractCoords(data); + if (wCoords) { + const prevPos = (next as any)._lastScannerPos; + const mouth = !((next as any)._pacmanOpen ?? true); // toggle + next.liveScanner = buildScannerGeoJSON( + wCoords.lat, wCoords.lng, + prevPos?.lat, prevPos?.lng, + mouth + ); + (next as any)._lastScannerPos = wCoords; + (next as any)._pacmanOpen = mouth; + } + break; + } + + case 'waypoint-finish': + next.stats = { + ...next.stats, + completedWaypoints: next.stats.completedWaypoints + 1, + apiCalls: data?.apiCalls ?? next.stats.apiCalls, + }; break; case 'node-start': @@ -101,10 +176,62 @@ function applyEventToState( case 'node': { const comp = normalizeLocation(type, data); next.competitors = [...next.competitors, comp]; + // Also add to liveNodes for map pins — replace any node-start placeholder + const pid = data?.place_id || data?.placeId; + if (pid) { + next.liveNodes = [ + ...next.liveNodes.filter((n: any) => (n.place_id || n.placeId) !== pid), + data + ]; + } else { + next.liveNodes = [...next.liveNodes, data]; + } + // Move pacman to the found location + const nCoords = extractCoords(data); + if (nCoords) { + const prevPos = (next as any)._lastScannerPos; + const mouth = !((next as any)._pacmanOpen ?? true); + next.liveScanner = buildScannerGeoJSON( + nCoords.lat, nCoords.lng, + prevPos?.lat, prevPos?.lng, + mouth + ); + (next as any)._lastScannerPos = nCoords; + (next as any)._pacmanOpen = mouth; + } + next.stats = { ...next.stats, totalResults: next.competitors.length }; + if (type === 'node') next.stats = { ...next.stats, totalLocationsEnriched: next.stats.totalLocationsEnriched + 1 }; next.statusMessage = `Found: ${comp?.title || comp?.name || 'Location'}`; break; } + case 'enrich-start': + next.stats = { ...next.stats, phase: 'enriching' }; + next.statusMessage = `Enriching ${data?.locationCount || ''} locations...`; + break; + + case 'node-error': + case 'nodePage': + next.stats = { ...next.stats, errors: next.stats.errors + 1 }; + break; + + case 'job_result': + next.jobResult = data; + next.stats = { + ...next.stats, + phase: 'complete', + totalResults: data?.totalResults ?? next.stats.totalResults, + apiCalls: data?.freshApiCalls ?? next.stats.apiCalls, + }; + next.statusMessage = 'Search Complete'; + break; + + case 'failed': + next.streaming = false; + next.stats = { ...next.stats, phase: 'failed' }; + next.statusMessage = data?.error || 'Job failed'; + break; + case 'log': break; // cpp logging forward – ignored } @@ -117,7 +244,8 @@ function applyEventToState( const STREAM_EVENTS = [ 'progress', 'area-start', 'area-finish', 'waypoint-start', 'waypoint-finish', 'radius-start', 'node-start', 'location', - 'node', 'log', 'job_result', 'stats' + 'node', 'node-error', 'nodePage', 'log', 'job_result', 'stats', + 'grid-ready', 'enrich-start', 'failed' ] as const; const GridSearchStreamContext = createContext(initialState); @@ -150,7 +278,6 @@ export function GridSearchStreamProvider({ if (!isActive) return; try { const { type, parsed, data } = parseStreamEvent(eventType, e.data); - console.log('sse - uds stream', parsed); setState(prev => applyEventToState(prev, type, parsed, data)); } catch (err) { console.error('Failed parsing stream event', eventType, err); @@ -173,7 +300,16 @@ export function GridSearchStreamProvider({ es.addEventListener('complete', () => { if (!isActive) return; - setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete' })); + setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: 'Search Complete', stats: { ...prev.stats, phase: 'complete' } })); + es?.close(); + }); + + es.addEventListener('failed', (e: MessageEvent) => { + if (!isActive) return; + try { + const parsed = JSON.parse(e.data); + setState(prev => ({ ...prev, streaming: false, connected: false, statusMessage: parsed?.error || 'Job failed', stats: { ...prev.stats, phase: 'failed' } })); + } catch { /* ignore */ } es?.close(); }); diff --git a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx index cd617787..707d0725 100644 --- a/packages/ui/src/modules/places/gridsearch/JobViewer.tsx +++ b/packages/ui/src/modules/places/gridsearch/JobViewer.tsx @@ -40,6 +40,7 @@ export function JobViewer({ jobId }: { jobId: string }) { const searchSettings = restoredState?.run?.request?.guided?.settings || null; const [retrying, setRetrying] = useState(false); + const [reloadKey, setReloadKey] = useState(0); // For CompetitorsGridView props const [excludedTypes, setExcludedTypes] = useState([]); @@ -54,16 +55,13 @@ export function JobViewer({ jobId }: { jobId: string }) { const data = await fetchPlacesGridSearchById(jobId); if (!active) return; - // Handle nested wrapper if present (sometimes { data: { ... } } or just the object) + // Backend always returns { request, result, areas, status } const payload = data?.data ? data.data : data; setJobData(payload); - if (payload && payload.result && Array.isArray(payload.result.enrichResults)) { - setCompetitors(payload.result.enrichResults); - } else if (Array.isArray(payload)) { - setCompetitors(payload); - } else if (payload && Array.isArray(payload.enrichResults)) { - setCompetitors(payload.enrichResults); + const enrichResults = payload?.result?.enrichResults; + if (Array.isArray(enrichResults)) { + setCompetitors(enrichResults); } else { setCompetitors([]); } @@ -78,7 +76,7 @@ export function JobViewer({ jobId }: { jobId: string }) { load(); return () => { active = false; }; - }, [jobId]); + }, [jobId, reloadKey]); const dummyUpdateExcluded = async (types: string[]) => setExcludedTypes(types); @@ -86,7 +84,8 @@ export function JobViewer({ jobId }: { jobId: string }) { try { setRetrying(true); await retryPlacesGridSearchJob(jobId); - window.location.reload(); + // Bust the dedup cache and re-trigger the useEffect instead of hard-reloading + setReloadKey(k => k + 1); } catch (e: any) { console.error(e); setError(e.message || 'Failed to retry job'); @@ -124,8 +123,8 @@ export function JobViewer({ jobId }: { jobId: string }) { ); } - if (jobData?.status === 'failed' || jobData?.run?.status === 'failed') { - const errorMsg = jobData.result?.error || jobData?.run?.result?.error || 'Unknown error during job execution. The backend worker may have crashed or timed out.'; + if (jobData?.status === 'failed') { + const errorMsg = jobData.result?.error || 'Unknown error during job execution. The backend worker may have crashed or timed out.'; return (