From d6b5fe2ccc24ed4549283ad425551954cf34b297 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 9 Apr 2026 21:52:12 +0900 Subject: [PATCH] fixed timers on card view, users can now expect items to update in real-time --- package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardDeviceCards.svelte | 158 ++++++++++++---- .../DashboardDeviceCards.svelte.spec.ts | 170 ++++++++++++++++++ .../DashboardDeviceCards.test-harness.svelte | 26 +++ .../dashboard/DashboardDeviceTable.svelte | 30 ++-- .../dashboard-device-refresh.spec.ts | 153 ++++++++++++++++ .../dashboard/dashboard-device-refresh.ts | 93 ++++++++++ .../components/dashboard/device-cards.spec.ts | 25 +-- src/lib/components/dashboard/device-cards.ts | 52 +++--- src/routes/+page.server.ts | 11 +- 11 files changed, 633 insertions(+), 97 deletions(-) create mode 100644 src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts create mode 100644 src/lib/components/dashboard/DashboardDeviceCards.test-harness.svelte create mode 100644 src/lib/components/dashboard/dashboard-device-refresh.spec.ts create mode 100644 src/lib/components/dashboard/dashboard-device-refresh.ts 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 });