// Dashy SQLite layer — uses sql.js (SQLite compiled to WASM) // Database is initialized once, seeded from SEED_TASKS / SEED_AUDIT / USERS, // persisted to localStorage on every write, and exposed as window.DashyDB. window.DashyDB = (function () { const LS_KEY = 'dashy.db.v1'; let db = null; let SQL = null; const listeners = new Set(); let ready = false; const readyWaiters = []; // -- schema ----------------------------------------------------------- const SCHEMA = ` CREATE TABLE users ( id TEXT PRIMARY KEY, name TEXT NOT NULL, role TEXT NOT NULL, hue INTEGER NOT NULL, initials TEXT NOT NULL, email TEXT, phone TEXT, photo TEXT, password_hash TEXT, account_type TEXT NOT NULL DEFAULT 'standard' CHECK (account_type IN ('admin','standard')), created_at TEXT DEFAULT (datetime('now')) ); CREATE TABLE tasks ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, assignee_id TEXT NOT NULL REFERENCES users(id), added_by TEXT NOT NULL, priority TEXT NOT NULL CHECK (priority IN ('low','med','high')), source TEXT NOT NULL CHECK (source IN ('manual','imessage','email','automation')), status TEXT NOT NULL DEFAULT 'open', added_at TEXT NOT NULL, due_at TEXT, reminder_at TEXT ); CREATE INDEX idx_tasks_assignee ON tasks(assignee_id); CREATE INDEX idx_tasks_status ON tasks(status); CREATE TABLE task_tags ( task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (task_id, tag) ); CREATE TABLE task_notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, author_id TEXT NOT NULL, body TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE audit_log ( id TEXT PRIMARY KEY, at TEXT NOT NULL, actor TEXT NOT NULL, action TEXT NOT NULL, summary TEXT NOT NULL, target TEXT ); CREATE INDEX idx_audit_at ON audit_log(at); CREATE TABLE sessions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id), device TEXT NOT NULL, location TEXT, last_active TEXT NOT NULL ); `; function bumpListeners() { listeners.forEach(fn => { try { fn(); } catch(e){} }); } function persist() { try { const bytes = db.export(); const b64 = btoa(String.fromCharCode(...bytes)); localStorage.setItem(LS_KEY, b64); } catch(e) { console.warn('DashyDB persist failed', e); } bumpListeners(); } function loadFromLS() { const b64 = localStorage.getItem(LS_KEY); if (!b64) return null; const bin = atob(b64); const arr = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i); return arr; } function seed() { db.exec('BEGIN'); USERS.forEach(u => { const isAdmin = u.id === 'rod' || u.id === 'ayron'; db.run( 'INSERT INTO users (id, name, role, hue, initials, email, phone, password_hash, account_type) VALUES (?,?,?,?,?,?,?,?,?)', [u.id, u.name, u.role, u.hue, u.initials, u.id + '@murchison-auto.co', '+64 27 555 0184', 'pbkdf2$' + Math.random().toString(36).slice(2, 18), isAdmin ? 'admin' : 'standard'] ); }); SEED_TASKS.forEach(t => { db.run( 'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)', [t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source, t.status || 'open', t.addedAt] ); (t.tags || []).forEach(tag => { db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]); }); }); SEED_AUDIT.forEach(a => { db.run( 'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)', [a.id, a.at, a.actor, a.action, a.summary, a.target] ); }); // sample notes db.run("INSERT INTO task_notes (task_id, author_id, body, created_at) VALUES ('t11','lani','Customer wants the work done before Mother''s Day weekend.','2026-05-08T09:14:00')"); db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','MacBook · Chrome','Christchurch, NZ','2026-05-08T10:30:00')"); db.run("INSERT INTO sessions (user_id, device, location, last_active) VALUES ('rod','iPhone · Safari','Christchurch, NZ','2026-05-08T08:15:00')"); db.exec('COMMIT'); } async function init() { SQL = await window.initSqlJs({ locateFile: f => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + f }); const existing = loadFromLS(); if (existing) { db = new SQL.Database(existing); // migration: add account_type if missing try { const cols = query("PRAGMA table_info(users)").map(c => c.name); if (!cols.includes('account_type')) { db.exec("ALTER TABLE users ADD COLUMN account_type TEXT NOT NULL DEFAULT 'standard'"); db.run("UPDATE users SET account_type = 'admin' WHERE id IN ('rod','ayron')"); persist(); } } catch(e) { console.warn('migration failed', e); } } else { db = new SQL.Database(); db.exec(SCHEMA); seed(); persist(); } ready = true; readyWaiters.splice(0).forEach(fn => fn()); bumpListeners(); } function whenReady() { if (ready) return Promise.resolve(); return new Promise(r => readyWaiters.push(r)); } // -- query helpers ---------------------------------------------------- function rowsFrom(stmt) { const out = []; while (stmt.step()) out.push(stmt.getAsObject()); stmt.free(); return out; } function query(sql, params = []) { if (!db) return []; const stmt = db.prepare(sql); if (params.length) stmt.bind(params); return rowsFrom(stmt); } function exec(sql, params = []) { if (!db) return; db.run(sql, params); persist(); } // -- domain ops ------------------------------------------------------- function listTasks() { const rows = query(` SELECT t.*, GROUP_CONCAT(tt.tag, '|') AS tags_csv FROM tasks t LEFT JOIN task_tags tt ON tt.task_id = t.id GROUP BY t.id ORDER BY t.added_at DESC `); return rows.map(r => ({ id: r.id, title: r.title, description: r.description, assignee: r.assignee_id, addedBy: r.added_by, priority: r.priority, source: r.source, status: r.status, addedAt: r.added_at, tags: r.tags_csv ? r.tags_csv.split('|') : [] })); } function listAudit() { return query('SELECT id, at, actor, action, summary, target FROM audit_log ORDER BY at DESC'); } function listUsers() { return query('SELECT * FROM users ORDER BY rowid ASC'); } function moveTask(taskId, toUserId, by) { exec('UPDATE tasks SET assignee_id = ? WHERE id = ?', [toUserId, taskId]); addAudit({ actor: by, action: 'task_moved', summary: 'Moved task to ' + (USERS.find(u=>u.id===toUserId)||{name:toUserId}).name, target: taskId }); } function setPriority(taskId, p) { exec('UPDATE tasks SET priority = ? WHERE id = ?', [p, taskId]); } function createTask(t) { exec( 'INSERT INTO tasks (id, title, description, assignee_id, added_by, priority, source, status, added_at) VALUES (?,?,?,?,?,?,?,?,?)', [t.id, t.title, t.description || '', t.assignee, t.addedBy, t.priority, t.source || 'manual', t.status || 'open', t.addedAt] ); (t.tags || []).forEach(tag => { db.run('INSERT INTO task_tags (task_id, tag) VALUES (?,?)', [t.id, tag]); }); persist(); } function addAudit(row) { const id = 'a_' + Date.now() + '_' + Math.random().toString(36).slice(2,5); exec( 'INSERT INTO audit_log (id, at, actor, action, summary, target) VALUES (?,?,?,?,?,?)', [id, row.at || new Date().toISOString(), row.actor, row.action, row.summary, row.target || null] ); } function updateUser(userId, edits) { const fields = []; const vals = []; if (edits.name !== undefined) { fields.push('name = ?'); vals.push(edits.name); } if (edits.role !== undefined) { fields.push('role = ?'); vals.push(edits.role); } if (edits.photo !== undefined) { fields.push('photo = ?'); vals.push(edits.photo); } if (edits.account_type !== undefined) { fields.push('account_type = ?'); vals.push(edits.account_type); } if (!fields.length) return; vals.push(userId); exec('UPDATE users SET ' + fields.join(', ') + ' WHERE id = ?', vals); } function createUser(u) { const id = u.id || (u.name.toLowerCase().replace(/[^a-z]/g,'').slice(0,8) + '_' + Math.random().toString(36).slice(2,5)); const initials = (u.name.split(' ').map(s=>s[0]).join('') || 'U').slice(0,2).toUpperCase(); const hue = u.hue || Math.floor(Math.random() * 360); exec( 'INSERT INTO users (id, name, role, hue, initials, email, phone, account_type, password_hash) VALUES (?,?,?,?,?,?,?,?,?)', [id, u.name, u.role || 'Team member', hue, initials, u.email || (id + '@murchison-auto.co'), u.phone || '', u.account_type || 'standard', 'pbkdf2$' + Math.random().toString(36).slice(2,18)] ); return id; } function deleteUser(userId) { // reassign any tasks to ROD before delete exec("UPDATE tasks SET assignee_id = 'rod' WHERE assignee_id = ?", [userId]); exec('DELETE FROM users WHERE id = ?', [userId]); } function tableNames() { return query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name").map(r => r.name); } function tableInfo(name) { return query(`PRAGMA table_info(${name})`); } function rowCount(name) { const r = query(`SELECT COUNT(*) AS c FROM ${name}`); return r[0] ? r[0].c : 0; } function exportFile() { const bytes = db.export(); return new Blob([bytes], { type: 'application/x-sqlite3' }); } function reset() { localStorage.removeItem(LS_KEY); db = new SQL.Database(); db.exec(SCHEMA); seed(); persist(); } function rawExec(sql) { // returns array of {columns, values} for SELECTs, or empty for writes const res = db.exec(sql); persist(); return res; } function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); } return { init, whenReady, query, exec, rawExec, listTasks, listAudit, listUsers, createTask, moveTask, setPriority, updateUser, createUser, deleteUser, addAudit, tableNames, tableInfo, rowCount, exportFile, reset, subscribe, get isReady() { return ready; }, }; })();