# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** ✅ 6/6 ADRs confirmed
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design
- [x] **Codex SDD Review completed** ✅ 2026-01-08
- [x] PRD Feature 5 (Accuracy Logging) fully designed
- [x] PRD edge cases codified in Query Contracts
- [x] Queue depth filters explicitly specified
- [x] Time slot expansion rule (30-49 transactions) implemented
- [x] Security considerations documented

---

## Constraints

**CON-1 Performance**: Wait time calculation must complete in < 100ms (per PRD). Redis cache lookups must complete in < 10ms, with graceful fallback to static factors if cache unavailable.

**CON-2 Language/Framework**: PHP 8.x, Slim 2.6.2, existing PSR-4 autoloading structure under `userfrosting/src/BuyerKiosk/`. Must follow existing patterns for repositories, services, and domain models.

**CON-3 Backward Compatibility**: Stores can opt-out of new features via feature flags. Existing `waitTimeFactor` and `minutesPerContainer` settings must remain functional. No breaking changes to existing `EstimatedWaitTime` class interface.

**CON-4 Data Requirements**: Minimum 50 transactions in rolling 30-day window for dynamic factors per time slot (PRD). Graceful degradation to static factors when data thresholds not met.

**CON-5 Database**: Multi-store architecture with central `kiosk_users` DB and store-specific databases (e.g., `kiosk_ou00`). New tables go in store databases. Use camelCase for column/table names.

**CON-6 Infrastructure**: Existing Redis cache (`Predis\Client`), TaskEngine for background jobs, MySQL (primary), existing migration system via `conductor run`.

## Implementation Context

### Required Context Sources

```yaml
# Internal Documentation
- doc: docs/specs/027-wait-time-quick-wins/product-requirements.md
  relevance: CRITICAL
  why: "Source of truth for all requirements and acceptance criteria"

- doc: docs/analysis/wait-time-prediction-analysis.md
  relevance: HIGH
  why: "Analysis that identified the four quick wins and MAPE calculations"

- doc: CLAUDE.md
  relevance: HIGH
  why: "Project conventions, commands, and architecture patterns"

# Core Source Files
- file: userfrosting/src/BuyerKiosk/Core/EstimatedWaitTime.php
  relevance: CRITICAL
  sections: [lines 14-70 - current calculation, lines 72-87 - extension methods]
  why: "Primary class to extend with new factors"

- file: userfrosting/src/BuyerKiosk/Analytics/Services/WaitTimeService.php
  relevance: HIGH
  sections: [lines 85-127 - getHeatmap(), lines 30 - cache TTL]
  why: "Existing heatmap aggregation and caching patterns"

- file: userfrosting/src/BuyerKiosk/Analytics/Repositories/WaitTimeRepository.php
  relevance: HIGH
  sections: [lines 94-112 - historical wait time queries]
  why: "SQL patterns for wait time data extraction"

- file: userfrosting/src/BuyerKiosk/Core/Store.php
  relevance: HIGH
  sections: [lines 1155-1338 - wait time config getters/setters]
  why: "Store configuration fields and access patterns"

# Supporting Infrastructure
- file: userfrosting/src/BuyerKiosk/TaskEngine/Domain/Job/BaseJob.php
  relevance: MEDIUM
  why: "Pattern for creating background jobs (calibration job)"

- file: userfrosting/src/BuyerKiosk/Scheduling/AiScheduling/Services/AiSuggestionCacheService.php
  relevance: MEDIUM
  why: "Redis caching pattern with TTL and key naming"

- file: userfrosting/src/BuyerKiosk/Workbook/WhenIWorkSchedule.php
  relevance: MEDIUM
  sections: [lines 61-86 - on-duty employee fetching]
  why: "Pattern for accessing on-duty employees"

- file: userfrosting/src/BuyerKiosk/Employee/Employee.php
  relevance: MEDIUM
  why: "Employee model with efficiency-related fields"
```

### Implementation Boundaries

- **Must Preserve**:
  - `EstimatedWaitTime` class public interface (backward compatibility)
  - Static `waitTimeFactor` and `minutesPerContainer` behavior for stores opting out
  - Existing `WaitTimeService` analytics methods
  - Store settings UI functionality

- **Can Modify**:
  - Internal calculation logic in `EstimatedWaitTime`
  - Add new methods to `Store` model for feature flags
  - Add new repository methods for factor calculations
  - Add new caching service for dynamic factors

- **Must Not Touch**:
  - Mobile API endpoints (already deployed to production apps)
  - `buyQueue` table schema (critical production table)
  - `statsStoreDaily` aggregation logic
  - WhenIWork sync process

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph "BuyerKiosk System"
        EWT[EstimatedWaitTime]
        WFS[WaitTimeFactorService]
        Cache[(Redis Cache)]
        StoreDB[(Store Database)]
        CentralDB[(Central Database)]
    end

    Customer[Customer Check-in] --> EWT
    EWT --> WFS
    WFS --> Cache
    WFS --> StoreDB

    TaskEngine[TaskEngine Scheduler] --> CalibrationJob[Calibration Job]
    CalibrationJob --> StoreDB
    CalibrationJob --> CentralDB

    WIW[WhenIWork API] --> ScheduleProvider
    ScheduleProvider --> EWT
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Customer Check-in Flow"
    type: PHP method call
    format: Internal
    data_flow: "Request for wait time estimate"
    entry_point: EstimatedWaitTime::calculate()

  - name: "Mobile API - Wait Time"
    type: HTTPS
    format: REST JSON
    authentication: Bearer token
    endpoint: GET /api/mobile/:typeNum/queue/wait-time
    data_flow: "Mobile app requests current wait time"

# Outbound Interfaces
outbound:
  - name: "WhenIWork API"
    type: HTTPS
    format: REST JSON
    authentication: API Key
    data_flow: "On-duty employee shifts"
    criticality: LOW (has fallback)

  - name: "Redis Cache"
    type: TCP
    format: Predis client
    data_flow: "Factor caching and retrieval"
    criticality: MEDIUM (fallback to static factors)

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: PDO via dbConnectByName()
    tables: [buyQueue, employees, waitTimeFactors (new)]
    data_flow: "Historical transaction data, employee efficiency"

  - name: "Central Database"
    type: MySQL
    connection: PDO via $app->usersDb
    tables: [stores, users, userStoreAssignments]
    data_flow: "Store configuration, employee assignments"
```

### Project Commands

```bash
# Development Setup
cd userfrosting && composer install

# Testing
./test.sh                              # Run all tests
./test.sh --testsuite unit             # Unit tests only
./test.sh tests/Unit/WaitTime/         # Run wait time tests specifically

