From 9bb5645f8d58c4f3cb4acafbe653832e9be1d1de Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Thu, 30 Jul 2026 09:49:14 +0000 Subject: [PATCH] Fix jumping field order, tidy labels/units on Dashboard field list The unordered plant_asset_latest query could return rows in a different order after every MQTT upsert, making the field list under each asset card appear to shuffle. Added ORDER BY, plus a curated label/unit/order table for the known water-softener fields (Salt Level 42%, Tank A Capacity 1850 L, Regeneration Active Yes/No, ...) with a title-case + alphabetical fallback for anything not yet mapped. --- backend/src/routes/status.js | 4 +++ frontend/src/pages/Dashboard.tsx | 51 +++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 7 deletions(-) diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 506fa63..e798e38 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -14,9 +14,13 @@ export async function statusRoutes(app) { SELECT * FROM plant_assets WHERE active = TRUE ORDER BY asset_type, name `) + // ORDER BY matters here — without it, Postgres can return this heavily- + // upserted table's rows in a different order after every MQTT message, + // which made the Dashboard's field list appear to shuffle on refresh. const { rows: latest } = await pool.query(` SELECT asset_id, field_key, value_numeric, value_text, updated_at FROM plant_asset_latest + ORDER BY asset_id, field_key `) const latestByAsset = new Map() for (const l of latest) { diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c47fcc0..7172839 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,20 +1,57 @@ import { useEffect, useState, useCallback } from 'react' import { AlertTriangle, WifiOff } from 'lucide-react' import { fetchStatus } from '../api' -import type { AssetStatus, AssetType } from '../types' +import type { AssetStatus, AssetType, TelemetryField } from '../types' import { ASSET_TYPES, ASSET_TYPE_LABELS } from '../types' import WaterSoftenerDiagram from '../components/WaterSoftenerDiagram' const POLL_MS = 30000 -function fieldLabel(key: string): string { - return key.replace(/_/g, ' ') +// Curated presentation for known MQTT field_keys — proper label, unit, a +// fixed display order, and whether the value is boolean-ish. Anything not +// listed here (other asset types, fields not yet known) falls back to a +// title-cased label and sorts alphabetically after all known fields, so the +// list never breaks for an unmapped field, it just looks less polished. +interface FieldMeta { label: string; order: number; unit?: string; boolean?: boolean } +const FIELD_META: Record = { + salt_level_pct: { label: 'Salt Level', order: 1, unit: '%' }, + salt_level_status: { label: 'Salt Status', order: 2 }, + tank_a_capacity_l: { label: 'Tank A Capacity', order: 3, unit: 'L' }, + tank_b_capacity_l: { label: 'Tank B Capacity', order: 4, unit: 'L' }, + tank_in_service: { label: 'Tank In Service', order: 5, boolean: true }, + regen_active: { label: 'Regeneration Active', order: 6, boolean: true }, } -function fieldValue(f: { value_numeric: string | null; value_text: string | null }): string { +function titleCase(key: string): string { + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +} + +function isTruthyField(f: TelemetryField): boolean { + if (f.value_numeric !== null) return Number(f.value_numeric) === 1 + const t = (f.value_text || '').trim().toLowerCase() + return t === 'true' || t === 'on' || t === '1' || t === 'yes' +} + +function fieldLabel(key: string): string { + return FIELD_META[key]?.label ?? titleCase(key) +} + +function fieldValue(f: TelemetryField): string { + const meta = FIELD_META[f.field_key] + if (meta?.boolean) return isTruthyField(f) ? 'Yes' : 'No' if (f.value_text !== null && f.value_text !== '' && isNaN(Number(f.value_text))) return f.value_text - if (f.value_numeric !== null) return f.value_numeric - return f.value_text ?? '—' + if (f.value_numeric === null) return f.value_text ?? '—' + const rounded = Math.round(Number(f.value_numeric) * 100) / 100 + if (!meta?.unit) return String(rounded) + return meta.unit === '%' ? `${rounded}%` : `${rounded} ${meta.unit}` +} + +function sortedFields(latest: TelemetryField[]): TelemetryField[] { + return [...latest].sort((a, b) => { + const oa = FIELD_META[a.field_key]?.order ?? 999 + const ob = FIELD_META[b.field_key]?.order ?? 999 + return oa !== ob ? oa - ob : a.field_key.localeCompare(b.field_key) + }) } function AssetCard({ asset }: { asset: AssetStatus }) { @@ -38,7 +75,7 @@ function AssetCard({ asset }: { asset: AssetStatus }) {
No telemetry received yet.
) : (
- {asset.latest.map(f => ( + {sortedFields(asset.latest).map(f => (
{fieldLabel(f.field_key)} {fieldValue(f)}