- Architecture Overview
- Data Flow
- Service Layer Pattern
- Component Architecture
- Page Architecture
- Adding New Features
- Code Quality Standards
- Testing Strategy
The application follows a layered architecture with strict separation of concerns:
┌─────────────────────────────────────────┐
│ UI Layer (Pages) │
│ OperatorPage, AdminPages (5 pages) │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ Component Layer │
│ Reusable UI components │
│ (DropdownSelector, TimerDisplay, etc) │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ Service Layer │
│ Business logic & state management │
│ (TimerService, EmployeeService, etc) │
└────────────┬────────────────────────────┘
│
┌────────────▼────────────────────────────┐
│ Database & Storage Layer │
│ SQLite (via sql.js), IndexedDB │
│ StorageAdapter for persistence │
└─────────────────────────────────────────┘
- Unidirectional Data Flow: Pages → Services → Database
- Event-Driven Updates: Services emit CustomEvent on state changes
- No Direct DB Access: All database queries go through services
- Immutable Audit Trail: Time entries cannot be modified after creation
- Single Responsibility: Each service handles one entity type
User clicks Play button
↓
OperatorPage.handlePlayClick()
↓
timer-service.start(workOrderId, operationId)
↓
TimerService emits 'timer-started' CustomEvent
↓
OperatorPage listens to event
↓
OperatorPage updates UI (timer starts counting)
↓
User stops timer after 5 minutes
↓
OperatorPage.handleStopClick()
↓
timer-service.stop()
↓
timeentry-service.create(employeeId, machineId, workOrderId, operationId, elapsedSeconds)
↓
database.insert('TimeEntry', record)
↓
StorageAdapter saves to IndexedDB
↓
timeentry-service emits 'time-entry-created'
↓
OperatorPage confirms success, clears form
Admin fills form and clicks Submit
↓
AdminMachinesPage.handleFormSubmit()
↓
validation-service.validateMachineName(name)
↓
machine-service.create(name, description)
↓
database.insert('Machine', {id, name, description, created_at})
↓
StorageAdapter saves to IndexedDB
↓
machine-service emits 'machine-created'
↓
AdminMachinesPage listens, reloads machine list
↓
AdminMachinesPage re-renders table
↓
OperatorPage's machine dropdown refreshed (next load)
Every service follows this pattern:
// Example: src/services/employee-service.js
import { database } from '../db/database.js';
import { eventSystem } from './event-system.js';
export const employeeService = {
// READ operations
async getAll() {
const employees = await database.query('Employee');
return employees;
},
async getActive() {
const employees = await database.query('Employee', { active_status: 1 });
return employees;
},
async getByBadge(badge) {
const employee = await database.queryOne('Employee', { badge_number: badge });
return employee;
},
// CREATE operation
async create(name, badge, role = null, department = null) {
// 1. Validate inputs
const existing = await this.getByBadge(badge);
if (existing) {
throw new Error(`Badge ${badge} already exists`);
}
// 2. Create record
const record = {
id: generateId('emp'),
name,
badge_number: badge,
role,
department,
active_status: 1,
created_at: currentISO8601(),
};
// 3. Insert to database
const created = await database.insert('Employee', record);
// 4. Emit event
eventSystem.emit('employee-created', { employee: created });
// 5. Return created record
return created;
},
// UPDATE operation
async update(id, updates) {
const record = await database.update('Employee', id, updates);
eventSystem.emit('employee-updated', { employee: record });
return record;
},
// DELETE operation (soft-delete via active_status)
async deactivate(id) {
const updated = await database.update('Employee', id, { active_status: 0 });
eventSystem.emit('employee-deleted', { employee: updated });
return updated;
},
};✅ DO:
- Validate inputs before database operations
- Call database.insert/update/delete/query
- Emit events on successful operations
- Return consistent data structures
- Handle FK relationships (validate entity exists)
- Implement soft-delete where needed
❌ DON'T:
- Accept HTML/DOM elements as parameters
- Manipulate the UI directly
- Use console.log (use error-handlers.js)
- Store state outside the service object
- Modify parameters (immutability)
Reusable components (DropdownSelector, TimerDisplay, ErrorDisplay) follow this pattern:
// Example: src/components/dropdown-selector.js
export class DropdownSelector {
constructor(label, options = [], onChange = null) {
this.label = label;
this.options = options; // [{id, label}, ...]
this.onChange = onChange;
this.container = null;
this.select = null;
}
render(parentElement) {
// 1. Create container
this.container = document.createElement('div');
this.container.className = 'dropdown-selector';
// 2. Create label
const labelEl = document.createElement('label');
labelEl.textContent = this.label;
// 3. Create select
this.select = document.createElement('select');
this.select.className = 'selector-input';
// 4. Add placeholder option
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = `Select ${this.label}...`;
this.select.appendChild(placeholder);
// 5. Add options
this.options.forEach(option => {
const opt = document.createElement('option');
opt.value = option.id;
opt.textContent = option.label;
this.select.appendChild(opt);
});
// 6. Wire event
this.select.addEventListener('change', () => {
if (this.onChange) {
this.onChange(this.getValue());
}
});
// 7. Assemble and mount
this.container.appendChild(labelEl);
this.container.appendChild(this.select);
parentElement.appendChild(this.container);
}
getValue() {
return this.select?.value || '';
}
setValue(value) {
if (this.select) {
this.select.value = value;
}
}
setOptions(options) {
this.options = options;
// Rebuild select options
this.select.innerHTML = '';
const placeholder = document.createElement('option');
placeholder.value = '';
placeholder.textContent = `Select ${this.label}...`;
this.select.appendChild(placeholder);
options.forEach(option => {
const opt = document.createElement('option');
opt.value = option.id;
opt.textContent = option.label;
this.select.appendChild(opt);
});
}
}- Constructor: Initialize state
- render(): Create DOM elements and mount to parent
- Event listeners: Wire up DOM events
- getValue/setValue: Provide data access interface
- Cleanup (optional): Remove event listeners if needed
Pages are the main UI containers. Example structure:
// Example: src/pages/admin-machines-page.js
export class AdminMachinesPage {
constructor() {
this.machines = [];
this.editing = false;
this.editingId = null;
}
async render(parentElement) {
parentElement.innerHTML = ''; // Clear
this.buildUI(parentElement);
await this.loadData();
}
buildUI(parentElement) {
// 1. Create header
const header = this.buildHeader();
parentElement.appendChild(header);
// 2. Create form section
const formSection = this.buildFormSection();
parentElement.appendChild(formSection);
// 3. Create table section
const tableSection = this.buildTableSection();
parentElement.appendChild(tableSection);
}
buildHeader() {
const header = document.createElement('div');
header.className = 'page-header';
header.innerHTML = '<h1>Admin: Machines</h1>';
return header;
}
buildFormSection() {
const section = document.createElement('div');
section.className = 'form-section';
section.innerHTML = `
<h2>${this.editing ? 'Edit Machine' : 'Add New Machine'}</h2>
<form id="machine-form">
${this.buildNameField()}
${this.buildDescriptionField()}
<button type="submit">Submit</button>
${this.editing ? '<button type="button" id="cancel-btn">Cancel</button>' : ''}
</form>
`;
this.wireFormEvents(section);
return section;
}
buildNameField() {
return `
<div class="form-group">
<label for="name">Machine Name (required)</label>
<input type="text" id="name" name="name" maxlength="50" required />
</div>
`;
}
buildDescriptionField() {
return `
<div class="form-group">
<label for="description">Description</label>
<textarea id="description" name="description" maxlength="200"></textarea>
</div>
`;
}
buildTableSection() {
const section = document.createElement('div');
section.className = 'table-section';
section.id = 'machine-table-container';
this.renderTable(section);
return section;
}
renderTable(container) {
if (!this.machines.length) {
container.innerHTML = '<p>No machines yet. Add one above.</p>';
return;
}
const table = document.createElement('table');
table.className = 'data-table';
table.innerHTML = `
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>Description</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.machines.map(m => `
<tr>
<td>${m.id}</td>
<td>${m.name}</td>
<td>${m.description || '—'}</td>
<td>${new Date(m.created_at).toLocaleDateString()}</td>
<td>
<button class="edit-btn" data-id="${m.id}">Edit</button>
<button class="delete-btn" data-id="${m.id}">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
`;
container.innerHTML = '';
container.appendChild(table);
this.wireTableEvents(container);
}
async loadData() {
this.machines = await machineService.getAll();
this.renderTable(document.getElementById('machine-table-container'));
}
wireFormEvents(formSection) {
const form = formSection.querySelector('#machine-form');
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
const cancelBtn = formSection.querySelector('#cancel-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => this.cancelEdit());
}
}
wireTableEvents(tableSection) {
const editBtns = tableSection.querySelectorAll('.edit-btn');
const deleteBtns = tableSection.querySelectorAll('.delete-btn');
editBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
this.editMachine(id);
});
});
deleteBtns.forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.target.dataset.id;
this.deleteMachine(id);
});
});
}
async handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const name = form.querySelector('#name').value;
const description = form.querySelector('#description').value;
try {
if (this.editing) {
await machineService.update(this.editingId, { name, description });
} else {
await machineService.create(name, description);
}
form.reset();
this.editing = false;
this.editingId = null;
await this.loadData();
} catch (error) {
console.error('Form error:', error);
// Show error to user
}
}
editMachine(id) {
const machine = this.machines.find(m => m.id === id);
if (!machine) return;
this.editing = true;
this.editingId = id;
const form = document.querySelector('#machine-form');
form.querySelector('#name').value = machine.name;
form.querySelector('#description').value = machine.description || '';
// Scroll to form
form.scrollIntoView({ behavior: 'smooth' });
}
cancelEdit() {
this.editing = false;
this.editingId = null;
const form = document.querySelector('#machine-form');
form.reset();
}
async deleteMachine(id) {
if (!confirm('Delete this machine? This cannot be undone.')) return;
try {
await machineService.remove(id);
await this.loadData();
} catch (error) {
console.error('Delete error:', error);
}
}
}✅ DO:
- Orchestrate component rendering
- Load data via services
- Wire event handlers
- Update UI on service events
- Handle user interactions
- Show/hide errors
❌ DON'T:
- Call database directly
- Store unrelated state
- Exceed 50 lines per function
- Have functions with complexity > 5
- Hardcode business logic
Edit src/db/schema.sql:
CREATE TABLE Project (
id TEXT PRIMARY KEY,
name TEXT UNIQUE NOT NULL,
description TEXT,
status TEXT CHECK (status IN ('planning', 'active', 'completed')) DEFAULT 'planning',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX idx_project_status ON Project(status);Create src/services/project-service.js:
import { database } from '../db/database.js';
import { eventSystem } from './event-system.js';
import { generateId, currentISO8601 } from '../utils/index.js';
export const projectService = {
async getAll() {
return await database.query('Project');
},
async getActive() {
return await database.query('Project', { status: 'active' });
},
async create(name, description = null, status = 'planning') {
const existing = await database.queryOne('Project', { name });
if (existing) throw new Error(`Project "${name}" already exists`);
const record = {
id: generateId('proj'),
name,
description,
status,
created_at: currentISO8601(),
updated_at: currentISO8601(),
};
const created = await database.insert('Project', record);
eventSystem.emit('project-created', { project: created });
return created;
},
async update(id, updates) {
const updated = {
...updates,
updated_at: currentISO8601(),
};
const result = await database.update('Project', id, updated);
eventSystem.emit('project-updated', { project: result });
return result;
},
async updateStatus(id, status) {
return this.update(id, { status });
},
async remove(id) {
await database.delete('Project', id);
eventSystem.emit('project-deleted', { id });
},
};Create src/services/project-service.test.js:
import { describe, it, expect, beforeEach } from 'vitest';
import { projectService } from './project-service.js';
import { database } from '../db/database.js';
describe('projectService', () => {
beforeEach(async () => {
await database.initialize();
});
it('should create a project', async () => {
const project = await projectService.create('Project A', 'Description');
expect(project.id).toBeTruthy();
expect(project.name).toBe('Project A');
expect(project.status).toBe('planning');
});
it('should prevent duplicate project names', async () => {
await projectService.create('Project B');
expect(() => projectService.create('Project B')).rejects.toThrow();
});
it('should update project status', async () => {
const project = await projectService.create('Project C');
const updated = await projectService.updateStatus(project.id, 'active');
expect(updated.status).toBe('active');
});
it('should list all projects', async () => {
await projectService.create('Project D');
await projectService.create('Project E');
const all = await projectService.getAll();
expect(all.length).toBeGreaterThanOrEqual(2);
});
});Create src/pages/admin-projects-page.js:
import { projectService } from '../services/project-service.js';
import { ErrorDisplay } from '../components/error-display.js';
export class AdminProjectsPage {
constructor() {
this.projects = [];
this.editing = false;
this.errorDisplay = new ErrorDisplay(document.body);
}
async render(parentElement) {
parentElement.innerHTML = '';
this.buildUI(parentElement);
await this.loadData();
}
buildUI(parentElement) {
const header = document.createElement('div');
header.innerHTML = '<h1>Admin: Projects</h1>';
parentElement.appendChild(header);
const form = document.createElement('form');
form.id = 'project-form';
form.innerHTML = `
<h2>Add New Project</h2>
<input type="text" id="name" placeholder="Project Name" required />
<textarea id="description" placeholder="Description"></textarea>
<select id="status">
<option value="planning">Planning</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<button type="submit">Submit</button>
`;
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
parentElement.appendChild(form);
const tableContainer = document.createElement('div');
tableContainer.id = 'project-table';
parentElement.appendChild(tableContainer);
}
async loadData() {
this.projects = await projectService.getAll();
this.renderTable();
}
renderTable() {
const container = document.getElementById('project-table');
if (!this.projects.length) {
container.innerHTML = '<p>No projects yet.</p>';
return;
}
const table = document.createElement('table');
table.innerHTML = `
<thead>
<tr>
<th>Name</th>
<th>Status</th>
<th>Description</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${this.projects.map(p => `
<tr>
<td>${p.name}</td>
<td>
<select class="status-select" data-id="${p.id}">
<option ${p.status === 'planning' ? 'selected' : ''}>planning</option>
<option ${p.status === 'active' ? 'selected' : ''}>active</option>
<option ${p.status === 'completed' ? 'selected' : ''}>completed</option>
</select>
</td>
<td>${p.description || '—'}</td>
<td>
<button class="delete-btn" data-id="${p.id}">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
`;
container.innerHTML = '';
container.appendChild(table);
// Wire events
table.querySelectorAll('.status-select').forEach(select => {
select.addEventListener('change', async (e) => {
const id = e.target.dataset.id;
const status = e.target.value;
await projectService.updateStatus(id, status);
});
});
table.querySelectorAll('.delete-btn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const id = e.target.dataset.id;
if (confirm('Delete?')) {
await projectService.remove(id);
await this.loadData();
}
});
});
}
async handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const name = form.querySelector('#name').value;
const description = form.querySelector('#description').value;
const status = form.querySelector('#status').value;
try {
await projectService.create(name, description, status);
form.reset();
await this.loadData();
this.errorDisplay.showSuccess('Project created!');
} catch (error) {
this.errorDisplay.showError(error.message);
}
}
}Edit src/index.js:
import { AdminProjectsPage } from './pages/admin-projects-page.js';
// Add to pages object
const pages = {
operator: new OperatorPage(),
machines: new AdminMachinesPage(),
employees: new AdminEmployeesPage(),
workorders: new AdminWorkOrdersPage(),
operations: new AdminOperationsPage(),
projects: new AdminProjectsPage(), // NEW
};
// Update navigation...npm run lint # Should pass
npm run build # Should succeed
npm test # Contract tests passEnforced Automatically:
- ✅ Max function complexity: 5
- ✅ Max function lines: 50
- ✅ No unused variables
- ✅ Consistent naming conventions
- ✅ No console.log in production (use error-handlers.js)
Style Conventions:
- Use
async/awaitfor all async operations (no.then()) - Use template literals for string concatenation
- Use arrow functions for callbacks
- Use
constby default,letonly when reassigning - Use descriptive variable names (no
x,temp,data)
If a function exceeds 50 lines:
- Identify logical sub-tasks
- Extract each sub-task into a named helper
- Call helpers from main function
- Repeat until all functions ≤50 lines
Example:
// TOO LONG (70 lines)
async handleFormSubmit(e) {
e.preventDefault();
const form = e.target;
const name = form.querySelector('#name').value;
const description = form.querySelector('#description').value;
// Validation (15 lines)
if (!name) { error... }
if (name.length > 50) { error... }
// Database insert (10 lines)
const existing = await db.queryOne(...);
// Error handling (15 lines)
// ... etc
}
// DECOMPOSED (now ~20 lines in main + 3 helpers of ~15 lines each)
async handleFormSubmit(e) {
e.preventDefault();
const formData = this.getFormData(e.target);
const validation = this.validateFormData(formData);
if (!validation.valid) {
this.errorDisplay.showError(validation.error);
return;
}
const result = await this.submitToDatabase(formData);
if (result) {
this.loadData();
}
}
validateFormData(data) { /* 15 lines */ }
getFormData(form) { /* 10 lines */ }
submitToDatabase(data) { /* 15 lines */ }Cyclomatic Complexity ≤ 5 means:
- ✅ Simple if-else chains (5 branches max)
- ✅ Single loops with simple conditions
- ✅ Multiple early returns
TOO COMPLEX (complexity 7+):
function process(value) {
if (value > 0) { // +1
if (value > 100) { // +1
for (let i = 0; i < 10; i++) { // +1
if (condition1) { /* +1 */ }
else if (condition2) { /* +1 */ }
else if (condition3) { /* +1 */ }
}
}
}
return result;
}SIMPLIFIED (complexity 3):
function process(value) {
if (value <= 0 || value > 100) return null;
let result;
for (let i = 0; i < 10; i++) {
const outcome = handleIteration(i);
if (outcome) { result = outcome; break; }
}
return result;
}
function handleIteration(i) {
if (condition1) return 'A';
if (condition2) return 'B';
if (condition3) return 'C';
return null;
}Test service API contracts (what methods exist, what they return):
// Test that service matches api-contracts.md
it('should export all required methods', () => {
expect(machineService.getAll).toBeDefined();
expect(machineService.create).toBeDefined();
expect(machineService.update).toBeDefined();
expect(machineService.remove).toBeDefined();
});
it('create should return record with id and timestamps', async () => {
const result = await machineService.create('CNC-1', 'desc');
expect(result.id).toBeTruthy();
expect(result.created_at).toBeTruthy();
expect(result.updated_at).toBeTruthy();
});Test end-to-end workflows:
// Test complete operator workflow
it('operator should complete time tracking workflow', async () => {
// Setup
const employee = await employeeService.create('John', 'E001');
const machine = await machineService.create('CNC-1');
const workOrder = await workorderService.create('ACME Corp');
const operation = await operationService.create(workOrder.id, 'Drilling', 1);
// Execute
timerService.start(workOrder.id, operation.id);
// Simulate 5 seconds...
timerService.pause();
timerService.resume();
const elapsed = timerService.getElapsedSeconds();
timerService.stop();
// Verify
const timeEntry = await timeentryService.create(
employee.id, machine.id, workOrder.id, operation.id, elapsed
);
expect(timeEntry).toBeTruthy();
expect(timeEntry.elapsed_time_seconds).toBeCloseTo(5, 1); // ±1 second
});Before release:
- Timer accurately tracks 5+ minutes (±2 seconds)
- All admin CRUD operations work (create, read, edit, delete)
- Soft-delete hides employees from dropdowns
- Unique constraints enforced (name, badge, sequence)
- Page navigation works smoothly
- Error messages are clear and actionable
- No console errors or warnings
- ESLint: 0 errors, 0 warnings
Last Updated: November 12, 2025
Version: 1.0.0