# Static Analysis
cd userfrosting && ./vendor/bin/phpstan analyse src/BuyerKiosk/WaitTime/

# Database Migrations
php userfrosting/conductor run         # Run pending migrations

# TaskEngine (for calibration job testing)
php userfrosting/bin/task job:list                           # List all jobs
php userfrosting/bin/task job:dispatch wait-time-calibration --store=ou00  # Test calibration
php userfrosting/bin/task scheduler:run                      # Run scheduler manually

# Cache Operations (for testing)
redis-cli KEYS "waittime:*"            # View cached factors
redis-cli DEL "waittime:ou00:*"        # Clear store cache
```

## Solution Strategy

### Architecture Pattern

**Decorator/Strategy Pattern** extending existing `EstimatedWaitTime` class. New factors are injected via a `WaitTimeFactorService` that encapsulates all dynamic factor calculations.

**Rationale**:
- Preserves backward compatibility (existing interface unchanged)
- Allows feature flag control per factor type
- Single responsibility - each factor is a separate, testable unit
- Cache strategy is centralized in the service

### Integration Approach

The solution integrates via **method enhancement** rather than class replacement:

1. `EstimatedWaitTime` delegates to `WaitTimeFactorService` for factor calculations
2. Service checks feature flags and data thresholds
3. Falls back gracefully to static factors
4. Results are cached in Redis with 24-hour TTL

### Key Decisions

| Decision | Choice | Rationale |
|----------|--------|-----------|
| Factor calculation location | New `WaitTimeFactorService` | Separation of concerns, testability |
| Caching layer | Redis with fallback | Performance (< 10ms reads), graceful degradation |
| Cache refresh | Nightly at 2am store local time | Minimize impact on peak hours |
| Factor combination | Multiplicative | PRD requirement, matches business logic |
| Employee efficiency source | WhenIWork → Scheduled → All Active → 1.0 | PRD-defined precedence order |

## Building Block View

### Components

```mermaid
graph LR
    subgraph "Calculation Layer"
        EWT[EstimatedWaitTime]
        WTFS[WaitTimeFactorService]
    end

    subgraph "Factor Calculators"
        DFC[DynamicFactorCalculator]
        QDC[QueueDepthCalculator]
        EEC[EmployeeEfficiencyCalculator]
    end

    subgraph "Data Layer"
        WTR[WaitTimeFactorRepository]
        EC[EmployeeCache]
        RC[(Redis)]
        DB[(MySQL)]
    end

    subgraph "Background Jobs"
        CJ[CalibrationJob]
        CRJ[CacheRefreshJob]
    end

    EWT --> WTFS
    WTFS --> DFC
    WTFS --> QDC
    WTFS --> EEC

    DFC --> WTR
    QDC --> WTR
    EEC --> EC

    WTR --> RC
    WTR --> DB
    EC --> RC
    EC --> DB

    CJ --> DB
    CRJ --> WTR
```

### Directory Map

```
userfrosting/src/BuyerKiosk/
├── Core/
│   ├── EstimatedWaitTime.php              # MODIFY: Integrate WaitTimeFactorService
│   └── Store.php                          # MODIFY: Add feature flag getters
│
├── WaitTime/                              # NEW: Wait time prediction module
│   ├── Services/
│   │   ├── WaitTimeFactorService.php      # NEW: Main orchestration service
│   │   ├── DynamicFactorCalculator.php    # NEW: Hour/day factor calculation
│   │   ├── QueueDepthCalculator.php       # NEW: Queue congestion multiplier
│   │   └── EmployeeEfficiencyCalculator.php # NEW: Staff efficiency factor
│   │
│   ├── Repositories/
│   │   └── WaitTimeFactorRepository.php   # NEW: Factor data access and caching
│   │
│   ├── Cache/
│   │   └── WaitTimeFactorCache.php        # NEW: Redis caching service
│   │
│   ├── Models/
│   │   ├── FactorResult.php               # NEW: Factor calculation result DTO
│   │   ├── WaitTimePrediction.php         # NEW: Full prediction with breakdown
│   │   └── CalibrationResult.php          # NEW: Calibration job result
│   │
│   └── Jobs/
│       ├── WaitTimeCacheRefreshJob.php    # NEW: Nightly factor cache refresh
│       ├── MinutesPerContainerCalibrationJob.php  # NEW: Weekly auto-calibration
│       ├── ActualWaitTimeBackfillJob.php  # NEW: Hourly backfill of actual wait times
│       └── DailyAccuracyAggregationJob.php # NEW: Daily MAPE/accuracy calculation
│
├── TaskEngine/Jobs/
│   └── (register new jobs in JobDefinitions)
│
userfrosting/migrations/input/
├── 20260108_020_wait_time_factors.json    # NEW: Dynamic factor cache table
├── 20260108_021_wait_time_predictions.json # NEW: Prediction logging table (with actuals)
├── 20260108_022_stores_wait_time_flags.json # NEW: Feature flag columns
└── 20260108_023_wait_time_accuracy_daily.json # NEW: Daily accuracy aggregation table
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Store Database Tables

Table: waitTimeFactorCache (NEW)
  factorId: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  dayOfWeek: TINYINT NOT NULL (0=Sunday, 6=Saturday)
  hourSlot: TINYINT NOT NULL (0-23)
  dynamicFactor: DECIMAL(4,3) NOT NULL (0.500-2.000)
  sampleSize: INT NOT NULL (transaction count)
  avgWaitMinutes: DECIMAL(6,2) NOT NULL
  calculatedAt: DATETIME NOT NULL
  expiresAt: DATETIME NOT NULL
  INDEX: idx_day_hour (dayOfWeek, hourSlot)

Table: waitTimePredictions (NEW)
  predictionId: BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  buyId: INT UNSIGNED NOT NULL
  predictedMinutes: DECIMAL(6,2) NOT NULL
  actualMinutes: DECIMAL(6,2) NULL            # Populated when transaction completes
  dynamicFactor: DECIMAL(4,3)
  queueMultiplier: DECIMAL(4,3)
  efficiencyFactor: DECIMAL(4,3)
  queueDepth: INT
  onDutyEmployeeCount: INT
  usedFallback: TINYINT(1) DEFAULT 0          # 1 if any factor used fallback
  createdAt: DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  actualRecordedAt: DATETIME NULL             # When actual was captured
  INDEX: idx_buy (buyId)
  INDEX: idx_created (createdAt)
  INDEX: idx_actual_pending (actualMinutes, createdAt)  # For backfill job

