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
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest'
import { formatTimestamp, formatRelative } from '../timeFormat'
describe('formatTimestamp', () => {
it('parses an ISO string with a Z suffix', () => {
expect(formatTimestamp('2026-01-02T10:00:00Z')).toContain('2026')
})
it('treats a suffix-less timestamp as UTC (appends Z)', () => {
// Same instant expressed with and without the explicit Z must match.
expect(formatTimestamp('2026-01-02 10:00:00')).toBe(formatTimestamp('2026-01-02T10:00:00Z'))
})
})
describe('formatRelative', () => {
const now = Date.parse('2026-06-27T12:00:00Z')
it('returns "just now" for sub-minute deltas', () => {
expect(formatRelative('2026-06-27T11:59:30Z', now)).toBe('just now')
})
it('formats minutes', () => {
expect(formatRelative('2026-06-27T11:45:00Z', now)).toBe('15m ago')
})
it('formats hours', () => {
expect(formatRelative('2026-06-27T09:00:00Z', now)).toBe('3h ago')
})
it('formats days', () => {
expect(formatRelative('2026-06-25T12:00:00Z', now)).toBe('2d ago')
})
it('formats weeks', () => {
expect(formatRelative('2026-06-06T12:00:00Z', now)).toBe('3w ago')
})
it('formats months', () => {
expect(formatRelative('2026-02-27T12:00:00Z', now)).toBe('4mo ago')
})
it('formats years', () => {
expect(formatRelative('2024-06-27T12:00:00Z', now)).toBe('2y ago')
})
it('clamps future timestamps to "just now"', () => {
expect(formatRelative('2026-06-27T12:05:00Z', now)).toBe('just now')
})
it('handles suffix-less (naive UTC) input', () => {
expect(formatRelative('2026-06-27 11:45:00', now)).toBe('15m ago')
})
})
+36
View File
@@ -0,0 +1,36 @@
// Shared timestamp formatting. Backend timestamps may arrive without a timezone
// suffix (naive UTC); append 'Z' when absent so the browser parses them as UTC
// rather than local time.
function toUtcIso(value: string): string {
return /[Zz]|[+-]\d{2}:?\d{2}$/.test(value) ? value : value + 'Z'
}
/** Full locale date+time, e.g. for tooltips and the detail panel. */
export function formatTimestamp(value: string): string {
return new Date(toUtcIso(value)).toLocaleString()
}
/**
* Compact relative time, e.g. "just now", "5m ago", "3h ago", "2d ago",
* "4w ago", "5mo ago", "2y ago". Future timestamps clamp to "just now".
* `now` is injectable for deterministic tests.
*/
export function formatRelative(value: string, now: number = Date.now()): string {
const then = new Date(toUtcIso(value)).getTime()
if (Number.isNaN(then)) return ''
const sec = Math.floor((now - then) / 1000)
if (sec < 60) return 'just now'
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.floor(hr / 24)
if (day < 7) return `${day}d ago`
const week = Math.floor(day / 7)
if (week < 5) return `${week}w ago`
const month = Math.floor(day / 30)
if (month < 12) return `${month}mo ago`
const year = Math.floor(day / 365)
return `${year}y ago`
}