diff --git a/package.json b/package.json
index 5ab84fe6..ad289c9f 100644
--- a/package.json
+++ b/package.json
@@ -53,7 +53,7 @@
},
"packageManager": "pnpm@10.18.2+sha512.9fb969fa749b3ade6035e0f109f0b8a60b5d08a1a87fdf72e337da90dcc93336e2280ca4e44f2358a649b83c17959e9993e777c2080879f3801e6f0d999ad3dd",
"dependencies": {
- "@cropwatchdevelopment/cwui": "0.1.73",
+ "@cropwatchdevelopment/cwui": "0.1.74",
"@supabase/supabase-js": "^2.98.0"
}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 4fb07269..5f6fb389 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@cropwatchdevelopment/cwui':
- specifier: 0.1.73
- version: 0.1.73(svelte@5.53.0)
+ specifier: 0.1.74
+ version: 0.1.74(svelte@5.53.0)
'@supabase/supabase-js':
specifier: ^2.98.0
version: 2.98.0
@@ -114,8 +114,8 @@ importers:
packages:
- '@cropwatchdevelopment/cwui@0.1.73':
- resolution: {integrity: sha512-OjRlY4ke0sv+gaVO3EpBSM6Uq8SQnyecuEv39auvAnDOiFzB0jp2ec7taKmZRZ3ztW1R5c9HVk5Wa0fVmGQtwQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.73/27e6d1ed76c3e8faeb3d2ed8dbdb3c0888b06a29}
+ '@cropwatchdevelopment/cwui@0.1.74':
+ resolution: {integrity: sha512-f90g0EgVOsH42w3LLkUeGbJ1zMCYcJVz5oyG5SvTB+syLdlxGJaMBo1VjMVPkfKwsxWzir0EdnwO+O4izY2MzQ==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.74/a6f76aa402ff6255796eb0b99b294579cc7da682}
peerDependencies:
svelte: ^5.0.0
@@ -2074,7 +2074,7 @@ packages:
snapshots:
- '@cropwatchdevelopment/cwui@0.1.73(svelte@5.53.0)':
+ '@cropwatchdevelopment/cwui@0.1.74(svelte@5.53.0)':
dependencies:
svelte: 5.53.0
diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte
index d03bef8f..9fc5e956 100644
--- a/src/lib/components/dashboard/DashboardDeviceCards.svelte
+++ b/src/lib/components/dashboard/DashboardDeviceCards.svelte
@@ -1,19 +1,22 @@
+
+
diff --git a/src/lib/components/dashboard/DashboardDeviceTable.svelte b/src/lib/components/dashboard/DashboardDeviceTable.svelte
index 39f1122a..ff50a20c 100644
--- a/src/lib/components/dashboard/DashboardDeviceTable.svelte
+++ b/src/lib/components/dashboard/DashboardDeviceTable.svelte
@@ -17,11 +17,12 @@
import type { IDevice } from '$lib/interfaces/device.interface';
import CHECK_CIRCLE_ICON from '$lib/images/icons/check_circle.svg';
import ALERT_ICON from '$lib/images/icons/active_alert.svg';
+ import { mapDashboardPrimaryDataToDevice } from './dashboard-device-data';
import {
- applyDashboardLatestReadings,
- mapDashboardPrimaryDataToDevice,
- mergeDashboardDevices
- } from './dashboard-device-data';
+ DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES,
+ isDashboardDeviceOffline,
+ refreshDashboardDevice
+ } from './dashboard-device-refresh';
import {
buildDashboardTableFilters,
DASHBOARD_DEVICE_BATCH_SIZE,
@@ -81,7 +82,6 @@
let tableFilters = $derived(buildDashboardTableFilters(filters));
let loading = $state(false);
let virtualScroll = $state(false);
- const DEVICE_OFFLINE_THRESHOLD_MS = 11 * 60 * 1000;
let tableSourceKey = $derived.by(() =>
[
app.accessToken ? 'auth' : 'anon',
@@ -99,12 +99,7 @@
}
function isOffline(row: IDevice): boolean {
- if (row.has_primary_data === false) {
- return true;
- }
-
- const lastSeenMs = new Date(row.created_at).getTime();
- return !Number.isFinite(lastSeenMs) || lastSeenMs < Date.now() - DEVICE_OFFLINE_THRESHOLD_MS;
+ return isDashboardDeviceOffline(row);
}
async function loadData(query: CwTableQuery): Promise> {
@@ -168,14 +163,11 @@
refreshingByDevEui[devEui] = true;
try {
- const api = new ApiService({
- authToken: app.accessToken
+ await refreshDashboardDevice({
+ app,
+ devEui,
+ targetDevice: row
});
- const latestDevice = mapDashboardPrimaryDataToDevice(
- await api.getDeviceLatestPrimaryData(devEui)
- );
- applyDashboardLatestReadings(row, latestDevice);
- app.devices = mergeDashboardDevices(app.devices, [latestDevice]);
} finally {
refreshingByDevEui[devEui] = false;
}
@@ -233,7 +225,7 @@
{:else}
void loadSingleDevice(row)}
/>
{/if}
diff --git a/src/lib/components/dashboard/dashboard-device-refresh.spec.ts b/src/lib/components/dashboard/dashboard-device-refresh.spec.ts
new file mode 100644
index 00000000..5be3934f
--- /dev/null
+++ b/src/lib/components/dashboard/dashboard-device-refresh.spec.ts
@@ -0,0 +1,153 @@
+import { describe, expect, it, vi } from 'vitest';
+import type { IDevice } from '$lib/interfaces/device.interface';
+import {
+ DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS,
+ DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES,
+ getDashboardDeviceNextRefreshDelayMs,
+ isDashboardDeviceOffline,
+ refreshDashboardDevice
+} from './dashboard-device-refresh';
+
+function createDevice(overrides: Partial = {}): IDevice {
+ return {
+ dev_eui: 'dev-1',
+ name: 'Canopy Sensor',
+ location_name: 'Room A',
+ group: 'air',
+ data_table: 'cw_air_data',
+ created_at: new Date('2026-04-09T10:00:00.000Z'),
+ has_primary_data: true,
+ co2: 820,
+ humidity: 56,
+ temperature_c: 24.5,
+ soil_temperature_c: null,
+ soil_humidity: null,
+ location_id: 7,
+ alert_count: 1,
+ device_type_id: 9,
+ raw_data: {
+ temperature_c: 24.5
+ },
+ ...overrides
+ };
+}
+
+describe('dashboard-device-refresh helpers', () => {
+ it('refreshes a live device and merges the latest reading back into app state', async () => {
+ const targetDevice = createDevice();
+ const app = {
+ accessToken: 'jwt-token',
+ devices: [targetDevice]
+ };
+ const api = {
+ getDeviceLatestPrimaryData: vi.fn(async () => ({
+ dev_eui: 'dev-1',
+ name: 'Canopy Sensor',
+ location_name: 'Room A',
+ group: 'air',
+ created_at: '2026-04-09T10:14:00.000Z',
+ co2: 901,
+ humidity: 61.2,
+ temperature_c: 25.7,
+ location_id: 7
+ }))
+ };
+
+ const latestDevice = await refreshDashboardDevice({
+ app,
+ devEui: targetDevice.dev_eui,
+ targetDevice,
+ api
+ });
+
+ expect(api.getDeviceLatestPrimaryData).toHaveBeenCalledWith('dev-1');
+ expect(latestDevice).toMatchObject({
+ dev_eui: 'dev-1',
+ created_at: new Date('2026-04-09T10:14:00.000Z'),
+ temperature_c: 25.7
+ });
+ expect(targetDevice).toMatchObject({
+ created_at: new Date('2026-04-09T10:14:00.000Z'),
+ temperature_c: 25.7,
+ humidity: 61.2,
+ co2: 901
+ });
+ expect(app.devices[0]).toMatchObject({
+ dev_eui: 'dev-1',
+ created_at: new Date('2026-04-09T10:14:00.000Z'),
+ temperature_c: 25.7
+ });
+ });
+
+ it('preserves metadata when the refresh payload omits location and type fields', async () => {
+ const targetDevice = createDevice({
+ location_name: 'Propagation Room',
+ group: 'air',
+ device_type_id: 42,
+ alert_count: 2
+ });
+ const app = {
+ accessToken: 'jwt-token',
+ devices: [targetDevice]
+ };
+ const api = {
+ getDeviceLatestPrimaryData: vi.fn(async () => ({
+ dev_eui: 'dev-1',
+ name: 'Canopy Sensor',
+ location_name: '',
+ group: '',
+ created_at: '2026-04-09T10:14:00.000Z',
+ co2: 777,
+ humidity: 59,
+ temperature_c: 23.9,
+ location_id: 0
+ }))
+ };
+
+ await refreshDashboardDevice({
+ app,
+ devEui: targetDevice.dev_eui,
+ targetDevice,
+ api
+ });
+
+ expect(targetDevice).toMatchObject({
+ location_name: 'Propagation Room',
+ group: 'air',
+ location_id: 7,
+ device_type_id: 42,
+ alert_count: 2,
+ temperature_c: 23.9
+ });
+ expect(app.devices[0]).toMatchObject({
+ location_name: 'Propagation Room',
+ group: 'air',
+ location_id: 7,
+ device_type_id: 42
+ });
+ });
+
+ it('uses the shared 10.3-minute refresh cadence and 11-minute offline threshold', () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-04-09T10:10:18.000Z'));
+
+ const dueSoonDevice = createDevice({
+ created_at: new Date('2026-04-09T10:00:01.000Z')
+ });
+ const nearlyOfflineDevice = createDevice({
+ created_at: new Date('2026-04-09T09:59:30.000Z')
+ });
+ const longStaleDevice = createDevice({
+ created_at: new Date('2026-04-09T08:00:00.000Z')
+ });
+
+ expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3);
+ expect(getDashboardDeviceNextRefreshDelayMs(dueSoonDevice)).toBe(1_000);
+ expect(isDashboardDeviceOffline(nearlyOfflineDevice)).toBe(false);
+ expect(isDashboardDeviceOffline(longStaleDevice)).toBe(true);
+ expect(getDashboardDeviceNextRefreshDelayMs(longStaleDevice)).not.toBeNull();
+ expect(DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS).toBe(11 * 60_000);
+
+ vi.useRealTimers();
+ });
+});
diff --git a/src/lib/components/dashboard/dashboard-device-refresh.ts b/src/lib/components/dashboard/dashboard-device-refresh.ts
new file mode 100644
index 00000000..42473511
--- /dev/null
+++ b/src/lib/components/dashboard/dashboard-device-refresh.ts
@@ -0,0 +1,93 @@
+import { ApiService } from '$lib/api/api.service';
+import type { IDevice } from '$lib/interfaces/device.interface';
+import {
+ applyDashboardLatestReadings,
+ mapDashboardPrimaryDataToDevice,
+ mergeDashboardDevices
+} from './dashboard-device-data';
+
+export const DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES = 10.3;
+export const DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS = 11 * 60_000;
+
+const DASHBOARD_DEVICE_REFRESH_INTERVAL_MS =
+ DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES * 60_000;
+
+interface DashboardDeviceRefreshApp {
+ accessToken?: string;
+ devices: IDevice[];
+}
+
+interface DashboardDeviceRefreshApi {
+ getDeviceLatestPrimaryData(devEui: string): Promise>;
+}
+
+interface RefreshDashboardDeviceOptions {
+ app: DashboardDeviceRefreshApp;
+ devEui: string;
+ targetDevice?: IDevice;
+ api?: DashboardDeviceRefreshApi;
+}
+
+function getDeviceTimestampMs(device: Pick): number {
+ return device.created_at instanceof Date
+ ? device.created_at.getTime()
+ : new Date(device.created_at).getTime();
+}
+
+export function isDashboardDeviceOffline(
+ device: Pick
+): boolean {
+ if (device.has_primary_data === false) {
+ return true;
+ }
+
+ const lastSeenMs = getDeviceTimestampMs(device);
+ return (
+ !Number.isFinite(lastSeenMs) || lastSeenMs < Date.now() - DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS
+ );
+}
+
+export function getDashboardDeviceNextRefreshDelayMs(
+ device: Pick,
+ nowMs = Date.now()
+): number | null {
+ if (device.has_primary_data === false) {
+ return null;
+ }
+
+ const lastSeenMs = getDeviceTimestampMs(device);
+ if (!Number.isFinite(lastSeenMs)) {
+ return null;
+ }
+
+ const elapsedMs = Math.max(0, nowMs - lastSeenMs);
+ const intervalsElapsed = Math.floor(elapsedMs / DASHBOARD_DEVICE_REFRESH_INTERVAL_MS);
+ return DASHBOARD_DEVICE_REFRESH_INTERVAL_MS * (intervalsElapsed + 1) - elapsedMs;
+}
+
+export async function refreshDashboardDevice({
+ app,
+ devEui,
+ targetDevice,
+ api
+}: RefreshDashboardDeviceOptions): Promise {
+ if (!devEui || !app.accessToken) {
+ return null;
+ }
+
+ const latestApi =
+ api ??
+ new ApiService({
+ authToken: app.accessToken
+ });
+ const latestDevice = mapDashboardPrimaryDataToDevice(
+ await latestApi.getDeviceLatestPrimaryData(devEui)
+ );
+
+ if (targetDevice) {
+ applyDashboardLatestReadings(targetDevice, latestDevice);
+ }
+
+ app.devices = mergeDashboardDevices(app.devices, [latestDevice]);
+ return latestDevice;
+}
diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts
index da88a528..806a9751 100644
--- a/src/lib/components/dashboard/device-cards.spec.ts
+++ b/src/lib/components/dashboard/device-cards.spec.ts
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from 'vitest';
import type { LocationDto } from '$lib/api/api.dtos';
import type { IDevice } from '$lib/interfaces/device.interface';
+import { buildDashboardLocationSensorCards } from './device-cards';
import {
- buildDashboardLocationSensorCards,
- DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES
-} from './device-cards';
+ DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES,
+ getDashboardDeviceNextRefreshDelayMs
+} from './dashboard-device-refresh';
describe('device-cards helpers', () => {
it('groups devices by location, sorts titles, and disambiguates duplicate labels', () => {
@@ -63,13 +64,14 @@ describe('device-cards helpers', () => {
'Canopy (dev-1)',
'Canopy (dev-2)'
]);
- expect(cards[1]?.deviceRouteParamsByLabel['Canopy (dev-1)']).toEqual({
+ expect(cards[1]?.deviceBindingsByLabel['Canopy (dev-1)']).toEqual({
devEui: 'dev-1',
- locationId: 2
+ locationId: 2,
+ sourceDevice: devices[1]
});
});
- it('marks stale devices offline and removes the alarm window after one hour', () => {
+ it('uses the shared 11-minute offline threshold and keeps refresh cadence beyond one hour', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-13T12:00:00.000Z'));
@@ -109,14 +111,17 @@ describe('device-cards helpers', () => {
expect(cards[0]?.devices[0]).toMatchObject({
label: 'Old Sensor',
- status: 'offline',
- expectedUpdateAfterMinutes: undefined
+ status: 'offline'
});
expect(cards[0]?.devices[1]).toMatchObject({
label: 'Recent Sensor',
- status: 'online',
- expectedUpdateAfterMinutes: DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES
+ status: 'online'
});
+ expect(cards[0]?.devices[1]?.expectedUpdateAfterMinutes).toBeUndefined();
+ expect(
+ getDashboardDeviceNextRefreshDelayMs(cards[0]!.deviceBindingsByLabel['Old Sensor'].sourceDevice)
+ ).not.toBeNull();
+ expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3);
vi.useRealTimers();
});
diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts
index ecc8053d..9ef6ad50 100644
--- a/src/lib/components/dashboard/device-cards.ts
+++ b/src/lib/components/dashboard/device-cards.ts
@@ -1,24 +1,29 @@
import type { CwSensorCardDetailRow, CwSensorCardDevice } from '@cropwatchdevelopment/cwui';
import type { LocationDto } from '$lib/api/api.dtos';
import type { IDevice } from '$lib/interfaces/device.interface';
-import { resolveDeviceTypeConfig, type DeviceTypeLookup } from './dashboard-device-data';
+import {
+ resolveDeviceTypeConfig,
+ type DeviceTypeConfig,
+ type DeviceTypeLookup
+} from './dashboard-device-data';
+import { isDashboardDeviceOffline } from './dashboard-device-refresh';
-export const DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES = 10;
export const DASHBOARD_SENSOR_CARD_LOCATION_BATCH_SIZE = 10;
export const DASHBOARD_SENSOR_CARD_PREFETCH_REMAINING = 5;
-const DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MS =
- DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES * 60_000;
-
-/** Stop arming the CwDuration alarm once a device is stale beyond this (ms). */
-const DASHBOARD_SENSOR_CARD_ALARM_CUTOFF_MS = 60 * 60_000; // 1 hour
+export interface DashboardLocationSensorCardDeviceBinding {
+ devEui: string;
+ locationId: number;
+ sourceDevice: IDevice;
+}
export interface DashboardLocationSensorCard {
id: string;
+ renderKey: string;
locationId: number;
title: string;
devices: CwSensorCardDevice[];
- deviceRouteParamsByLabel: Record;
+ deviceBindingsByLabel: Record;
}
function getDeviceBaseLabel(device: IDevice): string {
@@ -42,14 +47,14 @@ function applyTransform(rawValue: unknown, multiplier?: number | null, divider?:
}
function getDeviceStatus(device: IDevice): 'online' | 'offline' {
- if (device.has_primary_data === false) {
- return 'offline';
- }
+ return isDashboardDeviceOffline(device) ? 'offline' : 'online';
+}
- return Date.now() - new Date(device.created_at).getTime() >
- DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MS
- ? 'offline'
- : 'online';
+function toCardRenderKey(locationId: number, devices: IDevice[]): string {
+ return [
+ `location:${locationId}`,
+ ...devices.map((device) => `${device.dev_eui}:${device.created_at.toISOString()}`)
+ ].join('|');
}
function buildUnavailableDetailRows(label: string, typeConfig: DeviceTypeConfig | undefined): CwSensorCardDetailRow[] {
@@ -139,13 +144,14 @@ export function buildDashboardLocationSensorCards(
duplicateCounts.set(baseLabel, (duplicateCounts.get(baseLabel) ?? 0) + 1);
}
- const deviceRouteParamsByLabel: DashboardLocationSensorCard['deviceRouteParamsByLabel'] = {};
+ const deviceBindingsByLabel: DashboardLocationSensorCard['deviceBindingsByLabel'] = {};
const sensorDevices = sortedLocationDevices.map((device) => {
const label = getDeviceLabel(device, duplicateCounts);
- deviceRouteParamsByLabel[label] = {
+ deviceBindingsByLabel[label] = {
devEui: device.dev_eui,
- locationId: Number(device.location_id)
+ locationId: Number(device.location_id),
+ sourceDevice: device
};
if (device.has_primary_data === false) {
@@ -175,7 +181,6 @@ export function buildDashboardLocationSensorCards(
: device.humidity;
const secondaryValue = applyTransform(rawSecondary, typeConfig?.secondary_multiplier, typeConfig?.secondary_divider);
- const staleness = Date.now() - new Date(device.created_at).getTime();
return {
label,
primaryValue,
@@ -183,20 +188,17 @@ export function buildDashboardLocationSensorCards(
secondaryValue,
secondaryUnit: typeConfig?.secondary_data_notation ?? '%',
status: getDeviceStatus(device),
- lastUpdated: device.created_at,
- expectedUpdateAfterMinutes:
- staleness <= DASHBOARD_SENSOR_CARD_ALARM_CUTOFF_MS
- ? DASHBOARD_SENSOR_CARD_EXPECTED_UPDATE_AFTER_MINUTES
- : undefined
+ lastUpdated: device.created_at
} satisfies CwSensorCardDevice;
});
return {
id: `location:${locationId}`,
+ renderKey: toCardRenderKey(locationId, sortedLocationDevices),
locationId,
title: getLocationTitle(locationId, locationsById, sortedLocationDevices),
devices: sensorDevices,
- deviceRouteParamsByLabel
+ deviceBindingsByLabel
} satisfies DashboardLocationSensorCard;
})
.sort((left, right) =>
diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts
index 26f8d27e..7ecf8f79 100644
--- a/src/routes/+page.server.ts
+++ b/src/routes/+page.server.ts
@@ -1,3 +1,4 @@
+import { env as publicEnv } from '$env/dynamic/public';
import { ApiService, ApiServiceError } from '$lib/api/api.service';
import type { DevicePrimaryDataDto, LocationDto } from '$lib/api/api.dtos';
import type { IDevice } from '$lib/interfaces/device.interface';
@@ -11,6 +12,12 @@ import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashbo
import type { PageServerLoad } from './$types';
const LATEST_PRIMARY_DATA_PAGE_SIZE = 25;
+
+function resolveDashboardApiBaseUrl(url: URL): string {
+ const configuredBaseUrl = publicEnv.PUBLIC_API_BASE_URL?.trim();
+ return configuredBaseUrl && configuredBaseUrl.length > 0 ? configuredBaseUrl : url.origin;
+}
+
function serializeError(error: unknown): Record {
if (error instanceof ApiServiceError) {
return {
@@ -241,7 +248,7 @@ const EMPTY_DASHBOARD = {
dashboardDebug: null as Record | null
};
-export const load: PageServerLoad = async ({ locals, fetch }) => {
+export const load: PageServerLoad = async ({ locals, url }) => {
const authToken = locals.jwtString ?? null;
if (!authToken) {
@@ -252,7 +259,7 @@ export const load: PageServerLoad = async ({ locals, fetch }) => {
}
const apiServiceInstance = new ApiService({
- fetchFn: fetch,
+ baseUrl: resolveDashboardApiBaseUrl(url),
authToken
});