Table: waitTimeAccuracyDaily (NEW - PRD Feature 5)
  accuracyId: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  date: DATE NOT NULL
  mape: DECIMAL(5,2) NOT NULL                  # Mean Absolute Percentage Error
  accuracy10MinPct: DECIMAL(5,2) NOT NULL      # % within ±10 minutes
  accuracy15MinPct: DECIMAL(5,2) NOT NULL      # % within ±15 minutes
  sampleSize: INT NOT NULL                     # Transactions with actuals
  avgPredicted: DECIMAL(6,2) NOT NULL
  avgActual: DECIMAL(6,2) NOT NULL
  avgError: DECIMAL(6,2) NOT NULL              # Avg (predicted - actual)
  outlierCount: INT DEFAULT 0                  # Transactions capped at 480 min
  calculatedAt: DATETIME NOT NULL
  UNIQUE INDEX: idx_date (date)

Table: mpcCalibrationLog (NEW)
  logId: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY
  oldValue: DECIMAL(5,2) NOT NULL
  newValue: DECIMAL(5,2) NOT NULL
  percentChange: DECIMAL(5,2) NOT NULL
  transactionCount: INT NOT NULL
  calculatedAvg: DECIMAL(5,2) NOT NULL
  calibratedAt: DATETIME NOT NULL
  jobExecutionId: INT UNSIGNED

# Central Database (kiosk_users.stores)
Table: stores (MODIFY)
  ADD COLUMN: dynamicWaitFactorEnabled TINYINT(1) DEFAULT 1
  ADD COLUMN: autoCalibrationEnabled TINYINT(1) DEFAULT 1
  ADD COLUMN: efficiencyWeightingEnabled TINYINT(1) DEFAULT 1
  ADD COLUMN: waitTimeFactorsCachedAt DATETIME DEFAULT NULL
```

#### Internal API Changes

```yaml
# No external API changes - all internal PHP

Service: WaitTimeFactorService
  Methods:
    - calculateFactors(Store $store): FactorResult
    - getDynamicFactor(Store $store, DateTime $time): float
    - getQueueMultiplier(Store $store): float
    - getEfficiencyFactor(Store $store): float
    - refreshCache(Store $store): void

Service: WaitTimeFactorCache
  Methods:
    - get(string $typeNum, int $dayOfWeek, int $hour): ?float
    - set(string $typeNum, int $dayOfWeek, int $hour, float $factor, int $ttl): void
    - warmAll(string $typeNum): void
    - invalidate(string $typeNum): void

Repository: WaitTimeFactorRepository
  Methods:
    - getHistoricalAverageBySlot(string $typeNum, int $dayOfWeek, int $hour, int $days): ?float
    - getHistoricalAverageByExpandedSlot(string $typeNum, int $dayOfWeek, int $hour, int $days): ?float  # PRD: ±1 hour expansion for 30-49 transactions
    - getOverallAverage(string $typeNum, int $days): float
    - getSlotTransactionCount(string $typeNum, int $dayOfWeek, int $hour, int $days): int
    - getCurrentQueueDepth(string $typeNum): int  # PRD filters applied (see Queue Depth Query Contract below)
    - getAverageContainerTime(string $typeNum, int $days): float

Repository: WaitTimeAccuracyRepository (NEW - PRD Feature 5)
  Methods:
    - calculateActualWaitTime(int $buyId): ?float  # Returns null for abandoned/canceled
    - backfillActualWaitTimes(string $typeNum, DateTime $since): int  # Returns count updated
    - aggregateDailyAccuracy(string $typeNum, DateTime $date): WaitTimeAccuracyDaily
    - getAccuracyHistory(string $typeNum, int $days): array
    - pruneOldRecords(string $typeNum, int $retentionMonths = 12): int  # PRD: 12-month retention
```

#### Query Contracts (PRD Compliance)

**Queue Depth Query Contract** (PRD Definition):
```sql
-- getCurrentQueueDepth() must implement these exact filters:
SELECT COUNT(*) FROM buyQueue
WHERE isProcessed = 0                    -- Not yet completed
  AND (remote = 0 OR hasDroppedOff = 1)  -- In-store OR dropped off
  AND status NOT IN ('canceled', 'abandoned', 'on_hold')  -- Active only
```

**Actual Wait Time Calculation Contract** (PRD Edge Cases):
```php
/**
 * Calculate actual wait time with PRD-defined edge case handling.
 *
 * @return float|null Minutes waited, or null if excluded from calculations
 */
public function calculateActualWaitTime(array $transaction): ?float
{
    // EXCLUDE: Abandoned transactions
    if ($transaction['status'] === 'abandoned') {
        return null;
    }

    // EXCLUDE: Canceled transactions
    if ($transaction['status'] === 'canceled') {
        return null;
    }

    // EXCLUDE: Both timestamps invalid
    $sortStarted = $transaction['sortStarted'];
    $timeStarted = $transaction['timeStarted'];
    $timeEntered = $transaction['timeEntered'];

    if (!$this->isValidTimestamp($sortStarted) && !$this->isValidTimestamp($timeStarted)) {
        $this->logDataQualityIssue($transaction['id'], 'both_timestamps_invalid');
        return null;
    }

    // Determine end timestamp: sortStarted preferred, timeStarted fallback
    // PRD: "If sortStarted is 0000-00-00 00:00:00, fall back to timeStarted"
    $endTimestamp = $this->isValidTimestamp($sortStarted) ? $sortStarted : $timeStarted;

    // Calculate wait time in minutes
    $waitMinutes = (strtotime($endTimestamp) - strtotime($timeEntered)) / 60;

    // PRD: "Actual wait > 480 minutes (8 hours): Cap at 480 minutes; flag as outlier"
    if ($waitMinutes > 480) {
        $this->logOutlier($transaction['id'], $waitMinutes);
        return 480.0;
    }

    // Negative wait times are data errors - exclude
    if ($waitMinutes < 0) {
        $this->logDataQualityIssue($transaction['id'], 'negative_wait_time');
        return null;
    }

    return round($waitMinutes, 2);
}

private function isValidTimestamp(?string $timestamp): bool
{
    return $timestamp !== null
        && $timestamp !== '0000-00-00 00:00:00'
        && strtotime($timestamp) > 0;
}
```

**Note on Paused Transactions**: Per PRD, "Include full elapsed time; pauses are part of customer's perceived wait." No special handling needed - the elapsed time naturally includes pause duration.

#### Application Data Models

```pseudocode
ENTITY: FactorResult (NEW)
  FIELDS:
    dynamicFactor: float (0.5-2.0)
    queueMultiplier: float (1.0-2.0)
    efficiencyFactor: float (0.5-2.0)
    combinedFactor: float (product of all factors)
    breakdown: array (detailed calculation info)

  BEHAVIORS:
    getCombinedFactor(): float  # dynamicFactor * queueMultiplier * efficiencyFactor
    getBreakdown(): array       # Detailed info for logging/debugging
    isUsingFallbacks(): bool    # True if any factor defaulted

