From 500ecb4f71b6cc68101ffbc1460284ce9c5b617e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Wed, 20 May 2026 00:49:41 +0900 Subject: [PATCH 1/3] finally simplified --- messages/en.json | 44 +- messages/ja.json | 44 +- src/lib/api/api.dtos.ts | 59 ++ src/lib/api/api.service.ts | 55 ++ src/lib/appContext.svelte.ts | 9 +- .../dashboard/DashboardCards.svelte | 353 +++++++++ .../dashboard/DashboardDeviceCards.svelte | 740 ------------------ .../DashboardDeviceCards.svelte.spec.ts | 216 ----- .../DashboardDeviceCards.test-harness.svelte | 26 - .../dashboard/DashboardDeviceTable.svelte | 337 -------- .../dashboard/DashboardTable.svelte | 363 ++------- .../dashboard/dashboard-device-data.spec.ts | 355 --------- .../dashboard/dashboard-device-data.ts | 359 --------- .../dashboard-device-refresh.spec.ts | 152 ---- .../dashboard/dashboard-device-refresh.ts | 103 --- .../dashboard/dashboard-filter-values.ts | 37 - .../components/dashboard/device-cards.spec.ts | 332 -------- src/lib/components/dashboard/device-cards.ts | 408 ---------- .../components/dashboard/device-table.spec.ts | 139 ---- src/lib/components/dashboard/device-table.ts | 188 ----- src/lib/sensor-labels/index.ts | 75 ++ src/routes/+page.server.ts | 113 +-- src/routes/+page.svelte | 181 ++--- src/routes/Sidebar.svelte | 12 +- 24 files changed, 761 insertions(+), 3939 deletions(-) create mode 100644 src/lib/components/dashboard/DashboardCards.svelte delete mode 100644 src/lib/components/dashboard/DashboardDeviceCards.svelte delete mode 100644 src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts delete mode 100644 src/lib/components/dashboard/DashboardDeviceCards.test-harness.svelte delete mode 100644 src/lib/components/dashboard/DashboardDeviceTable.svelte delete mode 100644 src/lib/components/dashboard/dashboard-device-data.spec.ts delete mode 100644 src/lib/components/dashboard/dashboard-device-data.ts delete mode 100644 src/lib/components/dashboard/dashboard-device-refresh.spec.ts delete mode 100644 src/lib/components/dashboard/dashboard-device-refresh.ts delete mode 100644 src/lib/components/dashboard/dashboard-filter-values.ts delete mode 100644 src/lib/components/dashboard/device-cards.spec.ts delete mode 100644 src/lib/components/dashboard/device-cards.ts delete mode 100644 src/lib/components/dashboard/device-table.spec.ts delete mode 100644 src/lib/components/dashboard/device-table.ts create mode 100644 src/lib/sensor-labels/index.ts diff --git a/messages/en.json b/messages/en.json index 24fe3d9f..5e09b07a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -986,5 +986,47 @@ "rule_action_forever_acknowledge_responsibility": "You are responsible for periodically verifying that this device remains in the correct state. CropWatch will not change it back for you.", "rule_action_forever_acknowledge_option": "I understand and accept responsibility for monitoring this device.", "rule_action_forever_acknowledge_confirm": "I Understand, Continue", - "rule_action_revert_on_reset": "Revert relay state when the rule resets" + "rule_action_revert_on_reset": "Revert relay state when the rule resets", + "sensor_temperature": "Temperature", + "sensor_humidity": "Humidity", + "sensor_moisture": "Moisture", + "sensor_co2": "CO₂", + "sensor_co": "CO", + "sensor_pressure": "Pressure", + "sensor_ec": "Electrical Conductivity", + "sensor_ph": "pH", + "sensor_depth": "Depth", + "sensor_battery": "Battery", + "sensor_voltage": "Voltage", + "sensor_current": "Current", + "sensor_watts": "Power", + "sensor_rainfall": "Rainfall", + "sensor_wind_speed": "Wind Speed", + "sensor_wind_direction": "Wind Direction", + "sensor_lux": "Light", + "sensor_uv_index": "UV Index", + "sensor_smoke_detected": "Smoke Detected", + "sensor_vape_detected": "Vape Detected", + "sensor_spo2": "SpO₂", + "sensor_relay_1": "Relay 1", + "sensor_relay_2": "Relay 2", + "sensor_people_count": "People", + "sensor_car_count": "Vehicles", + "sensor_value_on": "ON", + "sensor_value_off": "OFF", + "sensor_value_true": "Yes", + "sensor_value_false": "No", + "dashboard_view_table": "Table", + "dashboard_view_cards": "Cards", + "dashboard_no_devices": "No devices to display.", + "dashboard_no_data_yet": "No data yet", + "dashboard_loading_details": "Loading details…", + "dashboard_column_name": "Device", + "dashboard_column_location": "Location", + "dashboard_column_group": "Group", + "dashboard_column_primary": "Primary", + "dashboard_column_secondary": "Secondary", + "dashboard_column_last_updated": "Last Updated", + "dashboard_load_more": "Load more", + "dashboard_no_location": "No Location" } diff --git a/messages/ja.json b/messages/ja.json index 1c3904b9..ab3ad00e 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -986,5 +986,47 @@ "rule_action_forever_acknowledge_responsibility": "このデバイスが正しい状態にあることを定期的に確認する責任は、お客様にあります。CropWatchが自動で元に戻すことはありません。", "rule_action_forever_acknowledge_option": "このデバイスの監視責任を理解し、引き受けます。", "rule_action_forever_acknowledge_confirm": "理解しました、続行", - "rule_action_revert_on_reset": "ルールがリセットされた時にリレーの状態を元に戻す" + "rule_action_revert_on_reset": "ルールがリセットされた時にリレーの状態を元に戻す", + "sensor_temperature": "温度", + "sensor_humidity": "湿度", + "sensor_moisture": "水分量", + "sensor_co2": "CO₂", + "sensor_co": "CO", + "sensor_pressure": "気圧", + "sensor_ec": "電気伝導度", + "sensor_ph": "pH", + "sensor_depth": "水深", + "sensor_battery": "バッテリー", + "sensor_voltage": "電圧", + "sensor_current": "電流", + "sensor_watts": "電力", + "sensor_rainfall": "降水量", + "sensor_wind_speed": "風速", + "sensor_wind_direction": "風向", + "sensor_lux": "照度", + "sensor_uv_index": "UV指数", + "sensor_smoke_detected": "煙検出", + "sensor_vape_detected": "蒸気検出", + "sensor_spo2": "SpO₂", + "sensor_relay_1": "リレー 1", + "sensor_relay_2": "リレー 2", + "sensor_people_count": "人数", + "sensor_car_count": "車両数", + "sensor_value_on": "オン", + "sensor_value_off": "オフ", + "sensor_value_true": "はい", + "sensor_value_false": "いいえ", + "dashboard_view_table": "テーブル", + "dashboard_view_cards": "カード", + "dashboard_no_devices": "表示するデバイスがありません。", + "dashboard_no_data_yet": "データがまだありません", + "dashboard_loading_details": "詳細を読み込み中…", + "dashboard_column_name": "デバイス", + "dashboard_column_location": "場所", + "dashboard_column_group": "グループ", + "dashboard_column_primary": "主要", + "dashboard_column_secondary": "補助", + "dashboard_column_last_updated": "最終更新", + "dashboard_load_more": "さらに読み込む", + "dashboard_no_location": "場所未設定" } diff --git a/src/lib/api/api.dtos.ts b/src/lib/api/api.dtos.ts index 3275eb6e..e340c58f 100644 --- a/src/lib/api/api.dtos.ts +++ b/src/lib/api/api.dtos.ts @@ -105,6 +105,65 @@ export interface LatestPrimaryDataQuery extends PaginationQuery { name?: string; } +export interface DashboardQuery extends PaginationQuery { + group?: string; + locationGroup?: string; + location?: string; + name?: string; +} + +export interface DashboardDeviceType { + id: number; + name: string; + data_table_v2: string; + primary_data_v2: string; + secondary_data_v2: string; + default_upload_interval: number | null; +} + +export interface DashboardLocation { + location_id: number; + name: string; + group: string | null; +} + +export interface DashboardLatest { + created_at: string | null; + primary: number | string | boolean | null; + secondary: number | string | boolean | null; +} + +export interface DashboardRow { + dev_eui: string; + name: string; + group: string | null; + upload_interval: number | null; + last_data_updated_at: string | null; + device_type: DashboardDeviceType; + location: DashboardLocation | null; + latest: DashboardLatest | null; +} + +export interface DashboardPage { + rows: DashboardRow[]; + total: number; + skip: number; + take: number; +} + +export interface DashboardLocationGroup { + key: string; + location: DashboardLocation | null; + devices: DashboardRow[]; +} + +export interface DashboardLocationPage { + groups: DashboardLocationGroup[]; + total: number; + skip: number; + take: number; +} + export interface SensorTimeSeriesPoint { created_at: string; dev_eui: string; diff --git a/src/lib/api/api.service.ts b/src/lib/api/api.service.ts index 8b8cad1b..03794d15 100644 --- a/src/lib/api/api.service.ts +++ b/src/lib/api/api.service.ts @@ -6,6 +6,9 @@ import type { CreateLocationRequest, CreateReportRequest, CreateRuleRequest, + DashboardLocationPage, + DashboardPage, + DashboardQuery, DeviceDataWithinRangeQuery, DeviceListQuery, DeviceDto, @@ -116,6 +119,9 @@ const DEVICE_BY_DEV_EUI_ENDPOINT = '/devices/{dev_eui}'; const DEVICE_DATA_ENDPOINT = '/devices/{dev_eui}/data'; const DEVICE_DATA_WITHIN_RANGE_ENDPOINT = '/devices/{dev_eui}/data-within-range'; const DEVICE_LATEST_DATA_ENDPOINT = '/devices/{dev_eui}/latest-data'; +const DASHBOARD_DEVICES_ENDPOINT = '/dashboard/devices'; +const DASHBOARD_LOCATIONS_ENDPOINT = '/dashboard/locations'; +const DASHBOARD_DEVICE_LATEST_ENDPOINT = '/dashboard/devices/{dev_eui}/latest'; const LOCATIONS_ENDPOINT = '/locations'; const LOCATION_BY_ID_ENDPOINT = '/locations/{id}'; const LOCATION_PERMISSION_ENDPOINT = '/locations/{id}/permission'; @@ -839,6 +845,55 @@ export class ApiService { return normalizePaginatedListResponse(payload).data ?? []; } + public getDashboardDevices( + query: DashboardQuery = {}, + options: ApiMethodOptions = {} + ): Promise { + return this.request(DASHBOARD_DEVICES_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: { + skip: query.skip, + take: query.take, + group: query.group, + name: query.name, + location: query.location, + locationGroup: query.locationGroup + } + }); + } + + public getDashboardLocations( + query: DashboardQuery = {}, + options: ApiMethodOptions = {} + ): Promise { + return this.request(DASHBOARD_LOCATIONS_ENDPOINT, { + method: 'GET', + signal: options.signal, + query: { + skip: query.skip, + take: query.take, + group: query.group, + name: query.name, + location: query.location, + locationGroup: query.locationGroup + } + }); + } + + public getDashboardDeviceLatest( + devEui: string, + options: ApiMethodOptions = {} + ): Promise | null> { + return this.request | null>( + replacePathParams(DASHBOARD_DEVICE_LATEST_ENDPOINT, { dev_eui: devEui }), + { + method: 'GET', + signal: options.signal + } + ); + } + public getLatestPrimaryDeviceData( query: LatestPrimaryDataQuery = {}, options: ApiMethodOptions = {} diff --git a/src/lib/appContext.svelte.ts b/src/lib/appContext.svelte.ts index beb1e0ec..985cf81b 100644 --- a/src/lib/appContext.svelte.ts +++ b/src/lib/appContext.svelte.ts @@ -7,7 +7,6 @@ import type { Profile } from './interfaces/profile.interface'; import { createCwAlarmScheduler } from '@cropwatchdevelopment/cwui'; import type { RuleDto, TriggeredRulesCountResponse } from './api/api.service'; import type { LocationDto } from './api/api.dtos'; -import type { DeviceTypeLookup } from './components/dashboard/dashboard-device-data'; const DEVICE_STALE_MINUTES = 10; const deviceAlarms = createCwAlarmScheduler(); @@ -21,7 +20,6 @@ export interface AppContext { deviceGroups?: string[]; locationGroups?: string[]; locations?: LocationDto[]; - deviceTypeLookup: DeviceTypeLookup; deviceStatuses: { online: number; offline: number }; totalDeviceCount?: number; rules: IRule[]; @@ -39,7 +37,6 @@ export const defaultAppContext: AppContext = { profile: undefined, session: null, devices: [], - deviceTypeLookup: { byModel: {}, idToModel: {} }, deviceStatuses: { online: 0, offline: 0 }, totalDeviceCount: 0, rules: [], @@ -64,8 +61,7 @@ export function createAppContext(initial: Partial = {}): AppContext profile: undefined, session: null, devices: [], - deviceTypeLookup: { byModel: {}, idToModel: {} }, - deviceStatuses: { online: 0, offline: 0 }, + deviceStatuses: { online: 0, offline: 0 }, totalDeviceCount: 0, rules: [], triggeredRules: [], @@ -84,8 +80,7 @@ export function createAppContext(initial: Partial = {}): AppContext profile: undefined, session: null, devices: [], - deviceTypeLookup: { byModel: {}, idToModel: {} }, - deviceStatuses: { online: 0, offline: 0 }, + deviceStatuses: { online: 0, offline: 0 }, totalDeviceCount: 0, rules: [], triggeredRules: [], diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte new file mode 100644 index 00000000..3d4ddd47 --- /dev/null +++ b/src/lib/components/dashboard/DashboardCards.svelte @@ -0,0 +1,353 @@ + + +
+ {#if !initialLoaded && loading} +
+ +
+ {:else if groups.length === 0} +

{m.dashboard_no_devices()}

+ {:else} +
+ {#each groups as group (group.key)} + + {#each group.devices as row (row.dev_eui)} + {@const primary = primaryProps(row)} + {@const secondary = secondaryProps(row)} + {@const details = detailsByDevEui[row.dev_eui]} + {@const detailRows = details && details !== 'loading' ? detailEntries(details) : []} + loadDetails(row.dev_eui)} + > + {#if details === undefined || details === 'loading'} +

{m.dashboard_loading_details()}

+ {:else if detailRows.length === 0} +

{m.dashboard_no_data_yet()}

+ {:else} +
+ {#each detailRows as { col, def, formatted } (col)} +
+
{def.label()}
+
+ {#if def.label() == 'created_at'} + + {:else} + {formatted.display}{def.unit ? ` ${def.unit}` : ''} + {/if} +
+
+ {/each} +
+ goto(`/locations/${row.location?.location_id ?? ''}/devices/${row.dev_eui}`)} + >Details + {/if} +
+ {/each} +
+ {/each} +
+ + {#if groups.length < total} +
+ { + console.debug('[dashboard] load-more clicked', { + groups: groups.length, + total, + loading + }); + loadPage(); + }} + > + {loading ? '…' : m.dashboard_load_more()} + +
+ {/if} + {/if} +
+ + diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte b/src/lib/components/dashboard/DashboardDeviceCards.svelte deleted file mode 100644 index e611ca90..00000000 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte +++ /dev/null @@ -1,740 +0,0 @@ - - - - -
- {#key filterKey} -
- {#if locationCards.length === 0} -
-

{m.dashboard_no_matching_locations_title()}

-

{m.dashboard_no_matching_locations_body()}

-
- {:else} -
- {#each visibleLocationCards as card, index (card.id)} -
- openLocation(card)} - > -
- {#each card.sensors as sensor (sensor.id)} - handleSensorExpand(sensor)} - onCollapse={() => handleSensorCollapse(sensor)} - > -
- - openDeviceDetails(sensor)}> - {VIEW_DEVICE_DETAILS_LABEL} - -
-
- {/each} -
-
-
- - {#if enableInfiniteScroll && canLoadMore && index === prefetchTriggerIndex} - - {/if} - {/each} -
- - {#if enableInfiniteScroll && canLoadMore} -

- {m.dashboard_loading_more_locations_hint()} -

- {/if} - {/if} -
- {/key} -
- - diff --git a/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts b/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts deleted file mode 100644 index 0b89a0ee..00000000 --- a/src/lib/components/dashboard/DashboardDeviceCards.svelte.spec.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { flushSync, mount, unmount } from 'svelte'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -const { gotoMock, pageState } = vi.hoisted(() => ({ - gotoMock: vi.fn(async () => {}), - pageState: { - url: new URL('https://example.com/') - } -})); - -class MockIntersectionObserver { - observe() {} - disconnect() {} -} - -vi.mock('$env/dynamic/public', () => ({ - env: {} -})); - -vi.mock('$app/navigation', () => ({ - afterNavigate: vi.fn(), - beforeNavigate: vi.fn(), - disableScrollHandling: vi.fn(), - goto: gotoMock, - invalidate: vi.fn(), - invalidateAll: vi.fn(), - onNavigate: vi.fn(), - preloadCode: vi.fn(), - preloadData: vi.fn(), - pushState: vi.fn(), - refreshAll: vi.fn(), - replaceState: vi.fn() -})); - -vi.mock('$app/paths', () => ({ - asset: (path: string) => path, - base: '', - assets: '', - match: async () => null, - resolve: (path: string, params?: Record) => { - if (!params) { - return path; - } - - if (path.includes('[dev_eui]')) { - return `/locations/${params.location_id}/devices/${params.dev_eui}`; - } - - return `/locations/${params.location_id}`; - }, - resolveRoute: (path: string, params?: Record) => { - if (!params) { - return path; - } - - return Object.entries(params).reduce( - (resolved, [key, value]) => resolved.replace(`[${key}]`, value), - path - ); - } -})); - -vi.mock('$app/state', () => ({ - page: pageState -})); - -import type { LocationDto } from '$lib/api/api.dtos'; -import { ApiService } from '$lib/api/api.service'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import DashboardDeviceCardsTestHarness from './DashboardDeviceCards.test-harness.svelte'; - -function createDevice(overrides: Partial = {}): IDevice { - return { - dev_eui: 'dev-1', - name: 'Canopy', - location_name: 'Grow Room', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date('2026-04-09T09:50:00.000Z'), - has_primary_data: true, - co2: 820, - humidity: 56, - temperature_c: 24.5, - soil_humidity: null, - location_id: 1, - alert_count: 0, - ...overrides - }; -} - -function renderDashboardDeviceCards( - options: { - devices?: IDevice[]; - locations?: LocationDto[]; - } = {} -) { - const component = mount(DashboardDeviceCardsTestHarness, { - target: document.body, - props: { - initialApp: { - accessToken: 'jwt-token', - devices: options.devices ?? [ - createDevice(), - createDevice({ - dev_eui: 'dev-2', - created_at: new Date('2026-04-09T09:59:30.000Z') - }) - ], - locations: options.locations ?? [ - { - location_id: 1, - name: 'Grow Room', - created_at: '2026-04-01T00:00:00.000Z' - } as LocationDto - ] - }, - filters: { - group: '', - locationGroup: '', - location: '' - }, - cardLayout: 'grid' - } - }); - - flushSync(); - return { component }; -} - -function getButtonsByText(text: string): HTMLButtonElement[] { - return [...document.body.querySelectorAll('button')].filter( - (button): button is HTMLButtonElement => - Boolean(button.textContent?.includes(text) && !button.closest('[aria-hidden="true"]')) - ); -} - -function getExpandTogglesByText(text: string): HTMLElement[] { - return [...document.body.querySelectorAll('[role="button"][aria-expanded]')].filter( - (element): element is HTMLElement => element.textContent?.includes(text) ?? false - ); -} - -describe('DashboardDeviceCards', () => { - beforeEach(() => { - document.body.innerHTML = ''; - localStorage.clear(); - gotoMock.mockReset(); - vi.useFakeTimers(); - vi.setSystemTime(new Date('2026-04-09T10:00:00.000Z')); - - vi.stubGlobal('IntersectionObserver', MockIntersectionObserver); - }); - - afterEach(async () => { - document.body.innerHTML = ''; - vi.unstubAllGlobals(); - vi.restoreAllMocks(); - vi.useRealTimers(); - }); - - it('disambiguates duplicate labels, routes the correct device details, and refreshes the live card device at 10.3 minutes', async () => { - const getDeviceLatestPrimaryData = vi - .spyOn(ApiService.prototype, 'getDeviceLatestPrimaryData') - .mockImplementation(async (devEui: string) => ({ - dev_eui: devEui, - name: 'Canopy', - location_name: 'Grow Room', - group: 'air', - created_at: '2026-04-09T10:00:18.000Z', - co2: 901, - humidity: 60.1, - temperature_c: 25.4, - location_id: 1 - })); - vi.spyOn(ApiService.prototype, 'getDeviceLatestData').mockImplementation( - async (devEui: string) => ({ - dev_eui: devEui, - name: 'Canopy', - location_name: 'Grow Room', - group: 'air', - created_at: '2026-04-09T09:50:00.000Z', - co2: 820, - humidity: 56, - temperature_c: 24.5, - location_id: 1 - }) - ); - const { component } = renderDashboardDeviceCards(); - - expect(document.body.textContent).toContain('Canopy (dev-1)'); - expect(document.body.textContent).toContain('Canopy (dev-2)'); - - const expandToggles = getExpandTogglesByText('Canopy (dev-1)'); - expect(expandToggles).toHaveLength(1); - expandToggles[0]?.click(); - flushSync(); - - const detailButtons = getButtonsByText('View Device Details'); - expect(detailButtons).toHaveLength(1); - detailButtons[0]?.click(); - flushSync(); - - expect(gotoMock).toHaveBeenCalledWith('/locations/1/devices/dev-1'); - - gotoMock.mockClear(); - - await vi.advanceTimersByTimeAsync(18_000); - flushSync(); - - expect(getDeviceLatestPrimaryData).toHaveBeenCalledTimes(1); - expect(getDeviceLatestPrimaryData).toHaveBeenCalledWith('dev-1'); - expect(document.body.textContent).toContain('25.40'); - - await unmount(component); - }); -}); diff --git a/src/lib/components/dashboard/DashboardDeviceCards.test-harness.svelte b/src/lib/components/dashboard/DashboardDeviceCards.test-harness.svelte deleted file mode 100644 index 325df6de..00000000 --- a/src/lib/components/dashboard/DashboardDeviceCards.test-harness.svelte +++ /dev/null @@ -1,26 +0,0 @@ - - - diff --git a/src/lib/components/dashboard/DashboardDeviceTable.svelte b/src/lib/components/dashboard/DashboardDeviceTable.svelte deleted file mode 100644 index 3b1c0a94..00000000 --- a/src/lib/components/dashboard/DashboardDeviceTable.svelte +++ /dev/null @@ -1,337 +0,0 @@ - - -
- {#key tableSourceKey} - - {#snippet cell(row: IDevice, col: CwColumnDef, defaultValue: string)} -
- {#if col.key === 'created_at'} -
- - {#if row.has_primary_data === false} - {m.dashboard_no_data_yet()} - {:else} - void loadSingleDevice(row)} - /> - {/if} -
- {:else if (col.key === 'name' || col.key === 'location_name') && app.privacyModeEnabled} -
- • • • • • • • • -
- {:else} -
- {#if col.key === 'temperature_c'} - {getMetricDisplayValue(row.temperature_c)} - {:else if col.key === 'humidity'} - {getMetricDisplayValue(row.humidity)} - {:else if col.key === 'soil_humidity'} - {getMetricDisplayValue(row.soil_humidity)} - {:else if col.key === 'co2'} - {getMetricDisplayValue(row.co2)} - {:else if col.key === 'alert_count'} - - {#if row.alert_count == null || row.alert_count === 0} - - {:else} - - {/if} - - {:else} - {defaultValue} - {/if} -
- {/if} -
- {/snippet} - - {#snippet rowActions(row: IDevice)} - openDeviceDetails(row)} - > - - - {/snippet} - - {#snippet actionsHeader()} - { - virtualScroll = !virtualScroll; - }} - > - {m.dashboard_virtual_scroll()} - {#if virtualScroll} - - {:else} - - {/if} - - {/snippet} -
- {/key} -
- - diff --git a/src/lib/components/dashboard/DashboardTable.svelte b/src/lib/components/dashboard/DashboardTable.svelte index 153c8722..05586bff 100644 --- a/src/lib/components/dashboard/DashboardTable.svelte +++ b/src/lib/components/dashboard/DashboardTable.svelte @@ -1,322 +1,99 @@ -
- - {#await initialTable} - - {:then initialTableState} - loadData(query, initialTableState)} - filters={tableFilters} - rowKey="dev_eui" - pageSize={initialTableState.query.pageSize} - pageSizeOptions={[25, 50, 100]} - searchable - groupBy="location_name" - fillParent - onRefresh={handleRefresh} - rowActionsHeader="Actions" - > - {#snippet cell( - row: DevicePrimaryDataDto, - col: CwColumnDef, - defaultValue: string - )} - {#if col.key === 'name'} - {row.name?.trim() || row.dev_eui} - {:else if col.key === 'temperature_c'} - {formatMetric(row.temperature_c)} - {:else if col.key === 'humidity'} - {formatMetric(row.humidity)} - {:else if col.key === 'co2'} - {formatMetric(row.co2)} - {:else if col.key === 'moisture'} - {formatMetric(row.moisture)} - {:else if col.key === 'created_at'} - - - - - {:else} - {defaultValue} - {/if} - {/snippet} - - {#snippet actionsHeader()} - void handleRefresh()}> - Refresh all - - {/snippet} - - {#snippet rowActions(row: DevicePrimaryDataDto)} - openDevice(row)}>View - {/snippet} - - {#snippet emptyState()} - -

Try clearing one or more filters or refreshing the dashboard data.

-
- {/snippet} - - {#snippet errorState(message: string)} - -

{message}

-
- {/snippet} -
- {/await} -
-
- - + { + if (row.location?.location_id != null) { + goto(resolve(`/locations/${row.location.location_id}/devices/${row.dev_eui}`)); + } + }} +/> diff --git a/src/lib/components/dashboard/dashboard-device-data.spec.ts b/src/lib/components/dashboard/dashboard-device-data.spec.ts deleted file mode 100644 index 7d60eec6..00000000 --- a/src/lib/components/dashboard/dashboard-device-data.spec.ts +++ /dev/null @@ -1,355 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { DeviceDto, DevicePrimaryDataDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import { - applyDashboardLatestReadings, - mapDashboardDeviceMetadataToDevice, - mapDashboardPrimaryDataToDevice, - mergeDashboardDevices, - uniqueDashboardDevices -} from './dashboard-device-data'; - -describe('dashboard-device-data helpers', () => { - it('maps device metadata into offline dashboard device rows', () => { - const payload: DeviceDto = { - dev_eui: 'dev-9', - name: 'Unreported Sensor', - group: 'air', - location_id: 17, - cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'], - cw_locations: [{ name: 'North Loft' }] - }; - - expect(mapDashboardDeviceMetadataToDevice(payload)).toEqual({ - dev_eui: 'dev-9', - name: 'Unreported Sensor', - location_name: 'North Loft', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date(0), - has_primary_data: false, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: 17, - cwloading: false, - device_type_id: undefined - }); - }); - - it('reads embedded location metadata when cw_locations is a single object', () => { - const payload = { - dev_eui: 'dev-10', - name: 'Lobby Sensor', - group: 'air', - cw_locations: { - location_id: 88, - name: 'Toyotama Floor 2' - }, - cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'] - } satisfies Omit; - - expect(mapDashboardDeviceMetadataToDevice(payload)).toMatchObject({ - dev_eui: 'dev-10', - data_table: 'cw_air_data', - location_id: 88, - location_name: 'Toyotama Floor 2', - has_primary_data: false - }); - }); - - it('maps latest primary API payloads into dashboard device rows', () => { - const payload: DevicePrimaryDataDto = { - dev_eui: 'dev-1', - name: 'Canopy 1', - location_name: 'Zone A', - group: 'air', - created_at: '2026-03-13T00:00:00.000Z', - co2: 914, - humidity: 63.2, - temperature_c: 24.8, - location_id: 42 - }; - - expect(mapDashboardPrimaryDataToDevice(payload)).toEqual({ - dev_eui: 'dev-1', - name: 'Canopy 1', - location_name: 'Zone A', - group: 'air', - created_at: new Date('2026-03-13T00:00:00.000Z'), - has_primary_data: true, - co2: 914, - humidity: 63.2, - temperature_c: 24.8, - soil_humidity: null, - location_id: 42, - cwloading: false, - device_type_id: undefined, - raw_data: { - dev_eui: 'dev-1', - name: 'Canopy 1', - location_name: 'Zone A', - group: 'air', - created_at: '2026-03-13T00:00:00.000Z', - co2: 914, - humidity: 63.2, - temperature_c: 24.8, - location_id: 42 - } - }); - }); - - it('maps soil primary API payloads into soil-specific dashboard columns', () => { - const payload = { - dev_eui: 'soil-1', - name: 'Soil Bed 1', - location_name: 'Zone S', - group: 'soil', - data_table: 'cw_soil_data', - created_at: '2026-03-13T00:00:00.000Z', - temperature_c: 18.6, - moisture: 34.5, - location_id: 77 - } satisfies DevicePrimaryDataDto; - - expect(mapDashboardPrimaryDataToDevice(payload)).toMatchObject({ - dev_eui: 'soil-1', - data_table: 'cw_soil_data', - temperature_c: 18.6, - soil_humidity: 34.5, - location_id: 77 - }); - }); - - it('applies and merges the latest readings while preserving device order', () => { - const currentDevices: IDevice[] = [ - { - dev_eui: 'dev-1', - name: 'Canopy 1', - location_name: 'Zone A', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date('2026-03-13T00:00:00.000Z'), - has_primary_data: true, - co2: 914, - humidity: 63.2, - temperature_c: 24.8, - soil_humidity: null, - location_id: 42, - cwloading: true - }, - { - dev_eui: 'dev-2', - name: 'Canopy 2', - location_name: 'Zone B', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date('2026-03-13T00:05:00.000Z'), - has_primary_data: true, - co2: 880, - humidity: 59.1, - temperature_c: 23.4, - soil_humidity: null, - location_id: 99 - } - ]; - const latestDev1 = mapDashboardPrimaryDataToDevice({ - dev_eui: 'dev-1', - name: 'Canopy 1', - location_name: 'Zone A', - group: 'air', - created_at: '2026-03-13T00:10:00.000Z', - co2: 920, - humidity: 61.7, - temperature_c: 25.1, - location_id: 42 - }); - const latestDev3 = mapDashboardPrimaryDataToDevice({ - dev_eui: 'dev-3', - name: 'Canopy 3', - location_name: 'Zone C', - group: 'air', - created_at: '2026-03-13T00:15:00.000Z', - co2: 901, - humidity: 58.4, - temperature_c: 22.9, - location_id: 100 - }); - const metadataOnlyDev4 = mapDashboardDeviceMetadataToDevice({ - dev_eui: 'dev-4', - name: 'Offline Device', - group: 'air', - location_id: 200, - location_name: 'Zone D', - cw_device_type: { data_table_v2: 'cw_air_data' } as DeviceDto['cw_device_type'] - }); - - const appliedTarget = structuredClone(currentDevices[0]); - applyDashboardLatestReadings(appliedTarget, latestDev1); - expect(appliedTarget).toMatchObject({ - created_at: new Date('2026-03-13T00:10:00.000Z'), - has_primary_data: true, - data_table: 'cw_air_data', - co2: 920, - humidity: 61.7, - temperature_c: 25.1, - soil_humidity: null - }); - - const mergedDevices = mergeDashboardDevices(currentDevices, [ - metadataOnlyDev4, - latestDev3, - latestDev1 - ]); - expect(mergedDevices.map((device) => device.dev_eui)).toEqual([ - 'dev-1', - 'dev-2', - 'dev-4', - 'dev-3' - ]); - expect(mergedDevices[0]).toMatchObject({ - dev_eui: 'dev-1', - created_at: new Date('2026-03-13T00:10:00.000Z'), - has_primary_data: true, - cwloading: true - }); - expect(mergedDevices[2]).toMatchObject({ - dev_eui: 'dev-4', - location_name: 'Zone D', - created_at: new Date(0), - has_primary_data: false, - cwloading: false - }); - expect(mergedDevices[3]).toMatchObject({ - dev_eui: 'dev-3', - location_name: 'Zone C', - has_primary_data: true, - cwloading: false - }); - }); - - it('preserves metadata when latest primary data omits location fields', () => { - const currentDevices: IDevice[] = [ - { - dev_eui: 'dev-11', - name: 'Hallway Sensor', - location_name: 'とよたま2階', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date(0), - has_primary_data: false, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: 501, - cwloading: false - } - ]; - - const mergedDevices = mergeDashboardDevices(currentDevices, [ - mapDashboardPrimaryDataToDevice({ - dev_eui: 'dev-11', - name: '', - location_name: '', - group: '', - created_at: '2026-03-13T00:20:00.000Z', - co2: 777, - humidity: 48.5, - temperature_c: 21.3, - location_id: 0 - }) - ]); - - expect(mergedDevices).toHaveLength(1); - expect(mergedDevices[0]).toMatchObject({ - dev_eui: 'dev-11', - name: 'Hallway Sensor', - location_name: 'とよたま2階', - group: 'air', - location_id: 501, - co2: 777, - humidity: 48.5, - temperature_c: 21.3, - has_primary_data: true - }); - }); - - it('preserves soil classification when a refresh payload omits explicit soil fields', () => { - const currentDevices: IDevice[] = [ - { - dev_eui: 'soil-2', - name: 'Soil Plot 2', - location_name: 'Bed B', - group: 'soil', - data_table: 'cw_soil_data', - created_at: new Date('2026-03-13T00:00:00.000Z'), - has_primary_data: true, - co2: 0, - humidity: 0, - temperature_c: 18.1, - soil_humidity: 31.4, - location_id: 612, - cwloading: false - } - ]; - - const mergedDevices = mergeDashboardDevices(currentDevices, [ - mapDashboardPrimaryDataToDevice({ - dev_eui: 'soil-2', - name: 'Soil Plot 2', - location_name: 'Bed B', - group: 'soil', - created_at: '2026-03-13T00:30:00.000Z', - temperature_c: 19.2, - location_id: 612 - }) - ]); - - expect(mergedDevices[0]).toMatchObject({ - dev_eui: 'soil-2', - data_table: 'cw_soil_data', - temperature_c: 19.2, - soil_humidity: 31.4 - }); - }); - - it('deduplicates dashboard devices by dev_eui and keeps the newest reading', () => { - const devices = [ - mapDashboardPrimaryDataToDevice({ - dev_eui: 'dev-20', - name: 'Canopy', - location_name: 'Zone A', - group: 'air', - created_at: '2026-03-13T00:05:00.000Z', - co2: 750, - humidity: 47, - temperature_c: 21, - location_id: 42 - }), - mapDashboardPrimaryDataToDevice({ - dev_eui: 'DEV-20', - name: 'Canopy', - location_name: 'Zone A', - group: 'air', - created_at: '2026-03-13T00:00:00.000Z', - co2: 700, - humidity: 45, - temperature_c: 19, - location_id: 42 - }) - ]; - - const uniqueDevices = uniqueDashboardDevices(devices); - - expect(uniqueDevices).toHaveLength(1); - expect(uniqueDevices[0]).toMatchObject({ - dev_eui: 'dev-20', - co2: 750, - humidity: 47, - temperature_c: 21, - created_at: new Date('2026-03-13T00:05:00.000Z') - }); - }); -}); diff --git a/src/lib/components/dashboard/dashboard-device-data.ts b/src/lib/components/dashboard/dashboard-device-data.ts deleted file mode 100644 index 947e3d75..00000000 --- a/src/lib/components/dashboard/dashboard-device-data.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { DeviceDto, DevicePrimaryDataDto, DeviceTypeDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function getDashboardEmbeddedLocations( - device: DeviceDto | Record -): Record[] { - if (Array.isArray(device.cw_locations)) { - return device.cw_locations.filter(isRecord); - } - - if (isRecord(device.cw_locations)) { - return [device.cw_locations]; - } - - return []; -} - -function getDashboardDeviceLocationName(device: DeviceDto | Record): string { - if (device.location_name) return device.location_name as string; - - for (const location of getDashboardEmbeddedLocations(device)) { - if (location?.name) return location.name as string; - } - - return ''; -} - -function getDashboardDeviceLocationId(device: DeviceDto | Record): number { - const directId = Number(device.location_id); - if (directId > 0) return directId; - - for (const location of getDashboardEmbeddedLocations(device)) { - const id = Number(location.location_id); - if (id > 0) return id; - } - - return directId; -} - -function preferIncomingText( - incoming: string | undefined, - current: string | undefined -): string | undefined { - return incoming?.trim() ? incoming : current; -} - -function preferIncomingLocationId(incoming: number, current: number): number { - return incoming > 0 ? incoming : current; -} - -function preferNewerCreatedAt(incoming: Date, current: Date): Date { - const incomingMs = incoming instanceof Date ? incoming.getTime() : NaN; - const currentMs = current instanceof Date ? current.getTime() : NaN; - if (!Number.isFinite(incomingMs)) return current; - if (!Number.isFinite(currentMs)) return incoming; - return incomingMs > currentMs ? incoming : current; -} - -function isNewerDeviceReading(incoming: IDevice, current: IDevice): boolean { - return preferNewerCreatedAt(incoming.created_at, current.created_at) === incoming.created_at; -} - -function getDashboardDeviceKey(device: IDevice): string { - return String(device.dev_eui ?? '') - .trim() - .toLowerCase(); -} - -function getDeviceDataTable( - device: DeviceDto | DevicePrimaryDataDto | Record -): string { - const directDataTable = device.data_table_v2 ?? device.data_table; - if (directDataTable) return directDataTable as string; - - if (isRecord(device.cw_device_type)) { - return (device.cw_device_type.data_table_v2 ?? - device.cw_device_type.data_table ?? - '') as string; - } - - return ''; -} - -function extractDeviceTypeId( - device: DeviceDto | DevicePrimaryDataDto | Record -): number | undefined { - const raw = (device as Record).type; - if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw; - - if (isRecord(device.cw_device_type)) { - const embeddedId = device.cw_device_type.id; - if (typeof embeddedId === 'number' && Number.isFinite(embeddedId) && embeddedId > 0) { - return embeddedId; - } - } - - return undefined; -} - -/** Configuration for a device type, keyed by cw_device_type.model. */ -export interface DeviceTypeConfig { - primary_data_key?: string; - secondary_data_key?: string; - primary_data_notation?: string; - secondary_data_notation?: string; -} - -/** Lookup maps built from the `/devices/device-types` endpoint. */ -export interface DeviceTypeLookup { - /** model string → full config */ - byModel: Record; - /** cw_device_type.id → model string (bridges CwDevice.type FK) */ - idToModel: Record; -} - -function mapDeviceTypeRecordToConfig( - deviceType: Record | undefined -): DeviceTypeConfig | undefined { - if (!deviceType) { - return undefined; - } - - const config: DeviceTypeConfig = { - primary_data_key: - typeof deviceType.primary_data_v2 === 'string' ? deviceType.primary_data_v2 : undefined, - secondary_data_key: - typeof deviceType.secondary_data_v2 === 'string' ? deviceType.secondary_data_v2 : undefined, - primary_data_notation: - typeof deviceType.primary_data_notation === 'string' - ? deviceType.primary_data_notation - : undefined, - secondary_data_notation: - typeof deviceType.secondary_data_notation === 'string' - ? deviceType.secondary_data_notation - : undefined - }; - - return Object.values(config).some((value) => value != null) ? config : undefined; -} - -/** - * Build lookup maps from the full device-type list (`getDeviceTypes()`). - * Keyed by `cw_device_type.model` with an id→model bridge so individual - * devices can resolve their type via their numeric FK (`device.type`). - */ -export function buildDeviceTypeLookup(deviceTypes: DeviceTypeDto[]): DeviceTypeLookup { - const byModel: Record = {}; - const idToModel: Record = {}; - - for (const dt of deviceTypes) { - const model = dt.model || undefined; - if (!model) continue; - - const id = typeof dt.id === 'number' && Number.isFinite(dt.id) ? dt.id : undefined; - if (id != null) idToModel[id] = model; - - if (byModel[model]) continue; - - byModel[model] = mapDeviceTypeRecordToConfig(dt) ?? {}; - } - - return { byModel, idToModel }; -} - -/** Resolve a DeviceTypeConfig for an IDevice using embedded API data first. */ -export function resolveDeviceTypeConfig( - device: IDevice, - lookup?: DeviceTypeLookup -): DeviceTypeConfig | undefined { - const embeddedConfig = mapDeviceTypeRecordToConfig( - isRecord(device.raw_data?.cw_device_type) ? device.raw_data.cw_device_type : undefined - ); - - if (embeddedConfig) { - return embeddedConfig; - } - - if (!lookup || !device.device_type_id) return undefined; - const model = lookup.idToModel[device.device_type_id]; - return model ? lookup.byModel[model] : undefined; -} - -export function mapDashboardDeviceMetadataToDevice( - device: DeviceDto | Record -): IDevice { - return { - dev_eui: device.dev_eui as string, - name: (device.name ?? device.dev_eui) as string, - location_name: getDashboardDeviceLocationName(device), - group: device.group as string, - data_table: getDeviceDataTable(device) || undefined, - created_at: new Date(0), - has_primary_data: false, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: getDashboardDeviceLocationId(device), - cwloading: false, - device_type_id: extractDeviceTypeId(device) - }; -} - -export function mapDashboardPrimaryDataToDevice( - device: DevicePrimaryDataDto | Record -): IDevice { - const dataTable = getDeviceDataTable(device); - const moisture = device.moisture; - const soilHumidity = moisture != null ? Number(moisture) : null; - - // Preserve raw sensor fields and embedded type config so dynamic keys can be resolved. - const raw_data: Record = {}; - for (const [key, value] of Object.entries(device)) { - if (key !== 'cw_locations' && key !== 'cw_device_owners') { - raw_data[key] = value; - } - } - - const createdAt = device.created_at; - const parsedDate = createdAt instanceof Date ? createdAt : new Date(createdAt as string); - - return { - dev_eui: device.dev_eui as string, - name: device.name as string, - location_name: device.location_name as string, - group: device.group as string, - ...(dataTable ? { data_table: dataTable } : {}), - created_at: parsedDate, - has_primary_data: true, - co2: Number(device.co2) || 0, - humidity: Number(device.humidity) || 0, - temperature_c: Number(device.temperature_c) || 0, - soil_humidity: soilHumidity, - location_id: Number(device.location_id) || 0, - cwloading: false, - raw_data, - device_type_id: extractDeviceTypeId(device) - }; -} - -export function applyDashboardLatestReadings(target: IDevice, source: IDevice): void { - const resolvedDataTable = preferIncomingText(source.data_table, target.data_table); - const isSoilDevice = resolvedDataTable === 'cw_soil_data'; - - target.temperature_c = source.temperature_c; - target.co2 = source.co2; - target.humidity = source.humidity; - target.soil_humidity = - source.soil_humidity ?? (isSoilDevice ? (target.soil_humidity ?? null) : null); - target.created_at = source.created_at; - target.has_primary_data = source.has_primary_data; - target.location_id = preferIncomingLocationId(source.location_id, target.location_id); - target.location_name = preferIncomingText(source.location_name, target.location_name) ?? ''; - target.group = preferIncomingText(source.group, target.group); - target.alert_count = source.alert_count ?? target.alert_count ?? 0; - target.data_table = resolvedDataTable; - target.name = preferIncomingText(source.name, target.name) ?? target.dev_eui; - - // Preserve device_type_id (metadata device has it, primary data refresh may not) - target.device_type_id = source.device_type_id ?? target.device_type_id; - - // Merge raw sensor data - if (source.raw_data) { - target.raw_data = { ...target.raw_data, ...source.raw_data }; - } -} - -function mergeDashboardDevice(device: IDevice, latestDevice: IDevice): IDevice { - const resolvedDataTable = preferIncomingText(latestDevice.data_table, device.data_table); - const isSoilDevice = resolvedDataTable === 'cw_soil_data'; - - return { - ...device, - ...latestDevice, - name: preferIncomingText(latestDevice.name, device.name) ?? device.dev_eui, - group: preferIncomingText(latestDevice.group, device.group), - data_table: resolvedDataTable, - location_name: preferIncomingText(latestDevice.location_name, device.location_name) ?? '', - location_id: preferIncomingLocationId(latestDevice.location_id, device.location_id), - has_primary_data: latestDevice.has_primary_data ?? device.has_primary_data, - soil_humidity: - latestDevice.soil_humidity ?? (isSoilDevice ? (device.soil_humidity ?? null) : null), - cwloading: device.cwloading ?? latestDevice.cwloading ?? false, - alert_count: latestDevice.alert_count ?? device.alert_count ?? 0, - device_type_id: device.device_type_id ?? latestDevice.device_type_id, - created_at: preferNewerCreatedAt(latestDevice.created_at, device.created_at), - raw_data: latestDevice.raw_data - ? { ...device.raw_data, ...latestDevice.raw_data } - : device.raw_data - }; -} - -export function uniqueDashboardDevices(devices: IDevice[]): IDevice[] { - const uniqueDevices: IDevice[] = []; - const indexByDevEui = new Map(); - - for (const device of devices) { - const deviceKey = getDashboardDeviceKey(device); - if (!deviceKey) { - uniqueDevices.push(device); - continue; - } - - const existingIndex = indexByDevEui.get(deviceKey); - if (existingIndex == null) { - indexByDevEui.set(deviceKey, uniqueDevices.length); - uniqueDevices.push(device); - continue; - } - - const existingDevice = uniqueDevices[existingIndex]; - uniqueDevices[existingIndex] = isNewerDeviceReading(device, existingDevice) - ? mergeDashboardDevice(existingDevice, device) - : mergeDashboardDevice(device, existingDevice); - } - - return uniqueDevices; -} - -export function mergeDashboardDevices( - currentDevices: IDevice[], - latestDevices: IDevice[] -): IDevice[] { - const mergedDevices = uniqueDashboardDevices(currentDevices); - if (latestDevices.length === 0) return mergedDevices; - - const latestByDevEui = new Map(); - for (const device of uniqueDashboardDevices(latestDevices)) { - const deviceKey = getDashboardDeviceKey(device); - if (deviceKey) { - latestByDevEui.set(deviceKey, device); - } - } - - for (let index = 0; index < mergedDevices.length; index += 1) { - const device = mergedDevices[index]; - const deviceKey = getDashboardDeviceKey(device); - const latestDevice = latestByDevEui.get(deviceKey); - if (!latestDevice) continue; - - latestByDevEui.delete(deviceKey); - mergedDevices[index] = mergeDashboardDevice(device, latestDevice); - } - - for (const latestDevice of latestByDevEui.values()) { - mergedDevices.push({ - ...latestDevice, - has_primary_data: latestDevice.has_primary_data ?? false, - cwloading: latestDevice.cwloading ?? false, - alert_count: latestDevice.alert_count ?? 0 - }); - } - - return mergedDevices; -} diff --git a/src/lib/components/dashboard/dashboard-device-refresh.spec.ts b/src/lib/components/dashboard/dashboard-device-refresh.spec.ts deleted file mode 100644 index bf0a328c..00000000 --- a/src/lib/components/dashboard/dashboard-device-refresh.spec.ts +++ /dev/null @@ -1,152 +0,0 @@ -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_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 deleted file mode 100644 index 8ec8c8c2..00000000 --- a/src/lib/components/dashboard/dashboard-device-refresh.ts +++ /dev/null @@ -1,103 +0,0 @@ -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 & { raw_data?: Record }, - nowMs = Date.now() -): boolean { - if (device.has_primary_data === false) { - return true; - } - - const lastSeenMs = getDeviceTimestampMs(device); - if (!Number.isFinite(lastSeenMs)) return true; - - const intervalMinutes = - typeof device.raw_data?.default_upload_interval === 'number' - ? device.raw_data.default_upload_interval - : null; - const thresholdMs = - intervalMinutes != null && intervalMinutes > 0 - ? intervalMinutes * 60_000 - : DASHBOARD_DEVICE_OFFLINE_THRESHOLD_MS; - - return lastSeenMs < nowMs - thresholdMs; -} - -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/dashboard-filter-values.ts b/src/lib/components/dashboard/dashboard-filter-values.ts deleted file mode 100644 index 33336867..00000000 --- a/src/lib/components/dashboard/dashboard-filter-values.ts +++ /dev/null @@ -1,37 +0,0 @@ -const DASHBOARD_FILTER_VALUE_KEYS = [ - 'group', - 'location_group', - 'locationGroup', - 'name', - 'value', - 'label' -] as const; - -export function readDashboardFilterValue(value: unknown): string { - if (typeof value === 'string') return value.trim(); - if (!value || typeof value !== 'object') return ''; - - const record = value as Record; - for (const key of DASHBOARD_FILTER_VALUE_KEYS) { - const candidate = typeof record[key] === 'string' ? (record[key] as string).trim() : ''; - if (candidate) return candidate; - } - - return ''; -} - -export function normalizeDashboardFilterValues( - values: readonly unknown[] | null | undefined -): string[] { - if (!Array.isArray(values)) { - return []; - } - - const normalizedValues = values - .map((value) => readDashboardFilterValue(value)) - .filter((value) => value.length > 0); - - return Array.from(new Set(normalizedValues)).sort((left, right) => - left.localeCompare(right) - ); -} diff --git a/src/lib/components/dashboard/device-cards.spec.ts b/src/lib/components/dashboard/device-cards.spec.ts deleted file mode 100644 index 9c6f6b85..00000000 --- a/src/lib/components/dashboard/device-cards.spec.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import type { LocationDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import { m } from '$lib/paraglide/messages.js'; -import { buildDashboardLocationSensorCards, buildRelayExpandedDetailRows } from './device-cards'; -import { - DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES, - getDashboardDeviceNextRefreshDelayMs -} from './dashboard-device-refresh'; - -describe('device-cards helpers', () => { - it('groups devices by location and keeps simple device labels', () => { - const devices: IDevice[] = [ - { - dev_eui: 'dev-2', - name: 'Canopy', - location_name: 'Zone B', - group: 'air', - created_at: new Date('2026-03-13T00:00:00.000Z'), - co2: 880, - humidity: 54, - temperature_c: 22, - location_id: 2 - }, - { - dev_eui: 'dev-1', - name: 'Canopy', - location_name: 'Zone B', - group: 'air', - created_at: new Date('2026-03-13T00:01:00.000Z'), - co2: 901, - humidity: 58, - temperature_c: 23, - location_id: 2 - }, - { - dev_eui: 'dev-3', - name: 'Atrium Sensor', - location_name: 'Atrium', - group: 'air', - created_at: new Date('2026-03-13T00:02:00.000Z'), - co2: 915, - humidity: 62, - temperature_c: 25, - location_id: 1 - } - ]; - const locations: LocationDto[] = [ - { - location_id: 2, - name: 'Zone B', - created_at: '2026-03-01T00:00:00.000Z' - } as LocationDto, - { - location_id: 1, - name: 'Atrium', - created_at: '2026-03-01T00:00:00.000Z' - } as LocationDto - ]; - - const cards = buildDashboardLocationSensorCards(devices, locations, Date.now()); - - expect(cards.map((card) => card.title)).toEqual(['Zone B', 'Atrium']); - expect(cards[0]?.sensors.map(({ sensor }) => sensor.label)).toEqual(['Canopy', 'Canopy']); - expect(cards[0]?.sensors[0]).toMatchObject({ - id: 'sensor:dev-2', - storageKey: 'dashboard-device-card:dev-2', - devEui: 'dev-2', - locationId: 2, - sourceDevice: devices[0] - }); - }); - - it('deduplicates repeated device rows so sensor keys stay unique', () => { - const devices: IDevice[] = [ - { - dev_eui: 'dev-1', - name: 'Canopy', - location_name: 'Zone A', - group: 'air', - created_at: new Date('2026-03-13T00:00:00.000Z'), - co2: 880, - humidity: 54, - temperature_c: 21, - location_id: 1 - }, - { - dev_eui: 'dev-1', - name: 'Canopy', - location_name: 'Zone A', - group: 'air', - created_at: new Date('2026-03-13T00:10:00.000Z'), - co2: 901, - humidity: 58, - temperature_c: 23, - location_id: 1 - } - ]; - - const cards = buildDashboardLocationSensorCards( - devices, - [{ location_id: 1, name: 'Zone A' } as LocationDto], - Date.now() - ); - - expect(cards[0]?.sensors).toHaveLength(1); - expect(cards[0]?.sensors[0]).toMatchObject({ - id: 'sensor:dev-1', - devEui: 'dev-1', - sensor: { - label: 'Canopy', - primaryValue: 23 - } - }); - }); - - 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')); - - const cards = buildDashboardLocationSensorCards( - [ - { - dev_eui: 'dev-1', - name: 'Recent Sensor', - location_name: 'Zone A', - group: 'air', - created_at: new Date('2026-03-13T11:56:00.000Z'), - co2: 900, - humidity: 58, - temperature_c: 23, - location_id: 1 - }, - { - dev_eui: 'dev-2', - name: 'Old Sensor', - location_name: 'Zone A', - group: 'air', - created_at: new Date('2026-03-13T10:30:00.000Z'), - co2: 860, - humidity: 52, - temperature_c: 20, - location_id: 1 - } - ], - [ - { - location_id: 1, - name: 'Zone A', - created_at: '2026-03-01T00:00:00.000Z' - } as LocationDto - ], - Date.now() - ); - - expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ - label: 'Recent Sensor', - status: 'online' - }); - expect(cards[0]?.sensors[0]?.sensor.expectedUpdateAfterMinutes).toBeUndefined(); - expect(cards[0]?.sensors[1]?.sensor).toMatchObject({ - label: 'Old Sensor', - status: 'offline' - }); - expect(getDashboardDeviceNextRefreshDelayMs(cards[0]!.sensors[1]!.sourceDevice)).not.toBeNull(); - expect(DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES).toBe(10.3); - - vi.useRealTimers(); - }); - - it('renders ON/OFF labels on the primary fields for relay devices', () => { - const recentNow = new Date('2026-04-30T12:00:00.000Z').getTime(); - const recentCreatedAt = new Date('2026-04-30T11:59:30.000Z'); - - const cards = buildDashboardLocationSensorCards( - [ - { - dev_eui: 'relay-mixed', - name: 'Greenhouse Relay', - location_name: 'North Bay', - group: 'relay', - data_table: 'cw_relay_data', - created_at: recentCreatedAt, - has_primary_data: true, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: 7, - raw_data: { relay_1: true, relay_2: false } - }, - { - dev_eui: 'relay-numeric', - name: 'Pump Relay', - location_name: 'North Bay', - group: 'relay', - data_table: 'cw_relay_data', - created_at: recentCreatedAt, - has_primary_data: true, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: 7, - raw_data: { relay_1: 1, relay_2: 0 } - }, - { - dev_eui: 'relay-blank', - name: 'Idle Relay', - location_name: 'North Bay', - group: 'relay', - data_table: 'cw_relay_data', - created_at: recentCreatedAt, - has_primary_data: true, - co2: 0, - humidity: 0, - temperature_c: 0, - soil_humidity: null, - location_id: 7 - } - ], - [{ location_id: 7, name: 'North Bay' } as LocationDto], - recentNow - ); - - const sensors = cards[0]!.sensors.map((entry) => entry.sensor); - const greenhouse = sensors.find((sensor) => sensor.label === 'Greenhouse Relay'); - const pump = sensors.find((sensor) => sensor.label === 'Pump Relay'); - const idle = sensors.find((sensor) => sensor.label === 'Idle Relay'); - - expect(greenhouse).toMatchObject({ - label: 'Greenhouse Relay', - primaryValue: 1, - primaryLabel: m.display_relay_state_on(), - primary_icon: 'relay', - secondaryValue: 0, - secondaryLabel: m.display_relay_state_off(), - secondary_icon: 'relay', - status: 'online' - }); - expect(greenhouse?.primaryUnit).toBe(m.display_relay_one()); - expect(greenhouse?.secondaryUnit).toBe(m.display_relay_two()); - - expect(pump).toMatchObject({ - primaryValue: 1, - primaryLabel: m.display_relay_state_on(), - secondaryValue: 0, - secondaryLabel: m.display_relay_state_off() - }); - - expect(idle).toMatchObject({ - primaryValue: 0, - primaryLabel: m.display_relay_state_unknown(), - secondaryValue: 0, - secondaryLabel: m.display_relay_state_unknown() - }); - }); - - it('builds expanded relay detail rows with the last-update timestamp', () => { - const createdAt = new Date('2026-04-30T11:59:30.000Z'); - const rows = buildRelayExpandedDetailRows( - { relay_1: true, relay_2: false, created_at: createdAt }, - 'Greenhouse Relay' - ); - - expect(rows).toHaveLength(3); - expect(rows[0]).toMatchObject({ - id: 'Greenhouse Relay-relay_1', - value: m.display_relay_state_on(), - icon: 'relay' - }); - expect(rows[1]).toMatchObject({ - id: 'Greenhouse Relay-relay_2', - value: m.display_relay_state_off(), - icon: 'relay' - }); - expect(rows[2]).toMatchObject({ - id: 'Greenhouse Relay-updated', - icon: 'timer' - }); - }); - - it('omits the last-update row when created_at is missing or invalid', () => { - expect(buildRelayExpandedDetailRows({ relay_1: true, relay_2: true }, 'X')).toHaveLength(2); - expect( - buildRelayExpandedDetailRows({ relay_1: true, relay_2: true, created_at: 'nope' }, 'X') - ).toHaveLength(2); - }); - - it('renders unknown labels in expanded rows when relay values are absent', () => { - const rows = buildRelayExpandedDetailRows({}, 'X'); - expect(rows[0].value).toBe(m.display_relay_state_unknown()); - expect(rows[1].value).toBe(m.display_relay_state_unknown()); - }); - - it('keeps metadata-only devices offline without arming an overdue timer', () => { - const cards = buildDashboardLocationSensorCards( - [ - { - dev_eui: 'dev-5', - name: 'No Data Yet', - location_name: 'Zone X', - group: 'air', - created_at: new Date(0), - has_primary_data: false, - co2: 0, - humidity: 0, - temperature_c: 0, - location_id: 5 - } - ], - [ - { - location_id: 5, - name: 'Zone X', - created_at: '2026-03-01T00:00:00.000Z' - } as LocationDto - ], - Date.now() - ); - - expect(cards[0]?.sensors[0]?.sensor).toMatchObject({ - label: 'No Data Yet', - status: 'offline', - detailRows: [ - expect.objectContaining({ label: 'Temperature', value: 'No reading' }), - expect.objectContaining({ label: 'Humidity', value: 'No reading' }), - expect.objectContaining({ label: 'Last Update', value: 'No primary data yet' }) - ] - }); - }); -}); diff --git a/src/lib/components/dashboard/device-cards.ts b/src/lib/components/dashboard/device-cards.ts deleted file mode 100644 index 9cf18e08..00000000 --- a/src/lib/components/dashboard/device-cards.ts +++ /dev/null @@ -1,408 +0,0 @@ -import type { CwSensorCardDetailRow, CwSensorCardDevice } from '@cropwatchdevelopment/cwui'; -import type { LocationDto } from '$lib/api/api.dtos'; -import { isRelayTable } from '$lib/config/deviceTables'; -import { coerceRelayValue } from '$lib/devices/relay-telemetry'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import { m } from '$lib/paraglide/messages.js'; -import { - resolveDeviceTypeConfig, - uniqueDashboardDevices, - type DeviceTypeConfig, - type DeviceTypeLookup -} from './dashboard-device-data'; -import { - DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES, - isDashboardDeviceOffline -} from './dashboard-device-refresh'; - -export const DASHBOARD_SENSOR_CARD_LOCATION_BATCH_SIZE = 10; -export const DASHBOARD_SENSOR_CARD_PREFETCH_REMAINING = 5; - -export interface DashboardSensorCardEntry { - id: string; - storageKey: string; - devEui: string; - locationId: number; - sourceDevice: IDevice; - sensor: CwSensorCardDevice; -} - -export interface DashboardLocationSensorCard { - id: string; - locationId: number; - title: string; - sensors: DashboardSensorCardEntry[]; -} - -function getDeviceLabel(device: IDevice): string { - return device.name?.trim() || device.dev_eui?.trim() || 'Unnamed device'; -} - -function getDeviceStatus(device: IDevice, nowMs: number): 'online' | 'offline' { - return isDashboardDeviceOffline(device, nowMs) ? 'offline' : 'online'; -} - -function getDeviceLocationId(device: IDevice): number { - const locationId = Number(device.location_id); - return Number.isFinite(locationId) ? locationId : 0; -} - -function getSensorEntryId(device: IDevice, fallbackId: string): string { - const devEui = device.dev_eui?.trim(); - return devEui ? `sensor:${devEui}` : fallbackId; -} - -function getSensorStorageKey(device: IDevice, entryId: string): string { - return `dashboard-device-card:${device.dev_eui?.trim() || entryId}`; -} - -function getRelayStateLabel(value: boolean | null): string { - if (value === true) return m.display_relay_state_on(); - if (value === false) return m.display_relay_state_off(); - return m.display_relay_state_unknown(); -} - -export function hasRelayKeys(payload: Record | null | undefined): boolean { - return payload != null && ('relay_1' in payload || 'relay_2' in payload); -} - -function isRelayDevice(device: IDevice): boolean { - return isRelayTable(device.data_table) || hasRelayKeys(device.raw_data); -} - -export function buildRelayExpandedDetailRows( - rawData: Record, - deviceLabel: string -): CwSensorCardDetailRow[] { - const relay1 = coerceRelayValue(rawData.relay_1); - const relay2 = coerceRelayValue(rawData.relay_2); - const rows: CwSensorCardDetailRow[] = [ - { - id: `${deviceLabel}-relay_1`, - label: m.display_relay_one(), - value: getRelayStateLabel(relay1), - icon: 'relay' - }, - { - id: `${deviceLabel}-relay_2`, - label: m.display_relay_two(), - value: getRelayStateLabel(relay2), - icon: 'relay' - } - ]; - - const createdAt = rawData.created_at; - if (createdAt != null) { - const lastUpdatedDate = createdAt instanceof Date ? createdAt : new Date(String(createdAt)); - if (Number.isFinite(lastUpdatedDate.getTime())) { - rows.push({ - id: `${deviceLabel}-updated`, - label: 'Last Update', - icon: 'timer', - lastUpdated: lastUpdatedDate, - expectedUpdateAfter: DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES - }); - } - } - - return rows; -} - -function buildUnavailableDetailRows( - label: string, - typeConfig: DeviceTypeConfig | undefined -): CwSensorCardDetailRow[] { - const primaryLabel = typeConfig?.primary_data_key ?? 'Temperature'; - const secondaryLabel = typeConfig?.secondary_data_key ?? 'Humidity'; - return [ - { - id: `${label}-primary`, - label: primaryLabel, - value: 'No reading', - icon: 'thermo' - }, - { - id: `${label}-secondary`, - label: secondaryLabel, - value: 'No reading', - icon: 'drop' - }, - { - id: `${label}-updated`, - label: 'Last Update', - value: 'No primary data yet', - icon: 'timer' - } - ]; -} - -function getLocationTitle( - locationId: number, - locationsById: Map, - locationDevices: IDevice[] -): string { - const location = locationsById.get(locationId); - if (location?.name?.trim()) { - return location.name.trim(); - } - - const deviceLocationName = locationDevices.find((device) => - device.location_name?.trim() - )?.location_name; - if (deviceLocationName) { - return deviceLocationName.trim(); - } - - return locationId > 0 ? `Location ${locationId}` : 'Unassigned'; -} - -/** Metadata keys present in the /latest-data payload that are not sensor readings. */ -const SENSOR_SKIP_KEYS = new Set([ - 'dev_eui', - 'name', - 'location_id', - 'location_name', - 'group', - 'data_table', - 'data_table_v2', - 'type', - 'id', - 'created_at', - 'cw_device_type', - 'cw_locations', - 'cw_device_owners' -]); - -interface SensorFieldMeta { - label: string; - unit: string; - icon: string; -} - -const KNOWN_SENSOR_FIELDS: Record = { - temperature_c: { label: 'Temperature', unit: '°C', icon: 'thermo' }, - humidity: { label: 'Humidity', unit: '%', icon: 'drop' }, - co2: { label: 'CO₂', unit: 'ppm', icon: 'thermo' }, - moisture: { label: 'Moisture', unit: '%', icon: 'drop' }, - ec: { label: 'EC', unit: 'dS/m', icon: 'drop' }, - battery: { label: 'Battery', unit: '%', icon: 'timer' }, - pressure: { label: 'Pressure', unit: 'hPa', icon: 'thermo' }, - light: { label: 'Light', unit: 'lux', icon: 'thermo' }, - light_level: { label: 'Light', unit: 'lux', icon: 'thermo' }, - voltage: { label: 'Voltage', unit: 'V', icon: 'timer' }, - deapth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' }, - depth_cm: { label: 'Depth', unit: 'cm', icon: 'drop' } -}; - -function formatKeyLabel(key: string): string { - return key - .replace(/_c$/, '') - .replace(/_/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); -} - -export function buildDeviceLoadingDetailRows(deviceLabel: string): CwSensorCardDetailRow[] { - return [ - { id: `${deviceLabel}-loading-primary`, label: 'Loading…', value: '—', icon: 'thermo' }, - { id: `${deviceLabel}-loading-secondary`, label: 'Loading…', value: '—', icon: 'drop' } - ]; -} - -/** - * Builds detail rows from the raw /latest-data payload, showing every numeric - * sensor field. Type-config transforms are applied to the primary/secondary keys. - */ -export function buildDeviceExpandedDetailRows( - rawData: Record, - typeConfig: DeviceTypeConfig | undefined, - deviceLabel: string -): CwSensorCardDetailRow[] { - const rows: CwSensorCardDetailRow[] = []; - - for (const [key, rawValue] of Object.entries(rawData)) { - if (SENSOR_SKIP_KEYS.has(key)) continue; - if (typeof rawValue !== 'number' || !Number.isFinite(rawValue)) continue; - - const isPrimary = typeConfig?.primary_data_key === key; - const isSecondary = typeConfig?.secondary_data_key === key; - - const displayValue = rawValue; - - const known = KNOWN_SENSOR_FIELDS[key]; - const label = isPrimary - ? (known?.label ?? typeConfig?.primary_data_key ?? formatKeyLabel(key)) - : isSecondary - ? (known?.label ?? typeConfig?.secondary_data_key ?? formatKeyLabel(key)) - : (known?.label ?? formatKeyLabel(key)); - const unit = isPrimary - ? (typeConfig?.primary_data_notation ?? known?.unit ?? '') - : isSecondary - ? (typeConfig?.secondary_data_notation ?? known?.unit ?? '') - : (known?.unit ?? ''); - const icon = known?.icon ?? 'thermo'; - - rows.push({ - id: `${deviceLabel}-${key}`, - label, - value: displayValue.toLocaleString('en-US', { - minimumFractionDigits: 2, - maximumFractionDigits: 2 - }), - unit, - icon - }); - } - - // Last updated timestamp - const createdAt = rawData.created_at; - if (createdAt != null) { - const lastUpdatedDate = createdAt instanceof Date ? createdAt : new Date(String(createdAt)); - if (Number.isFinite(lastUpdatedDate.getTime())) { - rows.push({ - id: `${deviceLabel}-updated`, - label: 'Last Update', - icon: 'timer', - lastUpdated: lastUpdatedDate, - expectedUpdateAfter: DASHBOARD_DEVICE_REFRESH_ALARM_AFTER_MINUTES - }); - } - } - - return rows; -} - -function buildRelaySensorCardEntry( - device: IDevice, - label: string, - nowMs: number, - entryId: string -): DashboardSensorCardEntry { - const relay1 = coerceRelayValue(device.raw_data?.relay_1); - const relay2 = coerceRelayValue(device.raw_data?.relay_2); - const relayToValue = (value: boolean | null): number => (value === true ? 1 : 0); - - return { - id: entryId, - storageKey: getSensorStorageKey(device, entryId), - devEui: device.dev_eui, - locationId: getDeviceLocationId(device), - sourceDevice: device, - sensor: { - label, - primaryValue: relayToValue(relay1), - primaryLabel: getRelayStateLabel(relay1), - primaryUnit: m.display_relay_one(), - primary_icon: 'relay', - secondaryValue: relayToValue(relay2), - secondaryLabel: getRelayStateLabel(relay2), - secondaryUnit: m.display_relay_two(), - secondary_icon: 'relay', - status: getDeviceStatus(device, nowMs), - lastUpdated: device.created_at, - detailRows: buildRelayExpandedDetailRows( - { ...(device.raw_data ?? {}), created_at: device.created_at }, - label - ) - } satisfies CwSensorCardDevice - }; -} - -function buildDashboardSensorCardEntry( - device: IDevice, - nowMs: number, - deviceTypeLookup: DeviceTypeLookup | undefined, - entryId: string -): DashboardSensorCardEntry { - const label = getDeviceLabel(device); - - if (isRelayDevice(device)) { - return buildRelaySensorCardEntry(device, label, nowMs, entryId); - } - - const typeConfig = resolveDeviceTypeConfig(device, deviceTypeLookup); - - if (device.has_primary_data === false) { - return { - id: entryId, - storageKey: getSensorStorageKey(device, entryId), - devEui: device.dev_eui, - locationId: getDeviceLocationId(device), - sourceDevice: device, - sensor: { - label, - primaryValue: 0, - primaryUnit: typeConfig?.primary_data_notation ?? '°C', - status: 'offline', - detailRows: buildUnavailableDetailRows(label, typeConfig) - } satisfies CwSensorCardDevice - }; - } - - const primaryDataKey = typeConfig?.primary_data_key ?? 'temperature_c'; - const secondaryDataKey = - typeConfig?.secondary_data_key ?? - (device.data_table === 'cw_soil_data' ? 'moisture' : 'humidity'); - const rawPrimary = device.raw_data?.[primaryDataKey] ?? device.temperature_c; - const rawSecondary = - device.raw_data?.[secondaryDataKey] ?? - (secondaryDataKey === 'moisture' ? device.soil_humidity : device.humidity) ?? - null; - - return { - id: entryId, - storageKey: getSensorStorageKey(device, entryId), - devEui: device.dev_eui, - locationId: getDeviceLocationId(device), - sourceDevice: device, - sensor: { - label, - primaryValue: typeof rawPrimary === 'number' ? rawPrimary : Number(rawPrimary ?? 0) || 0, - primaryUnit: typeConfig?.primary_data_notation ?? '°C', - ...(rawSecondary !== null && { - secondaryValue: typeof rawSecondary === 'number' ? rawSecondary : Number(rawSecondary) || 0, - secondaryUnit: typeConfig?.secondary_data_notation ?? '%' - }), - status: getDeviceStatus(device, nowMs), - lastUpdated: device.created_at - } satisfies CwSensorCardDevice - }; -} - -export function buildDashboardLocationSensorCards( - devices: IDevice[], - locations: LocationDto[], - nowMs: number, - deviceTypeLookup?: DeviceTypeLookup -): DashboardLocationSensorCard[] { - const locationsById = new Map(); - for (const location of locations) { - const locationId = Number(location.location_id); - if (Number.isFinite(locationId)) { - locationsById.set(locationId, location); - } - } - - const devicesByLocationId = new Map(); - - for (const device of uniqueDashboardDevices(devices)) { - const locationId = getDeviceLocationId(device); - const locationDevices = devicesByLocationId.get(locationId); - - if (locationDevices) { - locationDevices.push(device); - continue; - } - - devicesByLocationId.set(locationId, [device]); - } - - return Array.from(devicesByLocationId.entries()).map(([locationId, locationDevices]) => ({ - id: `location:${locationId}`, - locationId, - title: getLocationTitle(locationId, locationsById, locationDevices), - sensors: locationDevices.map((device, index) => { - const entryId = getSensorEntryId(device, `sensor:${locationId}:${index}`); - return buildDashboardSensorCardEntry(device, nowMs, deviceTypeLookup, entryId); - }) - })); -} diff --git a/src/lib/components/dashboard/device-table.spec.ts b/src/lib/components/dashboard/device-table.spec.ts deleted file mode 100644 index 79ec0287..00000000 --- a/src/lib/components/dashboard/device-table.spec.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import type { CwTableQuery } from '@cropwatchdevelopment/cwui'; -import type { LocationDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import { - countDashboardDevices, - queryDashboardDevices, - type DashboardDeviceFilters -} from './device-table'; - -const devices: IDevice[] = [ - { - dev_eui: 'dev-1', - name: 'Alpha Canopy', - location_name: 'Room A', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date('2026-03-09T00:00:00Z'), - co2: 900, - humidity: 51, - temperature_c: 24, - soil_humidity: null, - location_id: 1 - }, - { - dev_eui: 'dev-2', - name: 'Beta Canopy', - location_name: 'Room B', - group: 'air', - data_table: 'cw_air_data', - created_at: new Date('2026-03-09T01:00:00Z'), - co2: 870, - humidity: 54, - temperature_c: 23, - soil_humidity: null, - location_id: 2 - }, - { - dev_eui: 'dev-3', - name: 'Gamma Soil', - location_name: 'Room C', - group: 'soil', - data_table: 'cw_soil_data', - created_at: new Date('2026-03-09T02:00:00Z'), - co2: 0, - humidity: 0, - temperature_c: 18, - soil_humidity: 37, - location_id: 3 - } -]; - -const locations: LocationDto[] = [ - { - location_id: 1, - name: 'Room A', - created_at: '2026-03-01T00:00:00Z', - location_group: 'North' - } as LocationDto, - { - location_id: 2, - name: 'Room B', - created_at: '2026-03-01T00:00:00Z', - location_group: 'North' - } as LocationDto, - { - location_id: 3, - name: 'Room C', - created_at: '2026-03-01T00:00:00Z', - location_group: 'South' - } as LocationDto -]; - -function createQuery(overrides: Partial = {}): CwTableQuery { - return { - page: 1, - pageSize: 10, - search: '', - sort: null, - filters: {}, - signal: new AbortController().signal, - ...overrides - }; -} - -describe('device-table query helpers', () => { - it('counts devices using group, location-group, and location filters', () => { - const filters: DashboardDeviceFilters = { - group: 'air', - locationGroup: 'North', - location: '2' - }; - - expect(countDashboardDevices(devices, locations, filters)).toBe(1); - }); - - it('applies search, sort, and slicing across the full filtered dataset', () => { - const filters: DashboardDeviceFilters = { - group: 'air', - locationGroup: 'North', - location: '' - }; - - const result = queryDashboardDevices( - devices, - locations, - filters, - createQuery({ - page: 1, - pageSize: 1, - search: 'canopy', - sort: { column: 'created_at', direction: 'desc' } - }) - ); - - expect(result.total).toBe(2); - expect(result.rows).toHaveLength(1); - expect(result.rows[0]?.dev_eui).toBe('dev-2'); - }); - - it('sorts optional soil columns numerically when non-soil devices have no soil readings', () => { - const filters: DashboardDeviceFilters = { - group: '', - locationGroup: '', - location: '' - }; - - const result = queryDashboardDevices( - devices, - locations, - filters, - createQuery({ - sort: { column: 'soil_humidity', direction: 'desc' } - }) - ); - - expect(result.rows.map((device) => device.dev_eui)).toEqual(['dev-3', 'dev-1', 'dev-2']); - }); -}); diff --git a/src/lib/components/dashboard/device-table.ts b/src/lib/components/dashboard/device-table.ts deleted file mode 100644 index eb87d697..00000000 --- a/src/lib/components/dashboard/device-table.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { CwTableQuery, CwTableResult } from '@cropwatchdevelopment/cwui'; -import type { LocationDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; - -export interface DashboardDeviceFilters { - group: string; - locationGroup: string; - location: string; -} - -export const DASHBOARD_DEVICE_BATCH_SIZE = 25; -export const DASHBOARD_DEVICE_PAGE_SIZE_OPTIONS = [25, 50, 75, 100]; -export const DASHBOARD_DEVICE_ROW_HEIGHT = 52; -export const DASHBOARD_DEVICE_OVERSCAN = 16; - -type SortDirection = 'asc' | 'desc'; - -const LOCATION_GROUP_VALUE_KEYS = ['location_group', 'group', 'name', 'value', 'label'] as const; -const NUMERIC_SORT_COLUMNS = new Set([ - 'co2', - 'humidity', - 'temperature_c', - 'soil_humidity' -]); - -function readGroupValue(value: unknown): string { - if (typeof value === 'string') return value.trim(); - if (!value || typeof value !== 'object') return ''; - - const record = value as Record; - for (const key of LOCATION_GROUP_VALUE_KEYS) { - const candidate = typeof record[key] === 'string' ? (record[key] as string).trim() : ''; - if (candidate) return candidate; - } - - return ''; -} - -export function getLocationGroupName(location: LocationDto | undefined): string { - if (!location) { - return ''; - } - - const record = location as Record; - return readGroupValue(record.location_group) || readGroupValue(record.group); -} - -function buildLocationGroupById(locations: LocationDto[]): Map { - return new Map( - locations.map( - (location) => [Number(location.location_id), getLocationGroupName(location)] as const - ) - ); -} - -export function buildDashboardTableFilters( - filters: DashboardDeviceFilters -): Record { - return { - ...(filters.group ? { group: [filters.group] } : {}), - ...(filters.locationGroup ? { locationGroup: [filters.locationGroup] } : {}), - ...(filters.location ? { location: [filters.location] } : {}) - }; -} - - -export function filterDashboardDevices( - devices: IDevice[], - locations: LocationDto[], - filters: DashboardDeviceFilters -): IDevice[] { - const locationGroupById = buildLocationGroupById(locations); - - return devices.filter((device) => { - if (filters.group && device.group !== filters.group) { - return false; - } - - if (filters.location && String(device.location_id) !== filters.location) { - return false; - } - - if ( - filters.locationGroup && - locationGroupById.get(Number(device.location_id)) !== filters.locationGroup - ) { - return false; - } - - return true; - }); -} - -export function listDashboardDevices( - devices: IDevice[], - locations: LocationDto[], - filters: DashboardDeviceFilters, - search = '' -): IDevice[] { - return searchDashboardDevices(filterDashboardDevices(devices, locations, filters), search); -} - -export function countDashboardDevices( - devices: IDevice[], - locations: LocationDto[], - filters: DashboardDeviceFilters -): number { - return filterDashboardDevices(devices, locations, filters).length; -} - -function searchDashboardDevices(devices: IDevice[], search: string): IDevice[] { - const term = search.trim().toLowerCase(); - if (!term) { - return devices; - } - - return devices.filter((device) => - [device.name, device.dev_eui, device.location_name].some((value) => - value.toLowerCase().includes(term) - ) - ); -} - -function getSortValue(device: IDevice, column: string): number | string { - if (column === 'created_at') { - return device.created_at instanceof Date - ? device.created_at.getTime() - : new Date(device.created_at).getTime(); - } - - const value = (device as unknown as Record)[column]; - if (NUMERIC_SORT_COLUMNS.has(column)) { - return typeof value === 'number' && Number.isFinite(value) ? value : Number.NEGATIVE_INFINITY; - } - - if (typeof value === 'number') { - return value; - } - - return value != null ? String(value) : ''; -} - -function compareSortValues( - left: number | string, - right: number | string, - direction: SortDirection -): number { - const order = direction === 'asc' ? 1 : -1; - - if (typeof left === 'number' && typeof right === 'number') { - return (left - right) * order; - } - - return ( - left - .toString() - .localeCompare(right.toString(), undefined, { numeric: true, sensitivity: 'base' }) * order - ); -} - -function sortDashboardDevices(devices: IDevice[], query: CwTableQuery): IDevice[] { - if (!query.sort) { - return devices; - } - - const { column, direction } = query.sort; - return [...devices].sort((left, right) => - compareSortValues(getSortValue(left, column), getSortValue(right, column), direction) - ); -} - -export function queryDashboardDevices( - devices: IDevice[], - locations: LocationDto[], - filters: DashboardDeviceFilters, - query: CwTableQuery -): CwTableResult { - const filteredDevices = filterDashboardDevices(devices, locations, filters); - const searchedDevices = searchDashboardDevices(filteredDevices, query.search); - const sortedDevices = sortDashboardDevices(searchedDevices, query); - const start = (query.page - 1) * query.pageSize; - const end = start + query.pageSize; - - return { - rows: sortedDevices.slice(start, end), - total: sortedDevices.length - }; -} diff --git a/src/lib/sensor-labels/index.ts b/src/lib/sensor-labels/index.ts new file mode 100644 index 00000000..7c7a25b9 --- /dev/null +++ b/src/lib/sensor-labels/index.ts @@ -0,0 +1,75 @@ +import { m } from '$lib/paraglide/messages.js'; + +export type SensorFormat = 'number' | 'integer' | 'boolean'; + +export interface SensorLabel { + label: () => string; + unit: string; + icon?: 'thermo' | 'drop'; + format: SensorFormat; +} + +const SENSOR_LABELS: Record = { + temperature_c: { label: () => m.sensor_temperature(), unit: '°C', icon: 'thermo', format: 'number' }, + humidity: { label: () => m.sensor_humidity(), unit: '%', icon: 'drop', format: 'number' }, + moisture: { label: () => m.sensor_moisture(), unit: '%', icon: 'drop', format: 'number' }, + co2: { label: () => m.sensor_co2(), unit: 'ppm', format: 'integer' }, + co: { label: () => m.sensor_co(), unit: 'ppm', format: 'integer' }, + pressure: { label: () => m.sensor_pressure(), unit: 'hPa', format: 'number' }, + ec: { label: () => m.sensor_ec(), unit: 'mS/cm', format: 'number' }, + ph: { label: () => m.sensor_ph(), unit: '', format: 'number' }, + deapth_cm: { label: () => m.sensor_depth(), unit: 'cm', format: 'number' }, + battery: { label: () => m.sensor_battery(), unit: '%', format: 'integer' }, + battery_level: { label: () => m.sensor_battery(), unit: 'V', format: 'number' }, + voltage: { label: () => m.sensor_voltage(), unit: 'V', format: 'number' }, + current: { label: () => m.sensor_current(), unit: 'A', format: 'number' }, + watts: { label: () => m.sensor_watts(), unit: 'W', format: 'number' }, + rainfall: { label: () => m.sensor_rainfall(), unit: 'mm', format: 'number' }, + wind_speed: { label: () => m.sensor_wind_speed(), unit: 'm/s', format: 'number' }, + wind_direction: { label: () => m.sensor_wind_direction(), unit: '°', format: 'integer' }, + lux: { label: () => m.sensor_lux(), unit: 'lx', format: 'integer' }, + uv_index: { label: () => m.sensor_uv_index(), unit: '', format: 'integer' }, + smoke_detected: { label: () => m.sensor_smoke_detected(), unit: '', format: 'boolean' }, + vape_detected: { label: () => m.sensor_vape_detected(), unit: '', format: 'boolean' }, + spo2: { label: () => m.sensor_spo2(), unit: '%', format: 'number' }, + relay_1: { label: () => m.sensor_relay_1(), unit: '', format: 'boolean' }, + relay_2: { label: () => m.sensor_relay_2(), unit: '', format: 'boolean' }, + people_count: { label: () => m.sensor_people_count(), unit: '', format: 'integer' }, + car_count: { label: () => m.sensor_car_count(), unit: '', format: 'integer' } +}; + +const HIDDEN_COLUMNS = new Set(['dev_eui', 'id', 'is_simulated', 'last_update']); + +export function labelFor(column: string): SensorLabel { + return SENSOR_LABELS[column] ?? { label: () => column, unit: '', format: 'number' }; +} + +export function isDisplayableColumn(column: string): boolean { + return !HIDDEN_COLUMNS.has(column); +} + +export function formatSensorValue( + value: unknown, + format: SensorFormat +): { display: string; numeric: number | null } { + if (value === null || value === undefined) { + return { display: '—', numeric: null }; + } + + if (format === 'boolean') { + const truthy = + value === true || value === 'true' || value === 1 || value === '1' || value === 'on'; + return { display: truthy ? m.sensor_value_on() : m.sensor_value_off(), numeric: truthy ? 1 : 0 }; + } + + const n = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(n)) { + return { display: String(value), numeric: null }; + } + + if (format === 'integer') { + return { display: Math.round(n).toLocaleString(), numeric: n }; + } + + return { display: n.toLocaleString(undefined, { maximumFractionDigits: 2 }), numeric: n }; +} diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index 9972115b..45fd039d 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -1,114 +1,5 @@ -import { env as publicEnv } from '$env/dynamic/public'; -import { ApiService } from '$lib/api/api.service'; -import type { DevicePrimaryDataDto, LocationDto } from '$lib/api/api.dtos'; -import type { IDevice } from '$lib/interfaces/device.interface'; -import { - mapDashboardPrimaryDataToDevice, - uniqueDashboardDevices -} from '$lib/components/dashboard/dashboard-device-data'; -import { normalizeDashboardFilterValues } from '$lib/components/dashboard/dashboard-filter-values'; 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; -} - -async function getAllLatestPrimaryDeviceData( - apiServiceInstance: ApiService -): Promise { - const firstPage = await apiServiceInstance.getLatestPrimaryDeviceData({ - skip: 0, - take: LATEST_PRIMARY_DATA_PAGE_SIZE - }); - - const firstBatch = firstPage.data ?? []; - const total = - typeof firstPage.total === 'number' && Number.isFinite(firstPage.total) - ? Math.max(0, firstPage.total) - : firstBatch.length; - - const allDevices = [...firstBatch]; - let skip = LATEST_PRIMARY_DATA_PAGE_SIZE; - - while (skip < total) { - const nextPage = await apiServiceInstance.getLatestPrimaryDeviceData({ - skip, - take: LATEST_PRIMARY_DATA_PAGE_SIZE - }); - const nextBatch = nextPage.data ?? []; - - if (nextBatch.length === 0) { - break; - } - - allDevices.push(...nextBatch); - skip += LATEST_PRIMARY_DATA_PAGE_SIZE; - } - - return allDevices; -} - -async function loadDashboardList( - label: string, - loadList: () => Promise -): Promise { - try { - return (await loadList()) ?? []; - } catch (error) { - console.error(`Failed to load dashboard ${label}`, error); - return []; - } -} - -async function loadDashboardPayload(apiServiceInstance: ApiService) { - const [latestPrimaryData, deviceGroups, locations, locationGroups] = await Promise.all([ - await getAllLatestPrimaryDeviceData(apiServiceInstance), - loadDashboardList('device groups', () => apiServiceInstance.getDeviceGroups()), - loadDashboardList('locations', () => apiServiceInstance.getLocations()), - loadDashboardList('location groups', () => apiServiceInstance.getLocationGroups()) - ]); - - const devices: IDevice[] = uniqueDashboardDevices( - latestPrimaryData.map((device) => mapDashboardPrimaryDataToDevice(device)) - ); - - return { - devices, - deviceGroups: normalizeDashboardFilterValues(deviceGroups), - locations, - locationGroups: normalizeDashboardFilterValues(locationGroups) - }; -} - -const EMPTY_DASHBOARD = { - devices: [] as IDevice[], - deviceGroups: [] as string[], - locations: [] as LocationDto[], - locationGroups: [] as string[] -}; - -export const load: PageServerLoad = async ({ locals, fetch, url }) => { - const authToken = locals.jwtString ?? null; - - if (!authToken) { - return { - authToken: null, - dashboard: EMPTY_DASHBOARD - }; - } - - const apiServiceInstance = new ApiService({ - fetchFn: fetch, - baseUrl: resolveDashboardApiBaseUrl(url), - authToken - }); - - return { - authToken, - // Keep all dashboard fetches within the lifetime of this load function. - dashboard: await loadDashboardPayload(apiServiceInstance) - }; +export const load: PageServerLoad = async () => { + return {}; }; diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index a5ad6c65..46a8b832 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -1,111 +1,59 @@ @@ -117,59 +65,34 @@
-
+
setDashboardView('table')} + variant={view === 'table' ? 'info' : 'secondary'} + onclick={() => setView('table')} > - - {m.dashboard_table_view()} + + {m.dashboard_view_table()} setDashboardView('sensor-cards')} + variant={view === 'cards' ? 'info' : 'secondary'} + onclick={() => setView('cards')} > - - {m.dashboard_sensor_cards_view()} + + {m.dashboard_view_cards()} - {#if dashboardView === 'sensor-cards'} - - {/if}
- {#if dashboardViewReady} - {#if dashboardView === 'sensor-cards'} - + {#if viewReady} + {#if view === 'cards'} + {:else} - + {/if} {:else}
@@ -181,11 +104,9 @@