diff --git a/app/src/scripts/load.js b/app/src/scripts/load.js
index c4ff3fc..e88c077 100644
--- a/app/src/scripts/load.js
+++ b/app/src/scripts/load.js
@@ -65,6 +65,109 @@ function hideMessage() {
}, 500);
}
+// ========== OFFLINE DETECTION & AUTO-RESTORE (Solix Approach) ==========
+
+// Listen for online/offline events
+window.addEventListener("online", () => {
+ console.log("🟢 Back online - syncing queued changes...");
+ const indicator = document.getElementById("persistenceStatus");
+ if (indicator) {
+ indicator.textContent = "Syncing...";
+ indicator.className = "status-indicator status-loading";
+ }
+ // Sync queued changes
+ syncQueuedChanges();
+});
+
+window.addEventListener("offline", () => {
+ console.log("🔴 Going offline - using cached data...");
+ const indicator = document.getElementById("persistenceStatus");
+ if (indicator) {
+ indicator.textContent = "Offline mode";
+ indicator.className = "status-indicator status-offline";
+ }
+});
+
+// Function to sync queued changes when back online
+async function syncQueuedChanges() {
+ try {
+ if (typeof dashboardDB === "undefined" || !dashboardDB) return;
+
+ const transaction = dashboardDB.transaction(["syncQueue"], "readonly");
+ const store = transaction.objectStore("syncQueue");
+ const request = store.getAll();
+
+ request.onsuccess = async (event) => {
+ const queuedChanges = event.target.result;
+
+ if (queuedChanges.length > 0) {
+ console.log(`Syncing ${queuedChanges.length} queued changes...`);
+
+ for (const change of queuedChanges) {
+ try {
+ // Process each queued change (e.g., retry failed fetches)
+ if (change.type === "fresh_data_fetch") {
+ await backgroundFetchFreshData(change.data.source, change.data.inputValues);
+ }
+ // Remove from queue after processing
+ const deleteTransaction = dashboardDB.transaction(["syncQueue"], "readwrite");
+ const deleteStore = deleteTransaction.objectStore("syncQueue");
+ deleteStore.delete(change.id);
+ } catch (err) {
+ console.log("Failed to sync change:", err);
+ }
+ }
+
+ const indicator = document.getElementById("persistenceStatus");
+ if (indicator) {
+ indicator.textContent = "Synced ✓";
+ indicator.className = "status-indicator status-success";
+ }
+ }
+ };
+ } catch (err) {
+ console.log("Sync failed:", err);
+ }
+}
+
+// Auto-restore dashboard on page load
+document.addEventListener("DOMContentLoaded", async () => {
+ await loadComponents();
+
+ // Initialize IndexedDB and check for saved dashboard
+ setTimeout(async () => {
+ try {
+ if (typeof initIndexedDB === "function") {
+ await initIndexedDB();
+
+ if (typeof loadLatestDashboardFromIndexedDB === "function") {
+ const savedDashboard = await loadLatestDashboardFromIndexedDB();
+
+ if (savedDashboard) {
+ const age = new Date().getTime() - savedDashboard.timestamp;
+ const ageMinutes = Math.floor(age / (1000 * 60));
+
+ // Show auto-restore option
+ const restoreMsg = document.createElement("div");
+ restoreMsg.id = "restorePrompt";
+ restoreMsg.className = "restore-prompt";
+ restoreMsg.innerHTML = `
+ 📂 Restore dashboard from ${ageMinutes}min ago?
+
+
+ `;
+ document.body.insertBefore(restoreMsg, document.body.firstChild);
+ }
+ }
+ }
+ } catch (err) {
+ console.log("Auto-restore check failed:", err);
+ }
+ }, 100);
+});
+
+// ========== END OFFLINE DETECTION & AUTO-RESTORE ==========
+
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker
diff --git a/app/src/scripts/script.js b/app/src/scripts/script.js
index 5f7e4df..60c4c79 100644
--- a/app/src/scripts/script.js
+++ b/app/src/scripts/script.js
@@ -11,6 +11,146 @@ if (!sourceSelect || !loadBtn || !container) {
showMessage("Error: Required UI elements are missing.");
}
+// ========== INDEXEDDB PERSISTENCE (Solix Approach) ==========
+let dashboardDB = null;
+const DB_NAME = "OpenDotsDB";
+const DB_VERSION = 1;
+const STORE_NAME = "dashboards";
+const SYNC_QUEUE_STORE = "syncQueue";
+
+// Initialize IndexedDB
+async function initIndexedDB() {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ dashboardDB = request.result;
+ resolve(dashboardDB);
+ };
+
+ request.onupgradeneeded = (event) => {
+ const db = event.target.result;
+
+ // Create dashboards store
+ if (!db.objectStoreNames.contains(STORE_NAME)) {
+ const store = db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true });
+ store.createIndex("timestamp", "timestamp", { unique: false });
+ store.createIndex("source", "source", { unique: false });
+ }
+
+ // Create sync queue store
+ if (!db.objectStoreNames.contains(SYNC_QUEUE_STORE)) {
+ db.createObjectStore(SYNC_QUEUE_STORE, { keyPath: "id", autoIncrement: true });
+ }
+ };
+ });
+}
+
+// Save dashboard state to IndexedDB
+async function saveDashboardToIndexedDB(dashboardData) {
+ if (!dashboardDB) await initIndexedDB();
+
+ return new Promise((resolve, reject) => {
+ const transaction = dashboardDB.transaction([STORE_NAME], "readwrite");
+ const store = transaction.objectStore(STORE_NAME);
+
+ const dashboardObject = {
+ data: dashboardData.data,
+ chartConfigs: dashboardData.chartConfigs || [],
+ slicerValue: dashboardData.slicerValue || "all",
+ sourceSelection: {
+ source: dashboardData.source,
+ inputs: dashboardData.inputs
+ },
+ timestamp: new Date().getTime(),
+ status: "synced"
+ };
+
+ const request = store.add(dashboardObject);
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ updateStatusIndicator("Saved ✓", "success");
+ resolve(request.result);
+ };
+ });
+}
+
+// Load latest dashboard from IndexedDB
+async function loadLatestDashboardFromIndexedDB() {
+ if (!dashboardDB) await initIndexedDB();
+
+ return new Promise((resolve, reject) => {
+ const transaction = dashboardDB.transaction([STORE_NAME], "readonly");
+ const store = transaction.objectStore(STORE_NAME);
+ const index = store.index("timestamp");
+
+ const request = index.openCursor(null, "prev"); // Get latest first
+ let latestDashboard = null;
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = (event) => {
+ const cursor = event.target.result;
+ if (cursor) {
+ latestDashboard = cursor.value;
+ cursor.continue();
+ } else {
+ resolve(latestDashboard);
+ }
+ };
+ });
+}
+
+// Clear all dashboards from IndexedDB
+async function clearAllDashboards() {
+ if (!dashboardDB) await initIndexedDB();
+
+ return new Promise((resolve, reject) => {
+ const transaction = dashboardDB.transaction([STORE_NAME], "readwrite");
+ const store = transaction.objectStore(STORE_NAME);
+ const request = store.clear();
+
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => {
+ updateStatusIndicator("Cleared ✓", "info");
+ resolve();
+ };
+ });
+}
+
+// Queue change for sync (when offline)
+async function queueSyncChange(changeData) {
+ if (!dashboardDB) await initIndexedDB();
+
+ return new Promise((resolve, reject) => {
+ const transaction = dashboardDB.transaction([SYNC_QUEUE_STORE], "readwrite");
+ const store = transaction.objectStore(SYNC_QUEUE_STORE);
+
+ const syncItem = {
+ type: changeData.type, // "fetch", "config_update", etc.
+ timestamp: new Date().getTime(),
+ data: changeData.data,
+ status: "pending"
+ };
+
+ const request = store.add(syncItem);
+ request.onerror = () => reject(request.error);
+ request.onsuccess = () => resolve(request.result);
+ });
+}
+
+// Update status indicator UI
+function updateStatusIndicator(message, type = "info") {
+ const indicator = document.getElementById("persistenceStatus");
+ if (indicator) {
+ indicator.textContent = message;
+ indicator.className = `status-indicator status-${type}`;
+ }
+}
+
+// ========== END INDEXEDDB PERSISTENCE ==========
+
// Input configurations
const inputsConfig = {
mqtt: [
@@ -75,6 +215,227 @@ function copyRowData(btn) {
btn.innerHTML = `
`;
setTimeout(() => (btn.innerHTML = `
`), 1000);
}
+
+// ========== DATA EXPORT FUNCTIONS ==========
+
+/**
+ * Generate filename with timestamp (format: prefix_YYYY-MM-DD.extension)
+ */
+function generateExportFilename(prefix, extension) {
+ const now = new Date();
+ const dateStr = now.toISOString().split('T')[0]; // YYYY-MM-DD
+ return `${prefix}_${dateStr}.${extension}`;
+}
+
+/**
+ * Export chart as PNG image
+ */
+async function exportChartAsPNG(chartElement, chartTitle) {
+ const exportBtn = chartElement.querySelector('.chart-export-btn');
+ const originalText = exportBtn ? exportBtn.textContent : '📷 Export';
+
+ try {
+ // Check if required libraries are loaded
+ if (typeof html2canvas === 'undefined') {
+ throw new Error('html2canvas library not loaded');
+ }
+ if (typeof saveAs === 'undefined') {
+ throw new Error('FileSaver library not loaded');
+ }
+
+ // Show loading state
+ if (exportBtn) {
+ exportBtn.textContent = '⏳...';
+ exportBtn.disabled = true;
+ }
+
+ chartElement.style.opacity = '0.7';
+ chartElement.style.pointerEvents = 'none';
+
+ const canvas = chartElement.querySelector('canvas');
+ if (!canvas) {
+ throw new Error('No chart canvas found');
+ }
+
+ const canvasImage = await html2canvas(canvas, {
+ backgroundColor: '#ffffff',
+ scale: 2,
+ logging: false,
+ useCORS: true,
+ allowTaint: false,
+ foreignObjectRendering: false
+ });
+
+ // Convert to blob and trigger download
+ canvasImage.toBlob((blob) => {
+ try {
+ if (!blob) {
+ throw new Error('Failed to generate image blob');
+ }
+ const filename = generateExportFilename(`chart_${chartTitle.replace(/[^a-zA-Z0-9\s\-_]/g, '_').replace(/\s+/g, '_')}`, 'png');
+ saveAs(blob, filename);
+ showMessage(`Chart exported as ${filename} ✓`);
+ } catch (blobError) {
+ console.error('Blob download failed:', blobError);
+ showMessage('Failed to download chart: ' + blobError.message);
+ } finally {
+ // Restore button state after download
+ if (exportBtn) {
+ exportBtn.textContent = originalText;
+ exportBtn.disabled = false;
+ }
+ // Restore chart opacity
+ chartElement.style.opacity = '';
+ chartElement.style.pointerEvents = '';
+ }
+ });
+
+ } catch (error) {
+ console.error('Chart export failed:', error);
+ showMessage('Failed to export chart: ' + error.message);
+ // Restore button state on error
+ if (exportBtn) {
+ exportBtn.textContent = originalText;
+ exportBtn.disabled = false;
+ }
+ chartElement.style.opacity = '';
+ chartElement.style.pointerEvents = '';
+ }
+}
+
+/**
+ * Export data as CSV file
+ */
+function exportDataAsCSV(data, filename) {
+ const exportBtn = document.querySelector('.data-export-btn');
+ const originalText = exportBtn ? exportBtn.textContent : '📊 Export Data ▼';
+
+ try {
+ // Check if required libraries are loaded
+ if (typeof Papa === 'undefined') {
+ throw new Error('PapaParse library not loaded');
+ }
+ if (typeof saveAs === 'undefined') {
+ throw new Error('FileSaver library not loaded');
+ }
+
+ // Show loading state
+ if (exportBtn) {
+ exportBtn.textContent = '⏳ Exporting...';
+ exportBtn.disabled = true;
+ }
+
+ if (!data || !data.feeds || !data.fields) {
+ throw new Error('No data available for export');
+ }
+
+ const csvData = [];
+ const headers = ['Time', ...data.fields.map(field => field.label)];
+ csvData.push(headers);
+
+ data.feeds.forEach((feed, index) => {
+ const row = [data.labels[index] || ''];
+ data.fields.forEach(field => {
+ row.push(feed[field.key] || '');
+ });
+ csvData.push(row);
+ });
+
+ const csvString = Papa.unparse(csvData);
+ const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' });
+ saveAs(blob, filename);
+
+ showMessage(`Data exported as ${filename} ✓`);
+
+ } catch (error) {
+ console.error('CSV export failed:', error);
+ showMessage('Failed to export CSV: ' + error.message);
+ } finally {
+ // Restore button state
+ if (exportBtn) {
+ exportBtn.textContent = originalText;
+ exportBtn.disabled = false;
+ }
+ }
+}
+
+/**
+ * Export data as JSON file
+ */
+function exportDataAsJSON(data, filename) {
+ const exportBtn = document.querySelector('.data-export-btn');
+ const originalText = exportBtn ? exportBtn.textContent : '📊 Export Data ▼';
+
+ try {
+ // Check if required libraries are loaded
+ if (typeof saveAs === 'undefined') {
+ throw new Error('FileSaver library not loaded');
+ }
+
+ // Show loading state
+ if (exportBtn) {
+ exportBtn.textContent = '⏳ Exporting...';
+ exportBtn.disabled = true;
+ }
+
+ if (!data) {
+ throw new Error('No data available for export');
+ }
+
+ const exportData = {
+ metadata: {
+ name: data.name || 'OpenDots Export',
+ description: data.desc || '',
+ created: data.created || '',
+ updated: data.updated || new Date().toISOString(),
+ exportedAt: new Date().toISOString(),
+ totalRecords: data.feeds ? data.feeds.length : 0
+ },
+ fields: data.fields || [],
+ data: data.feeds || [],
+ labels: data.labels || []
+ };
+
+ const jsonString = JSON.stringify(exportData, null, 2);
+ const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
+ saveAs(blob, filename);
+
+ showMessage(`Data exported as ${filename} ✓`);
+
+ } catch (error) {
+ console.error('JSON export failed:', error);
+ showMessage('Failed to export JSON: ' + error.message);
+ } finally {
+ // Restore button state
+ if (exportBtn) {
+ exportBtn.textContent = originalText;
+ exportBtn.disabled = false;
+ }
+ }
+}
+
+/**
+ * Toggle export dropdown visibility
+ */
+function toggleExportDropdown() {
+ const dropdown = document.getElementById('exportOptions');
+ if (dropdown) {
+ dropdown.classList.toggle('active');
+ }
+}
+
+// Close export dropdown when clicking elsewhere
+document.addEventListener('click', (e) => {
+ const dropdown = document.getElementById('exportOptions');
+ const button = document.querySelector('.data-export-btn');
+
+ if (dropdown && button && !button.contains(e.target) && !dropdown.contains(e.target)) {
+ dropdown.classList.remove('active');
+ }
+});
+
+// ========== END DATA EXPORT FUNCTIONS ==========
+
// ---------- EVENT: Source Change ----------
sourceSelect.addEventListener("change", () => {
resetUI();
@@ -189,7 +550,26 @@ async function loadData() {
)
: null;
- if (data) renderData(data);
+ if (data) {
+ renderData(data);
+
+ // Save dashboard state to IndexedDB (Solix approach)
+ const inputValues = {};
+ for (const input of inputsConfig[source] || []) {
+ inputValues[input.id] = document.getElementById(input.id)?.value || "";
+ }
+
+ await saveDashboardToIndexedDB({
+ data: data,
+ chartConfigs: charts.map(c => ({ type: c.config.type, data: c.data, options: c.options })),
+ slicerValue: document.querySelector('input[name="slicer"]:checked')?.value || "all",
+ source: source,
+ inputs: inputValues
+ });
+
+ // Start background fetch for fresh data
+ backgroundFetchFreshData(source, inputValues);
+ }
} catch (err) {
resetUI();
showMessage("Error: " + err.message);
@@ -326,7 +706,15 @@ function renderChartsAndTable(data) {
// ✅ Combined chart (All data together)
const combinedBlock = document.createElement("div");
combinedBlock.className = "chart-block";
- combinedBlock.innerHTML = `