ENTITY: WaitTimePrediction (NEW)
  FIELDS:
    buyId: int
    predictedMinutes: float
    lowerBound: float
    upperBound: float
    factors: FactorResult
    timestamp: DateTime

  BEHAVIORS:
    toArray(): array            # For logging
    getRange(): string          # "25-35 minutes" format

ENTITY: Store (MODIFIED)
  FIELDS:
    + dynamicWaitFactorEnabled: bool (NEW)
    + autoCalibrationEnabled: bool (NEW)
    + efficiencyWeightingEnabled: bool (NEW)
    + waitTimeFactorsCachedAt: DateTime (NEW)

  BEHAVIORS:
    + isDynamicWaitFactorEnabled(): bool (NEW)
    + isAutoCalibrationEnabled(): bool (NEW)
    + isEfficiencyWeightingEnabled(): bool (NEW)
    ~ getWaitTimeFactor(): float (MODIFIED - respects dynamic factor)
```

#### Integration Points

```yaml
# Internal Integration
- from: EstimatedWaitTime
  to: WaitTimeFactorService
  protocol: PHP method call
  data_flow: "Request factors for current store/time"

- from: WaitTimeFactorService
  to: WaitTimeFactorCache
  protocol: PHP method call
  data_flow: "Fetch cached factors"

- from: WaitTimeFactorCache
  to: Redis
  protocol: Predis client
  key_format: "waittime:{typeNum}:factors:{dayOfWeek}:{hour}"
  data_flow: "Cache reads/writes"

- from: WaitTimeFactorService
  to: WhenIWorkSchedule
  protocol: PHP method call
  data_flow: "Get on-duty employees for efficiency"

# TaskEngine Jobs
- from: TaskEngine Scheduler
  to: WaitTimeCacheRefreshJob
  protocol: Queue message
  schedule: "Daily at 2am store local time"
  data_flow: "Trigger cache refresh per store"

- from: TaskEngine Scheduler
  to: MinutesPerContainerCalibrationJob
  protocol: Queue message
  schedule: "Weekly on Sunday at 3am store local time"
  data_flow: "Trigger auto-calibration per store"

- from: TaskEngine Scheduler
  to: ActualWaitTimeBackfillJob
  protocol: Queue message
  schedule: "Hourly"
  data_flow: "Backfill actual wait times for completed transactions (PRD Feature 5)"
  performance_budget: "< 30 seconds per store"

- from: TaskEngine Scheduler
  to: DailyAccuracyAggregationJob
  protocol: Queue message
  schedule: "Daily at 4am store local time"
  data_flow: "Calculate MAPE and accuracy metrics for previous day (PRD Feature 5)"
  performance_budget: "< 60 seconds per store"

- from: TaskEngine Scheduler
  to: AccuracyDataRetentionJob
  protocol: Queue message
  schedule: "Monthly on 1st at 5am"
  data_flow: "Prune prediction and accuracy data older than 12 months (PRD retention requirement)"
```

### Implementation Examples

#### Example: Dynamic Factor Calculation

**Why this example**: Demonstrates the core business logic for time-slot based factor calculation with threshold handling.

```php
// DynamicFactorCalculator.php
class DynamicFactorCalculator
{
    private const MIN_TRANSACTIONS = 50;
    private const EXPANSION_THRESHOLD = 30;  // PRD: Try ±1 hour expansion when 30-49 transactions
    private const DATA_WINDOW_DAYS = 30;
    private const FACTOR_MIN = 0.5;
    private const FACTOR_MAX = 2.0;

    public function calculate(Store $store, DateTime $time): float
    {
        // Check feature flag
        if (!$store->isDynamicWaitFactorEnabled()) {
            return $store->getWaitTimeFactor(); // Static fallback
        }

        $dayOfWeek = (int) $time->format('w'); // 0=Sunday
        $hour = (int) $time->format('G');       // 0-23

        // Try cache first
        $cached = $this->cache->get($store->getTypeNum(), $dayOfWeek, $hour);
        if ($cached !== null) {
            return $cached;
        }

        // Calculate from historical data
        $slotAvg = $this->repository->getHistoricalAverageBySlot(
            $store->getTypeNum(),
            $dayOfWeek,
            $hour,
            self::DATA_WINDOW_DAYS
        );

        $transactionCount = $this->repository->getSlotTransactionCount(
            $store->getTypeNum(),
            $dayOfWeek,
            $hour,
            self::DATA_WINDOW_DAYS
        );

        // Check threshold with PRD-specified expansion rule
        if ($transactionCount < self::MIN_TRANSACTIONS) {
            // PRD: "30-49 transactions: Expand window to ±1 hour; if still < 50, use static"
            if ($transactionCount >= self::EXPANSION_THRESHOLD) {
                $expandedCount = $this->repository->getExpandedSlotTransactionCount(
                    $store->getTypeNum(),
                    $dayOfWeek,
                    $hour,
                    self::DATA_WINDOW_DAYS
                );

                if ($expandedCount >= self::MIN_TRANSACTIONS) {
                    // Use expanded window data
                    $slotAvg = $this->repository->getHistoricalAverageByExpandedSlot(
                        $store->getTypeNum(),
                        $dayOfWeek,
                        $hour,
                        self::DATA_WINDOW_DAYS
                    );
                    $transactionCount = $expandedCount;
                } else {
                    return $store->getWaitTimeFactor(); // Still insufficient
                }
            } else {
                return $store->getWaitTimeFactor(); // Below expansion threshold
            }
        }

        $overallAvg = $this->repository->getOverallAverage(
            $store->getTypeNum(),
            self::DATA_WINDOW_DAYS
        );

        if ($overallAvg <= 0) {
            return 1.0; // Prevent division by zero
        }

        // Calculate and bound factor
        $factor = $slotAvg / $overallAvg;
        $factor = max(self::FACTOR_MIN, min(self::FACTOR_MAX, $factor));

        return round($factor, 3);
    }
}
```

#### Example: Queue Depth Multiplier

**Why this example**: Shows the PRD-specified formula with proper bounds.

```php
// QueueDepthCalculator.php
class QueueDepthCalculator
{
    private const MULTIPLIER_MAX = 2.0;

