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

1474 lines
39 KiB
Markdown

# Headroom - Architecture & Design
**Version:** 1.0
**Date:** February 17, 2026
**Status:** Approved for Implementation
---
## Table of Contents
1. [System Architecture](#system-architecture)
2. [Data Model](#data-model)
3. [API Architecture](#api-architecture)
4. [Authentication Flow](#authentication-flow)
5. [Deployment Architecture](#deployment-architecture)
6. [Technology Stack Details](#technology-stack-details)
7. [Quality Standards](#quality-standards)
8. [Development Workflow](#development-workflow)
---
## System Architecture
### High-Level Component Diagram
```mermaid
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
```mermaid
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
```mermaid
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
```mermaid
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:**
```json
// 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:**
```php
// 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:**
```json
{
"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
```mermaid
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
```mermaid
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):**
```json
{
"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
```mermaid
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
```mermaid
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
```yaml
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):**
```ini
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):**
```ini
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:**
```json
{
"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:**
```json
{
"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
```mermaid
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
```bash
# .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
```mermaid
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:**
```bash
# 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
```mermaid
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
```mermaid
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
```mermaid
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:**
```typescript
// 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`
```typescript
// 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`
```typescript
// 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
```typescript
// Theme toggle effect
$effect(() => {
if (browser) {
document.documentElement.setAttribute('data-theme', $theme);
}
});
```
### Route Layout Integration
**File:** `src/routes/+layout.svelte`
```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*