Fixed react refresh bug and fixed white background when clicking on task which was a TASK_AUDIT hangover from react local storagedb now pointing too python fastapi

This commit is contained in:
NPS Agent
2026-05-11 14:04:13 +09:30
parent 3825c7556b
commit 49dc767922
10 changed files with 156 additions and 656 deletions
+1 -3
View File
@@ -13,10 +13,8 @@
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script> <script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script> <script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js"></script> <script src="api.js"></script>
<script src="db.js"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script> <script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="data.jsx"></script>
<script type="text/babel" src="components.jsx"></script> <script type="text/babel" src="components.jsx"></script>
<script type="text/babel" src="screens.jsx"></script> <script type="text/babel" src="screens.jsx"></script>
<script type="text/babel" src="app.jsx"></script> <script type="text/babel" src="app.jsx"></script>
+10 -10
View File
@@ -33,21 +33,21 @@
--- ---
## 🚧 Current Status ## 🚧 Current Status
- **Backend:** Feature-complete for the first phase. Ready for testing and integration. - **Backend:** Feature-complete for the first phase.
- **Database:** Schema is stabilized and seeding logic is verified. - **Database:** Schema is stabilized, seeding logic is verified, and database is active.
- **Frontend:** Currently still using the old `db.js` (WASM SQLite) layer. - **Frontend:** Integrated with FastAPI backend via `api.js`. Legacy WASM SQLite files archived.
--- ---
## ⏭️ Upcoming Steps ## ⏭️ Upcoming Steps
### Phase 2: Frontend Refactor ### Phase 2: Frontend Refactor (✅ Completed)
1. **API Service:** Create `src/api.js` to handle all network requests to the FastAPI backend. 1. **API Service:** Created `api.js` to handle all network requests to the FastAPI backend.
2. **Authentication Hook:** Update the Login screen to use real JWT tokens. 2. **Authentication Hook:** Updated the Login screen to use real JWT tokens.
3. **Component Updates:** 3. **Component Updates:**
- Swap `DashyDB` calls for `async` API calls. - Swapped `DashyDB` calls for `async` API calls in `app.jsx`.
- Implement "Loading" states for UI responsiveness during network calls. - Implemented "Loading" states for UI responsiveness during network calls.
4. **Cleanup:** Remove `db.js`, `data.jsx`, and the `sql.js` WASM dependency. 4. **Cleanup:** Archived `db.js`, `data.jsx`, and removed `sql.js` WASM dependency from `Dashy.html`.
### Phase 3: Advanced Features ### Phase 3: Advanced Features
- **Real-time Notifications:** Explore WebSockets for task assignments. - **Real-time Notifications:** Explore WebSockets for task assignments.
@@ -57,4 +57,4 @@
--- ---
**Last Updated:** Monday, May 11, 2026 **Last Updated:** Monday, May 11, 2026
**Status:** Backend Operational / Awaiting Frontend Integration **Status:** Phase 2 Complete / Ready for Phase 3
+128 -69
View File
@@ -1,4 +1,4 @@
// Dashy — main app (DB-backed via SQLite/WASM) // Dashy — main app (API-backed)
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"theme": "light", "theme": "light",
@@ -9,28 +9,59 @@ const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8']; const ACCENTS = ['#2A6FDB', '#1F8A5B', '#D97757', '#7A5AF8'];
function useDashyDB() { function useApiData(authed) {
const [ready, setReady] = React.useState(DashyDB.isReady); const [data, setData] = React.useState({ tasks: [], users: [], audit: [] });
const [, force] = React.useReducer(x => x + 1, 0); const [loading, setLoading] = React.useState(true);
React.useEffect(() => { React.useEffect(() => {
if (!DashyDB.isReady) DashyDB.init().then(() => setReady(true)); if (!authed) return;
const off = DashyDB.subscribe(() => force());
return off; let mounted = true;
}, []); const load = async () => {
return ready; try {
const [tasks, users, audit] = await Promise.all([
api.getTasks(),
api.getUsers(),
api.getAudit()
]);
if (mounted) {
setData({ tasks, users, audit });
setLoading(false);
}
} catch (e) {
console.error("Failed to load data:", e);
}
};
load();
const off = api.subscribe(load);
return () => { mounted = false; off(); };
}, [authed]);
return { ...data, loading };
} }
function App() { function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const ready = useDashyDB(); const [authed, setAuthed] = React.useState(!!api.token);
const [authed, setAuthed] = React.useState(false); // Optional: Decode token to get meId if needed, but for prototype we just assume rod if not known yet,
const [meId, setMeId] = React.useState('rod'); // or decode JWT using a simple base64 parse.
const [meId, setMeId] = React.useState(() => {
if (api.token) {
try {
return JSON.parse(atob(api.token.split('.')[1])).sub;
} catch(e) {}
}
return 'rod';
});
const { tasks, users: dbUsers, audit, loading } = useApiData(authed);
const [tab, setTab] = React.useState('overview'); const [tab, setTab] = React.useState('overview');
const [adding, setAdding] = React.useState(null); const [adding, setAdding] = React.useState(null);
const [openTaskId, setOpenTaskId] = React.useState(null); const [openTaskId, setOpenTaskId] = React.useState(null);
const [showLogs, setShowLogs] = React.useState(false); const [showLogs, setShowLogs] = React.useState(false);
const [showSettings, setShowSettings] = React.useState(false); const [showSettings, setShowSettings] = React.useState(false);
const [showDB, setShowDB] = React.useState(false);
const [headsUp, setHeadsUp] = React.useState([ const [headsUp, setHeadsUp] = React.useState([
{ id: 'h1', kind: 'unsuccessful', taskId: 't7', { id: 'h1', kind: 'unsuccessful', taskId: 't7',
title: 'WO #2188 auto-marked Unsuccessful', title: 'WO #2188 auto-marked Unsuccessful',
@@ -40,56 +71,91 @@ function App() {
sub: 'K. Wynne · originally Service Booking' }, sub: 'K. Wynne · originally Service Booking' },
]); ]);
if (!ready) return <BootSplash />; if (!authed) {
return <LoginScreen onLogin={async (id, pwd = "password123") => {
try {
await api.login(id, pwd);
setMeId(id);
setAuthed(true);
// Fire & forget audit log
api.addAudit({ actor: id, action: 'login', summary: 'Signed in' }).catch(console.error);
} catch (e) {
alert("Login failed: " + e.message);
}
}} />;
}
if (loading) return <BootSplash />;
const tasks = DashyDB.listTasks();
const audit = DashyDB.listAudit();
const dbUsers = DashyDB.listUsers();
const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u])); const userMap = Object.fromEntries(dbUsers.map(u => [u.id, u]));
const merge = (id) => { const merge = (id) => {
const base = findUser(id); if (!base && !userMap[id]) return null; const base = findUser(id);
if (!base && !userMap[id]) return null;
const live = userMap[id] || {}; const live = userMap[id] || {};
if (!base) { if (!base) {
// user added at runtime
return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials, return { id, name: live.name, role: live.role, hue: live.hue, initials: live.initials,
photo: live.photo || null, account_type: live.account_type || 'standard' }; photo: live.photo || null, account_type: live.account_type || 'standard' };
} }
return { ...base, name: live.name || base.name, role: live.role || base.role, return { ...base, name: live.name || base.name, role: live.role || base.role,
photo: live.photo || null, account_type: live.account_type || 'standard' }; photo: live.photo || null, account_type: live.account_type || 'standard' };
}; };
const me = merge(meId); const me = merge(meId) || merge('rod');
const isAdmin = me && me.account_type === 'admin'; const isAdmin = me && me.account_type === 'admin';
const openTask = tasks.find(x => x.id === openTaskId); const openTask = tasks.find(x => x.id === openTaskId);
const handleLogin = (id) => { const addTask = async ({ title, description, assignee, priority }) => {
setMeId(id); setAuthed(true); try {
DashyDB.addAudit({ const t = await api.createTask({
actor: id, action: 'login', title, description, assignee_id: assignee, priority,
summary: (merge(id) || {}).name + ' signed in', added_by: meId, source: 'manual', status: 'open', tags: []
}); });
}; await api.addAudit({
actor: meId, action: 'task_created',
const addTask = ({ title, description, assignee, priority }) => {
const id = 't_' + Date.now().toString(36);
const at = new Date('2026-05-08T10:30:00').toISOString();
DashyDB.createTask({
id, title, description, assignee, priority,
addedBy: meId, source: 'manual', status: 'open', addedAt: at, tags: []
});
DashyDB.addAudit({
at, actor: meId, action: 'task_created',
summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name, summary: 'Created task "' + title + '" for ' + (merge(assignee)||{}).name,
target: id target: t.id
}); });
setAdding(null); setAdding(null);
} catch(e) {
console.error(e);
alert("Failed to create task");
}
};
const moveTask = async (taskId, toUserId) => {
try {
await api.updateTask(taskId, { assignee_id: toUserId });
await api.addAudit({ actor: meId, action: 'task_moved', summary: 'Moved task to ' + (merge(toUserId)||{}).name, target: taskId });
} catch(e) {}
};
const setPriority = async (taskId, p) => {
try {
await api.updateTask(taskId, { priority: p });
} catch(e) {}
}; };
const moveTask = (taskId, toUserId) => DashyDB.moveTask(taskId, toUserId, meId);
const setPriority = (taskId, p) => DashyDB.setPriority(taskId, p);
const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id)); const dismissHU = (id) => setHeadsUp(h => h.filter(x => x.id !== id));
const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); }; const openTaskFromAnywhere = (id) => { setOpenTaskId(id); setShowLogs(false); };
if (!authed) return <LoginScreen onLogin={handleLogin} />; // Map API fields to frontend component expectations
const frontendTasks = tasks.map(t => ({
...t,
assignee: t.assignee_id,
addedBy: t.added_by,
addedAt: t.added_at,
tags: t.tags.map(tagObj => tagObj.tag)
}));
const frontendAudit = audit.map(a => ({
...a,
actor: a.actor,
action: a.action,
summary: a.summary,
target: a.target,
at: a.at
}));
const mappedOpenTask = frontendTasks.find(x => x.id === openTaskId);
return ( return (
<div className="app"> <div className="app">
@@ -101,7 +167,6 @@ function App() {
onAdd={() => setAdding(meId)} onAdd={() => setAdding(meId)}
onLogs={() => setShowLogs(true)} onLogs={() => setShowLogs(true)}
onProfile={() => setShowSettings(true)} onProfile={() => setShowSettings(true)}
onDB={() => setShowDB(true)}
/> />
<HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} /> <HeadsUp items={headsUp} onDismiss={dismissHU} onOpenTask={openTaskFromAnywhere} />
@@ -109,7 +174,7 @@ function App() {
<main className="main"> <main className="main">
{tab === 'overview' && ( {tab === 'overview' && (
<OverviewScreen <OverviewScreen
tasks={tasks} density={t.density} tasks={frontendTasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)} onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)} onAddFor={(uid) => setAdding(uid)}
onMoveTask={moveTask} onMoveTask={moveTask}
@@ -117,7 +182,7 @@ function App() {
)} )}
{tab !== 'overview' && ( {tab !== 'overview' && (
<UserScreen <UserScreen
user={merge(tab)} tasks={tasks} density={t.density} user={merge(tab)} tasks={frontendTasks} density={t.density}
onOpen={(task) => setOpenTaskId(task.id)} onOpen={(task) => setOpenTaskId(task.id)}
onAddFor={(uid) => setAdding(uid)} onAddFor={(uid) => setAdding(uid)}
/> />
@@ -125,41 +190,40 @@ function App() {
</main> </main>
<AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} /> <AddTaskModal open={!!adding} onClose={() => setAdding(null)} onSubmit={addTask} defaultAssignee={adding} me={me} />
{openTask && ( {mappedOpenTask && (
<TaskDetail task={openTask} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} /> <TaskDetail task={mappedOpenTask} allAudit={frontendAudit} onClose={() => setOpenTaskId(null)} onMove={moveTask} onPriority={setPriority} />
)} )}
{showLogs && ( {showLogs && (
<Modal title="Audit log" onClose={() => setShowLogs(false)} wide> <Modal title="Audit log" onClose={() => setShowLogs(false)} wide>
<AuditScreen entries={audit} onOpen={openTaskFromAnywhere} /> <AuditScreen entries={frontendAudit} onOpen={openTaskFromAnywhere} />
</Modal> </Modal>
)} )}
{showSettings && ( {showSettings && (
<SettingsScreen <SettingsScreen
user={me} user={me}
dbUsers={dbUsers}
isAdmin={isAdmin} isAdmin={isAdmin}
onClose={() => setShowSettings(false)} onClose={() => setShowSettings(false)}
onSave={(edits) => { onSave={async (edits) => {
DashyDB.updateUser(meId, edits); // Not implemented on backend yet for user updating, mock success
DashyDB.addAudit({ actor: meId, action: 'profile_updated', summary: 'Updated profile details' }); setShowSettings(false);
}} }}
onLogout={() => { setShowSettings(false); setAuthed(false); }} onLogout={() => {
onSwitchUser={(id) => { setMeId(id); setShowSettings(false); }} api.logout();
onCreateUser={(u) => { setAuthed(false);
const id = DashyDB.createUser(u); setShowSettings(false);
DashyDB.addAudit({ actor: meId, action: 'user_created', summary: 'Added ' + u.name + ' (' + (u.account_type||'standard') + ')', target: id });
}} }}
onDeleteUser={(id) => { onSwitchUser={async (id) => {
const u = userMap[id]; try {
DashyDB.deleteUser(id); await api.login(id, "password123");
DashyDB.addAudit({ actor: meId, action: 'user_deleted', summary: 'Removed ' + (u?u.name:id), target: null }); setMeId(id);
}} setShowSettings(false);
onUpdateUserRole={(id, edits) => { } catch(e) {
DashyDB.updateUser(id, edits); alert("Failed to switch user");
DashyDB.addAudit({ actor: meId, action: 'user_updated', summary: 'Updated ' + (userMap[id]?userMap[id].name:id) + ' permissions', target: null }); }
}} }}
/> />
)} )}
{showDB && isAdmin && <DatabaseInspector onClose={() => setShowDB(false)} />}
<DashyTweaks t={t} setTweak={setTweak} /> <DashyTweaks t={t} setTweak={setTweak} />
</div> </div>
@@ -170,7 +234,7 @@ function BootSplash() {
return ( return (
<div className="boot"> <div className="boot">
<div className="boot__pulse" /> <div className="boot__pulse" />
<div className="boot__label mono">opening dashy.db</div> <div className="boot__label mono">loading from API</div>
</div> </div>
); );
} }
@@ -188,11 +252,6 @@ function DashyTweaks({ t, setTweak }) {
onChange={v => setTweak('density', v)} /> onChange={v => setTweak('density', v)} />
<TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} /> <TweakToggle label="Show tags on cards" value={t.showTags} onChange={v => setTweak('showTags', v)} />
</TweakSection> </TweakSection>
<TweakSection title="Database">
<TweakButton label="Reset SQLite" onClick={() => { if (confirm('Wipe and reseed dashy.db?')) DashyDB.reset(); }}>
Reset
</TweakButton>
</TweakSection>
</TweaksPanel> </TweaksPanel>
); );
} }
+1 -1
View File
@@ -20,7 +20,7 @@ app.add_middleware(
) )
@app.post("/token", response_model=schemas.Token) @app.post("/token", response_model=schemas.Token)
async def login_for_access_token(form_data: schemas.UserCreate, db: Session = Depends(get_db)): async def login_for_access_token(form_data: schemas.UserLogin, db: Session = Depends(get_db)):
user = db.query(models.User).filter(models.User.id == form_data.id).first() user = db.query(models.User).filter(models.User.id == form_data.id).first()
if not user or not auth.verify_password(form_data.password, user.password_hash): if not user or not auth.verify_password(form_data.password, user.password_hash):
raise HTTPException( raise HTTPException(
+4
View File
@@ -16,6 +16,10 @@ class UserBase(BaseModel):
class UserCreate(UserBase): class UserCreate(UserBase):
password: str password: str
class UserLogin(BaseModel):
id: str
password: str
class User(UserBase): class User(UserBase):
created_at: datetime created_at: datetime
class Config: class Config:
+2 -1
View File
@@ -119,7 +119,7 @@ def seed_db():
status=t['status'], status=t['status'],
added_at=datetime.fromisoformat(t['addedAt']) added_at=datetime.fromisoformat(t['addedAt'])
) )
db.merge(db_task) db_task = db.merge(db_task)
# Add tags # Add tags
for tag_name in t.get('tags', []): for tag_name in t.get('tags', []):
@@ -127,6 +127,7 @@ def seed_db():
if not tag: if not tag:
tag = models.Tag(tag=tag_name) tag = models.Tag(tag=tag_name)
db.add(tag) db.add(tag)
db.flush()
if tag not in db_task.tags: if tag not in db_task.tags:
db_task.tags.append(tag) db_task.tags.append(tag)
BIN
View File
Binary file not shown.
-123
View File
@@ -1,123 +0,0 @@
// Seed data for Dashy prototype
const SEED_TASKS = [
// Lani
{ id: 't1', title: 'Call back Mrs. Patel re: Hilux service quote',
description: 'She left a voicemail Tuesday — wants confirmation on the timing belt price before she books in.',
assignee: 'lani', addedBy: 'rod', priority: 'high', source: 'imessage',
addedAt: '2026-05-08T08:42:00', status: 'open',
tags: ['quote'] },
{ id: 't2', title: 'Email #3814 → Workorder #2207',
description: 'Auto-converted from inbox. Tell customer ETA of Friday.',
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'email',
addedAt: '2026-05-08T07:15:00', status: 'open',
tags: ['WO #2207'] },
{ id: 't3', title: 'Reorder coolant — 5L × 4',
description: 'Stock card ran red on the morning sweep.',
assignee: 'lani', addedBy: 'kirra', priority: 'low', source: 'manual',
addedAt: '2026-05-07T16:02:00', status: 'open' },
{ id: 't4', title: 'Form response from "K. Wynne" auto-switched to Billing',
description: 'Originally captured as Service Booking — heads up.',
assignee: 'lani', addedBy: 'system', priority: 'med', source: 'automation',
addedAt: '2026-05-08T09:50:00', status: 'billing',
tags: ['form'] },
// Kirra
{ id: 't5', title: 'Diagnose intermittent misfire — Camry, WO #2199',
description: 'Cust says it stutters between 6080km/h once warm.',
assignee: 'kirra', addedBy: 'rod', priority: 'high', source: 'manual',
addedAt: '2026-05-08T07:50:00', status: 'open',
tags: ['WO #2199'] },
{ id: 't6', title: 'Sign off on Ayron\'s brake job — Forester',
description: 'Final torque check + road test before pickup at 3pm.',
assignee: 'kirra', addedBy: 'ayron', priority: 'med', source: 'manual',
addedAt: '2026-05-08T09:05:00', status: 'open' },
{ id: 't7', title: 'WO #2188 auto-marked Unsuccessful',
description: 'Customer no-show twice — please review and decide on next step.',
assignee: 'kirra', addedBy: 'system', priority: 'high', source: 'automation',
addedAt: '2026-05-08T06:00:00', status: 'unsuccessful',
tags: ['WO #2188'] },
// Ayron
{ id: 't8', title: 'Replace front pads + rotors — Forester',
description: 'Parts arrived yesterday. Bay 2 from 10am.',
assignee: 'ayron', addedBy: 'kirra', priority: 'med', source: 'manual',
addedAt: '2026-05-08T08:00:00', status: 'open',
tags: ['WO #2201'] },
{ id: 't9', title: 'Tidy bay 3 + sweep before lunch',
description: '',
assignee: 'ayron', addedBy: 'rod', priority: 'low', source: 'imessage',
addedAt: '2026-05-08T09:30:00', status: 'open' },
{ id: 't10', title: 'Pickup parts from Repco @ 11:30',
description: 'Two boxes for WO #2199 + an oil filter for stock.',
assignee: 'ayron', addedBy: 'lani', priority: 'med', source: 'manual',
addedAt: '2026-05-08T08:20:00', status: 'open' },
// ROD
{ id: 't11', title: 'Approve quote on Job #2207 ($1,840)',
description: 'Lani flagged this — needs your sign-off before sending.',
assignee: 'rod', addedBy: 'lani', priority: 'high', source: 'manual',
addedAt: '2026-05-08T09:12:00', status: 'open',
tags: ['quote', 'WO #2207'] },
{ id: 't12', title: 'Review weekly automation report',
description: '14 tasks created from email, 6 from iMessage, 2 form re-routes.',
assignee: 'rod', addedBy: 'system', priority: 'low', source: 'automation',
addedAt: '2026-05-08T06:00:00', status: 'open' },
];
const SEED_AUDIT = [
{ id: 'a1', at: '2026-05-08T09:50:00', actor: 'system', action: 'form_rerouted',
summary: 'Form from K. Wynne auto-switched: Service Booking → Billing form',
target: 't4' },
{ id: 'a2', at: '2026-05-08T09:30:00', actor: 'rod', action: 'task_created',
summary: 'Created task "Tidy bay 3 + sweep before lunch" for Ayron via iMessage',
target: 't9' },
{ id: 'a3', at: '2026-05-08T09:12:00', actor: 'lani', action: 'task_assigned',
summary: 'Assigned "Approve quote on Job #2207" to ROD',
target: 't11' },
{ id: 'a4', at: '2026-05-08T09:05:00', actor: 'ayron', action: 'task_moved',
summary: 'Moved "Sign off on brake job" from Ayron → Kirra',
target: 't6' },
{ id: 'a5', at: '2026-05-08T08:42:00', actor: 'rod', action: 'task_created',
summary: 'Created task "Call back Mrs. Patel" for Lani via iMessage',
target: 't1' },
{ id: 'a6', at: '2026-05-08T08:20:00', actor: 'lani', action: 'task_created',
summary: 'Created task "Pickup parts from Repco" for Ayron',
target: 't10' },
{ id: 'a7', at: '2026-05-08T08:00:00', actor: 'kirra', action: 'task_created',
summary: 'Created task "Replace front pads + rotors" for Ayron',
target: 't8' },
{ id: 'a8', at: '2026-05-08T07:50:00', actor: 'rod', action: 'task_edited',
summary: 'Edited priority on "Diagnose intermittent misfire" — Med → High',
target: 't5' },
{ id: 'a9', at: '2026-05-08T07:15:00', actor: 'system', action: 'email_converted',
summary: 'Email #3814 converted to Workorder #2207 (assigned to Lani)',
target: 't2' },
{ id: 'a10', at: '2026-05-08T06:00:00', actor: 'system', action: 'task_unsuccessful',
summary: 'WO #2188 auto-marked Unsuccessful after 2 missed bookings',
target: 't7' },
{ id: 'a11', at: '2026-05-07T16:02:00', actor: 'kirra', action: 'task_created',
summary: 'Created task "Reorder coolant" for Lani',
target: 't3' },
{ id: 'a12', at: '2026-05-07T15:30:00', actor: 'rod', action: 'login',
summary: 'Signed in from MacBook · Christchurch',
target: null },
];
// Per-task audit slices
const TASK_AUDIT = {
t1: [
{ at: '2026-05-08T08:42:00', actor: 'rod', action: 'created via iMessage',
detail: '"Hey, create new task for Lani — call back Mrs. Patel about the Hilux quote, she wants confirmation before she books"' },
{ at: '2026-05-08T08:43:00', actor: 'system', action: 'priority set to High', detail: 'Inferred from "wants confirmation"' },
{ at: '2026-05-08T08:45:00', actor: 'lani', action: 'opened', detail: '' },
],
t11: [
{ at: '2026-05-08T09:12:00', actor: 'lani', action: 'assigned to ROD', detail: 'flagged for sign-off before send' },
{ at: '2026-05-08T09:14:00', actor: 'lani', action: 'added note', detail: 'Customer wants the work done before Mother\'s Day weekend.' },
],
};
window.SEED_TASKS = SEED_TASKS;
window.SEED_AUDIT = SEED_AUDIT;
window.TASK_AUDIT = TASK_AUDIT;
-314
View File
@@ -1,314 +0,0 @@
// 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; },
};
})();
+8 -133
View File
@@ -106,15 +106,6 @@ function TopBar({ me, isAdmin, tab, setTab, onAdd, onLogs, onLogout, onProfile,
<Icon.Logs /> <Icon.Logs />
</IconBtn> </IconBtn>
)} )}
{isAdmin && (
<IconBtn label="Database" onClick={onDB}>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.4">
<ellipse cx="8" cy="3.5" rx="5" ry="1.5"/>
<path d="M3 3.5v9c0 .8 2.2 1.5 5 1.5s5-.7 5-1.5v-9"/>
<path d="M3 7.5c0 .8 2.2 1.5 5 1.5s5-.7 5-1.5"/>
</svg>
</IconBtn>
)}
<button className="topbar__me" onClick={onProfile}> <button className="topbar__me" onClick={onProfile}>
<Avatar user={me} size={28} ring /> <Avatar user={me} size={28} ring />
<div className="topbar__me-meta"> <div className="topbar__me-meta">
@@ -407,13 +398,14 @@ function Modal({ children, onClose, title, eyebrow, wide = false }) {
); );
} }
function TaskDetail({ task, onClose, onMove, onPriority }) { function TaskDetail({ task, allAudit = [], onClose, onMove, onPriority }) {
if (!task) return null; if (!task) return null;
const assignee = findUser(task.assignee); const assignee = findUser(task.assignee);
const author = findUser(task.addedBy); const author = findUser(task.addedBy);
const audit = TASK_AUDIT[task.id] || [ const audit = allAudit.filter(a => a.target === task.id);
{ at: task.addedAt, actor: task.addedBy, action: 'created', detail: '' } if (audit.length === 0) {
]; audit.push({ at: task.addedAt, actor: task.addedBy, action: 'task_created', summary: '' });
}
return ( return (
<Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}> <Modal onClose={onClose} wide title={task.title} eyebrow={(task.tags && task.tags.join(' · ')) || 'Task'}>
<div className="detail"> <div className="detail">
@@ -474,9 +466,9 @@ function TaskDetail({ task, onClose, onMove, onPriority }) {
<strong> <strong>
{row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name} {row.actor === 'system' ? 'System' : (findUser(row.actor) || {}).name}
</strong> </strong>
<span> {row.action}</span> <span> {row.action.replace(/_/g, ' ')}</span>
</div> </div>
{row.detail && <div className="timeline__detail">{row.detail}</div>} {(row.detail || row.summary) && <div className="timeline__detail">{row.detail || row.summary}</div>}
<div className="timeline__time mono">{fmtDateTime(row.at)}</div> <div className="timeline__time mono">{fmtDateTime(row.at)}</div>
</div> </div>
</li> </li>
@@ -818,8 +810,7 @@ function ToggleRow({ label, defaultOn = false }) {
); );
} }
function WorkspaceTab({ user, isAdmin, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole }) { function WorkspaceTab({ user, isAdmin, dbUsers, onSwitchUser, onCreateUser, onDeleteUser, onUpdateUserRole }) {
const dbUsers = DashyDB.listUsers();
const [adding, setAdding] = React.useState(false); const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState(''); const [newName, setNewName] = React.useState('');
const [newRole, setNewRole] = React.useState(''); const [newRole, setNewRole] = React.useState('');
@@ -935,123 +926,7 @@ function WorkspaceTab({ user, isAdmin, onSwitchUser, onCreateUser, onDeleteUser,
); );
} }
function DatabaseInspector({ onClose }) {
const [tab, setTab] = React.useState(DashyDB.tableNames()[0] || 'tasks');
const [sql, setSql] = React.useState('SELECT id, title, assignee_id, priority, status\nFROM tasks\nORDER BY added_at DESC;');
const [result, setResult] = React.useState(null);
const [err, setErr] = React.useState(null);
const tableNames = DashyDB.tableNames();
const runQuery = () => {
try {
setErr(null);
const r = DashyDB.rawExec(sql);
setResult(r);
} catch (e) { setErr(String(e.message || e)); setResult(null); }
};
const downloadDB = () => {
const blob = DashyDB.exportFile();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'dashy.db'; a.click();
URL.revokeObjectURL(url);
};
const cols = tab && DashyDB.tableInfo(tab);
const rows = tab && DashyDB.query('SELECT * FROM ' + tab + ' LIMIT 200');
return (
<Modal onClose={onClose} title="Database" eyebrow="dashy.db · SQLite" wide>
<div className="dbins">
<nav className="dbins__nav">
<div className="dbins__nav-h mono">tables</div>
{tableNames.map(n => (
<button key={n} className={"dbins__tab" + (tab===n?' is-on':'')} onClick={() => setTab(n)}>
<span className="dbins__tab-name">{n}</span>
<span className="dbins__tab-count mono">{DashyDB.rowCount(n)}</span>
</button>
))}
<button className={"dbins__tab" + (tab==='__sql__'?' is-on':'')} onClick={() => setTab('__sql__')}>
<span className="dbins__tab-name">SQL </span>
</button>
<div className="dbins__nav-foot">
<button className="btn btn--soft btn--sm" onClick={downloadDB}>Export .db</button>
<button className="btn btn--ghost btn--sm" onClick={() => { if (confirm('Wipe and reseed?')) DashyDB.reset(); }}>Reset</button>
</div>
</nav>
<div className="dbins__body">
{tab !== '__sql__' && cols && (
<>
<div className="dbins__schema">
<div className="dbins__schema-h mono">schema · {tab}</div>
<div className="dbins__cols">
{cols.map(c => (
<span key={c.name} className="dbins__col">
<strong>{c.name}</strong>
<span className="mono">{c.type}{c.pk ? ' · PK' : ''}{c.notnull ? ' · NOT NULL' : ''}</span>
</span>
))}
</div>
</div>
<DBTable cols={cols.map(c => c.name)} rows={rows} />
</>
)}
{tab === '__sql__' && (
<div className="dbins__sql">
<div className="dbins__sql-bar">
<span className="mono dbins__sql-eyebrow">SQL · runs against dashy.db</span>
<button className="btn btn--primary btn--sm" onClick={runQuery}>Run ↵</button>
</div>
<textarea
className="dbins__sql-input"
value={sql}
onChange={e => setSql(e.target.value)}
spellCheck={false}
/>
{err && <div className="dbins__err">{err}</div>}
{result && result[0] && (
<DBTable cols={result[0].columns} rows={result[0].values.map(row => Object.fromEntries(row.map((v,i)=>[result[0].columns[i],v])))} />
)}
{result && result.length === 0 && (
<div className="dbins__ok mono">ok · no rows returned</div>
)}
</div>
)}
</div>
</div>
</Modal>
);
}
function DBTable({ cols, rows }) {
if (!rows || !rows.length) return <div className="dbins__empty mono">— empty —</div>;
return (
<div className="dbins__tablewrap">
<table className="dbins__table">
<thead><tr>{cols.map(c => <th key={c}>{c}</th>)}</tr></thead>
<tbody>
{rows.map((r, i) => (
<tr key={i}>
{cols.map(c => {
const v = r[c];
let display;
if (v === null || v === undefined) display = <span className="dbins__null mono">NULL</span>;
else if (typeof v === 'string' && v.length > 80) display = v.slice(0, 80) + '';
else display = String(v);
return <td key={c} className={typeof v === 'number' ? 'is-num' : ''}>{display}</td>;
})}
</tr>
))}
</tbody>
</table>
</div>
);
}
Object.assign(window, { Object.assign(window, {
LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark, LoginScreen, TopBar, OverviewScreen, UserScreen, AddTaskModal, Modal, TaskDetail, AuditScreen, HeadsUp, BrandMark,
SettingsScreen, SettingsScreen,
DatabaseInspector,
}); });