Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/lib/components/ui/base/Duration.svelte
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
<script lang="ts">
import { browser } from '$app/environment';
import { formatDuration, DurationUnits } from '$lib/utilities/duration';
export { DurationUnits };
let {
Expand All @@ -10,7 +11,34 @@
totalUnits?: number;
minUnits?: DurationUnits;
}>();
const value = $derived(formatDuration(start, { totalUnits, minUnits }));

let now = $state(Date.now());

function computeUpdateInterval(unit: DurationUnits): number {
switch (unit) {
case DurationUnits.Day:
return 60 * 60 * 1000; // hourly updates
case DurationUnits.Hour:
return 60 * 1000; // every minute
case DurationUnits.Minute:
return 15 * 1000; // four times per minute
case DurationUnits.Second:
default:
return 1000; // every second
}
}

const updateIntervalMs = $derived(computeUpdateInterval(minUnits));

$effect(() => {
if (!browser) return;
const intervalId = window.setInterval(() => {
now = Date.now();
}, updateIntervalMs);
return () => window.clearInterval(intervalId);
});

const value = $derived(formatDuration(start, { totalUnits, minUnits, now }));
</script>

<span>{value}</span>
7 changes: 4 additions & 3 deletions src/lib/utilities/duration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
211 changes: 187 additions & 24 deletions src/routes/app/dashboard/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,13 @@

// Create a timer manager instance
const timerManager = new DeviceTimerManager();
const TEN_MINUTES_IN_MS = 10 * 60 * 1000;

let locationRefreshIntervals = $state<Record<number, number>>({});
let locationRefreshInFlight = $state<Record<number, boolean>>({});

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
Expand Down Expand Up @@ -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';
Expand All @@ -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);
Expand All @@ -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
Expand Down Expand Up @@ -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;
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down