feat: show inventory timestamps on Device Inventory tiles

Surface the same lifecycle timestamps on the Device Inventory cards as on the
detail panel. Tiles for devices placed on a canvas show their linked node's
created / last scan / last modified / last seen (correlated by ip or
ieee_address, aggregated across matches: created = oldest, others = newest).
Devices not yet on any canvas fall back to their discovered_at.

Rendered as compact relative times ("2d ago") with the full date on hover, in
a tight two-column footer so the tile keeps its original footprint.

Backend: PendingDeviceResponse gains node_created_at / node_last_scan /
node_last_modified / node_last_seen; the canvas correlation now also pulls node
timestamps in the same single query. Frontend: shared timeFormat util
(absolute + relative), reused by the detail panel.

ha-relevant: maybe
This commit is contained in:
Pouzor
2026-06-27 13:24:29 +02:00
parent 19b7d38ec0
commit 612280e924
9 changed files with 279 additions and 35 deletions
@@ -29,6 +29,12 @@ export interface PendingDevice {
discovered_at: string
// How many canvases (designs) this device already appears on. Computed server-side.
canvas_count?: number
// Timestamps from the linked canvas node(s), correlated by ip/ieee_address.
// Null/absent when the device is not on any canvas yet.
node_created_at?: string | null
node_last_scan?: string | null
node_last_modified?: string | null
node_last_seen?: string | null
}
interface PendingDeviceModalProps {
@@ -13,6 +13,7 @@ import type { NodeType, ServiceInfo } from '@/types'
import { buildZigbeeProperties, isZigbeeType } from '@/utils/zigbeeProperties'
import { buildZwaveProperties, isZwaveType } from '@/utils/zwaveProperties'
import { buildMacProperty } from '@/utils/macProperty'
import { formatRelative, formatTimestamp } from '@/utils/timeFormat'
interface PendingDevicesModalProps {
open: boolean
@@ -663,6 +664,22 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
const visibleServices = services.slice(0, 4)
const moreServices = services.length - visibleServices.length
// Timestamps: a device placed on a canvas shows its linked node's lifecycle
// (created / last scan / last modified / last seen). A device that is only in
// the discovery inventory has no node yet, so it falls back to when the
// scanner first saw it.
const onCanvas = (device.canvas_count ?? 0) > 0
const timestamps: { label: string; iso: string }[] = []
if (onCanvas) {
if (device.node_created_at) timestamps.push({ label: 'Created', iso: device.node_created_at })
if (device.node_last_scan) timestamps.push({ label: 'Scan', iso: device.node_last_scan })
if (device.node_last_modified) timestamps.push({ label: 'Modified', iso: device.node_last_modified })
if (device.node_last_seen) timestamps.push({ label: 'Seen', iso: device.node_last_seen })
}
if (timestamps.length === 0) {
timestamps.push({ label: 'Discovered', iso: device.discovered_at })
}
const borderClass = highlighted
? 'border-[#e3b341] bg-[#2d3748]'
: selected
@@ -759,6 +776,14 @@ function DeviceCard({ device, selected, selectMode, highlighted, onClick, cardRe
)}
</div>
)}
{/* Timestamps — compact relative times, full date on hover. Kept tiny so
the tile footprint stays the same as before. */}
<div className="mt-2 pt-2 border-t border-border/50 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10px]">
{timestamps.map((t) => (
<TimeLine key={t.label} label={t.label} iso={t.iso} />
))}
</div>
</button>
)
}
@@ -771,3 +796,12 @@ function InfoLine({ label, value }: { label: string; value: string }) {
</div>
)
}
function TimeLine({ label, iso }: { label: string; iso: string }) {
return (
<div className="flex items-baseline gap-1 min-w-0" title={formatTimestamp(iso)}>
<span className="text-muted-foreground shrink-0">{label}</span>
<span className="font-mono text-foreground/80 truncate">{formatRelative(iso)}</span>
</div>
)
}
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react'
import { PendingDevicesModal } from '../PendingDevicesModal'
import { useCanvasStore } from '@/stores/canvasStore'
@@ -364,4 +364,37 @@ describe('PendingDevicesModal', () => {
expect(screen.queryByTestId('pending-card-dev-b')).not.toBeInTheDocument()
expect(screen.getByTestId('pending-card-dev-a')).toBeInTheDocument()
})
describe('tile timestamps', () => {
it('shows the linked node lifecycle timestamps for on-canvas devices', async () => {
mockPending.mockResolvedValue({
data: [{
...DEVICE_IP,
canvas_count: 1,
node_created_at: '2026-01-02T10:00:00Z',
node_last_scan: '2026-06-01T08:30:00Z',
node_last_modified: '2026-06-20T12:00:00Z',
node_last_seen: '2026-06-25T09:15:00Z',
}],
})
render(<PendingDevicesModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Created')).toBeInTheDocument()
expect(inCard.getByText('Scan')).toBeInTheDocument()
expect(inCard.getByText('Modified')).toBeInTheDocument()
expect(inCard.getByText('Seen')).toBeInTheDocument()
// Discovered fallback is not shown once node timestamps are present.
expect(inCard.queryByText('Discovered')).not.toBeInTheDocument()
})
it('falls back to Discovered for devices not on any canvas', async () => {
mockPending.mockResolvedValue({ data: [DEVICE_IP] })
render(<PendingDevicesModal {...baseProps} />)
const card = await screen.findByTestId('pending-card-dev-a')
const inCard = within(card)
expect(inCard.getByText('Discovered')).toBeInTheDocument()
expect(inCard.queryByText('Created')).not.toBeInTheDocument()
})
})
})