diff --git a/src/lib/components/ui/base/Duration.svelte b/src/lib/components/ui/base/Duration.svelte
index 1b7779e7..107f6c17 100644
--- a/src/lib/components/ui/base/Duration.svelte
+++ b/src/lib/components/ui/base/Duration.svelte
@@ -1,4 +1,5 @@
{value}
diff --git a/src/lib/utilities/duration.ts b/src/lib/utilities/duration.ts
index 074b4c81..16cbc115 100644
--- a/src/lib/utilities/duration.ts
+++ b/src/lib/utilities/duration.ts
@@ -10,11 +10,12 @@ export function formatDuration(
start: string | Date,
{
totalUnits = 2,
- minUnits = DurationUnits.Second
- }: { totalUnits?: number; minUnits?: DurationUnits } = {}
+ minUnits = DurationUnits.Second,
+ now = Date.now()
+ }: { totalUnits?: number; minUnits?: DurationUnits; now?: number } = {}
) {
const startTime = new Date(start).getTime();
- const diffMs = Date.now() - startTime;
+ const diffMs = now - startTime;
const units: { unit: DurationUnits; label: string; ms: number }[] = [
{ unit: DurationUnits.Day, label: 'd', ms: 86400000 },
{ unit: DurationUnits.Hour, label: 'h', ms: 3600000 },
diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte
index efa16dc2..4061370b 100644
--- a/src/routes/app/dashboard/+page.svelte
+++ b/src/routes/app/dashboard/+page.svelte
@@ -51,8 +51,13 @@
// Create a timer manager instance
const timerManager = new DeviceTimerManager();
+ const TEN_MINUTES_IN_MS = 10 * 60 * 1000;
+
+ let locationRefreshIntervals = $state>({});
+ let locationRefreshInFlight = $state>({});
let channel: RealtimeChannel | undefined = $state();
+ let broadcastChannel: RealtimeChannel | undefined = $state();
// Initialize stores and managers
// Use writable store for device active status - initialize as null (unknown) for all devices
@@ -86,6 +91,68 @@
}
}
+ function getLocationsWithInactiveDevices(): number[] {
+ const inactiveLocationIds: number[] = [];
+ const currentLocations = locationsStore.locations;
+ const currentStatuses = deviceActiveStatus;
+ if (!currentLocations || currentLocations.length === 0) return inactiveLocationIds;
+ currentLocations.forEach((location) => {
+ if (!location?.location_id) return;
+ const hasInactiveDevice = (location.cw_devices ?? []).some((device: DeviceWithSensorData) => {
+ const devEui = device?.dev_eui ?? null;
+ if (!devEui) return false;
+ return currentStatuses[devEui] === false;
+ });
+ if (hasInactiveDevice) inactiveLocationIds.push(location.location_id);
+ });
+ return inactiveLocationIds;
+ }
+
+ async function refreshLocationById(locationId: number, reason: string) {
+ if (!browser || !locationId) return;
+ if (locationRefreshInFlight[locationId]) return;
+ locationRefreshInFlight[locationId] = true;
+ try {
+ const refreshed = await refreshDevicesForLocation(locationId);
+ if (!refreshed) {
+ console.warn(`Refresh for location ${locationId} (${reason}) returned false.`);
+ }
+ } catch (error) {
+ console.error(`Failed to refresh location ${locationId} (${reason})`, error);
+ } finally {
+ locationRefreshInFlight[locationId] = false;
+ }
+ }
+
+ function ensureLocationRefreshInterval(locationId: number, reason: string) {
+ if (!browser || !locationId) return;
+ if (!locationRefreshIntervals[locationId]) {
+ void refreshLocationById(locationId, reason);
+ locationRefreshIntervals[locationId] = window.setInterval(() => {
+ void refreshLocationById(locationId, 'interval');
+ }, TEN_MINUTES_IN_MS);
+ }
+ }
+
+ function clearLocationRefreshInterval(locationId: number) {
+ const intervalId = locationRefreshIntervals[locationId];
+ if (intervalId) {
+ clearInterval(intervalId);
+ delete locationRefreshIntervals[locationId];
+ }
+ }
+
+ function clearAllLocationRefreshIntervals() {
+ Object.keys(locationRefreshIntervals).forEach((id) => {
+ clearLocationRefreshInterval(Number(id));
+ });
+ }
+
+ function refreshInactiveLocations(reason: string) {
+ const inactiveLocationIds = getLocationsWithInactiveDevices();
+ inactiveLocationIds.forEach((locationId) => ensureLocationRefreshInterval(locationId, reason));
+ }
+
$effect(() => {
function handleVisibilityChange() {
isTabVisible = document.visibilityState === 'visible';
@@ -96,16 +163,16 @@
if (savedState !== null) {
sidebarCollapsed = savedState === 'true';
}
+ setupBroadcastSubscription();
setupRealtimeSubscription();
+ refreshInactiveLocations('tab-visible');
}
} else {
console.log('Tab is not visible');
- data.supabase.removeAllChannels();
cleanupTimers();
cleanupRealtimeSubscription();
- if (channel) {
- data.supabase.realtime.removeChannel(channel);
- }
+ cleanupBroadcastSubscription();
+ clearAllLocationRefreshIntervals();
}
}
document.addEventListener('visibilitychange', handleVisibilityChange);
@@ -114,18 +181,31 @@
};
});
+ $effect(() => {
+ if (!browser || !isTabVisible) return;
+ const inactiveLocationIds = new Set(getLocationsWithInactiveDevices());
+ inactiveLocationIds.forEach((locationId) =>
+ ensureLocationRefreshInterval(locationId, 'inactive-device')
+ );
+ Object.keys(locationRefreshIntervals).forEach((id) => {
+ const locationId = Number(id);
+ if (!inactiveLocationIds.has(locationId)) {
+ clearLocationRefreshInterval(locationId);
+ }
+ });
+ });
+
// Initialize the dashboard UI store for preferences
const uiStore = getDashboardUIStore();
// Sidebar collapsed state
let sidebarCollapsed = $state(false);
- // Real-time channel for database updates
- let realtimeChannel: any = null;
-
// Setup real-time subscriptions with retry logic
function setupRealtimeSubscription(retryCount = 0) {
if (!browser) return;
+ setupBroadcastSubscription();
+ if (channel) return;
console.log('🔄 Setting up real-time subscription...');
channel = data.supabase
@@ -174,31 +254,116 @@
}
}
)
- .subscribe();
+ .subscribe((status, err) => {
+ console.debug('[Dashboard] DB channel status', { status, err });
+ if (status === 'CHANNEL_ERROR') {
+ console.error('DB channel error', err);
+ }
+ });
+
+ setupBroadcastSubscription();
}
// Handle real-time update
function handleRealtimeUpdate(payload: any) {
// Only process if we have valid data
if (payload.new && payload.new.dev_eui) {
- try {
- locationsStore.updateSingleDevice(payload.new.dev_eui, payload.new as AirData | SoilData);
+ console.debug('[Dashboard] Postgres change received', {
+ eventType: payload.eventType,
+ table: payload.table,
+ dev_eui: payload.new.dev_eui,
+ created_at: payload.new.created_at
+ });
+ applyDeviceDataUpdate(payload.new as AirData | SoilData);
+ } else {
+ console.debug('[Dashboard] Postgres change ignored', payload);
+ }
+ }
- // Update device active timer for the updated device
- const device = locationsStore.devices.find((d) => d.dev_eui === payload.new.dev_eui);
- if (device && device.latestData?.created_at) {
- setupDeviceActiveTimer(device, timerManager, deviceActiveStatus);
- }
- } catch (error) {
- console.error('Error updating device from real-time:', error);
+ function applyDeviceDataUpdate(record: AirData | SoilData) {
+ console.debug('[Dashboard] Applying device update', {
+ recordDevEui: record?.dev_eui,
+ timestamp: record?.created_at,
+ source: record?.data_source ?? 'unknown'
+ });
+ if (!record?.dev_eui) return;
+ try {
+ const beforeDevice = locationsStore.devices.find((d) => d.dev_eui === record.dev_eui);
+ console.debug('[Dashboard] Device snapshot before update', {
+ found: Boolean(beforeDevice),
+ latestCreatedAt: beforeDevice?.latestData?.created_at
+ });
+ locationsStore.updateSingleDevice(record.dev_eui, record);
+
+ const device = locationsStore.devices.find((d) => d.dev_eui === record.dev_eui);
+ if (device && device.latestData?.created_at) {
+ setupDeviceActiveTimer(device, timerManager, deviceActiveStatus);
}
+
+ console.debug('[Dashboard] Device snapshot after update', {
+ found: Boolean(device),
+ latestCreatedAt: device?.latestData?.created_at
+ });
+
+ lastRefresh = new Date();
+ } catch (error) {
+ console.error('Error applying device data update:', error);
}
}
+ function setupBroadcastSubscription() {
+ if (!browser) return;
+ if (broadcastChannel) return;
+
+ console.log('📡 Setting up broadcast subscription...');
+ broadcastChannel = data.supabase
+ .channel('cw_air_data', { config: { private: true } })
+ .on('broadcast', { event: 'INSERT' }, (payload) => {
+ const record = payload?.payload?.record;
+ console.debug('[Dashboard] Broadcast INSERT received', {
+ payload,
+ dev_eui: record?.dev_eui
+ });
+ if (record?.dev_eui) {
+ applyDeviceDataUpdate(record as AirData | SoilData);
+ }
+ })
+ .on('broadcast', { event: 'UPDATE' }, (payload) => {
+ const record = payload?.payload?.record;
+ console.debug('[Dashboard] Broadcast UPDATE received', {
+ payload,
+ dev_eui: record?.dev_eui
+ });
+ if (record?.dev_eui) {
+ applyDeviceDataUpdate(record as AirData | SoilData);
+ }
+ })
+ .subscribe((status, err) => {
+ console.debug('[Dashboard] Broadcast channel status', { status, err });
+ if (status === 'CHANNEL_ERROR') {
+ console.error('Broadcast channel error', err);
+ }
+ if (status === 'TIMED_OUT') {
+ console.warn('Broadcast channel timed out');
+ }
+ if (status === 'CLOSED') {
+ console.warn('Broadcast channel closed');
+ broadcastChannel = undefined;
+ }
+ });
+ }
+
function cleanupRealtimeSubscription() {
- if (realtimeChannel) {
- data.supabase.removeAllChannels();
- realtimeChannel = null;
+ if (channel) {
+ data.supabase.removeChannel(channel);
+ channel = undefined;
+ }
+ }
+
+ function cleanupBroadcastSubscription() {
+ if (broadcastChannel) {
+ data.supabase.removeChannel(broadcastChannel);
+ broadcastChannel = undefined;
}
}
@@ -214,12 +379,9 @@
});
onDestroy(() => {
console.log('the component is being destroyed');
- data.supabase.removeAllChannels();
cleanupTimers();
cleanupRealtimeSubscription();
- if (channel) {
- data.supabase.realtime.removeChannel(channel);
- }
+ cleanupBroadcastSubscription();
});
// Persist UI store values to localStorage when they change
@@ -271,6 +433,7 @@
timerManager.cleanupPolling();
// Clean up all active timers using the timer manager
timerManager.cleanupTimers();
+ clearAllLocationRefreshIntervals();
}
// Function to refresh devices for a location without changing the selected location