diff --git a/src/lib/components/UI/dashboard/AllDevices.svelte b/src/lib/components/UI/dashboard/AllDevices.svelte index e3fc4126..e8671563 100644 --- a/src/lib/components/UI/dashboard/AllDevices.svelte +++ b/src/lib/components/UI/dashboard/AllDevices.svelte @@ -29,7 +29,7 @@ enableDragAndDrop = false } = $props<{ locations: LocationWithDevices[]; - deviceActiveStatus: Record; + deviceActiveStatus: Record; onDeviceReorder?: (locationId: number, newDevices: DeviceWithSensorData[]) => void; enableDragAndDrop?: boolean; }>(); @@ -107,8 +107,8 @@ {:else} {#each filteredLocations as location (location.location_id)} - {@const hasNullStatus = (location.cw_devices ?? []).some( - (d: DeviceWithSensorData) => deviceActiveStatus[d.dev_eui] === null + {@const hasLoadingStatus = (location.cw_devices ?? []).some( + (d: DeviceWithSensorData) => deviceActiveStatus[d.dev_eui] === undefined )} {@const activeDevices = (location.cw_devices ?? []).filter((d: DeviceWithSensorData) => isDeviceActive(d, deviceActiveStatus) @@ -123,7 +123,7 @@ {activeDevices} {allActive} {allInactive} - loading={hasNullStatus} + loading={hasLoadingStatus} > {#snippet content()} {@const locationDevices = location.cw_devices ?? []} diff --git a/src/lib/components/UI/dashboard/DataRowItem.svelte b/src/lib/components/UI/dashboard/DataRowItem.svelte index 84b67eba..199aaca8 100644 --- a/src/lib/components/UI/dashboard/DataRowItem.svelte +++ b/src/lib/components/UI/dashboard/DataRowItem.svelte @@ -38,7 +38,7 @@ } = $props<{ device: DeviceWithLatestData; location?: Location; - isActive?: boolean; + isActive?: boolean | null | undefined; detailHref?: string; children?: any; // snippet passed by parent onDragStart?: (event: DragEvent, index: number) => void; @@ -51,17 +51,17 @@ dragEnabled?: boolean; }>(); + type DeviceStatus = 'loading' | 'active' | 'inactive' | 'not-applicable'; + + const deviceStatus = $derived(() => { + if (externalIsActive === undefined) return 'loading'; + if (externalIsActive === null) return 'not-applicable'; + return externalIsActive ? 'active' : 'inactive'; + }); + let isActive = $derived( - externalIsActive !== undefined - ? externalIsActive === null - ? null - : Boolean(externalIsActive) - : null + deviceStatus === 'active' ? true : deviceStatus === 'inactive' ? false : null ); - let statusConfirmed = $state(false); - $effect(() => { - if (externalIsActive !== undefined && externalIsActive !== null) statusConfirmed = true; - }); let primaryDataKey = $derived(device.cw_device_type.primary_data_v2); let secondaryDataKey = $derived(device.cw_device_type.secondary_data_v2); @@ -112,10 +112,10 @@ >
; + deviceActiveStatus?: Record; selectedDevice?: string | null; onDevicesReorder?: ((newDevices: DeviceWithSensorData[]) => void) | undefined; enableDragAndDrop?: boolean; diff --git a/src/lib/components/UI/dashboard/DeviceDataList.svelte b/src/lib/components/UI/dashboard/DeviceDataList.svelte index 9c1f6d59..854efc0e 100644 --- a/src/lib/components/UI/dashboard/DeviceDataList.svelte +++ b/src/lib/components/UI/dashboard/DeviceDataList.svelte @@ -21,7 +21,10 @@ }; } - let { device, isActive = false } = $props<{ device: DeviceWithLatestData; isActive?: boolean }>(); + let { device, isActive = undefined } = $props<{ + device: DeviceWithLatestData; + isActive?: boolean | null | undefined; + }>(); // Log the active status for debugging $effect(() => { @@ -76,14 +79,20 @@ {#if dataPointKey === 'created_at'} -

- -  {$_('ago')} -