    public function calculate(Store $store): float
    {
        $queueDepth = $this->repository->getCurrentQueueDepth($store->getTypeNum());

        // Formula from PRD: 1.0 + (queue_depth / 10) × 0.15
        // queue_depth = 0 → 1.0
        // queue_depth = 10 → 1.15
        // queue_depth = 30 → 1.45
        // queue_depth = 66+ → 2.0 (capped)

        $multiplier = 1.0 + ($queueDepth / 10) * 0.15;

        return min($multiplier, self::MULTIPLIER_MAX);
    }
}
```

#### Example: Employee Efficiency with Fallback Chain

**Why this example**: Documents the PRD-specified precedence order for efficiency data sources.

```php
// EmployeeEfficiencyCalculator.php
class EmployeeEfficiencyCalculator
{
    private const FACTOR_MIN = 0.5;
    private const FACTOR_MAX = 2.0;
    private const STALE_THRESHOLD_DAYS = 30;

    public function calculate(Store $store): float
    {
        if (!$store->isEfficiencyWeightingEnabled()) {
            return 1.0;
        }

        // PRD Fallback Precedence:
        // 1. WhenIWork on-duty list (if available and has >= 1 employee with metrics)
        // 2. Scheduled shift employees (if WhenIWork unavailable and >= 1 has metrics)
        // 3. All active employees' average (if no schedule data and >= 3 have metrics)
        // 4. Default to 1.0

        $employees = $this->getOnDutyEmployees($store);

        if (empty($employees)) {
            return 1.0;
        }

        $validMetrics = $this->filterFreshMetrics($employees);

        if (empty($validMetrics)) {
            return 1.0;
        }

        $avgEmployeeRate = array_sum($validMetrics) / count($validMetrics);
        $storeRate = $store->getMinutesPerContainer();

        if ($avgEmployeeRate <= 0) {
            return 1.0;
        }

        // Factor = store_rate / employee_avg_rate (per PRD)
        //
        // IMPORTANT: The factor is applied as a DIVISOR to the wait estimate, not multiplied.
        // This is because the efficiency factor adjusts HOW FAST work gets done:
        //
        // Fast employees (low avg, e.g., 4 min/container):
        //   factor = 6 / 4 = 1.5 → estimate REDUCED (divided by 1.5)
        //   Work gets done 1.5x faster, so wait is shorter
        //
        // Slow employees (high avg, e.g., 8 min/container):
        //   factor = 6 / 8 = 0.75 → estimate INCREASED (divided by 0.75 = ×1.33)
        //   Work gets done 0.75x speed, so wait is longer
        //
        // ALTERNATIVE INTERPRETATION (if factor is a multiplier):
        // The current PRD formula would produce counterintuitive results if multiplied.
        // To use multiplicatively, invert: factor = avgEmployeeRate / storeRate
        //
        // **CONFIRMED**: Using PRD formula as DIVISOR in final calculation.
        //
        $factor = $storeRate / $avgEmployeeRate;

        return max(self::FACTOR_MIN, min(self::FACTOR_MAX, round($factor, 3)));
    }

    private function getOnDutyEmployees(Store $store): array
    {
        // PRD Fallback Precedence (clarified):
        //
        // Step 1: Try WhenIWork integration
        // - "WhenIWork on-duty list (if available and has >= 1 employee with metrics)"
        // - "Available" means: store has WhenIWork enabled AND API returns data
        // - If WhenIWork is enabled but returns empty, this counts as "unavailable"
        //   and we proceed to step 2 (not skip to step 3)
        //
        if ($store->getWiwEnable()) {
            try {
                $wiwEmployees = $this->wiwSchedule->getOnDutyEmployees(new DateTime());
                if (!empty($wiwEmployees)) {
                    return $wiwEmployees;
                }
                // WhenIWork returned empty - treat as unavailable, fall through to step 2
            } catch (\Exception $e) {
                // WhenIWork API error - treat as unavailable, fall through to step 2
                $this->logger->warning('WhenIWork API unavailable for efficiency calculation', [
                    'store' => $store->getTypeNum(),
                    'error' => $e->getMessage()
                ]);
            }
        }

        // Step 2: Try scheduled shifts (internal schedule system)
        // - "Scheduled shift employees (if WhenIWork unavailable and >= 1 has metrics)"
        // - This is our fallback when WhenIWork is disabled, errors, or returns empty
        //
        $scheduled = $this->shiftRepository->getCurrentShiftEmployees($store->getTypeNum());
        if (!empty($scheduled)) {
            return $scheduled;
        }

        // Step 3: Fall back to all active employees
        // - "All active employees' average (if no schedule data and >= 3 have metrics)"
        // - Only use this if BOTH WhenIWork AND scheduled shifts are unavailable
        //
        $active = $this->employeeRepository->getActiveWithMetrics($store->getTypeNum());
        if (count($active) >= 3) {
            return $active;
        }

        // Step 4: Return empty (caller will use default factor of 1.0)
        return [];
    }
}
```

## Runtime View

### Primary Flow

#### Primary Flow: Customer Check-in Wait Time Estimation

1. Customer arrives at store with items to sell
2. Staff initiates check-in in buyQueue system
3. `EstimatedWaitTime::calculate()` is called
4. Service calculates combined factors
5. Final estimate displayed to customer as range (e.g., "25-35 minutes")

```mermaid
sequenceDiagram
    actor Staff
    participant CheckIn as Check-in UI
    participant EWT as EstimatedWaitTime
    participant WTFS as WaitTimeFactorService
    participant Cache as Redis Cache
    participant DB as Store Database

    Staff->>CheckIn: Create buy transaction
    CheckIn->>EWT: calculate(store, containers)

    EWT->>WTFS: calculateFactors(store, now)

    par Dynamic Factor
        WTFS->>Cache: get(typeNum, dayOfWeek, hour)
        alt Cache Hit
            Cache-->>WTFS: 1.23
        else Cache Miss
            WTFS->>DB: Query historical avg
            DB-->>WTFS: Historical data
            WTFS->>Cache: set(factor, TTL=24h)
        end
    and Queue Multiplier
        WTFS->>DB: getCurrentQueueDepth()
        DB-->>WTFS: 12
        Note over WTFS: 1.0 + (12/10) × 0.15 = 1.18
    and Efficiency Factor
        WTFS->>DB: getOnDutyEfficiency()
        DB-->>WTFS: Employee averages
    end

    WTFS-->>EWT: FactorResult(1.23, 1.18, 0.95)

    Note over EWT: Combined = 1.23 × 1.18 × 0.95 = 1.38
    Note over EWT: Base = 5 containers × 6 min = 30 min
    Note over EWT: Final = 30 × 1.38 = 41.4 → 45 min

    EWT-->>CheckIn: WaitTimePrediction(35-45 min)
    CheckIn->>DB: Log prediction with factors
    CheckIn-->>Staff: Display "35-45 minutes"
