From 055a88932ec101339cbbda2347b3dc32313be78a Mon Sep 17 00:00:00 2001 From: root Date: Tue, 9 Jun 2026 08:53:28 +1000 Subject: [PATCH] feat(devices): pure icon resolver + relativeTime helpers Co-Authored-By: Claude Opus 4.8 --- public/views/icon_util.js | 24 ++++++++++++++++++++++++ tests/views/icon_util.test.js | 29 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 public/views/icon_util.js create mode 100644 tests/views/icon_util.test.js diff --git a/public/views/icon_util.js b/public/views/icon_util.js new file mode 100644 index 0000000..ce6fb56 --- /dev/null +++ b/public/views/icon_util.js @@ -0,0 +1,24 @@ +// public/views/icon_util.js — pure helpers (no DOM), unit-tested. +const GROUP_DEFAULT = { + Network: 'router', Entertainment: 'tv', 'Smart Home': 'plug', Personal: 'phone' +}; +export function autoDefaultIcon(grp) { + return `set:devices:${GROUP_DEFAULT[grp] || 'unknown'}`; +} +// Note: bundled 'devices' icons are .svg; brand icons are served .png by the proxy. +export function resolveIcon(ref) { + if (typeof ref !== 'string') return null; + let m = ref.match(/^set:([a-z0-9-]+):([a-z0-9-]+)$/); + if (m) return `/api/icon-sets/${m[1]}/${m[2]}.svg`; + m = ref.match(/^brand:([a-z0-9-]+)$/); + if (m) return `/api/icons/${m[1]}.png`; + return null; +} +export function relativeTime(iso, now = Date.now()) { + const t = typeof iso === 'number' ? iso : Date.parse(iso); + const s = Math.max(0, Math.floor((now - t) / 1000)); + if (s < 60) return 'just now'; + if (s < 3600) return `${Math.floor(s / 60)}m ago`; + if (s < 86400) return `${Math.floor(s / 3600)}h ago`; + return `${Math.floor(s / 86400)}d ago`; +} diff --git a/tests/views/icon_util.test.js b/tests/views/icon_util.test.js new file mode 100644 index 0000000..ed028c9 --- /dev/null +++ b/tests/views/icon_util.test.js @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { resolveIcon, relativeTime, autoDefaultIcon } from '../../public/views/icon_util.js'; + +describe('autoDefaultIcon', () => { + it('maps groups to bundled icons', () => { + expect(autoDefaultIcon('Network')).toBe('set:devices:router'); + expect(autoDefaultIcon('Entertainment')).toBe('set:devices:tv'); + expect(autoDefaultIcon('Smart Home')).toBe('set:devices:plug'); + expect(autoDefaultIcon('Personal')).toBe('set:devices:phone'); + expect(autoDefaultIcon('whatever')).toBe('set:devices:unknown'); + }); +}); +describe('resolveIcon', () => { + it('resolves set + brand refs', () => { + expect(resolveIcon('set:devices:router')).toBe('/api/icon-sets/devices/router.svg'); + expect(resolveIcon('set:mine:nas')).toBe('/api/icon-sets/mine/nas.svg'); + expect(resolveIcon('brand:apple')).toBe('/api/icons/apple.png'); + }); + it('returns null for junk', () => { expect(resolveIcon('nope')).toBeNull(); }); +}); +describe('relativeTime', () => { + const base = Date.parse('2026-06-09T12:00:00Z'); + it('formats buckets', () => { + expect(relativeTime('2026-06-09T11:59:30Z', base)).toBe('just now'); + expect(relativeTime('2026-06-09T11:40:00Z', base)).toBe('20m ago'); + expect(relativeTime('2026-06-09T09:00:00Z', base)).toBe('3h ago'); + expect(relativeTime('2026-06-06T12:00:00Z', base)).toBe('3d ago'); + }); +});