diff --git a/src/lib/components/GlobalLoading.svelte b/src/lib/components/GlobalLoading.svelte
index d3040fda..864ea2c8 100644
--- a/src/lib/components/GlobalLoading.svelte
+++ b/src/lib/components/GlobalLoading.svelte
@@ -1,51 +1,51 @@
{#if $globalLoading}
-
+
{/if}
diff --git a/src/lib/components/dashboard/DashboardFilter.svelte b/src/lib/components/dashboard/DashboardFilter.svelte
new file mode 100644
index 00000000..3956b2f4
--- /dev/null
+++ b/src/lib/components/dashboard/DashboardFilter.svelte
@@ -0,0 +1,153 @@
+
+
+
+
+
+
+
+ {#if value}
+
+ {/if}
+
+
+
+
diff --git a/src/lib/components/devices/ExportButton.svelte b/src/lib/components/devices/ExportButton.svelte
index 41c12066..18e2c32c 100644
--- a/src/lib/components/devices/ExportButton.svelte
+++ b/src/lib/components/devices/ExportButton.svelte
@@ -49,6 +49,9 @@
});
const startDownload = async (type: ReportType) => {
+ if (downloading) return; // prevent duplicate clicks
+ downloading = true;
+ downloadError = '';
neutral($_('exporting_data', { values: { type: type.toUpperCase() } }));
const params = new URLSearchParams({
@@ -58,42 +61,65 @@
dataKeys: dataKeys.join(','),
locale: $appLocale ?? 'ja'
});
- const response = await fetch(`/api/devices/${devEui}/${type}?${params}`, {
- method: 'GET',
- headers: {
- 'Content-Type': type === 'csv' ? 'text/csv' : 'application/pdf'
- }
- });
+ let response: Response | null = null;
+ try {
+ response = await fetch(`/api/devices/${devEui}/${type}?${params}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': type === 'csv' ? 'text/csv' : 'application/pdf'
+ }
+ });
+ } catch (e) {
+ console.error('Network error downloading report', e);
+ error('Network error while generating report.');
+ downloadError = 'network';
+ downloading = false;
+ return;
+ }
- if (!response.ok) {
- console.error(`Failed to download ${type} for device ${devEui}:`, response.statusText);
- if (response.status === 404) {
- warning('Report Not Found, Please create a reaport first.');
- } else {
- error('Error Generating Report, contact support.');
+ if (!response || !response.ok) {
+ if (response) {
+ console.error(`Failed to download ${type} for device ${devEui}:`, response.statusText);
+ if (response.status === 404) {
+ warning('Report Not Found, Please create a reaport first.');
+ } else {
+ error('Error Generating Report, contact support.');
+ }
}
+ downloading = false;
return;
}
- const blob = await response.blob();
- const urlObj = window.URL.createObjectURL(blob);
- const a = document.createElement('a');
- a.href = urlObj;
- a.download = `${startDate.toString()} - ${endDate.toString()} ${devEui}.${type}`;
- document.body.appendChild(a);
- a.click();
- document.body.removeChild(a);
- window.URL.revokeObjectURL(urlObj);
+ try {
+ const blob = await response.blob();
+ const urlObj = window.URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = urlObj;
+ a.download = `${startDate.toString()} - ${endDate.toString()} ${devEui}.${type}`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ window.URL.revokeObjectURL(urlObj);
+ } catch (e) {
+ console.error('Error processing download', e);
+ error('Failed to process downloaded report.');
+ } finally {
+ downloading = false;
+ }
};
+
+ // Loading / error UI state
+ let downloading = $state(false);
+ let downloadError: string | null = $state(null);
{#if showDatePicker}
-
+
@@ -162,12 +193,36 @@
{:else}
{/if}
+
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 9281fa42..54b85905 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -4,6 +4,7 @@
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public';
import Header from '$lib/components/Header.svelte';
import GlobalSidebar from '$lib/components/GlobalSidebar.svelte';
+ import GlobalLoading from '$lib/components/GlobalLoading.svelte';
import ToastContainer from '$lib/components/Toast/ToastContainer.svelte';
import { i18n } from '$lib/i18n/index.svelte';
import { sidebarStore } from '$lib/stores/SidebarStore.svelte';
@@ -74,12 +75,18 @@
i18n.initialize();
});
- // Handle navigation loading states
+ // Handle navigation loading states with a small delay to avoid flash on fast transitions
+ let navTimer: ReturnType | null = null;
beforeNavigate(() => {
- startLoading();
+ if (navTimer) clearTimeout(navTimer);
+ navTimer = setTimeout(() => startLoading(), 150); // show after 150ms if still navigating
});
afterNavigate(() => {
+ if (navTimer) {
+ clearTimeout(navTimer);
+ navTimer = null;
+ }
stopLoading();
});
@@ -103,6 +110,7 @@
{@render children()}
+
{/if}
diff --git a/src/routes/app/dashboard/+page.svelte b/src/routes/app/dashboard/+page.svelte
index 832cfd4c..f4ca3bb5 100644
--- a/src/routes/app/dashboard/+page.svelte
+++ b/src/routes/app/dashboard/+page.svelte
@@ -34,6 +34,7 @@
onToggleCollapse: () => void;
}
import AllDevices from '$lib/components/UI/dashboard/AllDevices.svelte';
+ import DashboardFilter from '$lib/components/dashboard/DashboardFilter.svelte';
import type { RealtimeChannel } from '@supabase/supabase-js';
// Enhanced location type with deviceCount property
@@ -379,6 +380,8 @@
/> -->
+
+
{#if locationsStore.loadingLocations}
Loading locations and devices...
diff --git a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte
index 56e33d9f..22de9bd1 100644
--- a/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte
+++ b/src/routes/app/dashboard/location/[location_id]/devices/[devEui]/+page.svelte
@@ -26,6 +26,9 @@
import Header from './Header.svelte';
import { setupRealtimeSubscription } from './realtime.svelte';
import RelayControl from '$lib/components/RelayControl.svelte';
+ import { browser } from '$app/environment';
+ import { afterNavigate } from '$app/navigation';
+ import { createActiveTimer } from '$lib/utilities/ActiveTimer';
// Get device data from server load function
let { data }: PageProps = $props();
@@ -37,9 +40,17 @@
let latestData: DeviceDataRecord | null = $state(null);
let historicalData: DeviceDataRecord[] = $state([]);
let userId = $state(data.user.id); // User ID for permissions
- let devicePermissionLevelState = $state(data.device.cw_device_owners);
- let devicePermissionLevel = $derived(
- devicePermissionLevelState.find((owner) => owner.user_id === userId)?.permission_level || null
+ interface DeviceOwnerPerm {
+ user_id: string;
+ permission_level: number;
+ }
+ let devicePermissionLevelState = $state
(
+ // @ts-ignore allow fallback if structure differs
+ (data.device as any)?.cw_device_owners || []
+ );
+ let devicePermissionLevel = $derived(
+ devicePermissionLevelState.find((owner: DeviceOwnerPerm) => owner.user_id === userId)
+ ?.permission_level ?? null
);
// Define the type for a calendar event
@@ -94,6 +105,131 @@
phChartVisible
} = $derived(getDeviceDetailDerived(device, dataType, latestData));
let channel: RealtimeChannel | undefined = $state(undefined); // Channel for realtime updates
+ // Track last realtime update timestamp for stale detection
+ let lastRealtimeUpdate = $state(Date.now());
+ let staleCheckIntervalId: number | null = $state(null);
+ let wakeDetectorIntervalId: number | null = $state(null);
+ let lastWakeTick = $state(Date.now());
+ const EXPECTED_UPLOAD_MINUTES = $derived(
+ device.upload_interval || device.cw_device_type?.default_upload_interval || 10
+ );
+ const STALE_THRESHOLD_MS = $derived(
+ // Mark stale if no update in 2 * expected interval (cap between 2min and 30min)
+ Math.min(30, Math.max(2, EXPECTED_UPLOAD_MINUTES * 2)) * 60 * 1000
+ );
+
+ // Active status timer for THIS device (independent of dashboard list)
+ let activeTimerStore: ReturnType | null = null;
+ let isDeviceActiveFlag = $state(null);
+ function setupActiveTimer() {
+ if (!latestData?.created_at) return;
+ const intervalMin = EXPECTED_UPLOAD_MINUTES;
+ activeTimerStore = createActiveTimer(new Date(latestData.created_at), intervalMin);
+ activeTimerStore.subscribe((val) => (isDeviceActiveFlag = val));
+ }
+
+ // Rebuild timer whenever latestData timestamp changes
+ $effect(() => {
+ if (latestData?.created_at) {
+ setupActiveTimer();
+ }
+ });
+
+ async function fetchLatestDirect(reason: string) {
+ try {
+ // console.debug('[DeviceDetail] Fetching latest (reason=%s)', reason);
+ const resp = await fetch(`/api/devices/${devEui}/status`);
+ if (resp.ok) {
+ const latest = await resp.json();
+ if (!latestData || latest.created_at !== latestData.created_at) {
+ latestData = latest;
+ lastRealtimeUpdate = Date.now();
+ }
+ } else {
+ console.warn('[DeviceDetail] Failed to refresh latest data', resp.status);
+ }
+ } catch (e) {
+ console.error('[DeviceDetail] Error fetching latest', e);
+ }
+ }
+
+ function setupRealtime() {
+ if (channel || !device.cw_device_type?.data_table_v2) return;
+ channel = setupRealtimeSubscription(
+ data.supabase,
+ device.cw_device_type?.data_table_v2,
+ devEui,
+ (newData) => {
+ latestData = newData;
+ lastRealtimeUpdate = Date.now();
+ },
+ 0
+ );
+ }
+
+ function teardownRealtime() {
+ if (channel) {
+ data.supabase.removeChannel(channel);
+ channel = undefined;
+ }
+ }
+
+ function setupStaleMonitoring() {
+ if (!browser) return;
+ if (staleCheckIntervalId) return;
+ staleCheckIntervalId = window.setInterval(() => {
+ const now = Date.now();
+ if (now - lastRealtimeUpdate > STALE_THRESHOLD_MS) {
+ fetchLatestDirect('stale-check');
+ }
+ }, 60 * 1000); // check every minute
+ }
+
+ function setupWakeDetector() {
+ if (!browser) return;
+ if (wakeDetectorIntervalId) return;
+ wakeDetectorIntervalId = window.setInterval(() => {
+ const now = Date.now();
+ const delta = now - lastWakeTick;
+ lastWakeTick = now;
+ // If the tab/computer was asleep, delta will be large (e.g., > 90s)
+ if (delta > 90 * 1000) {
+ // console.debug('[DeviceDetail] Wake detected (delta=%dms)', delta);
+ // Recreate realtime channel and force refresh
+ teardownRealtime();
+ setupRealtime();
+ fetchLatestDirect('wake');
+ }
+ }, 30 * 1000); // tick every 30s
+ }
+
+ function setupVisibilityHandlers() {
+ if (!browser) return;
+ function handleVisibility() {
+ if (document.visibilityState === 'visible') {
+ setupRealtime();
+ fetchLatestDirect('visibility');
+ } else {
+ // pause realtime to save resources
+ teardownRealtime();
+ }
+ }
+ window.addEventListener('visibilitychange', handleVisibility);
+ window.addEventListener('focus', () => fetchLatestDirect('focus'));
+ window.addEventListener('online', () => {
+ teardownRealtime();
+ setupRealtime();
+ fetchLatestDirect('online');
+ });
+ // Cleanup registration
+ return () => {
+ window.removeEventListener('visibilitychange', handleVisibility);
+ window.removeEventListener('focus', () => fetchLatestDirect('focus'));
+ window.removeEventListener('online', () => fetchLatestDirect('online'));
+ };
+ }
+
+ let removeVisibilityHandlers: (() => void) | null | undefined = null;
onMount(() => {
// Initialize the device detail date range (this might be used internally by deviceDetail)
@@ -102,15 +238,12 @@
$effect(() => {
if (device.cw_device_type?.data_table_v2 && !channel) {
- channel = setupRealtimeSubscription(
- data.supabase,
- device.cw_device_type?.data_table_v2,
- devEui,
- (newData) => {
- latestData = newData;
- },
- 0 // Retry count starts at 0
- );
+ setupRealtime();
+ setupStaleMonitoring();
+ setupWakeDetector();
+ if (!removeVisibilityHandlers) {
+ removeVisibilityHandlers = setupVisibilityHandlers() || null;
+ }
}
});
@@ -273,6 +406,15 @@
// Expose formatDateForDisplay for the template, aliased from helpers
const formatDateForDisplay = utilFormatDateForDisplay;
+
+ // Cleanup on destroy
+ import { onDestroy } from 'svelte';
+ onDestroy(() => {
+ teardownRealtime();
+ if (staleCheckIntervalId) clearInterval(staleCheckIntervalId);
+ if (wakeDetectorIntervalId) clearInterval(wakeDetectorIntervalId);
+ if (removeVisibilityHandlers) removeVisibilityHandlers();
+ });
@@ -281,7 +423,7 @@