feat(display): add display builder for ESP32 JSON + tests (Task 7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-06 12:56:01 +02:00
co-authored by Claude Sonnet 4.6
parent 47d746cf71
commit 2990f9aa4d
2 changed files with 292 additions and 0 deletions
@@ -0,0 +1,176 @@
import { getDb } from '../../db/index';
export interface DisplayElement {
id: number;
type: string;
x: number;
y: number;
sort_order: number;
// Optional fields (only included when non-default)
width?: number;
height?: number;
icon?: string;
style?: string;
font_size?: number;
font_weight?: string;
text_align?: string;
// Type-specific
text?: string; // for label type
label?: string; // for value type
value?: string | null; // for value type
unit?: string | null; // for value type
grid_col?: number;
grid_row?: number;
}
export interface DisplayJson {
display: {
id: number;
name: string;
width: number;
height: number;
refresh_sec: number;
};
layout: {
type: string;
grid_cols?: number;
grid_rows?: number;
};
elements: DisplayElement[];
}
interface RawDisplay {
id: number;
name: string;
description: string | null;
layout_id: number | null;
api_token: string | null;
last_seen: string | null;
refresh_sec: number;
created_at: string;
}
interface RawLayout {
id: number;
name: string;
type: string;
width: number;
height: number;
grid_cols: number | null;
grid_rows: number | null;
}
interface RawElement {
id: number;
layout_id: number;
datapoint_id: number | null;
type: string;
x: number;
y: number;
width: number | null;
height: number | null;
font_size: number;
font_weight: string;
text_align: string;
icon: string | null;
style: string;
static_text: string | null;
grid_col: number | null;
grid_row: number | null;
sort_order: number;
// Joined from datapoints
dp_label: string | null;
dp_value: string | null;
dp_unit: string | null;
}
export function buildDisplayJson(displayId: number): DisplayJson | null {
const db = getDb();
const display = db
.prepare('SELECT * FROM displays WHERE id = ?')
.get(displayId) as RawDisplay | undefined;
if (!display) return null;
if (!display.layout_id) {
return {
display: {
id: display.id,
name: display.name,
width: 800,
height: 480,
refresh_sec: display.refresh_sec,
},
layout: { type: 'free' },
elements: [],
};
}
const layout = db
.prepare('SELECT * FROM layouts WHERE id = ?')
.get(display.layout_id) as RawLayout | undefined;
if (!layout) return null;
const rawElements = db
.prepare(`
SELECT
le.*,
d.label AS dp_label,
d.value AS dp_value,
d.unit AS dp_unit
FROM layout_elements le
LEFT JOIN datapoints d ON d.id = le.datapoint_id
WHERE le.layout_id = ?
ORDER BY le.sort_order ASC, le.id ASC
`)
.all(display.layout_id) as RawElement[];
const elements: DisplayElement[] = rawElements.map((el) => {
const base: DisplayElement = {
id: el.id,
type: el.type,
x: el.x,
y: el.y,
sort_order: el.sort_order,
};
// Include optional fields only when non-default
if (el.width != null) base.width = el.width;
if (el.height != null) base.height = el.height;
if (el.icon) base.icon = el.icon;
if (el.style && el.style !== 'plain') base.style = el.style;
if (el.font_size && el.font_size !== 24) base.font_size = el.font_size;
if (el.font_weight && el.font_weight !== 'normal') base.font_weight = el.font_weight;
if (el.text_align && el.text_align !== 'left') base.text_align = el.text_align;
if (el.grid_col != null) base.grid_col = el.grid_col;
if (el.grid_row != null) base.grid_row = el.grid_row;
if (el.type === 'label') {
base.text = el.static_text ?? '';
} else if (el.type === 'value') {
base.label = el.dp_label ?? '';
base.value = el.dp_value ?? null;
base.unit = el.dp_unit ?? null;
}
return base;
});
const layoutResult: DisplayJson['layout'] = { type: layout.type };
if (layout.grid_cols != null) layoutResult.grid_cols = layout.grid_cols;
if (layout.grid_rows != null) layoutResult.grid_rows = layout.grid_rows;
return {
display: {
id: display.id,
name: display.name,
width: layout.width,
height: layout.height,
refresh_sec: display.refresh_sec,
},
layout: layoutResult,
elements,
};
}