mit einem Klick
data-builder
// Build data-driven DenchClaw apps with full CRUD access to workspace objects (.object.yaml tables), DuckDB queries and mutations, data dashboards with Chart.js and D3.js, and interactive tools.
// Build data-driven DenchClaw apps with full CRUD access to workspace objects (.object.yaml tables), DuckDB queries and mutations, data dashboards with Chart.js and D3.js, and interactive tools.
Build and manage DenchClaw apps — self-contained web applications that run inside the workspace with access to DuckDB data, workspace objects, AI chat, and the full DenchClaw platform API.
DuckDB schema initialization, field types reference, auto-generated PIVOT views, and SQL CRUD operations for workspace objects, fields, and entries.
Full 3-step workflow for creating workspace objects (SQL → filesystem → verify), CRM patterns for common object types, kanban boards, and the post-mutation checklist.
Manage DuckDB CRM data, aggressive relation-linked fields, and synced markdown documents in the workspace. Use when creating or updating objects, fields, entries, foreign-table links, row notes, or entry-linked edit logs.
.object.yaml format and template, view type settings (kanban, calendar, timeline, gallery, list), saved views with filter operators, and date format rules.
Build AI-powered DenchClaw apps that interact with the OpenClaw agent — create chat sessions, send and receive messages with streaming, expose app tools for agent invocation, and access agent memory.
| name | data-builder |
| description | Build data-driven DenchClaw apps with full CRUD access to workspace objects (.object.yaml tables), DuckDB queries and mutations, data dashboards with Chart.js and D3.js, and interactive tools. |
| metadata | {"openclaw":{"inject":true,"always":true,"emoji":"📊"}} |
This skill covers building data apps that interact with workspace objects and DuckDB. For core app structure and manifest basics, see the parent app-builder skill (app-builder/SKILL.md).
objects permission required)The window.dench.objects.* API provides full CRUD access to workspace objects (.object.yaml tables). Add objects to your manifest permissions:
permissions:
- objects
const result = await dench.objects.list("people", {
search: "john",
filters: JSON.stringify([{ field: "Status", operator: "eq", value: "Active" }]),
sort: JSON.stringify({ field: "Full Name", direction: "asc" }),
page: 1,
pageSize: 50,
});
// Returns { object, fields, entries, totalCount, statuses }
Filter operators: eq, neq, contains, not_contains, starts_with, ends_with, gt, gte, lt, lte, is_empty, is_not_empty.
const entry = await dench.objects.get("people", "entry_id_here");
// Returns { entry: { id, fields: { "Full Name": "...", ... }, created_at, updated_at } }
const { entryId } = await dench.objects.create("people", {
"Full Name": "Jane Doe",
"Email Address": "jane@example.com",
Status: "Active",
});
await dench.objects.update("people", entryId, {
Status: "Lead",
});
await dench.objects.delete("people", entryId);
await dench.objects.bulkDelete("people", [id1, id2, id3]);
const schema = await dench.objects.getSchema("people");
// Returns { object, fields, statuses }
const options = await dench.objects.getOptions("people", "jane");
// Returns filtered list of entries matching query
Use this for building relation dropdowns and autocomplete fields that reference entries in other objects.
database / database:write permissions)The database permission grants read-only query access. The database:write permission grants full mutation access (INSERT, UPDATE, DELETE, CREATE TABLE, etc.).
permissions:
- database # SELECT queries only
- database:write # SELECT + mutations
database permission)const result = await dench.db.query("SELECT * FROM objects");
// Returns { rows: [...] }
database:write permission)await dench.db.execute("INSERT INTO game_scores (game, score) VALUES ('my-game', 1500)");
await dench.db.execute("CREATE TABLE IF NOT EXISTS app_data (key TEXT PRIMARY KEY, value TEXT)");
await dench.db.execute("UPDATE app_data SET value = 'new' WHERE key = 'setting1'");
await dench.db.execute("DELETE FROM app_data WHERE key = 'old'");
The workspace database uses an Entity-Attribute-Value (EAV) schema:
| Table | Columns | Description |
|---|---|---|
objects | id, name, description, icon | Workspace object definitions |
fields | id, object_id, name, type, required, position | Field definitions for each object |
entries | id, object_id, created_at, updated_at | Row entries in each object |
entry_fields | id, entry_id, field_id, value | Individual cell values (EAV) |
statuses | id, object_id, name, color, position | Status options for status-type fields |
PIVOT views provide columnar access to object data:
SELECT * FROM v_people
-- Returns rows with columns like: id, "Full Name", "Email Address", "Status", ...
The view name is v_{object_name} where the object name is lowercased with spaces replaced by underscores.
// List all objects
const objects = await dench.db.query("SELECT * FROM objects");
// Get entries via PIVOT view
const people = await dench.db.query("SELECT * FROM v_people");
// Aggregate stats
const stats = await dench.db.query(`
SELECT o.name, COUNT(e.id) as count
FROM objects o LEFT JOIN entries e ON e.object_id = o.id
GROUP BY o.name ORDER BY count DESC
`);
// Get field definitions
const fields = await dench.db.query(
"SELECT * FROM fields WHERE object_id = (SELECT id FROM objects WHERE name = 'people')",
);
Apps can create their own tables for storing app-specific data. Always use CREATE TABLE IF NOT EXISTS for idempotency:
await dench.db.execute(`
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY, value TEXT,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
`);
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chart Dashboard</title>
<script src="https://unpkg.com/chart.js@4/dist/chart.umd.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 24px;
transition:
background 0.2s,
color 0.2s;
}
body.dark {
--app-bg: #0f0f1a;
--app-surface: #1a1a2e;
--app-border: #2a2a45;
--app-text: #e8e8f0;
--app-text-muted: #8888a8;
--app-accent: #6366f1;
background: var(--app-bg);
color: var(--app-text);
}
body.light {
--app-bg: #ffffff;
--app-surface: #f8f9fa;
--app-border: #e2e4e8;
--app-text: #1a1a2e;
--app-text-muted: #6b7280;
--app-accent: #6366f1;
background: var(--app-bg);
color: var(--app-text);
}
h1 {
font-size: 24px;
margin-bottom: 24px;
}
.chart-container {
position: relative;
height: 400px;
padding: 20px;
border-radius: 12px;
background: var(--app-surface);
border: 1px solid var(--app-border);
}
</style>
</head>
<body>
<h1>Object Entries</h1>
<div class="chart-container">
<canvas id="barChart"></canvas>
</div>
<script>
async function init() {
try {
const theme = await window.dench.app.getTheme();
document.body.className = theme;
} catch {
document.body.className = "dark";
}
try {
const result = await window.dench.db.query(`
SELECT o.name, COUNT(e.id) as entry_count
FROM objects o LEFT JOIN entries e ON e.object_id = o.id
GROUP BY o.name ORDER BY entry_count DESC
`);
const isDark = document.body.classList.contains("dark");
const textColor = isDark ? "#e8e8f0" : "#1a1a2e";
const gridColor = isDark ? "#2a2a4530" : "#e2e4e830";
const ctx = document.getElementById("barChart").getContext("2d");
new Chart(ctx, {
type: "bar",
data: {
labels: result.rows.map((r) => r.name),
datasets: [
{
label: "Entries",
data: result.rows.map((r) => r.entry_count),
backgroundColor: "#6366f180",
borderColor: "#6366f1",
borderWidth: 1,
borderRadius: 6,
},
],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
},
scales: {
y: {
beginAtZero: true,
grid: { color: gridColor },
ticks: { color: textColor },
},
x: {
grid: { display: false },
ticks: { color: textColor },
},
},
},
});
} catch (err) {
document.querySelector(".chart-container").innerHTML =
'<p style="color:#ef4444">Error: ' + err.message + "</p>";
}
}
init();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>D3 Visualization</title>
<script src="https://unpkg.com/d3@7/dist/d3.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
padding: 24px;
transition:
background 0.2s,
color 0.2s;
}
body.dark {
--app-bg: #0f0f1a;
--app-text: #e8e8f0;
--app-accent: #6366f1;
background: var(--app-bg);
color: var(--app-text);
}
body.light {
--app-bg: #ffffff;
--app-text: #1a1a2e;
--app-accent: #6366f1;
background: var(--app-bg);
color: var(--app-text);
}
h1 {
font-size: 24px;
margin-bottom: 24px;
}
#chart {
width: 100%;
}
.bar {
transition: opacity 0.2s;
}
.bar:hover {
opacity: 0.8;
}
.axis text {
fill: var(--app-text);
font-size: 12px;
}
.axis path,
.axis line {
stroke: var(--app-text);
opacity: 0.2;
}
</style>
</head>
<body>
<h1>Workspace Overview</h1>
<div id="chart"></div>
<script>
async function init() {
try {
const theme = await window.dench.app.getTheme();
document.body.className = theme;
} catch {
document.body.className = "dark";
}
try {
const result = await window.dench.db.query(`
SELECT o.name, COUNT(e.id) as count
FROM objects o LEFT JOIN entries e ON e.object_id = o.id
GROUP BY o.name ORDER BY count DESC
`);
const data = result.rows;
const margin = { top: 20, right: 20, bottom: 40, left: 60 };
const width = Math.min(window.innerWidth - 48, 800) - margin.left - margin.right;
const height = 400 - margin.top - margin.bottom;
const svg = d3
.select("#chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const x = d3
.scaleBand()
.domain(data.map((d) => d.name))
.range([0, width])
.padding(0.3);
const y = d3
.scaleLinear()
.domain([0, d3.max(data, (d) => d.count) || 1])
.nice()
.range([height, 0]);
svg
.append("g")
.attr("class", "axis")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x));
svg.append("g").attr("class", "axis").call(d3.axisLeft(y).ticks(5));
svg
.selectAll(".bar")
.data(data)
.join("rect")
.attr("class", "bar")
.attr("x", (d) => x(d.name))
.attr("y", (d) => y(d.count))
.attr("width", x.bandwidth())
.attr("height", (d) => height - y(d.count))
.attr("rx", 4)
.attr("fill", "#6366f1");
} catch (err) {
document.getElementById("chart").innerHTML =
'<p style="color:#ef4444">Error: ' + err.message + "</p>";
}
}
init();
</script>
</body>
</html>
No charting library needed — use CSS grid and custom properties for simple metric displays:
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">Total Records</div>
<div class="stat-value" id="total">—</div>
<div class="stat-change positive">+12% this week</div>
</div>
<div class="stat-card">
<div class="stat-label">Active Users</div>
<div class="stat-value" id="active">—</div>
<div class="stat-change positive">+5 today</div>
</div>
<div class="stat-card">
<div class="stat-label">Objects</div>
<div class="stat-value" id="objects">—</div>
</div>
</div>
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
padding: 24px;
}
.stat-card {
padding: 20px;
border-radius: 12px;
background: var(--app-surface);
border: 1px solid var(--app-border);
}
.stat-label {
font-size: 13px;
color: var(--app-text-muted);
margin-bottom: 8px;
}
.stat-value {
font-size: 36px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.stat-change {
font-size: 12px;
margin-top: 4px;
}
.stat-change.positive {
color: var(--app-success);
}
.stat-change.negative {
color: var(--app-error);
}
Template for tools that collect input, process it, and display output:
<div class="tool-container">
<form id="tool-form">
<div class="field">
<label for="input">Input</label>
<textarea id="input" rows="6" placeholder="Paste your data here..."></textarea>
</div>
<button type="submit">Process</button>
<div id="output" class="output-box"></div>
</form>
</div>
.tool-container {
max-width: 720px;
margin: 0 auto;
padding: 24px;
}
.field {
margin-bottom: 16px;
}
.field label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--app-text-muted);
margin-bottom: 6px;
}
.field textarea,
.field input,
.field select {
width: 100%;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--app-border);
background: var(--app-surface);
color: var(--app-text);
font-size: 14px;
font-family: inherit;
resize: vertical;
}
.field textarea:focus,
.field input:focus {
outline: none;
border-color: var(--app-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--app-accent) 20%, transparent);
}
button[type="submit"] {
padding: 10px 20px;
border: none;
border-radius: 8px;
background: var(--app-accent);
color: white;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
button[type="submit"]:hover {
background: var(--app-accent-hover);
}
.output-box {
margin-top: 16px;
padding: 16px;
border-radius: 8px;
background: var(--app-surface);
border: 1px solid var(--app-border);
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
white-space: pre-wrap;
max-height: 400px;
overflow: auto;
}
Use SortableJS for draggable interfaces backed by workspace objects:
<script src="https://unpkg.com/sortablejs@1/Sortable.min.js"></script>
async function initKanban() {
const schema = await dench.objects.getSchema("tasks");
const statuses = schema.statuses || [];
const board = document.getElementById("board");
for (const status of statuses) {
const column = document.createElement("div");
column.className = "kanban-column";
column.dataset.status = status.name;
column.innerHTML = `
<div class="column-header" style="border-color: ${status.color}">
${status.name}
</div>
<div class="column-body" data-status="${status.name}"></div>
`;
board.appendChild(column);
}
const result = await dench.objects.list("tasks", { pageSize: 200 });
for (const entry of result.entries) {
const status = entry.fields["Status"] || statuses[0]?.name;
const body = board.querySelector(`.column-body[data-status="${status}"]`);
if (body) {
const card = document.createElement("div");
card.className = "kanban-card";
card.dataset.id = entry.id;
card.textContent = entry.fields["Title"] || entry.id;
body.appendChild(card);
}
}
document.querySelectorAll(".column-body").forEach((col) => {
Sortable.create(col, {
group: "tasks",
animation: 150,
ghostClass: "drag-ghost",
onEnd: async (evt) => {
const entryId = evt.item.dataset.id;
const newStatus = evt.to.dataset.status;
try {
await dench.objects.update("tasks", entryId, { Status: newStatus });
} catch (err) {
console.error("Failed to update status:", err);
}
},
});
});
}
A complete pattern for a form that creates, reads, updates, and deletes entries in a workspace object:
let currentEntries = [];
let editingId = null;
async function loadEntries() {
const result = await dench.objects.list("tasks", { pageSize: 100 });
currentEntries = result.entries;
renderTable(result.entries);
}
function renderTable(entries) {
const tbody = document.getElementById("entries-body");
tbody.innerHTML = entries
.map(
(e) => `
<tr>
<td>${e.fields["Title"] || ""}</td>
<td>${e.fields["Status"] || ""}</td>
<td>${new Date(e.created_at).toLocaleDateString()}</td>
<td>
<button onclick="editEntry('${e.id}')">Edit</button>
<button onclick="deleteEntry('${e.id}')">Delete</button>
</td>
</tr>
`,
)
.join("");
}
async function createEntry(formData) {
try {
const { entryId } = await dench.objects.create("tasks", formData);
await loadEntries();
dench.ui.toast("Entry created", { type: "success" });
resetForm();
} catch (err) {
dench.ui.toast("Failed: " + err.message, { type: "error" });
}
}
async function updateEntry(id, formData) {
try {
await dench.objects.update("tasks", id, formData);
await loadEntries();
dench.ui.toast("Entry updated", { type: "success" });
resetForm();
} catch (err) {
dench.ui.toast("Failed: " + err.message, { type: "error" });
}
}
async function deleteEntry(id) {
if (!confirm("Delete this entry?")) return;
try {
await dench.objects.delete("tasks", id);
await loadEntries();
dench.ui.toast("Entry deleted", { type: "success" });
} catch (err) {
dench.ui.toast("Failed: " + err.message, { type: "error" });
}
}
function editEntry(id) {
const entry = currentEntries.find((e) => e.id === id);
if (!entry) return;
editingId = id;
document.getElementById("title").value = entry.fields["Title"] || "";
document.getElementById("status").value = entry.fields["Status"] || "";
document.getElementById("submit-btn").textContent = "Update";
}
function resetForm() {
editingId = null;
document.getElementById("task-form").reset();
document.getElementById("submit-btn").textContent = "Create";
}
document.getElementById("task-form").addEventListener("submit", async (e) => {
e.preventDefault();
const formData = {
Title: document.getElementById("title").value,
Status: document.getElementById("status").value,
};
if (editingId) {
await updateEntry(editingId, formData);
} else {
await createEntry(formData);
}
});
loadEntries();
Pattern for an auto-refreshing dashboard that polls for updated data:
const REFRESH_INTERVAL = 30000; // 30 seconds
let refreshTimer = null;
async function loadDashboard() {
try {
const stats = await dench.db.query(`
SELECT o.name, COUNT(e.id) as count
FROM objects o LEFT JOIN entries e ON e.object_id = o.id
GROUP BY o.name ORDER BY count DESC
`);
renderStats(stats.rows);
document.getElementById("last-updated").textContent =
"Updated: " + new Date().toLocaleTimeString();
} catch (err) {
console.error("Refresh failed:", err);
}
}
function startAutoRefresh() {
loadDashboard();
refreshTimer = setInterval(loadDashboard, REFRESH_INTERVAL);
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
stopAutoRefresh();
} else {
startAutoRefresh();
}
});
startAutoRefresh();
A complete workspace dashboard app with stat cards, theme support, and live data from DuckDB.
.dench.yaml:
name: "Dashboard"
description: "Workspace overview dashboard"
icon: "layout-dashboard"
version: "1.0.0"
entry: "index.html"
runtime: "static"
permissions:
- database
index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Workspace Dashboard</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding: 24px;
transition:
background 0.2s,
color 0.2s;
}
body.dark {
--app-bg: #0f0f1a;
--app-surface: #1a1a2e;
--app-surface-hover: #252540;
--app-border: #2a2a45;
--app-text: #e8e8f0;
--app-text-muted: #8888a8;
--app-accent: #6366f1;
--app-success: #22c55e;
--app-warning: #f59e0b;
--app-error: #ef4444;
background: var(--app-bg);
color: var(--app-text);
}
body.light {
--app-bg: #ffffff;
--app-surface: #f8f9fa;
--app-surface-hover: #f0f1f3;
--app-border: #e2e4e8;
--app-text: #1a1a2e;
--app-text-muted: #6b7280;
--app-accent: #6366f1;
--app-success: #16a34a;
--app-warning: #d97706;
--app-error: #dc2626;
background: var(--app-bg);
color: var(--app-text);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.header h1 {
font-size: 24px;
font-weight: 700;
}
.header .meta {
font-size: 13px;
color: var(--app-text-muted);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 32px;
}
.stat-card {
padding: 20px;
border-radius: 12px;
background: var(--app-surface);
border: 1px solid var(--app-border);
transition: background 0.15s;
}
.stat-card:hover {
background: var(--app-surface-hover);
}
.stat-label {
font-size: 13px;
color: var(--app-text-muted);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 36px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.objects-table {
width: 100%;
border-collapse: collapse;
background: var(--app-surface);
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--app-border);
}
.objects-table th {
text-align: left;
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--app-text-muted);
border-bottom: 1px solid var(--app-border);
}
.objects-table td {
padding: 12px 16px;
font-size: 14px;
border-bottom: 1px solid var(--app-border);
}
.objects-table tr:last-child td {
border-bottom: none;
}
.objects-table tr:hover td {
background: var(--app-surface-hover);
}
.error-box {
padding: 16px;
background: color-mix(in srgb, var(--app-error) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--app-error) 30%, transparent);
border-radius: 8px;
color: var(--app-error);
font-size: 14px;
}
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--app-text-muted);
font-size: 15px;
}
.loading {
text-align: center;
padding: 48px 24px;
color: var(--app-text-muted);
}
</style>
</head>
<body>
<div class="header">
<h1>Workspace Dashboard</h1>
<div class="meta" id="last-updated"></div>
</div>
<div class="stats-grid" id="stats"></div>
<div id="table-container">
<div class="loading">Loading workspace data...</div>
</div>
<script>
async function init() {
try {
const theme = await window.dench.app.getTheme();
document.body.className = theme;
} catch {
document.body.className = "dark";
}
await loadDashboard();
}
async function loadDashboard() {
try {
const result = await window.dench.db.query(`
SELECT o.name, o.description, COUNT(e.id) as entry_count
FROM objects o
LEFT JOIN entries e ON e.object_id = o.id
GROUP BY o.name, o.description
ORDER BY entry_count DESC
`);
const rows = result.rows || [];
const totalObjects = rows.length;
const totalEntries = rows.reduce((sum, r) => sum + (r.entry_count || 0), 0);
const statsEl = document.getElementById("stats");
statsEl.innerHTML = `
<div class="stat-card">
<div class="stat-label">Objects</div>
<div class="stat-value">${totalObjects}</div>
</div>
<div class="stat-card">
<div class="stat-label">Total Entries</div>
<div class="stat-value">${totalEntries.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-label">Avg Entries / Object</div>
<div class="stat-value">${totalObjects ? Math.round(totalEntries / totalObjects) : 0}</div>
</div>
`;
const tableContainer = document.getElementById("table-container");
if (rows.length === 0) {
tableContainer.innerHTML =
'<div class="empty-state">No objects in workspace yet.</div>';
} else {
tableContainer.innerHTML = `
<table class="objects-table">
<thead>
<tr>
<th>Object</th>
<th>Description</th>
<th>Entries</th>
</tr>
</thead>
<tbody>
${rows
.map(
(r) => `
<tr>
<td><strong>${r.name}</strong></td>
<td style="color: var(--app-text-muted)">${r.description || "—"}</td>
<td>${r.entry_count || 0}</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
`;
}
document.getElementById("last-updated").textContent =
"Updated " + new Date().toLocaleTimeString();
} catch (err) {
document.getElementById("stats").innerHTML = "";
document.getElementById("table-container").innerHTML =
'<div class="error-box">Error loading data: ' + err.message + "</div>";
}
}
init();
</script>
</body>
</html>