From 998c32e7b7ac23ce6132dc08f0c93c7be9e8ebdc Mon Sep 17 00:00:00 2001 From: Brandon Satrom Date: Tue, 14 Apr 2026 17:18:41 -0700 Subject: [PATCH] fix(dashboard): analytics API client, auth hook caching, mutation error handling, type safety - Refactor analytics.ts to use shared apiFetch/apiGet/apiPost helpers instead of duplicating fetchAuthSession + raw fetch in every function (~150 lines removed) - Consolidate useIsAdmin, useUserGroups, useCanSendCommands onto a single shared TanStack Query cache entry to eliminate redundant Cognito round-trips - Fix DisplayPreferences and FleetDefaults to only clear hasChanges on mutation onSuccess callback, preventing silent save button disable on API failure - Add error feedback UI to both settings components on mutation failure - Fix catch (error: any) in Analytics.tsx with proper TypeScript narrowing - Replace index-based chat message keys with stable type+timestamp identity - Tighten QueryResult.data from any[] to QueryRow[] (Record[]) - Fix missing Satellite import in DeviceDetail.tsx Co-Authored-By: Claude Sonnet 4.6 --- songbird-dashboard/src/api/analytics.ts | 255 ++---------------- .../analytics/QueryVisualization.tsx | 30 ++- .../settings/DisplayPreferences.tsx | 10 +- .../src/components/settings/FleetDefaults.tsx | 27 +- songbird-dashboard/src/hooks/useAuth.ts | 103 +++---- songbird-dashboard/src/pages/Analytics.tsx | 9 +- songbird-dashboard/src/pages/DeviceDetail.tsx | 50 ++-- songbird-dashboard/src/types/analytics.ts | 4 +- 8 files changed, 129 insertions(+), 359 deletions(-) diff --git a/songbird-dashboard/src/api/analytics.ts b/songbird-dashboard/src/api/analytics.ts index ac9ea6a..85555c2 100644 --- a/songbird-dashboard/src/api/analytics.ts +++ b/songbird-dashboard/src/api/analytics.ts @@ -1,8 +1,8 @@ -import { fetchAuthSession } from 'aws-amplify/auth'; -import { getApiBaseUrl } from './client'; +import { apiFetch, apiGet, apiPost, apiPut, apiPatch, apiDelete } from './client'; import type { ChatRequest, QueryResult, + QueryRow, ChatHistoryResponse, SessionListResponse, SessionResponse, @@ -13,131 +13,44 @@ import type { } from '@/types/analytics'; export async function chatQuery(request: ChatRequest): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/chat`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(request), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `API error: ${response.status}`); - } - - return response.json(); + return apiPost('/analytics/chat', request); } export async function getChatHistory(userEmail: string, limit = 50): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/history?userEmail=${encodeURIComponent(userEmail)}&limit=${limit}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch chat history: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/history', { userEmail, limit }); } /** * List all chat sessions for a user */ export async function listSessions(userEmail: string, limit = 20): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions?userEmail=${encodeURIComponent(userEmail)}&limit=${limit}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch sessions: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/sessions', { userEmail, limit }); } /** * Get a specific chat session with all messages */ export async function getSession(sessionId: string, userEmail: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}`, - { - headers: { - 'Authorization': `Bearer ${token}`, - }, - } - ); - - if (!response.ok) { - throw new Error(`Failed to fetch session: ${response.status}`); - } - - return response.json(); + return apiGet(`/analytics/sessions/${encodeURIComponent(sessionId)}`, { userEmail }); } /** * Delete a chat session */ export async function deleteSession(sessionId: string, userEmail: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch( - `${getApiBaseUrl()}/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}`, - { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${token}`, - }, - } + return apiDelete( + `/analytics/sessions/${encodeURIComponent(sessionId)}?userEmail=${encodeURIComponent(userEmail)}` ); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete session: ${response.status}`); - } } /** - * Re-execute a stored SQL query to get fresh visualization data + * List RAG documents, optionally filtered by type */ export async function listRagDocuments(docType?: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - const params = docType ? `?doc_type=${encodeURIComponent(docType)}` : ''; - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents${params}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch RAG documents: ${response.status}`); - } - return response.json(); + return apiGet( + '/analytics/rag-documents', + docType ? { doc_type: docType } : undefined + ); } export async function createRagDocument(doc: { @@ -146,159 +59,49 @@ export async function createRagDocument(doc: { content: string; metadata?: Record; }): Promise<{ document: RagDocument }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify(doc), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to create RAG document: ${response.status}`); - } - return response.json(); + return apiPost<{ document: RagDocument }>('/analytics/rag-documents', doc); } export async function updateRagDocument( id: string, doc: { title?: string; content: string } ): Promise<{ document: RagDocument }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify(doc), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to update RAG document: ${response.status}`); - } - return response.json(); + return apiPut<{ document: RagDocument }>( + `/analytics/rag-documents/${encodeURIComponent(id)}`, + doc + ); } export async function deleteRagDocument(id: string): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete RAG document: ${response.status}`); - } + return apiDelete(`/analytics/rag-documents/${encodeURIComponent(id)}`); } export async function toggleRagDocumentPin(id: string, pinned: boolean): Promise<{ id: string; pinned: boolean }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/${encodeURIComponent(id)}/pin`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, - body: JSON.stringify({ pinned }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to toggle pin: ${response.status}`); - } - return response.json(); + return apiPatch<{ id: string; pinned: boolean }>( + `/analytics/rag-documents/${encodeURIComponent(id)}/pin`, + { pinned } + ); } export async function reseedRagDocuments(): Promise<{ message: string }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rag-documents/reseed`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to reseed: ${response.status}`); - } - return response.json(); + return apiPost<{ message: string }>('/analytics/rag-documents/reseed'); } -export async function rerunQuery(sql: string, userEmail: string): Promise<{ data: any[] }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/rerun`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify({ sql, userEmail }), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to rerun query: ${response.status}`); - } - - return response.json(); +export async function rerunQuery(sql: string, userEmail: string): Promise<{ data: QueryRow[] }> { + return apiPost<{ data: QueryRow[] }>('/analytics/rerun', { sql, userEmail }); } export async function deleteNegativeFeedback(userEmail: string, ratedAt: number): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback`, { + return apiFetch('/analytics/feedback', { method: 'DELETE', - headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ userEmail, ratedAt }), }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to delete feedback: ${response.status}`); - } } export async function listNegativeFeedback(limit = 100): Promise { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback?limit=${limit}`, { - headers: { 'Authorization': `Bearer ${token}` }, - }); - - if (!response.ok) { - throw new Error(`Failed to fetch feedback: ${response.status}`); - } - - return response.json(); + return apiGet('/analytics/feedback', { limit }); } export async function submitFeedback(req: FeedbackRequest): Promise<{ success: boolean; indexed: boolean }> { - const session = await fetchAuthSession(); - const token = session.tokens?.idToken?.toString(); - - const response = await fetch(`${getApiBaseUrl()}/analytics/feedback`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - body: JSON.stringify(req), - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ error: 'Unknown error' })); - throw new Error(error.error || `Failed to submit feedback: ${response.status}`); - } - - return response.json(); + return apiPost<{ success: boolean; indexed: boolean }>('/analytics/feedback', req); } diff --git a/songbird-dashboard/src/components/analytics/QueryVisualization.tsx b/songbird-dashboard/src/components/analytics/QueryVisualization.tsx index 1e6c52b..fcfa18d 100644 --- a/songbird-dashboard/src/components/analytics/QueryVisualization.tsx +++ b/songbird-dashboard/src/components/analytics/QueryVisualization.tsx @@ -16,7 +16,7 @@ import { import Map, { Marker, NavigationControl } from 'react-map-gl'; import { MapPin } from 'lucide-react'; import { Card } from '@/components/ui/card'; -import type { QueryResult } from '@/types/analytics'; +import type { QueryResult, QueryRow } from '@/types/analytics'; import 'mapbox-gl/dist/mapbox-gl.css'; interface QueryVisualizationProps { @@ -58,7 +58,7 @@ export function QueryVisualization({ result, mapboxToken }: QueryVisualizationPr } } -function LineChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function LineChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { // Get numeric keys for line series const keys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; @@ -111,7 +111,7 @@ function LineChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function BarChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function BarChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { const keys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; return typeof val === 'number'; @@ -160,7 +160,7 @@ function BarChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function ScatterChartViz({ data, colors }: { data: any[]; colors: string[] }) { +function ScatterChartViz({ data, colors }: { data: QueryRow[]; colors: string[] }) { const numericKeys = Object.keys(data[0] || {}).filter(key => { const val = data[0][key]; return typeof val === 'number'; @@ -192,7 +192,7 @@ function ScatterChartViz({ data, colors }: { data: any[]; colors: string[] }) { ); } -function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { +function MapViz({ data, mapboxToken }: { data: QueryRow[]; mapboxToken: string }) { // Extract and normalize location data const locations = useMemo(() => { return data @@ -202,21 +202,24 @@ function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { const lonValue = row.lon ?? row.longitude ?? row.last_location_lon; // Parse values - they might be strings or numbers - const lat = typeof latValue === 'string' ? parseFloat(latValue) : latValue; - const lon = typeof lonValue === 'string' ? parseFloat(lonValue) : lonValue; + const lat = typeof latValue === 'string' ? parseFloat(latValue) : typeof latValue === 'number' ? latValue : null; + const lon = typeof lonValue === 'string' ? parseFloat(lonValue) : typeof lonValue === 'number' ? lonValue : null; // Validate coordinates if (lat == null || lon == null || isNaN(lat) || isNaN(lon)) { return null; } + const timeVal = row.time; + const time = typeof timeVal === 'string' || typeof timeVal === 'number' ? timeVal : null; + return { id: index, lat, lon, - name: row.name || row.serial_number || `Location ${index + 1}`, - time: row.time, - source: row.source, + name: String(row.name || row.serial_number || `Location ${index + 1}`), + time, + source: row.source != null ? String(row.source) : null, }; }) .filter((loc): loc is NonNullable => loc !== null); @@ -343,7 +346,7 @@ function MapViz({ data, mapboxToken }: { data: any[]; mapboxToken: string }) { ); } -function GaugeViz({ data }: { data: any[] }) { +function GaugeViz({ data }: { data: QueryRow[] }) { const row = data[0] || {}; const value = Object.values(row).find(v => typeof v === 'number') as number; const label = Object.keys(row).find(k => typeof row[k] === 'number') || 'Value'; @@ -357,7 +360,7 @@ function GaugeViz({ data }: { data: any[] }) { ); } -function TableViz({ data }: { data: any[] }) { +function TableViz({ data }: { data: QueryRow[] }) { if (data.length === 0) return null; const columns = Object.keys(data[0]); @@ -401,11 +404,10 @@ function TableViz({ data }: { data: any[] }) { ); } -function formatCellValue(value: any): string { +function formatCellValue(value: string | number | boolean | null): string { if (value === null || value === undefined) return '--'; if (typeof value === 'boolean') return value ? 'Yes' : 'No'; if (typeof value === 'number') return value.toFixed(2); - if (value instanceof Date) return value.toLocaleString(); if (typeof value === 'string' && value.length > 50) return value.substring(0, 50) + '...'; return String(value); } diff --git a/songbird-dashboard/src/components/settings/DisplayPreferences.tsx b/songbird-dashboard/src/components/settings/DisplayPreferences.tsx index b3138f4..69c9bc6 100644 --- a/songbird-dashboard/src/components/settings/DisplayPreferences.tsx +++ b/songbird-dashboard/src/components/settings/DisplayPreferences.tsx @@ -48,8 +48,9 @@ export function DisplayPreferences() { }; const handleSave = () => { - updatePreferences.mutate(localPrefs); - setHasChanges(false); + updatePreferences.mutate(localPrefs, { + onSuccess: () => setHasChanges(false), + }); }; if (isLoading) { @@ -213,6 +214,11 @@ export function DisplayPreferences() { {updatePreferences.isSuccess && !hasChanges && ( Preferences saved! )} + {updatePreferences.isError && ( + + Failed to save preferences. Please try again. + + )} diff --git a/songbird-dashboard/src/components/settings/FleetDefaults.tsx b/songbird-dashboard/src/components/settings/FleetDefaults.tsx index d29142c..680cbdb 100644 --- a/songbird-dashboard/src/components/settings/FleetDefaults.tsx +++ b/songbird-dashboard/src/components/settings/FleetDefaults.tsx @@ -20,12 +20,9 @@ import { } from '@/components/ui/select'; import { useNotehubFleets, useFleetDefaults, useUpdateFleetDefaults } from '@/hooks/useSettings'; import { usePreferences } from '@/contexts/PreferencesContext'; +import { celsiusToFahrenheit, fahrenheitToCelsius } from '@/utils/formatters'; import type { FleetDefaults as FleetDefaultsType, OperatingMode, MotionSensitivity } from '@/types'; -// Temperature conversion helpers -const celsiusToFahrenheit = (c: number) => Math.round((c * 9) / 5 + 32); -const fahrenheitToCelsius = (f: number) => Math.round(((f - 32) * 5) / 9); - export function FleetDefaults() { const { data: fleets, isLoading: fleetsLoading } = useNotehubFleets(); const [selectedFleet, setSelectedFleet] = useState(''); @@ -37,8 +34,8 @@ export function FleetDefaults() { const useFahrenheit = preferences.temp_unit === 'fahrenheit'; const tempUnit = useFahrenheit ? '°F' : '°C'; - // Convert display temperature based on preference - const displayTemp = (celsius: number) => useFahrenheit ? celsiusToFahrenheit(celsius) : celsius; + // Convert display temperature based on preference (rounded for slider display) + const displayTemp = (celsius: number) => useFahrenheit ? Math.round(celsiusToFahrenheit(celsius)) : celsius; // Slider ranges based on unit const tempHighMin = useFahrenheit ? 14 : -10; // -10°C = 14°F @@ -74,11 +71,10 @@ export function FleetDefaults() { const handleSave = () => { if (!selectedFleet) return; - updateDefaults.mutate({ - fleetUid: selectedFleet, - config: localConfig, - }); - setHasChanges(false); + updateDefaults.mutate( + { fleetUid: selectedFleet, config: localConfig }, + { onSuccess: () => setHasChanges(false) } + ); }; if (fleetsLoading) { @@ -233,7 +229,7 @@ export function FleetDefaults() { { - const celsius = useFahrenheit ? fahrenheitToCelsius(v) : v; + const celsius = useFahrenheit ? Math.round(fahrenheitToCelsius(v)) : v; updateLocalConfig('temp_alert_high_c', celsius); }} min={tempHighMin} @@ -252,7 +248,7 @@ export function FleetDefaults() { { - const celsius = useFahrenheit ? fahrenheitToCelsius(v) : v; + const celsius = useFahrenheit ? Math.round(fahrenheitToCelsius(v)) : v; updateLocalConfig('temp_alert_low_c', celsius); }} min={tempLowMin} @@ -444,6 +440,11 @@ export function FleetDefaults() { Defaults saved and synced to Notehub! )} + {updateDefaults.isError && ( + + Failed to save fleet defaults. Please try again. + + )} {fleetConfig?.updated_at && (

Last updated: {new Date(fleetConfig.updated_at).toLocaleString()} diff --git a/songbird-dashboard/src/hooks/useAuth.ts b/songbird-dashboard/src/hooks/useAuth.ts index 70e4987..cb2eaa1 100644 --- a/songbird-dashboard/src/hooks/useAuth.ts +++ b/songbird-dashboard/src/hooks/useAuth.ts @@ -5,8 +5,9 @@ * Integrates with PostHog for user identification. */ -import { useState, useEffect } from 'react'; +import { useEffect } from 'react'; import { fetchAuthSession } from 'aws-amplify/auth'; +import { useQuery } from '@tanstack/react-query'; import posthog from 'posthog-js'; import type { UserGroup } from '@/types'; @@ -30,47 +31,32 @@ async function getUserGroups(): Promise { } } +/** + * Shared TanStack Query hook for user groups — a single Cognito round-trip + * is shared across all components that call any of the auth hooks simultaneously. + */ +function useUserGroupsQuery() { + return useQuery({ + queryKey: ['authSession', 'groups'], + queryFn: getUserGroups, + staleTime: 5 * 60_000, + retry: 1, + }); +} + /** * Hook to check if the current user is an admin */ export function useIsAdmin() { - const [isAdmin, setIsAdmin] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(groups => { - setIsAdmin(groups.includes('Admin')); - setIsLoading(false); - }) - .catch(() => { - setIsAdmin(false); - setIsLoading(false); - }); - }, []); - - return { isAdmin, isLoading }; + const { data: groups = [], isLoading } = useUserGroupsQuery(); + return { isAdmin: groups.includes('Admin'), isLoading }; } /** * Hook to get the current user's groups */ export function useUserGroups() { - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(g => { - setGroups(g); - setIsLoading(false); - }) - .catch(() => { - setGroups([]); - setIsLoading(false); - }); - }, []); - + const { data: groups = [], isLoading } = useUserGroupsQuery(); return { groups, isLoading }; } @@ -79,49 +65,28 @@ export function useUserGroups() { * Returns true for all roles except Viewer */ export function useCanSendCommands() { - const [canSend, setCanSend] = useState(false); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - getUserGroups() - .then(groups => { - // Viewers can only view, not send commands - // If user has no groups or only Viewer group, they cannot send - const isViewerOnly = groups.length === 0 || - (groups.length === 1 && groups.includes('Viewer')); - setCanSend(!isViewerOnly); - setIsLoading(false); - }) - .catch(() => { - setCanSend(false); - setIsLoading(false); - }); - }, []); - - return { canSend, isLoading }; + const { data: groups = [], isLoading } = useUserGroupsQuery(); + // Viewers can only view, not send commands + // If user has no groups or only Viewer group, they cannot send + const isViewerOnly = groups.length === 0 || + (groups.length === 1 && groups.includes('Viewer')); + return { canSend: !isViewerOnly, isLoading }; } /** * Hook to get the current user's email */ export function useCurrentUserEmail() { - const [email, setEmail] = useState(null); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - fetchAuthSession() - .then(session => { - const userEmail = session.tokens?.idToken?.payload['email'] as string | undefined; - setEmail(userEmail || null); - setIsLoading(false); - }) - .catch(() => { - setEmail(null); - setIsLoading(false); - }); - }, []); - - return { email, isLoading }; + const { data, isLoading } = useQuery({ + queryKey: ['authSession', 'email'], + queryFn: async () => { + const session = await fetchAuthSession(); + return (session.tokens?.idToken?.payload['email'] as string | undefined) ?? null; + }, + staleTime: 5 * 60_000, + retry: 1, + }); + return { email: data ?? null, isLoading }; } /** diff --git a/songbird-dashboard/src/pages/Analytics.tsx b/songbird-dashboard/src/pages/Analytics.tsx index 392543d..e89968f 100644 --- a/songbird-dashboard/src/pages/Analytics.tsx +++ b/songbird-dashboard/src/pages/Analytics.tsx @@ -167,10 +167,11 @@ export function Analytics({ mapboxToken }: AnalyticsProps) { }; setMessages(prev => [...prev, assistantMessage]); - } catch (error: any) { + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to process query'; const errorMessage = { type: 'assistant' as const, - content: `Error: ${error.message || 'Failed to process query'}`, + content: `Error: ${message}`, timestamp: Date.now(), }; setMessages(prev => [...prev, errorMessage]); @@ -311,8 +312,8 @@ export function Analytics({ mapboxToken }: AnalyticsProps) {

) : ( - messages.map((message, index) => ( - + messages.map((message) => ( + )) )} {chatMutation.isPending && ( diff --git a/songbird-dashboard/src/pages/DeviceDetail.tsx b/songbird-dashboard/src/pages/DeviceDetail.tsx index b2f09d4..06818f8 100644 --- a/songbird-dashboard/src/pages/DeviceDetail.tsx +++ b/songbird-dashboard/src/pages/DeviceDetail.tsx @@ -1,6 +1,6 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useParams, Link, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, ArrowRight, Settings, Thermometer, Droplets, Gauge, Battery, BatteryFull, BatteryCharging, Zap, AlertTriangle, Check, CheckCheck, Clock, Activity, MapPin, Satellite, Radio, Lock, Route, Navigation, ExternalLink } from 'lucide-react'; +import { ArrowLeft, ArrowRight, Settings, Thermometer, Droplets, Gauge, Battery, BatteryFull, BatteryCharging, Zap, AlertTriangle, Check, CheckCheck, Clock, Activity, MapPin, Lock, Route, Navigation, ExternalLink, Satellite } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; @@ -42,7 +42,8 @@ import { convertTemperature, getTemperatureUnit, } from '@/utils/formatters'; -import type { Alert, HealthPoint, LocationSource } from '@/types'; +import { getLocationSourceInfo } from '@/utils/locationSource'; +import type { Alert, HealthPoint } from '@/types'; const alertTypeLabels: Record = { temp_high: 'High Temperature', @@ -68,23 +69,6 @@ const healthMethodLabels: Record = { disconnected: 'Disconnected', }; -// Location source display configuration -function getLocationSourceInfo(source?: LocationSource | string) { - switch (source) { - case 'gps': - return { label: 'GPS', icon: Satellite, color: 'text-green-600', bgColor: 'bg-green-100' }; - case 'cell': - case 'tower': - return { label: 'Cell Tower', icon: Radio, color: 'text-blue-600', bgColor: 'bg-blue-100' }; - case 'wifi': - return { label: 'Wi-Fi', icon: Radio, color: 'text-purple-600', bgColor: 'bg-purple-100' }; - case 'triangulation': - case 'triangulated': // Handle raw Notehub value - return { label: 'Triangulated', icon: Radio, color: 'text-orange-600', bgColor: 'bg-orange-100' }; - default: - return { label: 'Unknown', icon: MapPin, color: 'text-gray-600', bgColor: 'bg-gray-100' }; - } -} interface DeviceDetailProps { mapboxToken: string; @@ -106,16 +90,22 @@ export function DeviceDetail({ mapboxToken }: DeviceDetailProps) { const [locationTab, setLocationTab] = useState(journeyIdFromUrl ? 'journeys' : 'current'); const [selectedJourneyId, setSelectedJourneyId] = useState(initialJourneyId); - // Update URL when journey selection changes - const handleJourneySelect = (journeyId: number | null) => { + // Update URL when journey selection changes. Wrapped in useCallback so the + // stable reference can be safely listed in useEffect dependency arrays. + // Uses the functional form of setSearchParams to avoid stale closure over + // the searchParams snapshot captured at render time. + const handleJourneySelect = useCallback((journeyId: number | null) => { setSelectedJourneyId(journeyId); - if (journeyId) { - setSearchParams({ journey: journeyId.toString() }); - } else { - searchParams.delete('journey'); - setSearchParams(searchParams); - } - }; + setSearchParams(prev => { + const next = new URLSearchParams(prev); + if (journeyId) { + next.set('journey', journeyId.toString()); + } else { + next.delete('journey'); + } + return next; + }); + }, [setSearchParams]); // Set default time range from preferences once loaded useEffect(() => { @@ -148,7 +138,7 @@ export function DeviceDetail({ mapboxToken }: DeviceDetailProps) { handleJourneySelect(null); setLocationTab('current'); } - }, [journeysLoading, journeys, selectedJourneyId, selectedJourney]); + }, [journeysLoading, journeys, selectedJourneyId, selectedJourney, handleJourneySelect]); // Calculate time range needed to cover the journey (if viewing one) const journeyTimeRangeHours = selectedJourney diff --git a/songbird-dashboard/src/types/analytics.ts b/songbird-dashboard/src/types/analytics.ts index 1a64234..f443006 100644 --- a/songbird-dashboard/src/types/analytics.ts +++ b/songbird-dashboard/src/types/analytics.ts @@ -1,8 +1,10 @@ +export type QueryRow = Record; + export interface QueryResult { sql: string; explanation: string; visualizationType: 'line_chart' | 'bar_chart' | 'table' | 'map' | 'scatter' | 'gauge'; - data: any[]; + data: QueryRow[]; insights: string; savedTimestamp?: number; // exact DynamoDB sort key for feedback linking }