diff --git a/public/components/sv_card.js b/public/components/sv_card.js new file mode 100644 index 0000000..c30b4da --- /dev/null +++ b/public/components/sv_card.js @@ -0,0 +1,12 @@ +import { el } from '../dom.js'; + +// Builds the refined-B chrome shell and returns { root, body }. The card module +// fills `body` in its mount(); start()/stop() own its refresh timer. +export function svCard(def) { + const body = el('div', { class: 'sv-card-body' }); + const root = el('div', { class: 'sv-card', dataset: { size: def.size || 'm', cardId: def.id } }, + el('div', { class: 'sv-card-title' }, def.title), + body + ); + return { root, body }; +} diff --git a/public/views/cards/clock.js b/public/views/cards/clock.js new file mode 100644 index 0000000..6d3322d --- /dev/null +++ b/public/views/cards/clock.js @@ -0,0 +1,2 @@ +// temporary stub — filled in Task 5 +export default { id: 'clock', title: 'Clock', size: 's', mount() {}, start() {}, stop() {} }; diff --git a/public/views/cards/host_perf.js b/public/views/cards/host_perf.js new file mode 100644 index 0000000..fce0371 --- /dev/null +++ b/public/views/cards/host_perf.js @@ -0,0 +1,2 @@ +// temporary stub — filled in Task 9 +export default { id: 'host-perf', title: 'Host Perf', size: 'm', mount() {}, start() {}, stop() {} }; diff --git a/public/views/cards/registry.js b/public/views/cards/registry.js new file mode 100644 index 0000000..934c70c --- /dev/null +++ b/public/views/cards/registry.js @@ -0,0 +1,14 @@ +// Pure ordering logic (kept DOM-free so it is unit-testable). The card MODULES +// themselves are imported by sacred_valley.js, which passes their defs here. +export function orderCards(defs, layout = { card_order: [], hidden: [] }) { + const byId = new Map(defs.map(d => [d.id, d])); + const hidden = new Set(layout.hidden || []); + const out = []; + for (const id of layout.card_order || []) { + if (byId.has(id) && !hidden.has(id)) { out.push(byId.get(id)); byId.delete(id); } + } + for (const d of defs) { + if (byId.has(d.id) && !hidden.has(d.id)) out.push(d); + } + return out; +} diff --git a/public/views/cards/weather.js b/public/views/cards/weather.js new file mode 100644 index 0000000..94a13e5 --- /dev/null +++ b/public/views/cards/weather.js @@ -0,0 +1,2 @@ +// temporary stub — filled in Task 7 +export default { id: 'weather', title: 'Weather', size: 's', mount() {}, start() {}, stop() {} }; diff --git a/public/views/sacred_valley.js b/public/views/sacred_valley.js index 15fd893..4459f82 100644 --- a/public/views/sacred_valley.js +++ b/public/views/sacred_valley.js @@ -1,15 +1,34 @@ -// T17 stub — placeholder card per plan (full widgets in Plan 6). import { el, mount } from '../dom.js'; +import { api } from '../api.js'; +import { svCard } from '../components/sv_card.js'; +import { orderCards } from './cards/registry.js'; +import clock from './cards/clock.js'; +import weather from './cards/weather.js'; +import hostPerf from './cards/host_perf.js'; + +const CARD_MODULES = [clock, weather, hostPerf]; // grows in later tasks +let active = []; // mounted cards needing stop() + export async function render(main) { + active.forEach(c => c.stop && c.stop()); active = []; mount(main, el('h1', { class: 'view-h1' }, 'Sacred Valley'), - el('p', { class: 'view-sub' }, 'The homelab dashboard. Widgets port over in Plan 6.'), - el('div', { class: 'card' }, - el('h3', {}, 'Coming home'), - el('p', { class: 'muted' }, - 'Weather, speedtest, host-perf, media cards — all ride in from Void 1.x in Plan 6. ' + - 'For now this card holds the route open so the sidebar link works.' - ) - ) + el('p', { class: 'view-sub' }, 'The homelab, at a glance.'), + el('div', { id: 'sv-cards' }), + el('div', { id: 'sv-health' }) ); + + let layout = { card_order: [], hidden: [], sizes: {} }; + try { layout = await api.get('/api/dashboard/layout'); } catch { /* defaults */ } + + const grid = document.getElementById('sv-cards'); + const ordered = orderCards(CARD_MODULES, layout); + for (const def of ordered) { + const size = layout.sizes?.[def.id] || def.size; + const { root, body } = svCard({ ...def, size }); + grid.appendChild(root); + try { def.mount(body); def.start && def.start(); active.push(def); } + catch (e) { body.appendChild(el('span', { class: 'muted' }, 'card failed')); console.error(def.id, e); } + } + // health band + drag wiring arrive in Tasks 22 and 10. } diff --git a/tests/frontend/card_registry.test.js b/tests/frontend/card_registry.test.js new file mode 100644 index 0000000..1eaf6a3 --- /dev/null +++ b/tests/frontend/card_registry.test.js @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { orderCards } from '../../public/views/cards/registry.js'; + +const defs = [{ id: 'clock' }, { id: 'weather' }, { id: 'host-perf' }]; + +describe('orderCards', () => { + it('uses saved order first, then appends new cards in default order', () => { + const out = orderCards(defs, { card_order: ['weather'], hidden: [] }); + expect(out.map(c => c.id)).toEqual(['weather', 'clock', 'host-perf']); + }); + it('drops hidden cards', () => { + const out = orderCards(defs, { card_order: [], hidden: ['clock'] }); + expect(out.map(c => c.id)).toEqual(['weather', 'host-perf']); + }); + it('ignores stale ids in saved order', () => { + const out = orderCards(defs, { card_order: ['gone', 'clock'], hidden: [] }); + expect(out.map(c => c.id)).toEqual(['clock', 'weather', 'host-perf']); + }); +});