Skip to content

Latest commit

 

History

History
996 lines (828 loc) · 26.6 KB

File metadata and controls

996 lines (828 loc) · 26.6 KB

Developer Guide: Architecture & Code Patterns

Table of Contents

  1. Architecture Overview
  2. Data Flow
  3. Service Layer Pattern
  4. Component Architecture
  5. Page Architecture
  6. Adding New Features
  7. Code Quality Standards
  8. Testing Strategy

Architecture Overview

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          │
└─────────────────────────────────────────┘

Key Principles

  1. Unidirectional Data Flow: Pages → Services → Database
  2. Event-Driven Updates: Services emit CustomEvent on state changes
  3. No Direct DB Access: All database queries go through services
  4. Immutable Audit Trail: Time entries cannot be modified after creation
  5. Single Responsibility: Each service handles one entity type

Data Flow

Example: Operator Starts Timer

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

Example: Admin Creates Machine

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)

Service Layer Pattern

Service Structure

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;
  },
};

Service Responsibilities

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)

Component Architecture

Component Structure

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);
    });
  }
}

Component Lifecycle

  1. Constructor: Initialize state
  2. render(): Create DOM elements and mount to parent
  3. Event listeners: Wire up DOM events
  4. getValue/setValue: Provide data access interface
  5. Cleanup (optional): Remove event listeners if needed

Page Architecture

Page Class Pattern

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);
    }
  }
}

Page Responsibilities

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

Adding New Features

Example: Add a New Entity Type (e.g., "Project")

Step 1: Define in Database Schema

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);

Step 2: Create Service

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 });
  },
};

Step 3: Create Contract Tests

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);
  });
});

Step 4: Create Admin Page

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);
    }
  }
}

Step 5: Integrate into App

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...

Step 6: Verify & Test

npm run lint          # Should pass
npm run build         # Should succeed
npm test              # Contract tests pass

Code Quality Standards

ESLint Rules

Enforced 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/await for all async operations (no .then())
  • Use template literals for string concatenation
  • Use arrow functions for callbacks
  • Use const by default, let only when reassigning
  • Use descriptive variable names (no x, temp, data)

Function Decomposition Rules

If a function exceeds 50 lines:

  1. Identify logical sub-tasks
  2. Extract each sub-task into a named helper
  3. Call helpers from main function
  4. 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 */ }

Complexity Rules

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;
}

Testing Strategy

Contract Tests

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();
});

Integration Tests

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
});

Manual QA Checklist

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