```

### Error Handling

| Error Type | Handling | User Impact |
|------------|----------|-------------|
| **Redis unavailable** | Fall back to static `waitTimeFactor` | None - estimate still provided |
| **Insufficient historical data** | Use static factor for affected slot | None - graceful degradation |
| **WhenIWork API failure** | Use scheduled shifts, then active employees | Slightly less accurate efficiency |
| **Database query timeout** | Return default factors (all 1.0) | Estimate may be less accurate |
| **Invalid calculation (NaN/Infinity)** | Clamp to bounds (0.5-2.0) and log warning | None - bounded estimate |

```pseudocode
ERROR_HANDLING: WaitTimeFactorService

TRY:
    factors = calculateAllFactors(store, time)
CATCH RedisException:
    LOG warning "Redis unavailable, using static factors"
    factors = getStaticFactors(store)
CATCH DatabaseException:
    LOG error "Database error in factor calculation"
    factors = getDefaultFactors()  # All 1.0
CATCH Exception:
    LOG error with stack trace
    factors = getDefaultFactors()
FINALLY:
    VALIDATE factors are within bounds
    RETURN factors
```

### Complex Logic: Factor Combination

```pseudocode
ALGORITHM: Calculate Combined Wait Time

INPUT: store, containerCount, timestamp
OUTPUT: WaitTimePrediction

1. GET base values:
   minutesPerContainer = store.getMinutesPerContainer()
   baseEstimate = containerCount × minutesPerContainer

2. CALCULATE individual factors:
   dynamicFactor = DynamicFactorCalculator.calculate(store, timestamp)
   queueMultiplier = QueueDepthCalculator.calculate(store)
   efficiencyFactor = EmployeeEfficiencyCalculator.calculate(store)

3. COMBINE factors (multiplicative per PRD):
   combinedFactor = dynamicFactor × queueMultiplier × efficiencyFactor

4. APPLY to base estimate:
   rawEstimate = baseEstimate × combinedFactor

5. ROUND to nearest 5 minutes:
   roundedEstimate = 5 × ceil(rawEstimate / 5)

6. CALCULATE bounds:
   lowerBound = max(0, roundedEstimate - intervalLower)
   upperBound = roundedEstimate + intervalUpper

7. LOG prediction:
   INSERT INTO waitTimePredictions (
     buyId, predictedMinutes, dynamicFactor,
     queueMultiplier, efficiencyFactor, queueDepth
   )

8. RETURN WaitTimePrediction(lowerBound, upperBound, factors)
```

## Deployment View

### Environment

- **Runtime**: PHP 8.x on Apache/nginx
- **Cache**: Redis (existing infrastructure)
- **Database**: MySQL 8.x (multi-store architecture)
- **Job Runner**: TaskEngine with cron-triggered scheduler

### Configuration

```yaml
# Feature flags (per-store in stores table)
dynamicWaitFactorEnabled: boolean (default: true)
autoCalibrationEnabled: boolean (default: true)
efficiencyWeightingEnabled: boolean (default: true)
mpcLocked: boolean (default: false)  # Prevents auto-calibration

# System constants (in code)
DATA_WINDOW_DAYS: 30
MIN_TRANSACTIONS_THRESHOLD: 50
MIN_CALIBRATION_TRANSACTIONS: 100
CALIBRATION_CHANGE_THRESHOLD: 0.15  # 15%
FACTOR_BOUNDS: [0.5, 2.0]
QUEUE_MULTIPLIER_MAX: 2.0
CACHE_TTL_SECONDS: 86400  # 24 hours
```

### Dependencies

| Dependency | Purpose | Fallback |
|------------|---------|----------|
| Redis | Factor caching | Static factors from store config |
| WhenIWork API | On-duty employees | Scheduled shifts → Active employees |
| TaskEngine | Background jobs | Manual cache refresh / calibration |
| MySQL | Historical data | Required (no fallback) |

### Performance

- **Target**: < 100ms for full calculation (per PRD)
- **Cache hit**: < 10ms (Redis read + PHP calculation)
- **Cache miss**: < 50ms (DB query + cache write + calculation)
- **Expected cache hit rate**: > 95% during business hours
- **Memory**: Minimal (factors are single floats)

### Rollout Strategy

1. **Phase 1**: Deploy code with all features disabled
2. **Phase 2**: Enable on pilot stores (2-3 stores)
3. **Phase 3**: Monitor MAPE improvement for 2 weeks
4. **Phase 4**: Enable for all stores with monitoring
5. **Rollback**: Disable via feature flags (no code deploy needed)

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: Service/Repository separation
  relevance: CRITICAL
  why: "All data access through repositories, business logic in services"

- pattern: Redis caching with TTL
  relevance: HIGH
  why: "Established pattern in AiSuggestionCacheService, VelocityService"

- pattern: TaskEngine background jobs
  relevance: HIGH
  why: "Established pattern for scheduled store-specific work"

- pattern: Feature flags via Store model
  relevance: MEDIUM
  why: "Existing pattern for per-store feature control"
```

### Security Considerations

**Authentication & Authorization**:
- Internal calculation services require no additional auth (called within authenticated contexts)
- Mobile API endpoint (`GET /api/mobile/:typeNum/queue/wait-time`) uses existing JWT auth
- All API endpoints must enforce `checkStoreGroup($typeNum)` for multi-tenancy
- TaskEngine jobs run with system-level permissions (no user context)

**Data Access Controls**:
- Store-specific data isolated via `dbConnectByName($store->getDbName())`
- Cross-store data never exposed through wait time APIs
- Employee efficiency data is internal only (never exposed to customers)

**Safe Defaults**:
- All factor calculations default to 1.0 on any error (fail-safe)
- Feature flags default to enabled but can be disabled per-store
- Auto-calibration changes are bounded (max 15% change per week)

**Input Validation**:
- `typeNum` validated against `[a-z][a-z]\d+` pattern before database access
- Factor bounds (0.5-2.0) enforced to prevent extreme estimates
- Negative or invalid timestamps are excluded (not calculated)

### System-Wide Patterns

- **Security**: Authentication inherited from request context; store isolation enforced
- **Error Handling**: Exception catching with graceful fallback
- **Performance**: Cache-first with DB fallback
- **Logging**: Prediction logging for accuracy tracking
- **Auditing**: Calibration changes logged with timestamp and values

### Implementation Patterns

#### Code Patterns

```php
// Follow existing naming conventions
class WaitTimeFactorService  // Service suffix
class WaitTimeFactorRepository  // Repository suffix
class FactorResult  // DTO without suffix

// Follow existing dependency injection
public function __construct(
    WaitTimeFactorRepository $repository,
    WaitTimeFactorCache $cache,
    Store $store
)

// Follow existing error handling
try {
    return $this->calculateDynamicFactor($store, $time);
} catch (\Exception $e) {
    $this->logger->warning('Factor calculation failed', ['error' => $e->getMessage()]);
    return $store->getWaitTimeFactor(); // Static fallback
}
```

