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.
This commit is contained in:
jtricerolph 2026-07-30 09:49:14 +00:00
parent ff145957fc
commit 9bb5645f8d
2 changed files with 48 additions and 7 deletions

View file

@ -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) {

View file

@ -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<string, FieldMeta> = {
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 }) {
<div className="muted" style={{ fontSize: 12.5 }}>No telemetry received yet.</div>
) : (
<div className="asset-field-list">
{asset.latest.map(f => (
{sortedFields(asset.latest).map(f => (
<div key={f.field_key} className="asset-field-row">
<span className="asset-field-key">{fieldLabel(f.field_key)}</span>
<span className="asset-field-value">{fieldValue(f)}</span>