+ {#if device.last_data_updated_at} +

+ +  {$_('ago')} +

+ {:else} +

+ {$_('Not applicable')} +

+ {/if} {:else}
diff --git a/src/lib/components/UI/dashboard/LocationsPanel.svelte b/src/lib/components/UI/dashboard/LocationsPanel.svelte index 8d13221b..3fdbbfe3 100644 --- a/src/lib/components/UI/dashboard/LocationsPanel.svelte +++ b/src/lib/components/UI/dashboard/LocationsPanel.svelte @@ -14,7 +14,7 @@ // Props export let locations: LocationWithCount[] = []; export let selectedLocation: number | null = null; - export let deviceActiveStatus: Record = {}; + export let deviceActiveStatus: Record = {}; export let search: string = ''; // Function to handle location selection diff --git a/src/lib/components/dashboard/LocationSidebar.svelte b/src/lib/components/dashboard/LocationSidebar.svelte index 36b08183..648f23fd 100644 --- a/src/lib/components/dashboard/LocationSidebar.svelte +++ b/src/lib/components/dashboard/LocationSidebar.svelte @@ -40,7 +40,7 @@ dashboardViewType: string; dashboardSortType: string; collapsed: boolean; - deviceActiveStatus?: Record; + deviceActiveStatus?: Record; onSelectLocation: (locationId: number | null) => void; onToggleCollapse: () => void; onsearch: (search: string) => void; @@ -73,7 +73,7 @@ dashboardViewType?: 'grid' | 'list' | string; dashboardSortType?: 'name' | 'status' | string; collapsed?: boolean; - deviceActiveStatus?: Record; + deviceActiveStatus?: Record; onSelectLocation?: (locationId: number | null) => void; onToggleCollapse?: () => void; onsearch?: (value: string) => void; @@ -156,32 +156,29 @@ } // Check if any devices have null status (loading state) - const hasNullStatus = location.cw_devices.some( - (device) => device.dev_eui && deviceActiveStatus[device.dev_eui] === null + const hasLoadingStatus = location.cw_devices.some( + (device) => device.dev_eui && deviceActiveStatus[device.dev_eui] === undefined ); - // If any device has null status, show loading - if (hasNullStatus) { + if (hasLoadingStatus) { return { statusClass: 'status-loading', icon: mdiClockOutline }; } - // Convert deviceActiveStatus to a Record for the utility function - // This matches how AllDevices.svelte handles it for the dashboard cards - const activeStatusMap: Record = {}; - for (const [key, value] of Object.entries(deviceActiveStatus)) { - activeStatusMap[key] = value === true; // Only true values are considered active - } - - // Filter active devices using the same logic as the dashboard const activeDevices = location.cw_devices.filter( - (device) => device.dev_eui && activeStatusMap[device.dev_eui] === true + (device) => device.dev_eui && deviceActiveStatus[device.dev_eui] === true + ); + + const inactiveDevices = location.cw_devices.filter( + (device) => device.dev_eui && deviceActiveStatus[device.dev_eui] === false ); - // Calculate status flags using the same logic as the dashboard const allActive = location.cw_devices.length > 0 && activeDevices.length === location.cw_devices.length; - const allInactive = location.cw_devices.length > 0 && activeDevices.length === 0; + const allInactive = + location.cw_devices.length > 0 && + activeDevices.length === 0 && + inactiveDevices.length === location.cw_devices.length; // Return status based on the same conditions as the dashboard if (allActive) { diff --git a/src/lib/stores/LocationsStore.svelte.ts b/src/lib/stores/LocationsStore.svelte.ts index 87920026..21844818 100644 --- a/src/lib/stores/LocationsStore.svelte.ts +++ b/src/lib/stores/LocationsStore.svelte.ts @@ -25,6 +25,33 @@ const selectedLocation = $derived( locations.find((loc) => loc.location_id === selectedLocationId) || null ); +// Initialize locations from preloaded data +function initialize( + initialLocations: (Location & { deviceCount?: number; cw_devices?: DeviceWithSensorData[] })[] +) { + loadingLocations = false; + locationError = null; + + locations = (initialLocations || []).map((location) => ({ + ...location, + deviceCount: location.deviceCount ?? location.cw_devices?.length ?? 0, + cw_devices: location.cw_devices ? [...location.cw_devices] : [] + })); + + // Preserve existing selection if present; otherwise default to "All Locations" + if (selectedLocationId === null) { + devices = locations.flatMap((location) => location.cw_devices || []); + } else { + const selected = locations.find((loc) => loc.location_id === selectedLocationId); + devices = selected?.cw_devices ? [...selected.cw_devices] : []; + } + + loadingDevices = false; + deviceError = null; + + return locations; +} + // Function to fetch all locations for a user async function fetchLocations(userId: string) { try { @@ -120,7 +147,14 @@ async function loadDevicesForLocation(locationId: number) { loadingDevices = true; devices = []; - // Fetch devices for the selected location + const existing = locations.find((location) => location.location_id === locationId); + if (existing && existing.cw_devices) { + devices = [...existing.cw_devices]; + loadingDevices = false; + return devices; + } + + // Fetch devices for the selected location if not already loaded const response = await fetch(`/api/locations/${locationId}/devices`); if (!response.ok) throw new Error('Failed to fetch devices'); @@ -244,6 +278,7 @@ export function getLocationsStore() { }, // Methods + initialize, fetchLocations, selectLocation, loadDevicesForLocation, diff --git a/src/lib/utilities/dashboard.ts b/src/lib/utilities/dashboard.ts index 7518b8a2..9ed07cbf 100644 --- a/src/lib/utilities/dashboard.ts +++ b/src/lib/utilities/dashboard.ts @@ -1,6 +1,6 @@ /** * Dashboard utility functions - * + * * These functions are used across the dashboard components to handle * common operations like checking device status, formatting data, etc. */ @@ -14,34 +14,34 @@ * @returns boolean indicating if the device is active */ export function isDeviceActive( - device: DeviceWithSensorData, - deviceActiveStatus: Record -): boolean { - if (!device) return false; - - // Get the device ID - const devEui = device.dev_eui as string; - - // Special handling for devices with negative upload intervals (always active) - const uploadInterval = - device.upload_interval || device.deviceType?.default_upload_interval || 10; - if (uploadInterval <= 0) { - return true; - } - - // Special handling for soil sensors - if (isSoilSensor(device)) { - if (device.deviceType?.isActive !== undefined) { - return Boolean(device.deviceType.isActive); - } - - // If the soil sensor has moisture data, consider it active - if (device.latestData && 'moisture' in device.latestData) { - return true; - } - } - - return getDeviceActiveStatus(devEui, deviceActiveStatus); + device: DeviceWithSensorData, + deviceActiveStatus: Record +): boolean | null | undefined { + if (!device) return undefined; + + const devEui = device.dev_eui as string; + + if (devEui && Object.prototype.hasOwnProperty.call(deviceActiveStatus, devEui)) { + return deviceActiveStatus[devEui]; + } + + const lastUpdated = device.last_data_updated_at ?? null; + if (!lastUpdated) { + return null; + } + + const uploadInterval = + device.upload_interval || + device.cw_device_type?.default_upload_interval || + device.deviceType?.default_upload_interval || + 0; + + if (!uploadInterval || uploadInterval <= 0) { + return null; + } + + const diffMs = Date.now() - new Date(lastUpdated).getTime(); + return diffMs < uploadInterval * 60 * 1000; } /** @@ -51,10 +51,10 @@ export function isDeviceActive( * @returns boolean indicating if the device is active */ export function getDeviceActiveStatus( - deviceId: string, - deviceActiveStatus: Record + deviceId: string, + deviceActiveStatus: Record ): boolean { - return Boolean(deviceActiveStatus[deviceId]); + return deviceActiveStatus[deviceId] === true; } /** @@ -63,24 +63,24 @@ export function getDeviceActiveStatus( * @returns boolean indicating if the device is a soil sensor */ export function isSoilSensor(device: DeviceWithSensorData): boolean { - // Check device name for soil-related terms - const deviceName = device.name?.toLowerCase() || ''; - const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; - - // Check device type (type 17 is soil sensor in your system) - if (device.type === 17) { - return true; - } - - // Check if the device name or type contains soil-related terms - return ( - deviceName.includes('soil') || - deviceName.includes('moisture') || - deviceTypeName.includes('soil') || - deviceTypeName.includes('moisture') || - // Check if the device has soil-specific data points - (device.latestData && 'moisture' in device.latestData) - ); + // Check device name for soil-related terms + const deviceName = device.name?.toLowerCase() || ''; + const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; + + // Check device type (type 17 is soil sensor in your system) + if (device.type === 17) { + return true; + } + + // Check if the device name or type contains soil-related terms + return ( + deviceName.includes('soil') || + deviceName.includes('moisture') || + deviceTypeName.includes('soil') || + deviceTypeName.includes('moisture') || + // Check if the device has soil-specific data points + (device.latestData && 'moisture' in device.latestData) + ); } /** @@ -90,28 +90,28 @@ export function isSoilSensor(device: DeviceWithSensorData): boolean { * @returns Object with active devices array and status flags */ export function getLocationActiveStatus( - location: LocationWithCount, - deviceActiveStatus: Record + location: LocationWithCount, + deviceActiveStatus: Record ) { - if (!location || !location.cw_devices || location.cw_devices.length === 0) { - return { activeDevices: [], allActive: false, allInactive: false }; - } - - const locationDevices = location.cw_devices; - // Use isDeviceActive instead of getDeviceActiveStatus for consistency - const activeDevices = locationDevices.filter((device) => - isDeviceActive(device, deviceActiveStatus) - ); - - const allActive = - locationDevices.length > 0 && - locationDevices.every((device) => isDeviceActive(device, deviceActiveStatus)); - - const allInactive = - locationDevices.length > 0 && - locationDevices.every((device) => !isDeviceActive(device, deviceActiveStatus)); - - return { activeDevices, allActive, allInactive }; + if (!location || !location.cw_devices || location.cw_devices.length === 0) { + return { activeDevices: [], allActive: false, allInactive: false }; + } + + const locationDevices = location.cw_devices; + // Use isDeviceActive instead of getDeviceActiveStatus for consistency + const activeDevices = locationDevices.filter((device) => + isDeviceActive(device, deviceActiveStatus) + ); + + const allActive = + locationDevices.length > 0 && + locationDevices.every((device) => isDeviceActive(device, deviceActiveStatus) === true); + + const allInactive = + locationDevices.length > 0 && + locationDevices.every((device) => isDeviceActive(device, deviceActiveStatus) === false); + + return { activeDevices, allActive, allInactive }; } /** @@ -120,16 +120,16 @@ export function getLocationActiveStatus( * @returns CSS class string */ export function getContainerClass(viewType: string): string { - switch (viewType) { - case 'grid': - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - case 'mozaic': - return 'columns-[20rem] gap-4 space-y-4'; - case 'list': - return 'flex flex-col gap-4'; - default: - return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; - } + switch (viewType) { + case 'grid': + return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; + case 'mozaic': + return 'columns-[20rem] gap-4 space-y-4'; + case 'list': + return 'flex flex-col gap-4'; + default: + return 'grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4 xl:grid-cols-5'; + } } /** @@ -139,12 +139,12 @@ export function getContainerClass(viewType: string): string { * @param handleLocationClick - Function to handle location selection */ export function handleKeyDown( - e: KeyboardEvent, - location: Location, - handleLocationClick: (location: Location) => void + e: KeyboardEvent, + location: Location, + handleLocationClick: (location: Location) => void ): void { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleLocationClick(location); - } + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleLocationClick(location); + } } diff --git a/src/lib/utilities/dashboardHelpers.ts b/src/lib/utilities/dashboardHelpers.ts index d602bbd6..127143d3 100644 --- a/src/lib/utilities/dashboardHelpers.ts +++ b/src/lib/utilities/dashboardHelpers.ts @@ -13,48 +13,47 @@ export function isSoilSensor(device: any): boolean { ); } -export function isDeviceActive(device: any, deviceActiveStatus: Record): boolean | null { - if (!device) return false; +export function isDeviceActive( + device: any, + deviceActiveStatus: Record +): boolean | null | undefined { + if (!device) return undefined; const devEui = device.dev_eui as string; - - // Always check the deviceActiveStatus first - this comes from our timer logic - // This ensures we respect the 35-minute maximum threshold - if (devEui in deviceActiveStatus) { - // If the status is null, return null to indicate unknown/loading state - if (deviceActiveStatus[devEui] === null) { - return null; - } - return Boolean(deviceActiveStatus[devEui]); + + if (devEui && Object.prototype.hasOwnProperty.call(deviceActiveStatus, devEui)) { + return deviceActiveStatus[devEui]; } - - // Only use these fallbacks if we don't have timer data - const uploadInterval = device.upload_interval || device.deviceType?.default_upload_interval || 10; - - // Default to inactive for devices with invalid upload intervals - if (uploadInterval <= 0) { - return false; + + const lastUpdated = device.last_data_updated_at ?? null; + if (!lastUpdated) { + return null; } - - // Only consider device type isActive if we don't have timer data - if (device.deviceType?.isActive !== undefined) { - return Boolean(device.deviceType.isActive); + + const uploadInterval = + device.upload_interval || + device.cw_device_type?.default_upload_interval || + device.deviceType?.default_upload_interval || + 0; + + if (!uploadInterval || uploadInterval <= 0) { + return null; } - - // Default to inactive if we can't determine status - return false; + + const diffMs = Date.now() - new Date(lastUpdated).getTime(); + return diffMs < uploadInterval * 60 * 1000; } export function getDeviceActiveStatus( devEui: string | null, - deviceActiveStatus: Record + deviceActiveStatus: Record ): boolean { - if (!devEui || deviceActiveStatus[devEui] == null) return false; - return Boolean(deviceActiveStatus[devEui]); + if (!devEui) return false; + return deviceActiveStatus[devEui] === true; } export function getLocationActiveStatus( location: any, - deviceActiveStatus: Record + deviceActiveStatus: Record ) { if (!location || !location.cw_devices || location.cw_devices.length === 0) { return { activeDevices: [], allActive: false, allInactive: false }; @@ -65,9 +64,9 @@ export function getLocationActiveStatus( ); const allActive = locationDevices.length > 0 && - locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus)); + locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus) === true); const allInactive = locationDevices.length > 0 && - locationDevices.every((device: any) => !isDeviceActive(device, deviceActiveStatus)); + locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus) === false); return { activeDevices, allActive, allInactive }; } diff --git a/src/lib/utilities/deviceTimerManager.ts b/src/lib/utilities/deviceTimerManager.ts index d9c464be..31141a52 100644 --- a/src/lib/utilities/deviceTimerManager.ts +++ b/src/lib/utilities/deviceTimerManager.ts @@ -47,45 +47,50 @@ export class DeviceTimerManager { uploadInterval: number, onStatusChange?: (deviceId: string, isActive: boolean | null) => void ): void { - if (!device.latestData?.created_at) return; const deviceId = device.dev_eui as string; + const lastUpdated = device.last_data_updated_at ?? null; + + if (!lastUpdated) { + this.cleanupDeviceTimer(deviceId); + this.deviceActiveStatus[deviceId] = null; + if (onStatusChange) { + onStatusChange(deviceId, null); + } + return; + } // Clear any existing timer for this device this.cleanupDeviceTimer(deviceId); // Use provided uploadInterval or fallback to device settings - const effectiveInterval = + const effectiveInterval = Number( uploadInterval || - device.upload_interval || - device.cw_device_type?.default_upload_interval || - 10; + device.upload_interval || + device.cw_device_type?.default_upload_interval || + 0 + ); + + if (!effectiveInterval || effectiveInterval <= 0) { + this.deviceActiveStatus[deviceId] = null; + if (onStatusChange) { + onStatusChange(deviceId, null); + } + return; + } - // Create a closure to capture the current timestamp - const currentTimestamp = device.latestData.created_at; + const currentTimestamp = lastUpdated; - const activeTimer = createActiveTimer(new Date(currentTimestamp), Number(effectiveInterval)); + const activeTimer = createActiveTimer(new Date(currentTimestamp), effectiveInterval); // Update device active status when timer changes const unsubscribe = activeTimer.subscribe((isActive) => { - // Only update if this is still the current device data - // This prevents old timers from incorrectly marking devices as inactive - if (device.latestData?.created_at === currentTimestamp) { - //console.log(`Device ${device.name} (${deviceId}) status updated:`, { - // isActive, - // lastUpdate: currentTimestamp, - // uploadInterval - // }); + const latestTimestamp = device.last_data_updated_at ?? null; + if (latestTimestamp === currentTimestamp) { this.deviceActiveStatus[deviceId] = isActive; - // Call the callback if provided if (onStatusChange) { onStatusChange(deviceId, isActive); } - } else { - //console.log(`Ignoring outdated timer update for ${device.name} (${deviceId})`, { - // timerTimestamp: currentTimestamp, - // currentTimestamp: device.latestData?.created_at - // }); } }); @@ -94,7 +99,6 @@ export class DeviceTimerManager { this.unsubscribers.push(unsubscribe); this.deviceTimers[deviceId] = timerIndex; } - /** * Clean up a device timer * @param deviceId The ID of the device to clean up the timer for @@ -198,9 +202,8 @@ export class DeviceTimerManager { * @param deviceId The device ID to get the active status for * @returns The active status of the device, or false if not found */ - getDeviceActiveStatus(deviceId: string): boolean { - // Convert null to false, but keep true/false values as is - return this.deviceActiveStatus[deviceId] ?? false; + getDeviceActiveStatus(deviceId: string): boolean | null | undefined { + return this.deviceActiveStatus[deviceId]; } /** diff --git a/src/lib/utilities/deviceTimerSetup.ts b/src/lib/utilities/deviceTimerSetup.ts index 86e5d60b..c7250939 100644 --- a/src/lib/utilities/deviceTimerSetup.ts +++ b/src/lib/utilities/deviceTimerSetup.ts @@ -18,14 +18,27 @@ export interface DeviceWithSensorData extends DeviceWithType { export function setupDeviceActiveTimer( device: DeviceWithSensorData, timerManager: DeviceTimerManager, - deviceActiveStatus: Record + deviceActiveStatus: Record ) { - if (!device.latestData?.created_at) return; const deviceId = device.dev_eui as string; + const lastUpdated = device.last_data_updated_at ?? null; + + if (!lastUpdated) { + deviceActiveStatus[deviceId] = null; + timerManager.cleanupDeviceTimer(deviceId); + return; + } + // Get the upload interval from the device const uploadInterval = - device.upload_interval || device.cw_device_type?.default_upload_interval || 10; + device.upload_interval || device.cw_device_type?.default_upload_interval || 0; + + if (!uploadInterval || uploadInterval <= 0) { + deviceActiveStatus[deviceId] = null; + timerManager.cleanupDeviceTimer(deviceId); + return; + } // Use the timer manager to set up a timer for this device timerManager.setupDeviceActiveTimer( @@ -33,7 +46,7 @@ export function setupDeviceActiveTimer( uploadInterval, (deviceId: string, isActive: boolean | null) => { // Update the device active status in our component state - deviceActiveStatus[deviceId] = isActive === null ? false : isActive; + deviceActiveStatus[deviceId] = isActive; } ); } diff --git a/src/lib/utilities/deviceUtils.ts b/src/lib/utilities/deviceUtils.ts index b102fefe..c218207e 100644 --- a/src/lib/utilities/deviceUtils.ts +++ b/src/lib/utilities/deviceUtils.ts @@ -8,24 +8,24 @@ * @returns boolean indicating if the device is a soil sensor */ export function isSoilSensor(device: any): boolean { - // Check device name for soil-related terms - const deviceName = device.name?.toLowerCase() || ''; - const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; + // Check device name for soil-related terms + const deviceName = device.name?.toLowerCase() || ''; + const deviceTypeName = device.deviceType?.name?.toLowerCase() || ''; - // Check device type (type 17 is soil sensor in your system) - if (device.type === 17) { - return true; - } + // Check device type (type 17 is soil sensor in your system) + if (device.type === 17) { + return true; + } - // Check if the device name or type contains soil-related terms - return ( - deviceName.includes('soil') || - deviceName.includes('moisture') || - deviceTypeName.includes('soil') || - deviceTypeName.includes('moisture') || - // Check if the device has soil-specific data points - (device.latestData && 'moisture' in device.latestData) - ); + // Check if the device name or type contains soil-related terms + return ( + deviceName.includes('soil') || + deviceName.includes('moisture') || + deviceTypeName.includes('soil') || + deviceTypeName.includes('moisture') || + // Check if the device has soil-specific data points + (device.latestData && 'moisture' in device.latestData) + ); } /** @@ -35,34 +35,34 @@ export function isSoilSensor(device: any): boolean { * @returns boolean indicating if the device is active */ export function isDeviceActive( - device: any, - deviceActiveStatus: Record -): boolean { - if (!device) return false; + device: any, + deviceActiveStatus: Record +): boolean | null | undefined { + if (!device) return undefined; - // Get the device ID - const devEui = device.dev_eui as string; + const devEui = device.dev_eui as string; - // Special handling for devices with negative upload intervals (always active) - const uploadInterval = - device.upload_interval || device.deviceType?.default_upload_interval || 10; - if (uploadInterval <= 0) { - return true; - } + if (devEui && Object.prototype.hasOwnProperty.call(deviceActiveStatus, devEui)) { + return deviceActiveStatus[devEui]; + } - // Special handling for soil sensors - if (isSoilSensor(device)) { - if (device.deviceType?.isActive !== undefined) { - return Boolean(device.deviceType.isActive); - } + const lastUpdated = device.last_data_updated_at ?? null; + if (!lastUpdated) { + return null; + } - // If the soil sensor has moisture data, consider it active - if (device.latestData && 'moisture' in device.latestData) { - return true; - } - } + const uploadInterval = + device.upload_interval || + device.cw_device_type?.default_upload_interval || + device.deviceType?.default_upload_interval || + 0; - return Boolean(deviceActiveStatus[devEui]); + if (!uploadInterval || uploadInterval <= 0) { + return null; + } + + const diffMs = Date.now() - new Date(lastUpdated).getTime(); + return diffMs < uploadInterval * 60 * 1000; } /** @@ -72,26 +72,26 @@ export function isDeviceActive( * @returns Object with active devices array and status flags */ export function getLocationActiveStatus( - location: any, - deviceActiveStatus: Record + location: any, + deviceActiveStatus: Record ) { - if (!location || !location.cw_devices || location.cw_devices.length === 0) { - return { activeDevices: [], allActive: false, allInactive: false }; - } + if (!location || !location.cw_devices || location.cw_devices.length === 0) { + return { activeDevices: [], allActive: false, allInactive: false }; + } + + const locationDevices = location.cw_devices; + // Use isDeviceActive instead of getDeviceActiveStatus for consistency + const activeDevices = locationDevices.filter((device: any) => + isDeviceActive(device, deviceActiveStatus) + ); + + const allActive = + locationDevices.length > 0 && + locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus) === true); - const locationDevices = location.cw_devices; - // Use isDeviceActive instead of getDeviceActiveStatus for consistency - const activeDevices = locationDevices.filter((device: any) => - isDeviceActive(device, deviceActiveStatus) - ); - - const allActive = - locationDevices.length > 0 && - locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus)); - - const allInactive = - locationDevices.length > 0 && - locationDevices.every((device: any) => !isDeviceActive(device, deviceActiveStatus)); + const allInactive = + locationDevices.length > 0 && + locationDevices.every((device: any) => isDeviceActive(device, deviceActiveStatus) === false); - return { activeDevices, allActive, allInactive }; + return { activeDevices, allActive, allInactive }; } diff --git a/src/routes/app/dashboard/+page.server.ts b/src/routes/app/dashboard/+page.server.ts index ac0dd115..b2e34e52 100644 --- a/src/routes/app/dashboard/+page.server.ts +++ b/src/routes/app/dashboard/+page.server.ts @@ -2,32 +2,166 @@ import { redirect } from '@sveltejs/kit'; import type { PageServerLoad } from './$types'; import { SessionService } from '$lib/services/SessionService'; +interface LocationWithDevices { + location_id: number; + name: string; + description: string | null; + lat: number | null; + long: number | null; + created_at: string; + map_zoom: number | null; + owner_id: string | null; + cw_devices: any[]; +} + export const load: PageServerLoad = async ({ locals }) => { - // Create a new SessionService instance with the per-request Supabase client const sessionService = new SessionService(locals.supabase); const sessionResult = await sessionService.getSafeSession(); - const { data: devices, error } = await locals.supabase.from('cw_devices').select(` - *, - cw_device_owners(user_id), - cw_locations(*), - cw_device_type(*) - `); - - // If no session exists, redirect to login if (!sessionResult || !sessionResult.user) { throw redirect(302, '/auth/login'); } const { user } = sessionResult; - // Return user data to the page + // Collect device identifiers the user has access to (owned + shared) + const { data: ownedDeviceRows, error: ownedDeviceError } = await locals.supabase + .from('cw_devices') + .select('dev_eui, location_id') + .eq('user_id', user.id); + + if (ownedDeviceError) { + console.error('Failed to load owned devices for dashboard:', ownedDeviceError); + } + + const { data: sharedDeviceRows, error: sharedDeviceError } = await locals.supabase + .from('cw_device_owners') + .select('cw_devices(dev_eui, location_id)') + .eq('user_id', user.id) + .lte('permission_level', 3); + + if (sharedDeviceError) { + console.error('Failed to load shared devices for dashboard:', sharedDeviceError); + } + + const deviceEuis = new Set(); + const deviceLocationLookup = new Map(); + + (ownedDeviceRows || []).forEach((device) => { + deviceEuis.add(device.dev_eui); + deviceLocationLookup.set(device.dev_eui, device.location_id ?? null); + }); + + (sharedDeviceRows || []).forEach((row: any) => { + const sharedDevice = row.cw_devices; + if (sharedDevice?.dev_eui) { + deviceEuis.add(sharedDevice.dev_eui); + deviceLocationLookup.set(sharedDevice.dev_eui, sharedDevice.location_id ?? null); + } + }); + + const { data: ownedLocations, error: ownedLocationsError } = await locals.supabase + .from('cw_locations') + .select('*') + .eq('owner_id', user.id) + .order('name'); + + if (ownedLocationsError) { + console.error('Failed to load owned locations for dashboard:', ownedLocationsError); + } + + const { data: sharedLocationsRows, error: sharedLocationsError } = await locals.supabase + .from('cw_location_owners') + .select('cw_locations(*)') + .eq('user_id', user.id) + .eq('is_active', true); + + if (sharedLocationsError) { + console.error('Failed to load shared locations for dashboard:', sharedLocationsError); + } + + const locationsMap = new Map(); + + (ownedLocations || []).forEach((location) => { + locationsMap.set(location.location_id, { + ...location, + cw_devices: [] + }); + }); + + (sharedLocationsRows || []).forEach((row: any) => { + const location = row.cw_locations; + if (location && !locationsMap.has(location.location_id)) { + locationsMap.set(location.location_id, { + ...location, + cw_devices: [] + }); + } + }); + + const deviceList = Array.from(deviceEuis); + let devices: any[] = []; + + if (deviceList.length > 0) { + const { data: deviceRows, error: deviceDetailsError } = await locals.supabase + .from('cw_devices') + .select( + ` + *, + cw_device_type(*), + cw_locations(*) + ` + ) + .in('dev_eui', deviceList) + .order('name'); + + if (deviceDetailsError) { + console.error('Failed to load device details for dashboard:', deviceDetailsError); + } + + devices = deviceRows || []; + + devices.forEach((device) => { + const locationId = device.location_id ?? deviceLocationLookup.get(device.dev_eui) ?? null; + if (locationId != null && locationsMap.has(locationId)) { + const locationEntry = locationsMap.get(locationId)!; + locationEntry.cw_devices.push(device); + } + }); + } + + // Create an "Unassigned" bucket for devices without a location + const unassignedDevices = devices.filter((device) => { + const locationId = device.location_id ?? deviceLocationLookup.get(device.dev_eui) ?? null; + return locationId == null; + }); + + if (unassignedDevices.length > 0) { + locationsMap.set(-1, { + location_id: -1, + name: 'Unassigned Devices', + description: 'Devices not currently assigned to a location', + lat: null, + long: null, + created_at: new Date(0).toISOString(), + map_zoom: null, + owner_id: user.id, + cw_devices: unassignedDevices + }); + } + + const locations = Array.from(locationsMap.values()).map((location) => ({ + ...location, + cw_devices: location.cw_devices || [] + })); + return { user: { - devices, id: user.id, email: user.email, name: user.user_metadata?.name || user.email?.split('@')[0] || 'User' - } + }, + devices, + locations }; }; diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte index 17578a51..6a27073d 100644 --- a/src/routes/app/dashboard/+page.svelte +++ b/src/routes/app/dashboard/+page.svelte @@ -5,10 +5,20 @@ import { getDashboardUIStore } from '$lib/stores/DashboardUIStore.svelte'; import { DeviceTimerManager } from '$lib/utilities/deviceTimerManager'; import { setupDeviceActiveTimer } from '$lib/utilities/deviceTimerSetup'; - import { applyStoredDeviceOrder } from '$lib/utilities/deviceOrderStorage'; + import type { Location } from '$lib/models/Location'; + + interface DashboardPageData { + user: { + id: string; + email: string; + name: string; + }; + locations: LocationWithDevices[]; + devices: DeviceWithType[]; + } // Get user data from the server load function - let { data } = $props(); + let { data }: { data: DashboardPageData } = $props(); const user = data.user; let isTabVisible = $state(true); let lastRefresh = $state(new Date()); @@ -46,25 +56,39 @@ let channel: RealtimeChannel | undefined = $state(); // Initialize stores and managers - // Use writable store for device active status - initialize as null (unknown) for all devices - const deviceActiveStatus = $state>({}); + // Use writable store for device active status + const deviceActiveStatus = $state>({}); // Initialize the locations store const locationsStore = getLocationsStore(); - // Pre-initialize all devices as null (unknown status) to prevent flash of green - $effect(() => { - if (locationsStore.locations.length > 0) { - locationsStore.locations.forEach((location) => { - if (location.cw_devices && location.cw_devices.length > 0) { - location.cw_devices.forEach((device) => { - if (device.dev_eui && !(device.dev_eui in deviceActiveStatus)) { - deviceActiveStatus[device.dev_eui] = null; - } - }); + let hasInitialized = $state(false); + + function initializeLocationsFromServer() { + if (hasInitialized) { + return; + } + + locationsStore.initialize(data.locations || []); + hasInitialized = true; + } + + function setupTimersForCurrentLocations() { + locationsStore.locations.forEach((location) => { + if (!location.cw_devices || location.cw_devices.length === 0) { + return; + } + + location.cw_devices.forEach((device: DeviceWithSensorData) => { + if (device.last_data_updated_at) { + setupDeviceActiveTimer(device, timerManager, deviceActiveStatus); + } else if (device.dev_eui) { + deviceActiveStatus[device.dev_eui] = null; } }); - } - }); + }); + } + + initializeLocationsFromServer(); // Device reordering handler function handleDeviceReorder(locationId: number, newDevices: any) { @@ -141,23 +165,41 @@ // Handle real-time update function handleRealtimeUpdate(payload: any) { - // Only process if we have valid data - if (!payload) return; - if (payload.new && payload.new.dev_eui) { - try { - // Update device active timer for the updated device - const device = locationsStore.devices.find((d) => d.dev_eui === payload.new.dev_eui); - if (!device) { - console.warn('Device not found for real-time update:', payload.new.dev_eui); - return; + if (!payload?.new?.dev_eui) return; + + try { + const devEui = payload.new.dev_eui; + const lastUpdated = payload.new.last_data_updated_at ?? null; + + // Attempt to locate the device in the currently selected devices first + let device = locationsStore.devices.find((d) => d.dev_eui === devEui); + + if (!device) { + for (const location of locationsStore.locations) { + const match = location.cw_devices?.find((d) => d.dev_eui === devEui); + if (match) { + device = match as DeviceWithSensorData; + break; + } } - // Update the device's last_data_updated_at success - console.log('Updating device from real-time:', payload.new.dev_eui); - device.last_data_updated_at = payload.new.last_data_updated_at; + } + + if (!device) { + console.warn('Device not found for real-time update:', devEui); + return; + } + + console.log('Updating device from real-time:', devEui); + device.last_data_updated_at = lastUpdated; + + if (lastUpdated) { setupDeviceActiveTimer(device, timerManager, deviceActiveStatus); - } catch (error) { - console.error('Error updating device from real-time:', error); + } else if (devEui) { + deviceActiveStatus[devEui] = null; + timerManager.cleanupDeviceTimer(devEui); } + } catch (error) { + console.error('Error updating device from real-time:', error); } } @@ -205,22 +247,9 @@ // This is the main onMount function for the dashboard onMount(async () => { try { - // Setup real-time subscription + initializeLocationsFromServer(); setupRealtimeSubscription(); - - // Fetch locations using the store - this also selects the first location - await locationsStore.fetchLocations(user.id); - - // Setup active timers for all devices in all locations - locationsStore.locations.forEach((location) => { - if (location.cw_devices && location.cw_devices.length > 0) { - location.cw_devices.forEach((device: DeviceWithSensorData) => { - if (device.latestData?.created_at) { - setupDeviceActiveTimer(device, timerManager, deviceActiveStatus); - } - }); - } - }); + setupTimersForCurrentLocations(); // Set up polling for the selected location if (locationsStore.selectedLocationId) { @@ -261,8 +290,10 @@ // Setup active timers for each device if (locationsStore.devices && Array.isArray(locationsStore.devices)) { locationsStore.devices.forEach((device: DeviceWithSensorData) => { - if (device.latestData?.created_at) { + if (device.last_data_updated_at) { setupDeviceActiveTimer(device, timerManager, deviceActiveStatus); + } else if (device.dev_eui) { + deviceActiveStatus[device.dev_eui] = null; } }); @@ -273,7 +304,7 @@ locationId, deviceCount: locationsStore.devices.length, activeCount: locationsStore.devices.filter( - (d: DeviceWithSensorData) => deviceActiveStatus[d.dev_eui as string] + (d: DeviceWithSensorData) => deviceActiveStatus[d.dev_eui as string] === true ).length }); }