#### Test Pattern

```php
// Unit test example following existing patterns
class DynamicFactorCalculatorTest extends TestCase
{
    use StoreMock, RedisMock;

    public function testCalculatesFactorWithSufficientData(): void
    {
        // Arrange
        $store = $this->createStoreMock(['dynamicWaitFactorEnabled' => true]);
        $repository = $this->createMock(WaitTimeFactorRepository::class);
        $repository->method('getSlotTransactionCount')->willReturn(75);
        $repository->method('getHistoricalAverageBySlot')->willReturn(25.0);
        $repository->method('getOverallAverage')->willReturn(20.0);

        $calculator = new DynamicFactorCalculator($repository, $cache, $store);

        // Act
        $factor = $calculator->calculate($store, new DateTime('2025-01-08 14:30:00'));

        // Assert
        $this->assertEquals(1.25, $factor); // 25/20 = 1.25
    }

    public function testFallsBackToStaticWhenInsufficientData(): void
    {
        // Arrange
        $store = $this->createStoreMock([
            'dynamicWaitFactorEnabled' => true,
            'waitTimeFactor' => 1.1
        ]);
        $repository = $this->createMock(WaitTimeFactorRepository::class);
        $repository->method('getSlotTransactionCount')->willReturn(30); // < 50 threshold

        $calculator = new DynamicFactorCalculator($repository, $cache, $store);

        // Act
        $factor = $calculator->calculate($store, new DateTime());

        // Assert
        $this->assertEquals(1.1, $factor); // Falls back to static
    }
}
```

## Architecture Decisions

### ADR-1: Separate Service Layer vs. Inline Calculation

- [x] **Decision**: Create dedicated `WaitTimeFactorService` rather than adding logic directly to `EstimatedWaitTime`
- **Rationale**:
  - Separation of concerns (calculation vs. orchestration)
  - Easier unit testing of individual factors
  - Allows independent evolution of factor logic
  - Clear dependency injection
- **Trade-offs**: Additional class indirection, slightly more code
- **User confirmed**: ✅ 2026-01-08

### ADR-2: Redis for Factor Caching (vs. Database-only)

- [x] **Decision**: Use Redis as primary cache with database as source of truth
- **Rationale**:
  - Sub-10ms reads for high-frequency calculation
  - Graceful degradation to static factors if Redis unavailable
  - Follows existing caching patterns in codebase
  - 24-hour TTL appropriate for daily patterns
- **Trade-offs**: Additional infrastructure dependency (but already required)
- **User confirmed**: ✅ 2026-01-08

### ADR-3: Multiplicative Factor Combination

- [x] **Decision**: Combine factors multiplicatively: `base × dynamic × queue × efficiency`
- **Rationale**:
  - PRD explicitly requires multiplicative combination
  - Matches intuitive understanding (each factor scales the estimate)
  - Individual factors maintain their bounded ranges
- **Trade-offs**: Factors compound (1.5 × 1.5 × 1.5 = 3.375 before capping)
- **User confirmed**: ✅ 2026-01-08 (per PRD requirement)

### ADR-4: Nightly Cache Refresh vs. Real-time Calculation

- [x] **Decision**: Pre-calculate dynamic factors nightly at 2am, cache for 24 hours
- **Rationale**:
  - 30-day rolling window means data changes slowly
  - Nightly refresh at low-traffic time minimizes impact
  - Cache hit rate > 95% improves performance
  - Reduces database load during business hours
- **Trade-offs**: Up to 24-hour lag in reflecting new patterns
- **User confirmed**: ✅ 2026-01-08

### ADR-5: Employee Efficiency Precedence Order

- [x] **Decision**: WhenIWork → Scheduled → All Active → Default (per PRD)
- **Rationale**:
  - WhenIWork provides real-time on-duty data (most accurate)
  - Scheduled shifts are second-best when WhenIWork unavailable
  - Active employees provide fallback when no schedule
  - Default 1.0 ensures always-functional estimate
- **Trade-offs**: Complexity in fallback chain
- **User confirmed**: ✅ 2026-01-08

### ADR-6: Efficiency Factor Application Method

- [x] **Decision**: Apply efficiency factor as DIVISOR (not multiplier) in final calculation
- **Rationale**:
  - PRD formula: `efficiencyFactor = store_minutesPerContainer / avg_employee_rate`
  - Factor > 1.0 when employees are faster than store baseline
  - To make estimates SHORTER for fast employees, factor must DIVIDE the estimate
  - Formula: `finalEstimate = (base × dynamic × queue) / efficiencyFactor`
  - This produces intuitive behavior:
    - Fast team (factor 1.5) → estimate reduced by 33%
    - Slow team (factor 0.75) → estimate increased by 33%
- **Trade-offs**: Differs from the multiplicative combination of other factors
- **User confirmed**: ✅ 2026-01-08 - Apply as DIVISOR confirmed

## Quality Requirements

| Quality | Requirement | Measurement |
|---------|-------------|-------------|
| **Performance** | < 100ms calculation time | Application logging, APM |
| **Reliability** | 99.9% successful calculations | Error rate monitoring |
| **Accuracy** | MAPE improvement from ~35% to ~25% | Daily accuracy aggregation |
| **Availability** | Graceful degradation on any component failure | Feature flag + fallback tests |
| **Testability** | > 80% code coverage for new code | PHPUnit coverage report |

## Risks and Technical Debt

### Known Technical Issues

- **Current MAPE ~35-40%**: Documented in analysis, this spec aims to improve it
- **Sparse data for some stores**: New stores may not meet 50-transaction threshold
- **Employee efficiency data staleness**: Some employees may have outdated `averagePerContainer`

### Technical Debt

- **Legacy `EstimatedWaitTime` class**: Could benefit from full refactor, but out of scope
- **Direct database queries in some areas**: Eventually should use repositories consistently

### Implementation Gotchas

- **Timezone handling**: All timestamps stored in UTC, but store local time used for hour/day slots. Use `DateTimeZone($store->getTimezone())` consistently.
- **dayOfWeek format**: PHP `format('w')` returns 0=Sunday (matches PRD), MySQL `DAYOFWEEK()` returns 1=Sunday. Repository must handle conversion.
- **`0000-00-00` sentinel value**: Many MySQL datetime fields use this as NULL. Always check: `!= '0000-00-00 00:00:00'`
- **Predis argument format**: Use positional args for SET with options: `$redis->set($key, $value, 'EX', $ttl)`

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Happy Path - All Factors Available**
```gherkin
Given: Store has dynamic factors enabled
And: 75 transactions exist for current time slot (> 50 threshold)
And: Queue has 10 active transactions
And: 2 employees are on-duty with efficiency metrics
When: Customer checks in with 5 containers
Then: Dynamic factor is calculated from historical data
And: Queue multiplier is 1.15 (10 transactions)
And: Efficiency factor reflects on-duty staff
And: Combined estimate is displayed as range
And: Prediction is logged with all factor values
```

