Files
headroom/docs/headroom-architecture.md
Santhosh Janardhanan 3e36ea8888 docs(ui): Add UI layout refactor plan and OpenSpec changes
- Update decision-log with UI layout decisions (Feb 18, 2026)
- Update architecture with frontend layout patterns
- Update config.yaml with TDD, documentation, UI standards rules
- Create p00-api-documentation change (Scribe annotations)
- Create p01-ui-foundation change (types, stores, Lucide)
- Create p02-app-layout change (AppLayout, Sidebar, TopBar)
- Create p03-dashboard-enhancement change (PageHeader, StatCard)
- Create p04-content-patterns change (DataTable, FilterBar)
- Create p05-page-migrations change (page migrations)
- Fix E2E auth tests (11/11 passing)
- Add JWT expiry validation to dashboard guard
2026-02-18 13:03:08 -05:00

39 KiB

Headroom - Architecture & Design

Version: 1.0
Date: February 17, 2026
Status: Approved for Implementation


Table of Contents

  1. System Architecture
  2. Data Model
  3. API Architecture
  4. Authentication Flow
  5. Deployment Architecture
  6. Technology Stack Details
  7. Quality Standards
  8. Development Workflow

System Architecture

High-Level Component Diagram

graph TB
    subgraph "Client Layer"
        Browser[Web Browser]
    end
    
    subgraph "Frontend Container :5173"
        SvelteKit[SvelteKit App]
        Store[Svelte Stores]
        Forms[Superforms + Zod]
        Charts[Recharts]
        Tables[TanStack Table]
    end
    
    subgraph "Backend Container :3000"
        API[Laravel API]
        Resources[API Resources]
        Auth[JWT Auth]
        Queue[Queue System]
        Scribe[API Docs - Scribe]
    end
    
    subgraph "Data Layer"
        Postgres[(PostgreSQL)]
        Redis[(Redis Cache)]
    end
    
    subgraph "Reverse Proxy"
        NPM[Nginx Proxy Manager]
    end
    
    Browser -->|HTTPS| NPM
    NPM -->|/api/*| API
    NPM -->|/*| SvelteKit
    
    SvelteKit -->|REST API| API
    SvelteKit --> Store
    SvelteKit --> Forms
    SvelteKit --> Charts
    SvelteKit --> Tables
    
    API --> Resources
    API --> Auth
    API --> Queue
    API --> Scribe
    
    API -->|Read/Write| Postgres
    API -->|Cache| Redis
    Queue -->|Job Storage| Redis
    
    style SvelteKit fill:#ff3e00
    style API fill:#ff2d20
    style Postgres fill:#336791
    style Redis fill:#dc382d

Request Flow

sequenceDiagram
    participant User
    participant Browser
    participant NPM as Nginx Proxy Manager
    participant SvelteKit
    participant Laravel as Laravel API
    participant Redis
    participant DB as PostgreSQL
    
    User->>Browser: Access dashboard
    Browser->>NPM: GET /
    NPM->>SvelteKit: Forward request
    SvelteKit-->>Browser: Return HTML + JS
    
    Browser->>NPM: GET /api/allocations?month=2026-02
    NPM->>Laravel: Forward API request
    Laravel->>Redis: Check cache
    
    alt Cache Hit
        Redis-->>Laravel: Return cached data
    else Cache Miss
        Laravel->>DB: Query allocations
        DB-->>Laravel: Return data
        Laravel->>Redis: Store in cache
    end
    
    Laravel->>Laravel: Transform via API Resources
    Laravel-->>Browser: JSON response
    Browser->>Browser: Render with TanStack Table

Data Model

Entity Relationship Diagram

erDiagram
    TEAM_MEMBER ||--o{ ALLOCATION : "receives"
    TEAM_MEMBER ||--o{ ACTUAL : "logs"
    TEAM_MEMBER ||--o{ PTO : "requests"
    TEAM_MEMBER }o--|| ROLE : "has"
    
    PROJECT ||--o{ ALLOCATION : "requires"
    PROJECT ||--o{ ACTUAL : "tracks"
    PROJECT }o--|| PROJECT_STATUS : "in"
    PROJECT }o--|| PROJECT_TYPE : "categorized_as"
    
    ALLOCATION }o--|| MONTH : "for"
    ACTUAL }o--|| MONTH : "in"
    
    HOLIDAY }o--|| MONTH : "occurs_in"
    PTO }o--|| MONTH : "occurs_in"
    
    TEAM_MEMBER {
        uuid id PK
        string name
        uuid role_id FK
        decimal hourly_rate
        boolean active
        timestamp created_at
        timestamp updated_at
    }
    
    ROLE {
        uuid id PK
        string name
        string description
        timestamp created_at
    }
    
    PROJECT {
        uuid id PK
        string project_code UK
        string title
        uuid status_id FK
        uuid type_id FK
        decimal approved_estimate
        json forecasted_effort
        date start_date
        date end_date
        timestamp created_at
        timestamp updated_at
    }
    
    PROJECT_STATUS {
        uuid id PK
        string name
        int order
        boolean is_active
        boolean is_billable
    }
    
    PROJECT_TYPE {
        uuid id PK
        string name
        string description
    }
    
    ALLOCATION {
        uuid id PK
        uuid project_id FK
        uuid team_member_id FK
        string month
        decimal allocated_hours
        text notes
        timestamp created_at
        timestamp updated_at
    }
    
    ACTUAL {
        uuid id PK
        uuid project_id FK
        uuid team_member_id FK
        string month
        decimal hours_logged
        text notes
        timestamp created_at
        timestamp updated_at
    }
    
    MONTH {
        string id PK
        int year
        int month
        int working_days
    }
    
    HOLIDAY {
        uuid id PK
        date date
        string name
        string description
    }
    
    PTO {
        uuid id PK
        uuid team_member_id FK
        date start_date
        date end_date
        string reason
        string status
        timestamp created_at
    }

Database Schema Details

Key Design Decisions:

  1. UUIDs for Primary Keys

    • Prevents ID enumeration attacks
    • Easier distributed system support (future)
    • No auto-increment exposure
  2. Normalized Master Data

    • Roles, Statuses, Types in separate tables
    • Allows dynamic configuration without code changes
  3. Month as String (YYYY-MM)

    • Simplifies queries and grouping
    • Index-friendly
    • Human-readable
  4. JSON for Forecasted Effort

    • Flexible structure: {"2026-02": 40, "2026-03": 60}
    • Easy to extend without schema changes
    • PostgreSQL JSON operators for querying
  5. Soft Deletes

    • Team members: use active boolean (preserve historical allocations)
    • Projects: deleted_at timestamp (audit trail)

API Architecture

REST Endpoint Structure

graph LR
    subgraph "API Routes"
        Auth[/api/auth]
        TeamMembers[/api/team-members]
        Projects[/api/projects]
        Allocations[/api/allocations]
        Actuals[/api/actuals]
        Reports[/api/reports]
        MasterData[/api/master-data]
    end
    
    Auth --> Login[POST /login]
    Auth --> Logout[POST /logout]
    Auth --> Refresh[POST /refresh]
    
    TeamMembers --> TMList[GET /]
    TeamMembers --> TMCreate[POST /]
    TeamMembers --> TMShow[GET /:id]
    TeamMembers --> TMUpdate[PUT /:id]
    TeamMembers --> TMDelete[DELETE /:id]
    
    Projects --> PList[GET /]
    Projects --> PCreate[POST /]
    Projects --> PShow[GET /:id]
    Projects --> PUpdate[PUT /:id]
    Projects --> PDelete[DELETE /:id]
    
    Allocations --> AList[GET /?month=YYYY-MM]
    Allocations --> ACreate[POST /]
    Allocations --> ABulk[POST /bulk]
    Allocations --> AUpdate[PUT /:id]
    Allocations --> ADelete[DELETE /:id]
    
    Actuals --> ACList[GET /?month=YYYY-MM]
    Actuals --> ACCreate[POST /]
    Actuals --> ACBulk[POST /bulk]
    Actuals --> ACUpdate[PUT /:id]
    
    Reports --> RForecast[GET /forecast]
    Reports --> RUtilization[GET /utilization]
    Reports --> RCost[GET /cost]
    Reports --> RAllocation[GET /allocation]
    Reports --> RVariance[GET /variance]
    
    MasterData --> MDRoles[GET /roles]
    MasterData --> MDStatuses[GET /statuses]
    MasterData --> MDTypes[GET /types]

API Resource Transformations

Laravel API Resources ensure consistent JSON structure:

// Team Member Resource
{
  "data": {
    "id": "uuid",
    "name": "John Doe",
    "role": {
      "id": "uuid",
      "name": "Backend Developer"
    },
    "hourly_rate": 150.00,
    "active": true,
    "created_at": "2026-02-01T10:00:00Z"
  }
}

// Allocation Resource
{
  "data": {
    "id": "uuid",
    "project": {
      "id": "uuid",
      "code": "PROJ-001",
      "title": "Client Dashboard Redesign"
    },
    "team_member": {
      "id": "uuid",
      "name": "Jane Smith"
    },
    "month": "2026-02",
    "allocated_hours": 40.0,
    "notes": "Focus on frontend work",
    "created_at": "2026-02-01T10:00:00Z"
  }
}

// Report Resource (Forecast)
{
  "data": {
    "period": {
      "from": "2026-02",
      "to": "2026-05"
    },
    "projects": [
      {
        "id": "uuid",
        "code": "PROJ-001",
        "title": "Client Dashboard Redesign",
        "approved_estimate": 120.0,
        "forecast_by_month": {
          "2026-02": 40.0,
          "2026-03": 60.0,
          "2026-04": 20.0
        },
        "allocated_by_month": {
          "2026-02": 38.0,
          "2026-03": 62.0,
          "2026-04": 20.0
        },
        "variance": {
          "2026-02": -2.0,
          "2026-03": 2.0,
          "2026-04": 0
        },
        "status": "under_allocated"
      }
    ],
    "summary": {
      "total_approved": 120.0,
      "total_allocated": 120.0,
      "variance": 0
    }
  }
}

Validation & Error Responses

Laravel Form Requests handle validation:

// Example: AllocationRequest
public function rules(): array
{
    return [
        'project_id' => 'required|exists:projects,id',
        'team_member_id' => 'required|exists:team_members,id',
        'month' => 'required|date_format:Y-m',
        'allocated_hours' => 'required|numeric|min:0|max:9999.99',
        'notes' => 'nullable|string|max:1000',
    ];
}

Error Response Format:

{
  "message": "The given data was invalid.",
  "errors": {
    "allocated_hours": [
      "The allocated hours must be at least 0."
    ],
    "month": [
      "The month field must be in YYYY-MM format."
    ]
  }
}

Caching Strategy

graph TD
    Request[API Request] --> CheckCache{Cache Exists?}
    
    CheckCache -->|Yes| ReturnCache[Return Cached Response]
    CheckCache -->|No| QueryDB[Query Database]
    
    QueryDB --> Transform[Transform via API Resource]
    Transform --> StoreCache[Store in Redis]
    StoreCache --> ReturnFresh[Return Fresh Response]
    
    Mutation[Create/Update/Delete] --> InvalidateCache[Invalidate Related Caches]
    InvalidateCache --> ExecuteMutation[Execute Mutation]
    
    style CheckCache fill:#ffd700
    style StoreCache fill:#dc382d
    style InvalidateCache fill:#dc382d

Cache Keys Pattern:

allocations:month:{YYYY-MM}
allocations:project:{project_id}
allocations:member:{member_id}
reports:forecast:{from}:{to}:{filters_hash}
reports:utilization:{month}:{team_id}
capacity:month:{YYYY-MM}

Cache Invalidation Rules:

Mutation Invalidate Keys
Allocation created/updated allocations:month:{month}, allocations:project:{project_id}, allocations:member:{member_id}, reports:*
Actual logged actuals:month:{month}, reports:utilization:*, reports:variance:*
Team member updated allocations:member:{id}, capacity:*, reports:*
Project updated allocations:project:{id}, reports:*

TTL (Time to Live):

  • Allocations: 1 hour
  • Reports: 15 minutes
  • Master data: 24 hours

Authentication Flow

JWT Authentication Sequence

sequenceDiagram
    participant User
    participant SvelteKit
    participant Laravel
    participant Redis
    participant DB
    
    User->>SvelteKit: Enter credentials
    SvelteKit->>Laravel: POST /api/auth/login
    Laravel->>DB: Validate credentials
    DB-->>Laravel: User found
    
    Laravel->>Laravel: Generate JWT token
    Laravel->>Redis: Store refresh token
    Laravel-->>SvelteKit: {access_token, refresh_token, user}
    
    SvelteKit->>SvelteKit: Store tokens in localStorage
    SvelteKit-->>User: Redirect to dashboard
    
    Note over SvelteKit,Laravel: Subsequent requests
    
    User->>SvelteKit: Access protected resource
    SvelteKit->>Laravel: GET /api/allocations<br/>Header: Authorization: Bearer {token}
    Laravel->>Laravel: Validate JWT
    
    alt Token Valid
        Laravel->>DB: Fetch data
        DB-->>Laravel: Return data
        Laravel-->>SvelteKit: JSON response
    else Token Expired
        Laravel-->>SvelteKit: 401 Unauthorized
        SvelteKit->>Laravel: POST /api/auth/refresh<br/>{refresh_token}
        Laravel->>Redis: Validate refresh token
        Redis-->>Laravel: Token valid
        Laravel->>Laravel: Generate new access token
        Laravel-->>SvelteKit: {access_token}
        SvelteKit->>Laravel: Retry original request
    end

Token Structure

Access Token (JWT):

{
  "sub": "user-uuid",
  "role": "manager",
  "permissions": ["allocate_resources", "view_reports"],
  "iat": 1708171200,
  "exp": 1708174800,
  "jti": "token-uuid"
}

Refresh Token:

  • Stored in Redis with 7-day TTL
  • One-time use (rotated on refresh)
  • Revocable (logout invalidates)

Authorization Middleware

graph TD
    Request[Incoming Request] --> ExtractToken[Extract JWT from Header]
    ExtractToken --> ValidateToken{Token Valid?}
    
    ValidateToken -->|No| Return401[Return 401 Unauthorized]
    ValidateToken -->|Yes| LoadUser[Load User from Token]
    
    LoadUser --> CheckPermission{Has Permission?}
    CheckPermission -->|No| Return403[Return 403 Forbidden]
    CheckPermission -->|Yes| ExecuteController[Execute Controller]
    
    ExecuteController --> CheckPolicy{Model Policy?}
    CheckPolicy -->|Fail| Return403
    CheckPolicy -->|Pass| ReturnResponse[Return Response]
    
    style ValidateToken fill:#ffd700
    style CheckPermission fill:#ffd700
    style CheckPolicy fill:#ffd700

Permission Matrix:

Role Permissions
Superuser * (all permissions)
Manager view_all_projects, create_project, edit_own_project, allocate_own_team, view_reports
Developer view_own_allocations, log_hours, view_assigned_projects
Top Brass view_all_reports (read-only)

Deployment Architecture

Docker Compose Structure

graph TB
    subgraph "Docker Compose Environment"
        subgraph "Frontend Container"
            SK[SvelteKit<br/>Node:latest<br/>Port 5173]
            SKVol[/app mounted<br/>from ./frontend]
        end
        
        subgraph "Backend Container"
            LAR[Laravel<br/>PHP 8.4-FPM<br/>Port 3000]
            LARVol[/var/www mounted<br/>from ./backend]
        end
        
        subgraph "Database Container"
            PG[PostgreSQL:latest<br/>Port 5432]
            PGVol[/var/lib/postgresql/data<br/>mounted from ./data/postgres]
        end
        
        subgraph "Cache Container"
            RD[Redis:latest<br/>Port 6379]
            RDVol[/data mounted<br/>from ./data/redis]
        end
    end
    
    subgraph "Host Machine"
        NPM[Nginx Proxy Manager<br/>Port 80/443]
        FrontendCode[./frontend/<br/>SvelteKit source]
        BackendCode[./backend/<br/>Laravel source]
    end
    
    NPM -->|Proxy /api/*| LAR
    NPM -->|Proxy /*| SK
    
    SK -->|API calls| LAR
    LAR -->|DB queries| PG
    LAR -->|Cache| RD
    
    SK -.->|Code mount| FrontendCode
    LAR -.->|Code mount| BackendCode
    
    style SK fill:#ff3e00
    style LAR fill:#ff2d20
    style PG fill:#336791
    style RD fill:#dc382d

docker-compose.yml

version: '3.8'

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile.dev
    container_name: headroom_frontend
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
      - /app/node_modules
    environment:
      - VITE_API_URL=http://backend:3000/api
    depends_on:
      - backend
    networks:
      - headroom_network
    restart: unless-stopped

  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    container_name: headroom_backend
    ports:
      - "3000:3000"
    volumes:
      - ./backend:/var/www
      - /var/www/vendor
    environment:
      - APP_ENV=local
      - APP_DEBUG=true
      - DB_CONNECTION=pgsql
      - DB_HOST=postgres
      - DB_PORT=5432
      - DB_DATABASE=headroom
      - DB_USERNAME=headroom_user
      - DB_PASSWORD=${DB_PASSWORD}
      - REDIS_HOST=redis
      - REDIS_PORT=6379
    depends_on:
      - postgres
      - redis
    networks:
      - headroom_network
    restart: unless-stopped

  postgres:
    image: postgres:latest
    container_name: headroom_postgres
    ports:
      - "5432:5432"
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    environment:
      - POSTGRES_DB=headroom
      - POSTGRES_USER=headroom_user
      - POSTGRES_PASSWORD=${DB_PASSWORD}
    networks:
      - headroom_network
    restart: unless-stopped

  redis:
    image: redis:latest
    container_name: headroom_redis
    ports:
      - "6379:6379"
    volumes:
      - ./data/redis:/data
    networks:
      - headroom_network
    restart: unless-stopped

networks:
  headroom_network:
    driver: bridge

volumes:
  postgres_data:
  redis_data:

Environment Variables (.env)

Backend (.env):

APP_NAME=Headroom
APP_ENV=local
APP_KEY=base64:...
APP_DEBUG=true
APP_URL=http://localhost:3000

DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=headroom
DB_USERNAME=headroom_user
DB_PASSWORD=secure_password_here

REDIS_HOST=redis
REDIS_PORT=6379

JWT_SECRET=your_jwt_secret_here
JWT_TTL=60
JWT_REFRESH_TTL=10080

CACHE_DRIVER=redis
QUEUE_CONNECTION=redis

Frontend (.env):

VITE_API_URL=http://localhost:3000/api
VITE_APP_NAME=Headroom

Reverse Proxy Configuration (Nginx Proxy Manager)

Proxy Host 1: Frontend

  • Domain: headroom.local (or your domain)
  • Forward to: http://headroom_frontend:5173
  • WebSocket support: Enabled

Proxy Host 2: API

  • Domain: headroom.local/api/* (or your domain)
  • Forward to: http://headroom_backend:3000/api
  • Custom headers:
    X-Forwarded-For: $remote_addr
    X-Forwarded-Proto: $scheme
    

Technology Stack Details

Backend: Laravel

Project Structure:

backend/
├── app/
│   ├── Http/
│   │   ├── Controllers/
│   │   │   ├── AuthController.php
│   │   │   ├── TeamMemberController.php
│   │   │   ├── ProjectController.php
│   │   │   ├── AllocationController.php
│   │   │   ├── ActualController.php
│   │   │   └── ReportController.php
│   │   ├── Requests/
│   │   │   ├── AllocationRequest.php
│   │   │   ├── ProjectRequest.php
│   │   │   └── ...
│   │   ├── Resources/
│   │   │   ├── TeamMemberResource.php
│   │   │   ├── ProjectResource.php
│   │   │   ├── AllocationResource.php
│   │   │   └── ...
│   │   └── Middleware/
│   │       └── JwtMiddleware.php
│   ├── Models/
│   │   ├── TeamMember.php
│   │   ├── Project.php
│   │   ├── Allocation.php
│   │   ├── Actual.php
│   │   └── ...
│   ├── Policies/
│   │   ├── ProjectPolicy.php
│   │   ├── AllocationPolicy.php
│   │   └── ...
│   └── Services/
│       ├── CapacityService.php
│       ├── AllocationService.php
│       └── ReportService.php
├── database/
│   ├── migrations/
│   └── seeders/
├── routes/
│   └── api.php
├── tests/
│   ├── Unit/
│   └── Feature/
└── composer.json

Key Packages:

{
  "require": {
    "php": "^8.3",
    "laravel/framework": "^11.0",
    "tymon/jwt-auth": "^2.0",
    "predis/predis": "^2.0",
    "knuckleswtf/scribe": "^4.0"
  },
  "require-dev": {
    "pestphp/pest": "^2.0",
    "phpunit/phpunit": "^10.0",
    "laravel/pint": "^1.0"
  }
}

Frontend: SvelteKit

Project Structure:

frontend/
├── src/
│   ├── lib/
│   │   ├── components/
│   │   │   ├── AllocationTable.svelte
│   │   │   ├── CapacityCalendar.svelte
│   │   │   ├── ForecastChart.svelte
│   │   │   └── ...
│   │   ├── stores/
│   │   │   ├── auth.ts
│   │   │   ├── filters.ts
│   │   │   └── ui.ts
│   │   ├── api/
│   │   │   ├── client.ts
│   │   │   ├── allocations.ts
│   │   │   ├── projects.ts
│   │   │   └── ...
│   │   └── schemas/
│   │       ├── allocation.ts (Zod schemas)
│   │       ├── project.ts
│   │       └── ...
│   ├── routes/
│   │   ├── +layout.svelte
│   │   ├── +page.svelte (dashboard)
│   │   ├── login/
│   │   ├── capacity/
│   │   ├── projects/
│   │   ├── allocations/
│   │   ├── actuals/
│   │   └── reports/
│   └── app.html
├── static/
├── tests/
│   ├── unit/
│   └── e2e/
└── package.json

Key Packages:

{
  "dependencies": {
    "@sveltejs/kit": "^2.0.0",
    "svelte": "^5.0.0",
    "tailwindcss": "^3.4.0",
    "daisyui": "^4.0.0",
    "recharts": "^2.10.0",
    "@tanstack/svelte-table": "^8.0.0",
    "sveltekit-superforms": "^2.0.0",
    "zod": "^3.22.0"
  },
  "devDependencies": {
    "vitest": "^1.0.0",
    "@playwright/test": "^1.40.0",
    "prettier": "^3.1.0",
    "eslint": "^8.55.0"
  }
}

Quality Standards

Testing Strategy

graph TB
    Code[Code Change] --> UnitTests[Unit Tests]
    UnitTests --> FeatureTests[Feature Tests]
    FeatureTests --> E2ETests[E2E Tests]
    
    UnitTests -->|PHPUnit/Vitest| UnitPass{Pass?}
    FeatureTests -->|PHPUnit/Pest| FeaturePass{Pass?}
    E2ETests -->|Playwright| E2EPass{Pass?}
    
    UnitPass -->|No| FailBuild[❌ Build Failed]
    FeaturePass -->|No| FailBuild
    E2EPass -->|No| FailBuild
    
    E2EPass -->|Yes| Coverage[Check Coverage]
    Coverage -->|< 70%| FailBuild
    Coverage -->|>= 70%| CodeReview[Code Review]
    
    CodeReview --> StyleCheck{Style OK?}
    StyleCheck -->|No| FailBuild
    StyleCheck -->|Yes| SecurityCheck{Security OK?}
    SecurityCheck -->|No| FailBuild
    SecurityCheck -->|Yes| Merge[✅ Merge]
    
    style FailBuild fill:#ff0000,color:#fff
    style Merge fill:#00ff00,color:#000

Test Coverage Targets

Layer Target Tools
Backend Unit >80% PHPUnit
Backend Feature >70% Pest
Frontend Unit >70% Vitest
E2E Critical paths Playwright
Overall >70% Combined

Code Quality Tools

Backend (Laravel):

  • Linting: Laravel Pint (PSR-12)
  • Static Analysis: PHPStan (level 5+)
  • Security: Laravel Security Checker

Frontend (SvelteKit):

  • Linting: ESLint + Svelte plugin
  • Formatting: Prettier
  • Type Checking: TypeScript strict mode

Pre-Commit Hooks

# .git/hooks/pre-commit

#!/bin/bash

echo "Running pre-commit checks..."

# Backend checks
cd backend
./vendor/bin/pint --test || exit 1
./vendor/bin/phpstan analyse || exit 1
php artisan test || exit 1

# Frontend checks
cd ../frontend
npm run lint || exit 1
npm run type-check || exit 1
npm run test:unit || exit 1

echo "✅ All checks passed!"

Development Workflow

OpenSpec Integration

graph TD
    Idea[New Feature/Fix] --> Explore[/opsx-explore<br/>Think through problem]
    
    Explore --> NewChange[/opsx-new headroom-feature<br/>Create change]
    
    NewChange --> Proposal[Write Proposal<br/>Why + What]
    Proposal --> Specs[Write Specs<br/>Requirements + Scenarios]
    Specs --> Design[Write Design<br/>How + Decisions]
    Design --> Tasks[Write Tasks<br/>Implementation checklist]
    
    Tasks --> Apply[/opsx-apply<br/>Implement tasks]
    
    Apply --> WriteTests[Write Tests<br/>Unit + E2E]
    WriteTests --> RunTests[Run Tests]
    RunTests --> TestPass{Pass?}
    TestPass -->|No| FixCode[Fix Code]
    FixCode --> RunTests
    
    TestPass -->|Yes| Verify[/opsx-verify<br/>Check coverage + standards]
    Verify --> VerifyPass{Pass?}
    VerifyPass -->|No| FixCode
    
    VerifyPass -->|Yes| Commit[Commit Changes<br/>Granular commits]
    Commit --> Archive[/opsx-archive<br/>Archive change]
    
    Archive --> Done[✅ Feature Complete]
    
    style Explore fill:#ffd700
    style Apply fill:#00bfff
    style Verify fill:#ff8c00
    style Done fill:#00ff00

Branching Strategy

Branch Types:

  • main - Production-ready code
  • develop - Integration branch (not needed for solo dev initially)
  • feature/opsx-<change-name> - Feature branches (one per OpenSpec change)
  • hotfix/description - Emergency fixes

Workflow:

# Start new change
git checkout -b feature/opsx-capacity-planning

# Implement (from /opsx-apply)
# ... make changes, commit granularly ...

# Before merge
/opsx-verify capacity-planning

# Merge
git checkout main
git merge feature/opsx-capacity-planning
git push origin main

Commit Message Format

[Type] Brief description (50 chars max)

Detailed explanation (optional, 72 char wrap)

Refs: openspec/changes/<change-name>

Types:

  • feat: New feature
  • fix: Bug fix
  • refactor: Code restructuring
  • test: Add/update tests
  • docs: Documentation only
  • chore: Tooling, config, etc.

Examples:

[feat] Add allocation validation rules

Implemented over/under allocation detection with RED flag indicators.
Validation checks approved estimate vs allocated hours per project.

Refs: openspec/changes/allocation-validation

Appendix: Data Flow Examples

Capacity Planning Flow

sequenceDiagram
    participant Manager
    participant UI as SvelteKit UI
    participant API as Laravel API
    participant DB as PostgreSQL
    participant Cache as Redis
    
    Manager->>UI: Navigate to Capacity Planning
    UI->>API: GET /api/capacity?month=2026-02
    API->>Cache: Check cache
    Cache-->>API: Cache miss
    
    API->>DB: Query team members
    API->>DB: Query holidays
    API->>DB: Query PTOs
    API->>API: Calculate capacity
    
    API->>Cache: Store result (TTL: 1h)
    API-->>UI: Return capacity data
    UI->>UI: Render calendar + summary
    
    Manager->>UI: Mark PTO for Team Member A (2026-02-10 to 2026-02-12)
    UI->>API: POST /api/pto
    API->>DB: Insert PTO record
    API->>Cache: Invalidate capacity:month:2026-02
    API-->>UI: Success
    
    UI->>API: GET /api/capacity?month=2026-02
    API->>DB: Recalculate (cache invalidated)
    API-->>UI: Updated capacity
    UI->>UI: Re-render with reduced capacity

Allocation Flow with Validation

sequenceDiagram
    participant Manager
    participant UI as SvelteKit UI
    participant API as Laravel API
    participant DB as PostgreSQL
    
    Manager->>UI: Open allocation matrix (Feb 2026)
    UI->>API: GET /api/allocations?month=2026-02
    API->>DB: Query allocations
    API-->>UI: Return allocations
    
    Manager->>UI: Allocate Dev A: 50h to Project X
    UI->>UI: Superforms validation (Zod)
    UI->>API: POST /api/allocations
    
    API->>API: Validate request (Laravel)
    API->>DB: Check project approved estimate
    DB-->>API: Approved: 100h, Currently allocated: 55h
    
    API->>API: Calculate: 55 + 50 = 105h > 100h
    API->>API: Over-allocation detected!
    API-->>UI: 422 Unprocessable Entity<br/>{error: "Over-allocated by 5h"}
    
    UI->>UI: Show RED flag indicator
    UI->>Manager: "⚠️ Project X will be over-allocated by 5h"
    
    Manager->>UI: Reduce allocation to 45h
    UI->>API: POST /api/allocations (45h)
    API->>API: Validate: 55 + 45 = 100h ✅
    API->>DB: Insert allocation
    API->>Cache: Invalidate related caches
    API-->>UI: Success
    
    UI->>UI: Show GREEN indicator (100% allocated)

Frontend Layout Architecture

Added: February 18, 2026
Status: Approved for Implementation

Layout System Overview

The Headroom frontend uses a sidebar + content layout pattern optimized for data-dense resource planning workflows.

┌─────────────────────────────────────────────────────────────────────────────┐
│                         HEADROOM LAYOUT ARCHITECTURE                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌──────────┬──────────────────────────────────────────────────────────┐   │
│  │          │  ┌────────────────────────────────────────────────────┐  │   │
│  │          │  │ 🔍 Search...    [Feb 2026 ▼]  [+ Add]  👤 User ▼  │  │   │
│  │          │  ├────────────────────────────────────────────────────┤  │   │
│  │  SIDEBAR │  │  Breadcrumbs: Dashboard > Overview                 │  │   │
│  │  240px   │  ├────────────────────────────────────────────────────┤  │   │
│  │          │  │                                                    │  │   │
│  │  ◀ ▶     │  │                                                    │  │   │
│  │          │  │                                                    │  │   │
│  │  ─────── │  │         MAIN CONTENT AREA                          │  │   │
│  │  PLANNING│  │                                                    │  │   │
│  │  ─────── │  │    (Tables, Grids, Charts, Forms)                  │  │   │
│  │  📊 Dash │  │                                                    │  │   │
│  │  👥 Team │  │    Full width, minimal padding                     │  │   │
│  │  📁 Projs│  │                                                    │  │   │
│  │  📅 Alloc│  │                                                    │  │   │
│  │  ✅ Actu │  │                                                    │  │   │
│  │          │  │                                                    │  │   │
│  │  ─────── │  │                                                    │  │   │
│  │  REPORTS │  │                                                    │  │   │
│  │  ─────── │  │                                                    │  │   │
│  │  📈 Forecast  │                                                    │  │   │
│  │  📉 Util     │                                                    │  │   │
│  │  💰 Costs    │                                                    │  │   │
│  │  📋 Variance │                                                    │  │   │
│  │          │  │                                                    │  │   │
│  │  ─────── │  │                                                    │  │   │
│  │  ADMIN*  │  │                                                    │  │   │
│  │  ─────── │  │                                                    │  │   │
│  │  ⚙️ Set  │  │                                                    │  │   │
│  │          │  │                                                    │  │   │
│  │  ─────── │  │                                                    │  │   │
│  │  🌙/☀️   │  │                                                    │  │   │
│  └──────────┴──────────────────────────────────────────────────────────┘   │
│                                                                             │
│  * Admin section visible only to superuser                                 │
└─────────────────────────────────────────────────────────────────────────────┘

Component Hierarchy

graph TB
    subgraph "Route Layer"
        Layout["+layout.svelte"]
    end
    
    subgraph "Layout Layer"
        AppLayout["AppLayout.svelte"]
        Sidebar["Sidebar.svelte"]
        TopBar["TopBar.svelte"]
        Breadcrumbs["Breadcrumbs.svelte"]
    end
    
    subgraph "State Layer"
        LayoutStore["layout.ts store"]
        PeriodStore["period.ts store"]
        AuthStore["auth.ts store"]
    end
    
    subgraph "Page Layer"
        PageContent["<slot /> Page Content"]
        PageHeader["PageHeader.svelte"]
    end
    
    Layout --> AppLayout
    AppLayout --> Sidebar
    AppLayout --> TopBar
    AppLayout --> Breadcrumbs
    AppLayout --> PageContent
    
    Sidebar --> LayoutStore
    Sidebar --> AuthStore
    TopBar --> PeriodStore
    TopBar --> AuthStore
    
    PageContent --> PageHeader
    
    style AppLayout fill:#ff3e00
    style LayoutStore fill:#336791
    style PeriodStore fill:#336791

Sidebar Component

File: src/lib/components/layout/Sidebar.svelte

States:

  • expanded (240px) — Full navigation with labels
  • collapsed (64px) — Icons only
  • hidden (0px) — Completely hidden (mobile drawer)

Features:

  • Toggle button in header
  • Section headers: PLANNING, REPORTS, ADMIN
  • Role-based visibility (ADMIN section for superuser only)
  • Active route highlighting
  • Dark mode toggle at bottom
  • Keyboard shortcut: Cmd/Ctrl + \ to toggle

Responsive Behavior:

Breakpoint Default State Toggle Method
≥1280px expanded Manual
1024-1279px collapsed Manual
<1024px hidden Hamburger menu (drawer overlay)

TopBar Component

File: src/lib/components/layout/TopBar.svelte

Elements:

  • Left: Hamburger menu (mobile only)
  • Center: Breadcrumbs
  • Right: Month selector, User menu

Global Month Selector:

// src/lib/stores/period.ts
import { writable } from 'svelte/store';

export const selectedMonth = writable<string>(getCurrentMonth());

function getCurrentMonth(): string {
  const now = new Date();
  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
}

export function setMonth(month: string): void {
  selectedMonth.set(month);
}

Layout Store

File: src/lib/stores/layout.ts

// src/lib/stores/layout.ts
import { writable } from 'svelte/store';
import { browser } from '$app/environment';

export type SidebarState = 'expanded' | 'collapsed' | 'hidden';
export type Theme = 'light' | 'dark';

function createLayoutStore() {
  const defaultSidebar: SidebarState = browser && window.innerWidth >= 1280 
    ? 'expanded' 
    : browser && window.innerWidth >= 1024 
      ? 'collapsed' 
      : 'hidden';
  
  const storedSidebar = browser 
    ? (localStorage.getItem('headroom_sidebar_state') as SidebarState) || defaultSidebar 
    : defaultSidebar;
  
  const storedTheme = browser 
    ? (localStorage.getItem('headroom_theme') as Theme) || 'light' 
    : 'light';
  
  const sidebarState = writable<SidebarState>(storedSidebar);
  const theme = writable<Theme>(storedTheme);
  
  return {
    sidebarState: { subscribe: sidebarState.subscribe },
    theme: { subscribe: theme.subscribe },
    toggleSidebar: () => {
      sidebarState.update(current => {
        const next = current === 'expanded' ? 'collapsed' : 
                     current === 'collapsed' ? 'hidden' : 'expanded';
        if (browser) localStorage.setItem('headroom_sidebar_state', next);
        return next;
      });
    },
    setTheme: (newTheme: Theme) => {
      theme.set(newTheme);
      if (browser) localStorage.setItem('headroom_theme', newTheme);
    }
  };
}

export const layoutStore = createLayoutStore();

Navigation Configuration

File: src/lib/config/navigation.ts

// src/lib/config/navigation.ts
import type { NavSection } from '$lib/types/layout';

export const navigationSections: NavSection[] = [
  {
    title: 'PLANNING',
    items: [
      { label: 'Dashboard', href: '/dashboard', icon: 'layout-dashboard' },
      { label: 'Team Members', href: '/team-members', icon: 'users' },
      { label: 'Projects', href: '/projects', icon: 'folder' },
      { label: 'Allocations', href: '/allocations', icon: 'calendar' },
      { label: 'Actuals', href: '/actuals', icon: 'check-circle' },
    ]
  },
  {
    title: 'REPORTS',
    items: [
      { label: 'Forecast', href: '/reports/forecast', icon: 'trending-up' },
      { label: 'Utilization', href: '/reports/utilization', icon: 'bar-chart-2' },
      { label: 'Costs', href: '/reports/costs', icon: 'dollar-sign' },
      { label: 'Variance', href: '/reports/variance', icon: 'alert-circle' },
      { label: 'Allocation Matrix', href: '/reports/allocation', icon: 'grid-3x3' },
    ]
  },
  {
    title: 'ADMIN',
    roles: ['superuser'],
    items: [
      { label: 'Settings', href: '/settings', icon: 'settings' },
      { label: 'Master Data', href: '/master-data', icon: 'database' },
    ]
  },
];

Theme System

Implementation:

  • DaisyUI theme switching via data-theme attribute on <html> element
  • Light theme: light (DaisyUI default)
  • Dark theme: dark or business
  • Persisted to localStorage
  • Respects system preference on first visit
// Theme toggle effect
$effect(() => {
  if (browser) {
    document.documentElement.setAttribute('data-theme', $theme);
  }
});

Route Layout Integration

File: src/routes/+layout.svelte

<script lang="ts">
  import AppLayout from '$lib/components/layout/AppLayout.svelte';
  import { page } from '$app/stores';
  
  // Pages without AppLayout
  const publicPages = ['/login', '/auth'];
  $: isPublicPage = publicPages.some(p => $page.url.pathname.startsWith(p));
</script>

{#if isPublicPage}
  <slot />
{:else}
  <AppLayout>
    <slot />
  </AppLayout>
{/if}

Data Density Patterns

Table Configurations (DaisyUI):

View Classes Purpose
Allocation Matrix table table-compact table-pin-rows table-pin-cols Max density, pinned header/first column
Projects List table table-zebra table-pin-rows Readability, pinned header
Team Members table table-zebra Standard readability
Reports table table-compact table-pin-rows Dense data, pinned header

Accessibility Requirements

  1. Keyboard Navigation:

    • Tab through sidebar items
    • Enter/Space to activate
    • Escape to close mobile drawer
    • Cmd/Ctrl + \ to toggle sidebar
  2. ARIA Attributes:

    • aria-expanded on sidebar toggle
    • aria-current="page" on active nav item
    • role="navigation" on sidebar
    • role="main" on content area
  3. Focus Management:

    • Focus trap in mobile drawer
    • Focus restored on drawer close

Document Control:

  • Owner: Santhosh J
  • Approver: Santhosh J
  • Next Review: Post-MVP implementation
  • Change History:
    • v1.0 (2026-02-17): Initial architecture approved
    • v1.1 (2026-02-18): Added Frontend Layout Architecture section

End of Architecture Document