**Scenario 2: Insufficient Data - Fallback to Static**
```gherkin
Given: Store has dynamic factors enabled
And: Only 30 transactions exist for current time slot (< 50 threshold)
When: Customer checks in
Then: Static waitTimeFactor is used instead of dynamic
And: Queue multiplier and efficiency are still calculated
And: Prediction is logged with fallback indicator
```

**Scenario 3: Redis Unavailable - Graceful Degradation**
```gherkin
Given: Redis cache is unavailable
When: Customer checks in
Then: System falls back to static factors
And: Warning is logged
And: Customer still receives wait estimate
And: No error is displayed to user
```

**Scenario 4: Auto-Calibration Execution**
```gherkin
Given: Store has autoCalibrationEnabled = true
And: mpcLocked = false
And: 150 transactions in last 30 days
And: Calculated average differs by 20% from current setting
When: Weekly calibration job runs
Then: minutesPerContainer is updated to new value
And: Change is logged with old/new values
And: Store manager is NOT notified (per "Won't Have")
```

**Scenario 5: Time Slot Expansion (30-49 Transactions)**
```gherkin
Given: Store has dynamic factors enabled
And: 35 transactions exist for current time slot (3pm-4pm)
And: 80 transactions exist for expanded window (2pm-5pm)
When: Dynamic factor is calculated
Then: System expands window to ±1 hour
And: Uses expanded data for factor calculation
And: Factor is calculated from 80 transactions
```

**Scenario 6: Actual Wait Time Edge Cases**
```gherkin
Given: Transaction has invalid sortStarted (0000-00-00 00:00:00)
And: Transaction has valid timeStarted
When: Actual wait time is calculated
Then: System falls back to timeStarted
And: Wait time is calculated correctly

Given: Transaction status is 'abandoned'
When: Actual wait time is calculated
Then: Transaction is excluded from calculations
And: Returns null

Given: Actual wait time would be 600 minutes
When: Actual wait time is calculated
Then: Wait time is capped at 480 minutes
And: Transaction is flagged as outlier
```

**Scenario 7: Queue Depth Calculation**
```gherkin
Given: Queue has 15 transactions total
And: 3 are remote (not dropped off)
And: 2 are canceled
And: 1 is abandoned
And: 1 is on hold
When: Queue depth is calculated
Then: Queue depth = 8 (15 - 3 remote - 2 canceled - 1 abandoned - 1 hold)
And: Multiplier = 1.0 + (8/10) × 0.15 = 1.12
```

**Scenario 8: Daily Accuracy Aggregation**
```gherkin
Given: 50 predictions exist for yesterday
And: 45 have actual wait times recorded
And: Average predicted was 30 minutes
And: Average actual was 35 minutes
When: Daily aggregation job runs
Then: MAPE is calculated as avg(abs(pred-actual)/actual × 100)
And: Accuracy within ±10 minutes percentage is calculated
And: Results are stored in waitTimeAccuracyDaily
And: Records older than 12 months are pruned
```

**Scenario 9: Employee Efficiency Fallback Chain**
```gherkin
Given: Store has WhenIWork enabled
And: WhenIWork API returns error
When: Efficiency factor is calculated
Then: System falls back to scheduled shifts
And: Logs warning about WhenIWork unavailability

Given: Store has no WhenIWork
And: No scheduled shifts exist
And: 5 active employees have metrics
When: Efficiency factor is calculated
Then: System uses all active employees (>= 3 required)
And: Factor is calculated from 5 employees' averages
```

### Test Coverage Requirements

| Area | Coverage Target | Test Types |
|------|-----------------|------------|
| DynamicFactorCalculator | 90% | Unit tests with mocked repository |
| QueueDepthCalculator | 90% | Unit tests with edge cases |
| EmployeeEfficiencyCalculator | 85% | Unit tests with fallback chain |
| WaitTimeFactorService | 85% | Unit + Integration tests |
| WaitTimeFactorCache | 80% | Unit tests with RedisMock |
| WaitTimeFactorRepository | 80% | Unit tests with PdoMockBuilder |
| CalibrationJob | 80% | Unit tests with mocked dependencies |
| CacheRefreshJob | 80% | Unit tests |
| **WaitTimeAccuracyRepository** | 85% | Unit tests with PRD edge cases |
| **ActualWaitTimeBackfillJob** | 85% | Unit tests for edge case handling |
| **DailyAccuracyAggregationJob** | 80% | Unit tests with sample data |
| **Time Slot Expansion Logic** | 90% | Unit tests for threshold handling |

**PRD Edge Case Test Matrix**:
| Edge Case | Test Required | Component |
|-----------|--------------|-----------|
| Abandoned transaction exclusion | ✓ | WaitTimeAccuracyRepository |
| Canceled transaction exclusion | ✓ | WaitTimeAccuracyRepository |
| Invalid sortStarted fallback | ✓ | calculateActualWaitTime() |
| Both timestamps invalid | ✓ | calculateActualWaitTime() |
| 480-minute outlier cap | ✓ | calculateActualWaitTime() |
| Queue depth filter precision | ✓ | getCurrentQueueDepth() |
| 30-49 transaction expansion | ✓ | DynamicFactorCalculator |
| WhenIWork → Scheduled fallback | ✓ | EmployeeEfficiencyCalculator |

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| MAPE | Mean Absolute Percentage Error | Primary accuracy metric: `avg(abs(predicted - actual) / actual)` |
| Dynamic Factor | Multiplier based on historical patterns | Replaces static waitTimeFactor when enabled |
| Queue Depth | Count of active, in-store transactions | Used for congestion multiplier |
| Time Slot | Combination of day-of-week (0-6) and hour (0-23) | Granularity for dynamic factors |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| typeNum | Store identifier pattern `[a-z][a-z]\d+` | e.g., `ou00`, `pa00` |
| buyQueue | Main transaction table in store database | Source of historical wait times |
| sortStarted | Timestamp when sorting phase began | Primary end-of-wait marker |
| minutesPerContainer | Base processing time per container | Store config, can be auto-calibrated |
| waitTimeFactor | Static multiplier for estimates | Fallback when dynamic factors unavailable |
