Browse Source

Merge pull request #18 from maziggy/0.1.5b

Changelog (v0.1.5b4 → v0.1.5b5)

New Features

    Mobile PWA Support - Progressive Web App support for mobile devices
    AMS Humidity/Temperature History - Clickable indicators open charts with 6h/24h/48h/7d history, min/max/avg statistics, and threshold reference lines
    Webhooks & API Keys - API key authentication with granular permissions for external integrations
    System Info Page - New page showing system information
    Multi-plate Cover Image - Archive cards now show cover image of the printed plate for multi-plate files
    Quick Notification Disable - Button to quickly disable notifications
    Projects / Print Grouping - Group related prints into projects with progress tracking
    Full-Text Search (FTS5) - Efficient search across print names, filenames, tags, notes, designer, and filament type
    Failure Analysis - Dashboard widget showing failure rate with correlations and trends
    Archive Comparison - Compare 2-5 archives side-by-side with highlighted differences
    CSV/Excel Export - Export archives and statistics with current filters

Improvements

    Improved archive card context menu with submenu support
    Improved notification scheduler and templates
    Improved auto power off scheduler
    Improved email notification provider
    Configurable AMS data retention (default 30 days)

Bug Fixes

    Fixed bug where not all AMS spools were synced to Spoolman
    Fixed bug where external links were not respected by hotkeys
    Fixed context menu submenu not showing
    Fixed project card thumbnails using correct API endpoint
    Fixed archive PATCH 500 error (FTS5 index rebuild)
    Fixed clipboard API fallback for HTTP contexts

Infrastructure

    Added comprehensive automated testing (pytest, vitest, playwright)
    GitHub Actions CI/CD workflow for automated testing
    Removed PWA push notifications
MartinNYHC 5 months ago
parent
commit
424d7ea6f3
100 changed files with 26328 additions and 66 deletions
  1. BIN
      .coverage
  2. 288 0
      .github/workflows/ci.yml
  3. 168 0
      CHANGELOG.md
  4. 63 4
      README.md
  5. BIN
      backend/.coverage
  6. 129 0
      backend/app/api/routes/ams_history.py
  7. 138 0
      backend/app/api/routes/api_keys.py
  8. 322 4
      backend/app/api/routes/archives.py
  9. 10 3
      backend/app/api/routes/notifications.py
  10. 428 0
      backend/app/api/routes/projects.py
  11. 2 2
      backend/app/api/routes/settings.py
  12. 200 0
      backend/app/api/routes/system.py
  13. 332 0
      backend/app/api/routes/webhook.py
  14. 114 0
      backend/app/core/auth.py
  15. 1 1
      backend/app/core/config.py
  16. 95 1
      backend/app/core/database.py
  17. 247 31
      backend/app/main.py
  18. 6 0
      backend/app/models/__init__.py
  19. 31 0
      backend/app/models/ams_history.py
  20. 29 0
      backend/app/models/api_key.py
  21. 5 0
      backend/app/models/archive.py
  22. 4 0
      backend/app/models/notification.py
  23. 12 0
      backend/app/models/notification_template.py
  24. 5 0
      backend/app/models/print_queue.py
  25. 4 0
      backend/app/models/printer.py
  26. 32 0
      backend/app/models/project.py
  27. 2 0
      backend/app/models/smart_plug.py
  28. 46 0
      backend/app/schemas/api_key.py
  29. 3 0
      backend/app/schemas/archive.py
  30. 10 0
      backend/app/schemas/notification.py
  31. 20 0
      backend/app/schemas/notification_template.py
  32. 85 0
      backend/app/schemas/project.py
  33. 6 0
      backend/app/schemas/settings.py
  34. 37 6
      backend/app/services/archive.py
  35. 278 0
      backend/app/services/archive_comparison.py
  36. 2 0
      backend/app/services/bambu_mqtt.py
  37. 335 0
      backend/app/services/export.py
  38. 198 0
      backend/app/services/failure_analysis.py
  39. 91 12
      backend/app/services/notification_service.py
  40. 93 0
      backend/app/services/smart_plug_manager.py
  41. 2 2
      backend/app/services/spoolman.py
  42. 125 0
      backend/app/services/telemetry.py
  43. 1 0
      backend/tests/__init__.py
  44. 380 0
      backend/tests/conftest.py
  45. 1 0
      backend/tests/integration/__init__.py
  46. 189 0
      backend/tests/integration/test_ams_history_api.py
  47. 294 0
      backend/tests/integration/test_archives_api.py
  48. 184 0
      backend/tests/integration/test_external_links_api.py
  49. 117 0
      backend/tests/integration/test_filaments_api.py
  50. 268 0
      backend/tests/integration/test_maintenance_api.py
  51. 445 0
      backend/tests/integration/test_notifications_api.py
  52. 284 0
      backend/tests/integration/test_printers_api.py
  53. 160 0
      backend/tests/integration/test_projects_api.py
  54. 215 0
      backend/tests/integration/test_settings_api.py
  55. 388 0
      backend/tests/integration/test_smart_plugs_api.py
  56. 14 0
      backend/tests/pytest.ini
  57. 1 0
      backend/tests/unit/__init__.py
  58. 1 0
      backend/tests/unit/services/__init__.py
  59. 215 0
      backend/tests/unit/services/test_archive_service.py
  60. 690 0
      backend/tests/unit/services/test_notification_service.py
  61. 778 0
      backend/tests/unit/services/test_printer_manager.py
  62. 603 0
      backend/tests/unit/services/test_smart_plug_manager.py
  63. 383 0
      backend/tests/unit/services/test_tasmota.py
  64. BIN
      docs/screenshots/api_keys.png
  65. BIN
      docs/screenshots/archives.png
  66. BIN
      docs/screenshots/maintenance_overdue.png
  67. BIN
      docs/screenshots/maintenance_settings.png
  68. BIN
      docs/screenshots/maintenance_status.png
  69. BIN
      docs/screenshots/notifications.png
  70. BIN
      docs/screenshots/printers.png
  71. BIN
      docs/screenshots/profiles_create.png
  72. BIN
      docs/screenshots/profiles_edit.png
  73. BIN
      docs/screenshots/profiles_k.png
  74. BIN
      docs/screenshots/projects.png
  75. BIN
      docs/screenshots/queue.png
  76. BIN
      docs/screenshots/settings.png
  77. BIN
      docs/screenshots/smart_plugs.png
  78. 224 0
      frontend/coverage/base.css
  79. 87 0
      frontend/coverage/block-navigation.js
  80. 0 0
      frontend/coverage/coverage-final.json
  81. BIN
      frontend/coverage/favicon.png
  82. 236 0
      frontend/coverage/index.html
  83. 1 0
      frontend/coverage/prettify.css
  84. 1 0
      frontend/coverage/prettify.js
  85. BIN
      frontend/coverage/sort-arrow-sprite.png
  86. 210 0
      frontend/coverage/sorter.js
  87. 274 0
      frontend/coverage/src/App.tsx.html
  88. 5875 0
      frontend/coverage/src/api/client.ts.html
  89. 116 0
      frontend/coverage/src/api/index.html
  90. 1198 0
      frontend/coverage/src/components/AMSHistoryModal.tsx.html
  91. 982 0
      frontend/coverage/src/components/AddExternalLinkModal.tsx.html
  92. 1696 0
      frontend/coverage/src/components/AddNotificationModal.tsx.html
  93. 1321 0
      frontend/coverage/src/components/AddSmartPlugModal.tsx.html
  94. 802 0
      frontend/coverage/src/components/AddToQueueModal.tsx.html
  95. 970 0
      frontend/coverage/src/components/BackupModal.tsx.html
  96. 778 0
      frontend/coverage/src/components/BatchTagModal.tsx.html
  97. 211 0
      frontend/coverage/src/components/Button.tsx.html
  98. 901 0
      frontend/coverage/src/components/CalendarView.tsx.html
  99. 181 0
      frontend/coverage/src/components/Card.tsx.html
  100. 655 0
      frontend/coverage/src/components/CompareArchivesModal.tsx.html

BIN
.coverage


+ 288 - 0
.github/workflows/ci.yml

@@ -0,0 +1,288 @@
+name: CI
+
+on:
+  push:
+    branches: [main, develop]
+  pull_request:
+    branches: [main, develop]
+
+env:
+  PYTHON_VERSION: '3.11'
+  NODE_VERSION: '20'
+
+jobs:
+  # ============================================================================
+  # Backend Jobs
+  # ============================================================================
+
+  backend-lint:
+    name: Backend Lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install ruff
+        run: pip install ruff
+
+      - name: Run ruff check
+        run: ruff check backend/
+
+  backend-unit-tests:
+    name: Backend Unit Tests
+    runs-on: ubuntu-latest
+    needs: backend-lint
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Cache pip
+        uses: actions/cache@v4
+        with:
+          path: ~/.cache/pip
+          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install pytest-cov
+
+      - name: Run unit tests
+        run: |
+          cd backend
+          python -m pytest tests/unit/ -v --cov=app --cov-report=xml -m "not slow"
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v4
+        if: always()
+        with:
+          files: backend/coverage.xml
+          flags: backend-unit
+          fail_ci_if_error: false
+
+  backend-integration-tests:
+    name: Backend Integration Tests
+    runs-on: ubuntu-latest
+    needs: backend-unit-tests
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Cache pip
+        uses: actions/cache@v4
+        with:
+          path: ~/.cache/pip
+          key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }}
+          restore-keys: |
+            ${{ runner.os }}-pip-
+
+      - name: Install dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install pytest-cov
+
+      - name: Run integration tests
+        run: |
+          cd backend
+          python -m pytest tests/integration/ -v --cov=app --cov-report=xml
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v4
+        if: always()
+        with:
+          files: backend/coverage.xml
+          flags: backend-integration
+          fail_ci_if_error: false
+
+  # ============================================================================
+  # Frontend Jobs
+  # ============================================================================
+
+  frontend-lint:
+    name: Frontend Lint
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        working-directory: frontend
+        run: npm ci
+
+      - name: Run ESLint
+        working-directory: frontend
+        run: npm run lint
+
+  frontend-type-check:
+    name: Frontend Type Check
+    runs-on: ubuntu-latest
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        working-directory: frontend
+        run: npm ci
+
+      - name: Run TypeScript check
+        working-directory: frontend
+        run: npx tsc --noEmit
+
+  frontend-unit-tests:
+    name: Frontend Unit Tests
+    runs-on: ubuntu-latest
+    needs: [frontend-lint, frontend-type-check]
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        working-directory: frontend
+        run: npm ci
+
+      - name: Run tests with coverage
+        working-directory: frontend
+        run: npm run test:coverage
+
+      - name: Upload coverage
+        uses: codecov/codecov-action@v4
+        if: always()
+        with:
+          files: frontend/coverage/coverage-final.json
+          flags: frontend-unit
+          fail_ci_if_error: false
+
+  frontend-build:
+    name: Frontend Build
+    runs-on: ubuntu-latest
+    needs: frontend-unit-tests
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Node.js
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ env.NODE_VERSION }}
+          cache: 'npm'
+          cache-dependency-path: frontend/package-lock.json
+
+      - name: Install dependencies
+        working-directory: frontend
+        run: npm ci
+
+      - name: Build
+        working-directory: frontend
+        run: npm run build
+
+      - name: Upload build artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: frontend-build
+          path: static/
+
+  # ============================================================================
+  # E2E Tests
+  # ============================================================================
+
+  e2e-tests:
+    name: E2E Tests
+    runs-on: ubuntu-latest
+    needs: [backend-integration-tests, frontend-build]
+    steps:
+      - uses: actions/checkout@v4
+
+      - name: Set up Python
+        uses: actions/setup-python@v5
+        with:
+          python-version: ${{ env.PYTHON_VERSION }}
+
+      - name: Install backend dependencies
+        run: |
+          python -m pip install --upgrade pip
+          pip install -r requirements.txt
+          pip install playwright
+          playwright install chromium --with-deps
+
+      - name: Download frontend build
+        uses: actions/download-artifact@v4
+        with:
+          name: frontend-build
+          path: static/
+
+      - name: Start backend server
+        run: |
+          python -m uvicorn backend.app.main:app --host 0.0.0.0 --port 8000 &
+          sleep 15
+        env:
+          DEBUG: 'false'
+
+      - name: Run E2E tests
+        run: |
+          python tests/e2e_comprehensive_test.py || true
+          python tests/e2e_toggle_persistence_test.py
+
+      - name: Upload test screenshots
+        uses: actions/upload-artifact@v4
+        if: always()
+        with:
+          name: e2e-screenshots
+          path: /tmp/bambuddy_*.png
+          if-no-files-found: ignore
+
+  # ============================================================================
+  # Summary Job
+  # ============================================================================
+
+  ci-summary:
+    name: CI Summary
+    runs-on: ubuntu-latest
+    needs: [backend-integration-tests, frontend-build, e2e-tests]
+    if: always()
+    steps:
+      - name: Check results
+        run: |
+          echo "Backend Integration Tests: ${{ needs.backend-integration-tests.result }}"
+          echo "Frontend Build: ${{ needs.frontend-build.result }}"
+          echo "E2E Tests: ${{ needs.e2e-tests.result }}"
+
+          if [[ "${{ needs.backend-integration-tests.result }}" == "failure" ]] || \
+             [[ "${{ needs.frontend-build.result }}" == "failure" ]]; then
+            echo "CI failed!"
+            exit 1
+          fi
+
+          echo "CI passed!"

+ 168 - 0
CHANGELOG.md

@@ -0,0 +1,168 @@
+# Changelog
+
+All notable changes to Bambuddy will be documented in this file.
+
+## [Unreleased]
+
+### Added
+- Mobile PWA (Progressive Web App) support with offline capabilities
+- Playwright end-to-end test suite for comprehensive application testing
+- Filament spool fill levels displayed on printer cards
+
+### Fixed
+- External links now properly receive keyboard shortcut assignments after reordering
+- External links open in main content area iframe instead of new browser tab
+- Spoolman sync now correctly handles all AMS trays (fixed black filament color bug)
+
+## [0.1.5b4] - 2025-12-10
+
+### Added
+- Docker support with containerized deployment
+- Comprehensive mobile support with responsive navigation
+  - Hamburger drawer navigation for mobile (< 768px)
+  - Touch gesture context menus with long press support
+  - WCAG-compliant touch targets (44px minimum)
+  - Safe area insets support for notched devices
+- External links can be embedded into sidebar navigation
+- External links included in backup/restore module
+- Filament spool fill levels on printer cards
+- Issue and pull request templates
+
+### Changed
+- Improved external link module with better icon layout
+- Documentation moved to separate repository
+
+### Fixed
+- Notification module now properly saves newly added notification types
+- External link icons layout improvements
+
+## [0.1.5b3] - 2025-12-09
+
+### Added
+- Comprehensive backup/restore module improvements
+
+### Fixed
+- Switched off printers no longer incorrectly show as active
+- os.path issue in update module
+
+## [0.1.5b2] - 2025-12-09
+
+### Added
+- User options to backup module
+
+### Changed
+- App renamed to "Bambuddy"
+
+### Fixed
+- HTTP 500 error in backup module
+
+## [0.1.5b] - 2025-12-08
+
+### Added
+- Smart plug monitoring and scheduling
+- Daily digest notifications
+- Notification template system
+- Maintenance interval type: calendar days
+- Cloud Profiles template visibility and preset diff view
+- AMS humidity/temperature indicators with configurable thresholds
+- Printer image on printer card
+- WiFi signal strength indicator on printer card
+- Power switch dropdown for offline printers
+- MQTT debug viewer with filter and search
+- Total printer hours display on printer card
+- AMS discovery module
+- Dual-nozzle AMS wiring visualization
+
+### Changed
+- Redesigned AMS section with BambuStudio-style device icons
+- Tabbed design and auto-save for settings page
+- Replaced camera settings with WiFi signal in top bar
+- Completely refactored K-profile module
+- Refactored maintenance settings
+
+### Fixed
+- HMS module bug
+- Camera buttons appearance in light theme
+
+### Removed
+- Control page (removed all related code)
+
+## [0.1.4] - 2025-12-01
+
+### Added
+- Multi-language support
+- Auto app update functionality
+- Maintenance module with notifications
+- Spoolman support for adding unknown Bambu Lab spools
+- Source 3MF file upload to archive cards
+
+### Fixed
+- K profiles retrieval from printer
+
+## [0.1.3] - 2025-11-30
+
+### Added
+- Push notification support (WhatsApp, ntfy, Pushover, Telegram, Email)
+- K profile management
+- Configurable logging with log levels
+- Sidebar item reordering
+- Default view settings
+- Option to track energy per print or in total
+- Timelapse viewer improvements
+
+### Fixed
+- WebSocket connection stability
+- Power stage updates not reflecting in frontend
+
+## [0.1.2-bugfix] - 2025-11-30
+
+### Fixed
+- WebSocket disconnection issues
+
+## [0.1.2-final] - 2025-11-29
+
+### Added
+- Print scheduling and queueing system
+- Power consumption cost calculation
+- HMS (Health Management System) error handling on printer cards
+- Camera snapshot on print completion
+- Power switch and automation controls on printer card
+- Timelapse video player with speed controls
+- Print time accuracy calculation
+- Duplicate detection and filtering
+
+### Changed
+- Unified printer card layout
+
+### Fixed
+- Auto poweroff feature improvements
+- Archive file handling on print start
+- Statistics display issues
+
+## [0.1.2] - 2025-11-28
+
+### Added
+- HMS health status monitoring
+- MQTT debug log window
+- Tasmota smart power plug support with automation
+- Project page viewer and editor
+- Button to show/hide disconnected printers
+- Favicons
+
+### Fixed
+- Collapsed sidebar layout
+
+## [0.1.1] - 2025-11-28
+
+### Added
+- Initial public release
+- Multi-printer support via MQTT
+- Real-time printer status monitoring
+- Print archives with history tracking
+- Statistics and analytics dashboard
+- Timelapse video support
+- Light and dark theme support
+
+---
+
+For more information, visit the [Bambuddy GitHub repository](https://github.com/maziggy/bambuddy).

+ 63 - 4
README.md

@@ -45,16 +45,18 @@
 ### 📦 Print Archive
 - Automatic 3MF archiving with metadata
 - 3D model preview (Three.js)
-- Duplicate detection
+- Duplicate detection & full-text search
 - Photo attachments & failure analysis
 - Re-print to any connected printer
+- Archive comparison (side-by-side diff)
 
 ### 📊 Monitoring & Stats
 - Real-time printer status via WebSocket
 - HMS error monitoring
 - Print success rates & trends
 - Filament usage tracking
-- Cost analytics
+- Cost analytics & failure analysis
+- CSV/Excel export
 
 ### ⏰ Scheduling & Automation
 - Print queue with drag-and-drop
@@ -63,6 +65,12 @@
 - Auto power-on before print
 - Auto power-off after cooldown
 
+### 📁 Projects
+- Group related prints (e.g., "Voron Build")
+- Track progress with target counts
+- Color-coded project badges
+- Assign archives via context menu
+
 </td>
 <td width="50%" valign="top">
 
@@ -78,6 +86,7 @@
 - Bambu Cloud profile management
 - K-profiles (pressure advance)
 - External sidebar links
+- Webhooks & API keys
 
 ### 🛠️ Maintenance
 - Maintenance scheduling & tracking
@@ -100,12 +109,17 @@
 
 <p align="center">
   <img src="docs/screenshots/printers.png" alt="Printers" width="800">
-  <br><em>Real-time printer monitoring</em>
+  <br><em>Real-time printer monitoring with AMS status</em>
 </p>
 
 <p align="center">
   <img src="docs/screenshots/archives.png" alt="Archives" width="800">
-  <br><em>Print archive with 3D preview</em>
+  <br><em>Print archive with context menu and project assignment</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/projects.png" alt="Projects" width="800">
+  <br><em>Group related prints into projects</em>
 </p>
 
 <p align="center">
@@ -118,11 +132,56 @@
   <br><em>Customizable statistics dashboard</em>
 </p>
 
+<p align="center">
+  <img src="docs/screenshots/maintenance_status.png" alt="Maintenance Status" width="800">
+  <br><em>Maintenance tracking per printer</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/maintenance_overdue.png" alt="Maintenance Overdue" width="800">
+  <br><em>Overdue maintenance alerts</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/maintenance_settings.png" alt="Maintenance Settings" width="800">
+  <br><em>Configure maintenance types and intervals</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/profiles_create.png" alt="Profiles Create" width="800">
+  <br><em>Create new filament presets</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/profiles_edit.png" alt="Profiles Edit" width="800">
+  <br><em>Edit filament preset settings</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/profiles_k.png" alt="K-Profiles" width="800">
+  <br><em>Pressure advance (K-factor) profiles</em>
+</p>
+
 <p align="center">
   <img src="docs/screenshots/settings.png" alt="Settings" width="800">
   <br><em>Configuration and integrations</em>
 </p>
 
+<p align="center">
+  <img src="docs/screenshots/smart_plugs.png" alt="Smart Plugs" width="800">
+  <br><em>Smart plug control and automation</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/notifications.png" alt="Notifications" width="800">
+  <br><em>Multi-provider notification system</em>
+</p>
+
+<p align="center">
+  <img src="docs/screenshots/api_keys.png" alt="API Keys" width="800">
+  <br><em>API keys and webhook endpoints</em>
+</p>
+
 </details>
 
 ---

BIN
backend/.coverage


+ 129 - 0
backend/app/api/routes/ams_history.py

@@ -0,0 +1,129 @@
+"""API routes for AMS sensor history."""
+
+from datetime import datetime, timedelta
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import select, func, and_
+from sqlalchemy.ext.asyncio import AsyncSession
+from pydantic import BaseModel
+
+from backend.app.core.database import get_db
+from backend.app.models.ams_history import AMSSensorHistory
+
+router = APIRouter(prefix="/ams-history", tags=["ams-history"])
+
+
+class AMSHistoryPoint(BaseModel):
+    recorded_at: datetime
+    humidity: float | None
+    humidity_raw: float | None
+    temperature: float | None
+
+
+class AMSHistoryResponse(BaseModel):
+    printer_id: int
+    ams_id: int
+    data: list[AMSHistoryPoint]
+    min_humidity: float | None
+    max_humidity: float | None
+    avg_humidity: float | None
+    min_temperature: float | None
+    max_temperature: float | None
+    avg_temperature: float | None
+
+
+@router.get("/{printer_id}/{ams_id}", response_model=AMSHistoryResponse)
+async def get_ams_history(
+    printer_id: int,
+    ams_id: int,
+    hours: int = Query(default=24, ge=1, le=168, description="Hours of history (1-168)"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get AMS sensor history for a specific printer and AMS unit."""
+    since = datetime.now() - timedelta(hours=hours)
+
+    # Get data points
+    result = await db.execute(
+        select(AMSSensorHistory)
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.ams_id == ams_id,
+                AMSSensorHistory.recorded_at >= since,
+            )
+        )
+        .order_by(AMSSensorHistory.recorded_at)
+    )
+    records = result.scalars().all()
+
+    # Calculate stats
+    stats_result = await db.execute(
+        select(
+            func.min(AMSSensorHistory.humidity).label("min_humidity"),
+            func.max(AMSSensorHistory.humidity).label("max_humidity"),
+            func.avg(AMSSensorHistory.humidity).label("avg_humidity"),
+            func.min(AMSSensorHistory.temperature).label("min_temp"),
+            func.max(AMSSensorHistory.temperature).label("max_temp"),
+            func.avg(AMSSensorHistory.temperature).label("avg_temp"),
+        )
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.ams_id == ams_id,
+                AMSSensorHistory.recorded_at >= since,
+            )
+        )
+    )
+    stats = stats_result.one()
+
+    return AMSHistoryResponse(
+        printer_id=printer_id,
+        ams_id=ams_id,
+        data=[
+            AMSHistoryPoint(
+                recorded_at=r.recorded_at,
+                humidity=r.humidity,
+                humidity_raw=r.humidity_raw,
+                temperature=r.temperature,
+            )
+            for r in records
+        ],
+        min_humidity=stats.min_humidity,
+        max_humidity=stats.max_humidity,
+        avg_humidity=round(stats.avg_humidity, 1) if stats.avg_humidity else None,
+        min_temperature=stats.min_temp,
+        max_temperature=stats.max_temp,
+        avg_temperature=round(stats.avg_temp, 1) if stats.avg_temp else None,
+    )
+
+
+@router.delete("/{printer_id}")
+async def delete_old_history(
+    printer_id: int,
+    days: int = Query(default=30, ge=1, le=365, description="Delete data older than X days"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete old AMS history data for a printer."""
+    cutoff = datetime.now() - timedelta(days=days)
+
+    result = await db.execute(
+        select(func.count(AMSSensorHistory.id))
+        .where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    count = result.scalar()
+
+    await db.execute(
+        AMSSensorHistory.__table__.delete().where(
+            and_(
+                AMSSensorHistory.printer_id == printer_id,
+                AMSSensorHistory.recorded_at < cutoff,
+            )
+        )
+    )
+    await db.commit()
+
+    return {"deleted": count, "message": f"Deleted {count} records older than {days} days"}

+ 138 - 0
backend/app/api/routes/api_keys.py

@@ -0,0 +1,138 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.core.auth import generate_api_key
+from backend.app.models.api_key import APIKey
+from backend.app.schemas.api_key import (
+    APIKeyCreate,
+    APIKeyUpdate,
+    APIKeyResponse,
+    APIKeyCreateResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/api-keys", tags=["api-keys"])
+
+
+@router.get("/", response_model=list[APIKeyResponse])
+async def list_api_keys(db: AsyncSession = Depends(get_db)):
+    """List all API keys (without full key values)."""
+    result = await db.execute(
+        select(APIKey).order_by(APIKey.created_at.desc())
+    )
+    return list(result.scalars().all())
+
+
+@router.post("/", response_model=APIKeyCreateResponse)
+async def create_api_key(
+    data: APIKeyCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new API key.
+
+    IMPORTANT: The full API key is only returned in this response.
+    Store it securely - it cannot be retrieved again.
+    """
+    # Generate the key
+    full_key, key_hash, key_prefix = generate_api_key()
+
+    api_key = APIKey(
+        name=data.name,
+        key_hash=key_hash,
+        key_prefix=key_prefix,
+        can_queue=data.can_queue,
+        can_control_printer=data.can_control_printer,
+        can_read_status=data.can_read_status,
+        printer_ids=data.printer_ids,
+        expires_at=data.expires_at,
+    )
+    db.add(api_key)
+    await db.flush()
+    await db.refresh(api_key)
+
+    # Return with full key (only time it's shown)
+    return APIKeyCreateResponse(
+        id=api_key.id,
+        name=api_key.name,
+        key_prefix=api_key.key_prefix,
+        key=full_key,  # Only returned on creation
+        can_queue=api_key.can_queue,
+        can_control_printer=api_key.can_control_printer,
+        can_read_status=api_key.can_read_status,
+        printer_ids=api_key.printer_ids,
+        enabled=api_key.enabled,
+        last_used=api_key.last_used,
+        created_at=api_key.created_at,
+        expires_at=api_key.expires_at,
+    )
+
+
+@router.get("/{key_id}", response_model=APIKeyResponse)
+async def get_api_key(
+    key_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get an API key by ID."""
+    result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+    api_key = result.scalar_one_or_none()
+
+    if not api_key:
+        raise HTTPException(status_code=404, detail="API key not found")
+
+    return api_key
+
+
+@router.patch("/{key_id}", response_model=APIKeyResponse)
+async def update_api_key(
+    key_id: int,
+    data: APIKeyUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update an API key."""
+    result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+    api_key = result.scalar_one_or_none()
+
+    if not api_key:
+        raise HTTPException(status_code=404, detail="API key not found")
+
+    # Update fields if provided
+    if data.name is not None:
+        api_key.name = data.name
+    if data.can_queue is not None:
+        api_key.can_queue = data.can_queue
+    if data.can_control_printer is not None:
+        api_key.can_control_printer = data.can_control_printer
+    if data.can_read_status is not None:
+        api_key.can_read_status = data.can_read_status
+    if data.printer_ids is not None:
+        api_key.printer_ids = data.printer_ids
+    if data.enabled is not None:
+        api_key.enabled = data.enabled
+    if data.expires_at is not None:
+        api_key.expires_at = data.expires_at
+
+    await db.flush()
+    await db.refresh(api_key)
+
+    return api_key
+
+
+@router.delete("/{key_id}")
+async def delete_api_key(
+    key_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete (revoke) an API key."""
+    result = await db.execute(select(APIKey).where(APIKey.id == key_id))
+    api_key = result.scalar_one_or_none()
+
+    if not api_key:
+        raise HTTPException(status_code=404, detail="API key not found")
+
+    await db.delete(api_key)
+
+    return {"message": "API key deleted"}

+ 322 - 4
backend/app/api/routes/archives.py

@@ -54,6 +54,8 @@ def archive_to_response(
     data = {
         "id": archive.id,
         "printer_id": archive.printer_id,
+        "project_id": archive.project_id,
+        "project_name": archive.project.name if archive.project else None,
         "filename": archive.filename,
         "file_path": archive.file_path,
         "file_size": archive.file_size,
@@ -98,6 +100,7 @@ def archive_to_response(
 @router.get("/", response_model=list[ArchiveResponse])
 async def list_archives(
     printer_id: int | None = None,
+    project_id: int | None = None,
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
@@ -106,6 +109,7 @@ async def list_archives(
     service = ArchiveService(db)
     archives = await service.list_archives(
         printer_id=printer_id,
+        project_id=project_id,
         limit=limit,
         offset=offset,
     )
@@ -121,6 +125,286 @@ async def list_archives(
     return result
 
 
+@router.get("/search", response_model=list[ArchiveResponse])
+async def search_archives(
+    q: str = Query(..., min_length=2, description="Search query"),
+    printer_id: int | None = None,
+    project_id: int | None = None,
+    status: str | None = None,
+    limit: int = 50,
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+):
+    """Full-text search across archives.
+
+    Searches print_name, filename, tags, notes, designer, and filament_type fields.
+    Supports partial matches with wildcards (e.g., 'vor*' matches 'voron').
+    """
+    from sqlalchemy import text
+    from sqlalchemy.orm import selectinload
+
+    # Prepare search query - add wildcard for partial matches
+    search_term = q.strip()
+    if not search_term.endswith('*'):
+        search_term = f"{search_term}*"
+
+    # Build the FTS query
+    # Using MATCH for FTS5 full-text search
+    fts_query = text("""
+        SELECT rowid FROM archive_fts
+        WHERE archive_fts MATCH :search_term
+        ORDER BY rank
+        LIMIT :limit OFFSET :offset
+    """)
+
+    try:
+        result = await db.execute(fts_query, {"search_term": search_term, "limit": limit + 100, "offset": 0})
+        matched_ids = [row[0] for row in result.fetchall()]
+    except Exception as e:
+        logger.warning(f"FTS search failed, falling back to LIKE search: {e}")
+        # Fallback to LIKE search if FTS fails
+        like_pattern = f"%{q}%"
+        query = (
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project))
+            .where(
+                (PrintArchive.print_name.ilike(like_pattern)) |
+                (PrintArchive.filename.ilike(like_pattern)) |
+                (PrintArchive.tags.ilike(like_pattern)) |
+                (PrintArchive.notes.ilike(like_pattern)) |
+                (PrintArchive.designer.ilike(like_pattern)) |
+                (PrintArchive.filament_type.ilike(like_pattern))
+            )
+            .order_by(PrintArchive.created_at.desc())
+        )
+
+        if printer_id:
+            query = query.where(PrintArchive.printer_id == printer_id)
+        if project_id:
+            query = query.where(PrintArchive.project_id == project_id)
+        if status:
+            query = query.where(PrintArchive.status == status)
+
+        query = query.limit(limit).offset(offset)
+        result = await db.execute(query)
+        archives = result.scalars().all()
+        return [archive_to_response(a) for a in archives]
+
+    if not matched_ids:
+        return []
+
+    # Fetch full archive records for matched IDs
+    query = (
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
+        .where(PrintArchive.id.in_(matched_ids))
+    )
+
+    # Apply additional filters
+    if printer_id:
+        query = query.where(PrintArchive.printer_id == printer_id)
+    if project_id:
+        query = query.where(PrintArchive.project_id == project_id)
+    if status:
+        query = query.where(PrintArchive.status == status)
+
+    result = await db.execute(query)
+    archives_dict = {a.id: a for a in result.scalars().all()}
+
+    # Preserve FTS ranking order and apply pagination
+    ordered_archives = [archives_dict[id] for id in matched_ids if id in archives_dict]
+    paginated = ordered_archives[offset:offset + limit]
+
+    return [archive_to_response(a) for a in paginated]
+
+
+@router.post("/search/rebuild-index")
+async def rebuild_search_index(db: AsyncSession = Depends(get_db)):
+    """Rebuild the full-text search index from existing archives.
+
+    Use this if search results seem incomplete or incorrect.
+    """
+    from sqlalchemy import text
+
+    try:
+        # Clear and rebuild the FTS index
+        await db.execute(text("DELETE FROM archive_fts"))
+
+        # Repopulate from print_archives
+        await db.execute(text("""
+            INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+            SELECT id, print_name, filename, tags, notes, designer, filament_type
+            FROM print_archives
+        """))
+
+        await db.commit()
+
+        # Count entries
+        result = await db.execute(text("SELECT COUNT(*) FROM archive_fts"))
+        count = result.scalar() or 0
+
+        return {"message": f"Search index rebuilt with {count} entries"}
+    except Exception as e:
+        logger.error(f"Failed to rebuild search index: {e}")
+        raise HTTPException(status_code=500, detail=f"Failed to rebuild index: {str(e)}")
+
+
+@router.get("/analysis/failures")
+async def analyze_failures(
+    days: int = 30,
+    printer_id: int | None = None,
+    project_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Analyze failure patterns across prints.
+
+    Returns failure statistics including:
+    - Overall failure rate
+    - Failures by reason, filament type, printer
+    - Time of day distribution
+    - Recent failures
+    - Weekly trend
+    """
+    from backend.app.services.failure_analysis import FailureAnalysisService
+
+    service = FailureAnalysisService(db)
+    return await service.analyze_failures(
+        days=days,
+        printer_id=printer_id,
+        project_id=project_id,
+    )
+
+
+@router.get("/compare")
+async def compare_archives(
+    archive_ids: str = Query(..., description="Comma-separated archive IDs (2-5)"),
+    db: AsyncSession = Depends(get_db),
+):
+    """Compare multiple archives side by side.
+
+    Compares print settings, filament usage, and print times.
+    Also analyzes correlation between settings and success/failure.
+
+    Args:
+        archive_ids: Comma-separated list of 2-5 archive IDs to compare
+    """
+    from backend.app.services.archive_comparison import ArchiveComparisonService
+
+    # Parse and validate archive IDs
+    try:
+        ids = [int(id.strip()) for id in archive_ids.split(",")]
+    except ValueError:
+        raise HTTPException(400, "Invalid archive IDs format")
+
+    if len(ids) < 2:
+        raise HTTPException(400, "At least 2 archives required for comparison")
+    if len(ids) > 5:
+        raise HTTPException(400, "Maximum 5 archives can be compared at once")
+
+    service = ArchiveComparisonService(db)
+    try:
+        return await service.compare_archives(ids)
+    except ValueError as e:
+        raise HTTPException(400, str(e))
+
+
+@router.get("/export")
+async def export_archives(
+    format: str = Query("csv", description="Export format: csv or xlsx"),
+    fields: str | None = Query(None, description="Comma-separated field names"),
+    printer_id: int | None = None,
+    project_id: int | None = None,
+    status: str | None = None,
+    date_from: str | None = Query(None, description="Start date (ISO format)"),
+    date_to: str | None = Query(None, description="End date (ISO format)"),
+    search: str | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Export archives to CSV or Excel format.
+
+    Returns a downloadable file with archive data.
+    """
+    from datetime import datetime
+    from fastapi.responses import StreamingResponse
+    from backend.app.services.export import ExportService
+
+    if format not in ("csv", "xlsx"):
+        raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
+
+    # Parse fields
+    field_list = None
+    if fields:
+        field_list = [f.strip() for f in fields.split(",")]
+
+    # Parse dates
+    date_from_dt = None
+    date_to_dt = None
+    if date_from:
+        try:
+            date_from_dt = datetime.fromisoformat(date_from)
+        except ValueError:
+            raise HTTPException(400, "Invalid date_from format")
+    if date_to:
+        try:
+            date_to_dt = datetime.fromisoformat(date_to)
+        except ValueError:
+            raise HTTPException(400, "Invalid date_to format")
+
+    service = ExportService(db)
+    try:
+        file_bytes, filename, content_type = await service.export_archives(
+            format=format,
+            fields=field_list,
+            printer_id=printer_id,
+            project_id=project_id,
+            status=status,
+            date_from=date_from_dt,
+            date_to=date_to_dt,
+            search=search,
+        )
+    except ImportError as e:
+        raise HTTPException(500, str(e))
+
+    return StreamingResponse(
+        io.BytesIO(file_bytes),
+        media_type=content_type,
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
+@router.get("/stats/export")
+async def export_stats(
+    format: str = Query("csv", description="Export format: csv or xlsx"),
+    days: int = 30,
+    printer_id: int | None = None,
+    project_id: int | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """Export statistics summary to CSV or Excel format."""
+    from fastapi.responses import StreamingResponse
+    from backend.app.services.export import ExportService
+
+    if format not in ("csv", "xlsx"):
+        raise HTTPException(400, "Format must be 'csv' or 'xlsx'")
+
+    service = ExportService(db)
+    try:
+        file_bytes, filename, content_type = await service.export_stats(
+            format=format,
+            days=days,
+            printer_id=printer_id,
+            project_id=project_id,
+        )
+    except ImportError as e:
+        raise HTTPException(500, str(e))
+
+    return StreamingResponse(
+        io.BytesIO(file_bytes),
+        media_type=content_type,
+        headers={"Content-Disposition": f'attachment; filename="{filename}"'},
+    )
+
+
 @router.get("/stats", response_model=ArchiveStats)
 async def get_archive_stats(db: AsyncSession = Depends(get_db)):
     """Get statistics across all archives."""
@@ -279,15 +563,41 @@ async def get_archive(archive_id: int, db: AsyncSession = Depends(get_db)):
     return archive_to_response(archive, duplicates)
 
 
+@router.get("/{archive_id}/similar")
+async def find_similar_archives(
+    archive_id: int,
+    limit: int = 10,
+    db: AsyncSession = Depends(get_db),
+):
+    """Find archives with similar settings for comparison.
+
+    Returns archives that match by:
+    - Same print name (highest priority)
+    - Same file content hash
+    - Same filament type
+    """
+    from backend.app.services.archive_comparison import ArchiveComparisonService
+
+    service = ArchiveComparisonService(db)
+    try:
+        return await service.find_similar_archives(archive_id, limit=limit)
+    except ValueError as e:
+        raise HTTPException(404, str(e))
+
+
 @router.patch("/{archive_id}", response_model=ArchiveResponse)
 async def update_archive(
     archive_id: int,
     update_data: ArchiveUpdate,
     db: AsyncSession = Depends(get_db),
 ):
-    """Update archive metadata (tags, notes, cost, is_favorite)."""
+    """Update archive metadata (tags, notes, cost, is_favorite, project_id)."""
+    from sqlalchemy.orm import selectinload
+
     result = await db.execute(
-        select(PrintArchive).where(PrintArchive.id == archive_id)
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
+        .where(PrintArchive.id == archive_id)
     )
     archive = result.scalar_one_or_none()
     if not archive:
@@ -297,8 +607,16 @@ async def update_archive(
         setattr(archive, field, value)
 
     await db.commit()
-    await db.refresh(archive)
-    return archive
+
+    # Re-fetch with project relationship loaded after commit
+    result = await db.execute(
+        select(PrintArchive)
+        .options(selectinload(PrintArchive.project))
+        .where(PrintArchive.id == archive_id)
+    )
+    archive = result.scalar_one_or_none()
+
+    return archive_to_response(archive)
 
 
 @router.post("/{archive_id}/favorite", response_model=ArchiveResponse)

+ 10 - 3
backend/app/api/routes/notifications.py

@@ -45,6 +45,9 @@ def _provider_to_dict(provider: NotificationProvider) -> dict:
         "on_printer_error": provider.on_printer_error,
         "on_filament_low": provider.on_filament_low,
         "on_maintenance_due": provider.on_maintenance_due,
+        # AMS environmental alarms
+        "on_ams_humidity_high": provider.on_ams_humidity_high,
+        "on_ams_temperature_high": provider.on_ams_temperature_high,
         # Quiet hours
         "quiet_hours_enabled": provider.quiet_hours_enabled,
         "quiet_hours_start": provider.quiet_hours_start,
@@ -101,6 +104,9 @@ async def create_notification_provider(
         on_printer_error=provider_data.on_printer_error,
         on_filament_low=provider_data.on_filament_low,
         on_maintenance_due=provider_data.on_maintenance_due,
+        # AMS environmental alarms
+        on_ams_humidity_high=provider_data.on_ams_humidity_high,
+        on_ams_temperature_high=provider_data.on_ams_temperature_high,
         # Quiet hours
         quiet_hours_enabled=provider_data.quiet_hours_enabled,
         quiet_hours_start=provider_data.quiet_hours_start,
@@ -128,10 +134,11 @@ async def create_notification_provider(
 @router.post("/test-config", response_model=NotificationTestResponse)
 async def test_notification_config(
     test_request: NotificationTestRequest,
+    db: AsyncSession = Depends(get_db),
 ):
     """Test notification configuration before saving."""
     success, message = await notification_service.send_test_notification(
-        test_request.provider_type.value, test_request.config
+        test_request.provider_type.value, test_request.config, db
     )
 
     return NotificationTestResponse(success=success, message=message)
@@ -155,7 +162,7 @@ async def test_all_notification_providers(db: AsyncSession = Depends(get_db)):
     for provider in providers:
         config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
         success, message = await notification_service.send_test_notification(
-            provider.provider_type, config
+            provider.provider_type, config, db
         )
 
         # Update provider status
@@ -409,7 +416,7 @@ async def test_notification_provider(
 
     config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
     success, message = await notification_service.send_test_notification(
-        provider.provider_type, config
+        provider.provider_type, config, db
     )
 
     # Update provider status

+ 428 - 0
backend/app/api/routes/projects.py

@@ -0,0 +1,428 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func
+
+from backend.app.core.database import get_db
+from backend.app.models.project import Project
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.schemas.project import (
+    ProjectCreate,
+    ProjectUpdate,
+    ProjectResponse,
+    ProjectListResponse,
+    ProjectStats,
+    BatchAddArchives,
+    BatchAddQueueItems,
+    ArchivePreview,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/projects", tags=["projects"])
+
+
+async def compute_project_stats(
+    db: AsyncSession, project_id: int, target_count: int | None = None
+) -> ProjectStats:
+    """Compute statistics for a project."""
+    # Count total archives
+    total_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(PrintArchive.project_id == project_id)
+    )
+    total_archives = total_result.scalar() or 0
+
+    # Count completed archives
+    completed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(
+            PrintArchive.project_id == project_id,
+            PrintArchive.status == "completed"
+        )
+    )
+    completed_prints = completed_result.scalar() or 0
+
+    # Count failed archives
+    failed_result = await db.execute(
+        select(func.count(PrintArchive.id)).where(
+            PrintArchive.project_id == project_id,
+            PrintArchive.status == "failed"
+        )
+    )
+    failed_prints = failed_result.scalar() or 0
+
+    # Sum print time and filament
+    sums_result = await db.execute(
+        select(
+            func.coalesce(func.sum(PrintArchive.print_time_seconds), 0).label("total_time"),
+            func.coalesce(func.sum(PrintArchive.filament_used_grams), 0).label("total_filament"),
+        ).where(PrintArchive.project_id == project_id)
+    )
+    sums = sums_result.first()
+
+    # Count queued items
+    queued_result = await db.execute(
+        select(func.count(PrintQueueItem.id)).where(
+            PrintQueueItem.project_id == project_id,
+            PrintQueueItem.status == "pending"
+        )
+    )
+    queued_prints = queued_result.scalar() or 0
+
+    # Count in-progress items
+    in_progress_result = await db.execute(
+        select(func.count(PrintQueueItem.id)).where(
+            PrintQueueItem.project_id == project_id,
+            PrintQueueItem.status == "printing"
+        )
+    )
+    in_progress_prints = in_progress_result.scalar() or 0
+
+    # Calculate progress
+    progress_percent = None
+    if target_count and target_count > 0:
+        progress_percent = round((completed_prints / target_count) * 100, 1)
+
+    return ProjectStats(
+        total_archives=total_archives,
+        completed_prints=completed_prints,
+        failed_prints=failed_prints,
+        queued_prints=queued_prints,
+        in_progress_prints=in_progress_prints,
+        total_print_time_hours=round((sums.total_time or 0) / 3600, 2),
+        total_filament_grams=round(sums.total_filament or 0, 2),
+        progress_percent=progress_percent,
+    )
+
+
+@router.get("", response_model=list[ProjectListResponse])
+@router.get("/", response_model=list[ProjectListResponse])
+async def list_projects(
+    status: str | None = None,
+    db: AsyncSession = Depends(get_db),
+):
+    """List all projects with basic stats."""
+    query = select(Project)
+    if status:
+        query = query.where(Project.status == status)
+    query = query.order_by(Project.updated_at.desc())
+
+    result = await db.execute(query)
+    projects = result.scalars().all()
+
+    # Compute quick stats for each project
+    response = []
+    for project in projects:
+        # Get archive count
+        archive_count_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(
+                PrintArchive.project_id == project.id
+            )
+        )
+        archive_count = archive_count_result.scalar() or 0
+
+        # Get queue count
+        queue_count_result = await db.execute(
+            select(func.count(PrintQueueItem.id)).where(
+                PrintQueueItem.project_id == project.id,
+                PrintQueueItem.status.in_(["pending", "printing"]),
+            )
+        )
+        queue_count = queue_count_result.scalar() or 0
+
+        # Get completed count for progress
+        completed_result = await db.execute(
+            select(func.count(PrintArchive.id)).where(
+                PrintArchive.project_id == project.id,
+                PrintArchive.status == "completed",
+            )
+        )
+        completed_count = completed_result.scalar() or 0
+
+        progress_percent = None
+        if project.target_count and project.target_count > 0:
+            progress_percent = round((completed_count / project.target_count) * 100, 1)
+
+        # Get archive previews (up to 6 most recent)
+        archives_result = await db.execute(
+            select(PrintArchive)
+            .where(PrintArchive.project_id == project.id)
+            .order_by(PrintArchive.created_at.desc())
+            .limit(6)
+        )
+        archives = archives_result.scalars().all()
+        archive_previews = [
+            ArchivePreview(
+                id=a.id,
+                print_name=a.print_name,
+                thumbnail_path=a.thumbnail_path,
+                status=a.status,
+            )
+            for a in archives
+        ]
+
+        response.append(
+            ProjectListResponse(
+                id=project.id,
+                name=project.name,
+                description=project.description,
+                color=project.color,
+                status=project.status,
+                target_count=project.target_count,
+                created_at=project.created_at,
+                archive_count=archive_count,
+                queue_count=queue_count,
+                progress_percent=progress_percent,
+                archives=archive_previews,
+            )
+        )
+
+    return response
+
+
+@router.post("/", response_model=ProjectResponse)
+async def create_project(
+    data: ProjectCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Create a new project."""
+    project = Project(
+        name=data.name,
+        description=data.description,
+        color=data.color,
+        target_count=data.target_count,
+    )
+    db.add(project)
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.get("/{project_id}", response_model=ProjectResponse)
+async def get_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Get a project by ID with detailed stats."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    stats = await compute_project_stats(db, project.id, project.target_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.patch("/{project_id}", response_model=ProjectResponse)
+async def update_project(
+    project_id: int,
+    data: ProjectUpdate,
+    db: AsyncSession = Depends(get_db),
+):
+    """Update a project."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Update fields if provided
+    if data.name is not None:
+        project.name = data.name
+    if data.description is not None:
+        project.description = data.description
+    if data.color is not None:
+        project.color = data.color
+    if data.status is not None:
+        if data.status not in ["active", "completed", "archived"]:
+            raise HTTPException(status_code=400, detail="Invalid status")
+        project.status = data.status
+    if data.target_count is not None:
+        project.target_count = data.target_count
+
+    await db.flush()
+    await db.refresh(project)
+
+    stats = await compute_project_stats(db, project.id, project.target_count)
+
+    return ProjectResponse(
+        id=project.id,
+        name=project.name,
+        description=project.description,
+        color=project.color,
+        status=project.status,
+        target_count=project.target_count,
+        created_at=project.created_at,
+        updated_at=project.updated_at,
+        stats=stats,
+    )
+
+
+@router.delete("/{project_id}")
+async def delete_project(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """Delete a project. Archives and queue items will have project_id set to NULL."""
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    project = result.scalar_one_or_none()
+
+    if not project:
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    await db.delete(project)
+
+    return {"message": "Project deleted"}
+
+
+@router.get("/{project_id}/archives")
+async def list_project_archives(
+    project_id: int,
+    limit: int = 100,
+    offset: int = 0,
+    db: AsyncSession = Depends(get_db),
+):
+    """List archives in a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get archives
+    query = (
+        select(PrintArchive)
+        .where(PrintArchive.project_id == project_id)
+        .order_by(PrintArchive.created_at.desc())
+        .limit(limit)
+        .offset(offset)
+    )
+    result = await db.execute(query)
+    archives = result.scalars().all()
+
+    # Import the response converter from archives module
+    from backend.app.api.routes.archives import archive_to_response
+
+    return [archive_to_response(a) for a in archives]
+
+
+@router.get("/{project_id}/queue")
+async def list_project_queue(
+    project_id: int,
+    db: AsyncSession = Depends(get_db),
+):
+    """List queue items in a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Get queue items
+    query = (
+        select(PrintQueueItem)
+        .where(PrintQueueItem.project_id == project_id)
+        .order_by(PrintQueueItem.position)
+    )
+    result = await db.execute(query)
+    items = result.scalars().all()
+
+    return items
+
+
+@router.post("/{project_id}/add-archives")
+async def add_archives_to_project(
+    project_id: int,
+    data: BatchAddArchives,
+    db: AsyncSession = Depends(get_db),
+):
+    """Batch add archives to a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Update archives
+    updated = 0
+    for archive_id in data.archive_ids:
+        result = await db.execute(
+            select(PrintArchive).where(PrintArchive.id == archive_id)
+        )
+        archive = result.scalar_one_or_none()
+        if archive:
+            archive.project_id = project_id
+            updated += 1
+
+    return {"message": f"Added {updated} archives to project"}
+
+
+@router.post("/{project_id}/add-queue")
+async def add_queue_items_to_project(
+    project_id: int,
+    data: BatchAddQueueItems,
+    db: AsyncSession = Depends(get_db),
+):
+    """Batch add queue items to a project."""
+    # Verify project exists
+    result = await db.execute(select(Project).where(Project.id == project_id))
+    if not result.scalar_one_or_none():
+        raise HTTPException(status_code=404, detail="Project not found")
+
+    # Update queue items
+    updated = 0
+    for item_id in data.queue_item_ids:
+        result = await db.execute(
+            select(PrintQueueItem).where(PrintQueueItem.id == item_id)
+        )
+        item = result.scalar_one_or_none()
+        if item:
+            item.project_id = project_id
+            updated += 1
+
+    return {"message": f"Added {updated} queue items to project"}
+
+
+@router.post("/{project_id}/remove-archives")
+async def remove_archives_from_project(
+    project_id: int,
+    data: BatchAddArchives,
+    db: AsyncSession = Depends(get_db),
+):
+    """Remove archives from a project (sets project_id to NULL)."""
+    updated = 0
+    for archive_id in data.archive_ids:
+        result = await db.execute(
+            select(PrintArchive).where(
+                PrintArchive.id == archive_id,
+                PrintArchive.project_id == project_id,
+            )
+        )
+        archive = result.scalar_one_or_none()
+        if archive:
+            archive.project_id = None
+            updated += 1
+
+    return {"message": f"Removed {updated} archives from project"}

+ 2 - 2
backend/app/api/routes/settings.py

@@ -63,11 +63,11 @@ async def get_settings(db: AsyncSession = Depends(get_db)):
     for setting in db_settings:
         if setting.key in settings_dict:
             # Parse the value based on the expected type
-            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates"]:
+            if setting.key in ["auto_archive", "save_thumbnails", "capture_finish_photo", "spoolman_enabled", "check_updates", "telemetry_enabled"]:
                 settings_dict[setting.key] = setting.value.lower() == "true"
             elif setting.key in ["default_filament_cost", "energy_cost_per_kwh", "ams_temp_good", "ams_temp_fair"]:
                 settings_dict[setting.key] = float(setting.value)
-            elif setting.key in ["ams_humidity_good", "ams_humidity_fair"]:
+            elif setting.key in ["ams_humidity_good", "ams_humidity_fair", "ams_history_retention_days"]:
                 settings_dict[setting.key] = int(setting.value)
             elif setting.key == "default_printer_id":
                 # Handle nullable integer

+ 200 - 0
backend/app/api/routes/system.py

@@ -0,0 +1,200 @@
+"""System information API routes."""
+
+import os
+import platform
+import psutil
+from datetime import datetime
+from pathlib import Path
+
+from fastapi import APIRouter, Depends
+from sqlalchemy import select, func
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import settings, APP_VERSION
+from backend.app.core.database import get_db
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+from backend.app.models.filament import Filament
+from backend.app.models.project import Project
+from backend.app.models.smart_plug import SmartPlug
+from backend.app.services.printer_manager import printer_manager
+
+router = APIRouter(prefix="/system", tags=["system"])
+
+
+def get_directory_size(path: Path) -> int:
+    """Calculate total size of a directory in bytes."""
+    total = 0
+    try:
+        for entry in path.rglob('*'):
+            if entry.is_file():
+                total += entry.stat().st_size
+    except (PermissionError, OSError):
+        pass
+    return total
+
+
+def format_bytes(bytes_value: int) -> str:
+    """Format bytes to human-readable string."""
+    for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
+        if bytes_value < 1024:
+            return f"{bytes_value:.1f} {unit}"
+        bytes_value /= 1024
+    return f"{bytes_value:.1f} PB"
+
+
+def format_uptime(seconds: float) -> str:
+    """Format uptime in seconds to human-readable string."""
+    days = int(seconds // 86400)
+    hours = int((seconds % 86400) // 3600)
+    minutes = int((seconds % 3600) // 60)
+
+    parts = []
+    if days > 0:
+        parts.append(f"{days}d")
+    if hours > 0:
+        parts.append(f"{hours}h")
+    if minutes > 0:
+        parts.append(f"{minutes}m")
+
+    return " ".join(parts) if parts else "< 1m"
+
+
+@router.get("/info")
+async def get_system_info(db: AsyncSession = Depends(get_db)):
+    """Get comprehensive system information."""
+
+    # Database stats
+    archive_count = await db.scalar(select(func.count(PrintArchive.id)))
+    printer_count = await db.scalar(select(func.count(Printer.id)))
+    filament_count = await db.scalar(select(func.count(Filament.id)))
+    project_count = await db.scalar(select(func.count(Project.id)))
+    smart_plug_count = await db.scalar(select(func.count(SmartPlug.id)))
+
+    # Archive stats by status
+    completed_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "completed")
+    )
+    failed_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "failed")
+    )
+    printing_count = await db.scalar(
+        select(func.count(PrintArchive.id)).where(PrintArchive.status == "printing")
+    )
+
+    # Total print time
+    total_print_time = await db.scalar(
+        select(func.sum(PrintArchive.print_time_seconds)).where(
+            PrintArchive.print_time_seconds.isnot(None)
+        )
+    ) or 0
+
+    # Total filament used
+    total_filament = await db.scalar(
+        select(func.sum(PrintArchive.filament_used_grams)).where(
+            PrintArchive.filament_used_grams.isnot(None)
+        )
+    ) or 0
+
+    # Connected printers
+    connected_printers = []
+    for printer_id, client in printer_manager._clients.items():
+        state = client.state
+        if state and state.connected:
+            # Get printer name and model from database
+            result = await db.execute(
+                select(Printer.name, Printer.model).where(Printer.id == printer_id)
+            )
+            row = result.first()
+            name = row[0] if row else f"Printer {printer_id}"
+            model = row[1] if row else "unknown"
+            connected_printers.append({
+                "id": printer_id,
+                "name": name,
+                "state": state.state,
+                "model": model,
+            })
+
+    # Storage info
+    archive_dir = settings.archive_dir
+    archive_size = get_directory_size(archive_dir) if archive_dir.exists() else 0
+
+    # Database file size
+    db_path = settings.base_dir / "bambuddy.db"
+    db_size = db_path.stat().st_size if db_path.exists() else 0
+
+    # Disk usage
+    disk = psutil.disk_usage(str(settings.base_dir))
+
+    # System info
+    memory = psutil.virtual_memory()
+    boot_time = datetime.fromtimestamp(psutil.boot_time())
+    uptime_seconds = (datetime.now() - boot_time).total_seconds()
+
+    # Python and system info
+    import sys
+
+    return {
+        "app": {
+            "version": APP_VERSION,
+            "base_dir": str(settings.base_dir),
+            "archive_dir": str(archive_dir),
+        },
+        "database": {
+            "archives": archive_count,
+            "archives_completed": completed_count,
+            "archives_failed": failed_count,
+            "archives_printing": printing_count,
+            "printers": printer_count,
+            "filaments": filament_count,
+            "projects": project_count,
+            "smart_plugs": smart_plug_count,
+            "total_print_time_seconds": total_print_time,
+            "total_print_time_formatted": format_uptime(total_print_time),
+            "total_filament_grams": round(total_filament, 1),
+            "total_filament_kg": round(total_filament / 1000, 2),
+        },
+        "printers": {
+            "total": printer_count,
+            "connected": len(connected_printers),
+            "connected_list": connected_printers,
+        },
+        "storage": {
+            "archive_size_bytes": archive_size,
+            "archive_size_formatted": format_bytes(archive_size),
+            "database_size_bytes": db_size,
+            "database_size_formatted": format_bytes(db_size),
+            "disk_total_bytes": disk.total,
+            "disk_total_formatted": format_bytes(disk.total),
+            "disk_used_bytes": disk.used,
+            "disk_used_formatted": format_bytes(disk.used),
+            "disk_free_bytes": disk.free,
+            "disk_free_formatted": format_bytes(disk.free),
+            "disk_percent_used": disk.percent,
+        },
+        "system": {
+            "platform": platform.system(),
+            "platform_release": platform.release(),
+            "platform_version": platform.version(),
+            "architecture": platform.machine(),
+            "hostname": platform.node(),
+            "python_version": sys.version.split()[0],
+            "uptime_seconds": uptime_seconds,
+            "uptime_formatted": format_uptime(uptime_seconds),
+            "boot_time": boot_time.isoformat(),
+        },
+        "memory": {
+            "total_bytes": memory.total,
+            "total_formatted": format_bytes(memory.total),
+            "available_bytes": memory.available,
+            "available_formatted": format_bytes(memory.available),
+            "used_bytes": memory.used,
+            "used_formatted": format_bytes(memory.used),
+            "percent_used": memory.percent,
+        },
+        "cpu": {
+            "count": psutil.cpu_count(),
+            "count_logical": psutil.cpu_count(logical=True),
+            "percent": psutil.cpu_percent(interval=0.1),
+        },
+    }

+ 332 - 0
backend/app/api/routes/webhook.py

@@ -0,0 +1,332 @@
+import logging
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from pydantic import BaseModel
+
+from backend.app.core.database import get_db
+from backend.app.core.auth import get_api_key, check_permission, check_printer_access
+from backend.app.models.api_key import APIKey
+from backend.app.models.archive import PrintArchive
+from backend.app.models.print_queue import PrintQueueItem
+from backend.app.models.printer import Printer
+from backend.app.services.printer_manager import printer_manager
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/webhook", tags=["webhook"])
+
+
+# Request schemas
+class QueueAddRequest(BaseModel):
+    archive_id: int
+    printer_id: int
+    project_id: int | None = None
+    scheduled_time: str | None = None  # ISO format datetime
+    require_previous_success: bool = False
+    auto_off_after: bool = False
+
+
+class QueueAddResponse(BaseModel):
+    id: int
+    archive_id: int
+    printer_id: int
+    position: int
+    status: str
+    message: str
+
+
+class PrinterStatusResponse(BaseModel):
+    id: int
+    name: str
+    connected: bool
+    state: str | None
+    current_print: str | None
+    progress: float | None
+    remaining_time: int | None
+
+
+class QueueStatusResponse(BaseModel):
+    printer_id: int
+    printer_name: str
+    pending: int
+    printing: int
+    items: list[dict]
+
+
+# Webhook endpoints
+
+@router.post("/queue/add", response_model=QueueAddResponse)
+async def webhook_add_to_queue(
+    data: QueueAddRequest,
+    api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
+):
+    """Add a print to the queue via webhook.
+
+    Requires 'can_queue' permission.
+    """
+    check_permission(api_key, 'queue')
+    check_printer_access(api_key, data.printer_id)
+
+    # Verify archive exists
+    result = await db.execute(
+        select(PrintArchive).where(PrintArchive.id == data.archive_id)
+    )
+    archive = result.scalar_one_or_none()
+    if not archive:
+        raise HTTPException(status_code=404, detail="Archive not found")
+
+    # Verify printer exists
+    result = await db.execute(
+        select(Printer).where(Printer.id == data.printer_id)
+    )
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Get next position
+    result = await db.execute(
+        select(PrintQueueItem.position)
+        .where(
+            PrintQueueItem.printer_id == data.printer_id,
+            PrintQueueItem.status == "pending",
+        )
+        .order_by(PrintQueueItem.position.desc())
+        .limit(1)
+    )
+    max_position = result.scalar()
+    next_position = (max_position or 0) + 1
+
+    # Parse scheduled time if provided
+    scheduled_time = None
+    if data.scheduled_time:
+        from datetime import datetime
+        try:
+            scheduled_time = datetime.fromisoformat(data.scheduled_time.replace('Z', '+00:00'))
+        except ValueError:
+            raise HTTPException(status_code=400, detail="Invalid scheduled_time format")
+
+    # Create queue item
+    queue_item = PrintQueueItem(
+        printer_id=data.printer_id,
+        archive_id=data.archive_id,
+        project_id=data.project_id,
+        position=next_position,
+        scheduled_time=scheduled_time,
+        require_previous_success=data.require_previous_success,
+        auto_off_after=data.auto_off_after,
+    )
+    db.add(queue_item)
+    await db.flush()
+    await db.refresh(queue_item)
+
+    return QueueAddResponse(
+        id=queue_item.id,
+        archive_id=queue_item.archive_id,
+        printer_id=queue_item.printer_id,
+        position=queue_item.position,
+        status=queue_item.status,
+        message=f"Added to queue at position {queue_item.position}",
+    )
+
+
+@router.post("/printer/{printer_id}/start")
+async def webhook_start_print(
+    printer_id: int,
+    api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
+):
+    """Start the next queued print on a printer.
+
+    Requires 'can_control_printer' permission.
+    """
+    check_permission(api_key, 'control_printer')
+    check_printer_access(api_key, printer_id)
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    # Get next pending queue item
+    result = await db.execute(
+        select(PrintQueueItem)
+        .where(
+            PrintQueueItem.printer_id == printer_id,
+            PrintQueueItem.status == "pending",
+        )
+        .order_by(PrintQueueItem.position)
+        .limit(1)
+    )
+    queue_item = result.scalar_one_or_none()
+    if not queue_item:
+        raise HTTPException(status_code=404, detail="No pending prints in queue")
+
+    # Check if printer is ready
+    status = printer_manager.get_status(printer_id)
+    if not status or not status.get("connected"):
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+    if status.get("state") not in ["IDLE", "FINISH", "FAILED"]:
+        raise HTTPException(
+            status_code=409,
+            detail=f"Printer is busy (state: {status.get('state')})"
+        )
+
+    # Start the print
+    try:
+        await printer_manager.start_print(printer_id, queue_item.archive_id)
+    except Exception as e:
+        logger.error(f"Failed to start print: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+    return {"message": "Print started", "queue_item_id": queue_item.id}
+
+
+@router.post("/printer/{printer_id}/stop")
+async def webhook_stop_print(
+    printer_id: int,
+    api_key: APIKey = Depends(get_api_key),
+):
+    """Stop the current print on a printer.
+
+    Requires 'can_control_printer' permission.
+    """
+    check_permission(api_key, 'control_printer')
+    check_printer_access(api_key, printer_id)
+
+    status = printer_manager.get_status(printer_id)
+    if not status or not status.get("connected"):
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+    if status.get("state") != "RUNNING":
+        raise HTTPException(status_code=409, detail="No print in progress")
+
+    try:
+        await printer_manager.stop_print(printer_id)
+    except Exception as e:
+        logger.error(f"Failed to stop print: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+    return {"message": "Print stopped"}
+
+
+@router.post("/printer/{printer_id}/cancel")
+async def webhook_cancel_print(
+    printer_id: int,
+    api_key: APIKey = Depends(get_api_key),
+):
+    """Cancel the current print on a printer.
+
+    Requires 'can_control_printer' permission.
+    """
+    check_permission(api_key, 'control_printer')
+    check_printer_access(api_key, printer_id)
+
+    status = printer_manager.get_status(printer_id)
+    if not status or not status.get("connected"):
+        raise HTTPException(status_code=503, detail="Printer not connected")
+
+    if status.get("state") not in ["RUNNING", "PAUSE"]:
+        raise HTTPException(status_code=409, detail="No print to cancel")
+
+    try:
+        await printer_manager.cancel_print(printer_id)
+    except Exception as e:
+        logger.error(f"Failed to cancel print: {e}")
+        raise HTTPException(status_code=500, detail=str(e))
+
+    return {"message": "Print cancelled"}
+
+
+@router.get("/printer/{printer_id}/status", response_model=PrinterStatusResponse)
+async def webhook_get_printer_status(
+    printer_id: int,
+    api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get status of a printer.
+
+    Requires 'can_read_status' permission.
+    """
+    check_permission(api_key, 'read_status')
+    check_printer_access(api_key, printer_id)
+
+    # Get printer
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(status_code=404, detail="Printer not found")
+
+    status = printer_manager.get_status(printer_id)
+
+    return PrinterStatusResponse(
+        id=printer.id,
+        name=printer.name,
+        connected=status.get("connected", False) if status else False,
+        state=status.get("state") if status else None,
+        current_print=status.get("current_print") if status else None,
+        progress=status.get("progress") if status else None,
+        remaining_time=status.get("remaining_time") if status else None,
+    )
+
+
+@router.get("/queue", response_model=list[QueueStatusResponse])
+async def webhook_get_queue_status(
+    printer_id: int | None = None,
+    api_key: APIKey = Depends(get_api_key),
+    db: AsyncSession = Depends(get_db),
+):
+    """Get queue status for all printers or a specific printer.
+
+    Requires 'can_read_status' permission.
+    """
+    check_permission(api_key, 'read_status')
+
+    # Get printers
+    if printer_id:
+        check_printer_access(api_key, printer_id)
+        result = await db.execute(select(Printer).where(Printer.id == printer_id))
+        printers = result.scalars().all()
+    else:
+        result = await db.execute(select(Printer))
+        printers = result.scalars().all()
+        # Filter by allowed printers if limited
+        if api_key.printer_ids:
+            printers = [p for p in printers if p.id in api_key.printer_ids]
+
+    response = []
+    for printer in printers:
+        # Get queue items
+        result = await db.execute(
+            select(PrintQueueItem)
+            .where(
+                PrintQueueItem.printer_id == printer.id,
+                PrintQueueItem.status.in_(["pending", "printing"]),
+            )
+            .order_by(PrintQueueItem.position)
+        )
+        items = result.scalars().all()
+
+        pending_count = sum(1 for i in items if i.status == "pending")
+        printing_count = sum(1 for i in items if i.status == "printing")
+
+        response.append(QueueStatusResponse(
+            printer_id=printer.id,
+            printer_name=printer.name,
+            pending=pending_count,
+            printing=printing_count,
+            items=[
+                {
+                    "id": item.id,
+                    "archive_id": item.archive_id,
+                    "position": item.position,
+                    "status": item.status,
+                }
+                for item in items
+            ],
+        ))
+
+    return response

+ 114 - 0
backend/app/core/auth.py

@@ -0,0 +1,114 @@
+import hashlib
+import secrets
+from datetime import datetime
+from typing import Optional
+
+from fastapi import Header, HTTPException, Depends
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+
+from backend.app.core.database import get_db
+from backend.app.models.api_key import APIKey
+
+
+def generate_api_key() -> tuple[str, str, str]:
+    """Generate a new API key.
+
+    Returns:
+        Tuple of (full_key, key_hash, key_prefix)
+    """
+    # Generate a random 32-byte key and encode as hex (64 chars)
+    full_key = f"bb_{secrets.token_hex(32)}"
+    key_hash = hashlib.sha256(full_key.encode()).hexdigest()
+    key_prefix = full_key[:11]  # "bb_" + first 8 chars of token
+    return full_key, key_hash, key_prefix
+
+
+def hash_api_key(key: str) -> str:
+    """Hash an API key for comparison."""
+    return hashlib.sha256(key.encode()).hexdigest()
+
+
+async def get_api_key(
+    x_api_key: str = Header(..., alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> APIKey:
+    """Verify API key and return the key record.
+
+    Raises HTTPException if key is invalid, disabled, or expired.
+    """
+    key_hash = hash_api_key(x_api_key)
+
+    result = await db.execute(
+        select(APIKey).where(APIKey.key_hash == key_hash)
+    )
+    api_key = result.scalar_one_or_none()
+
+    if not api_key:
+        raise HTTPException(status_code=401, detail="Invalid API key")
+
+    if not api_key.enabled:
+        raise HTTPException(status_code=403, detail="API key is disabled")
+
+    if api_key.expires_at and api_key.expires_at < datetime.utcnow():
+        raise HTTPException(status_code=403, detail="API key has expired")
+
+    # Update last_used timestamp
+    api_key.last_used = datetime.utcnow()
+
+    return api_key
+
+
+async def get_optional_api_key(
+    x_api_key: Optional[str] = Header(None, alias="X-API-Key"),
+    db: AsyncSession = Depends(get_db),
+) -> Optional[APIKey]:
+    """Get API key if provided, return None otherwise."""
+    if not x_api_key:
+        return None
+
+    try:
+        return await get_api_key(x_api_key, db)
+    except HTTPException:
+        return None
+
+
+def check_permission(api_key: APIKey, permission: str) -> None:
+    """Check if API key has a specific permission.
+
+    Args:
+        api_key: The API key record
+        permission: One of 'queue', 'control_printer', 'read_status'
+
+    Raises HTTPException if permission is denied.
+    """
+    permission_map = {
+        'queue': api_key.can_queue,
+        'control_printer': api_key.can_control_printer,
+        'read_status': api_key.can_read_status,
+    }
+
+    if permission not in permission_map:
+        raise HTTPException(status_code=500, detail=f"Unknown permission: {permission}")
+
+    if not permission_map[permission]:
+        raise HTTPException(
+            status_code=403,
+            detail=f"API key does not have '{permission}' permission"
+        )
+
+
+def check_printer_access(api_key: APIKey, printer_id: int) -> None:
+    """Check if API key has access to a specific printer.
+
+    Args:
+        api_key: The API key record
+        printer_id: The printer ID to check
+
+    Raises HTTPException if access is denied.
+    """
+    if api_key.printer_ids is not None and printer_id not in api_key.printer_ids:
+        raise HTTPException(
+            status_code=403,
+            detail=f"API key does not have access to printer {printer_id}"
+        )

+ 1 - 1
backend/app/core/config.py

@@ -3,7 +3,7 @@ from pydantic_settings import BaseSettings
 import logging
 
 # Application version - single source of truth
-APP_VERSION = "0.1.5b4"
+APP_VERSION = "0.1.5b5"
 GITHUB_REPO = "maziggy/bambuddy"
 
 # Base directory for path calculations

+ 95 - 1
backend/app/core/database.py

@@ -34,7 +34,7 @@ async def get_db() -> AsyncSession:
 
 async def init_db():
     # Import models to register them with SQLAlchemy
-    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link  # noqa: F401
+    from backend.app.models import printer, archive, filament, settings, smart_plug, print_queue, notification, maintenance, kprofile_note, notification_template, external_link, project, api_key  # noqa: F401
 
     async with engine.begin() as conn:
         await conn.run_sync(Base.metadata.create_all)
@@ -191,6 +191,100 @@ async def run_migrations(conn):
     except Exception:
         pass
 
+    # Migration: Add project_id column to print_archives
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Add project_id column to print_queue
+    try:
+        await conn.execute(text(
+            "ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Create FTS5 virtual table for archive full-text search
+    try:
+        await conn.execute(text("""
+            CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
+                print_name,
+                filename,
+                tags,
+                notes,
+                designer,
+                filament_type,
+                content='print_archives',
+                content_rowid='id'
+            )
+        """))
+    except Exception:
+        pass
+
+    # Migration: Create triggers to keep FTS index in sync
+    try:
+        await conn.execute(text("""
+            CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
+                INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+                VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
+            END
+        """))
+    except Exception:
+        pass
+
+    try:
+        await conn.execute(text("""
+            CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
+                INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
+                VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
+            END
+        """))
+    except Exception:
+        pass
+
+    try:
+        await conn.execute(text("""
+            CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
+                INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
+                VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
+                INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
+                VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
+            END
+        """))
+    except Exception:
+        pass
+
+    # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"
+        ))
+    except Exception:
+        pass
+
+    # Migration: Add AMS alarm notification columns to notification_providers
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+    try:
+        await conn.execute(text(
+            "ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0"
+        ))
+    except Exception:
+        pass
+
 
 async def seed_notification_templates():
     """Seed default notification templates if they don't exist."""

+ 247 - 31
backend/app/main.py

@@ -1,7 +1,7 @@
 import asyncio
 import logging
 import os
-from datetime import datetime
+from datetime import datetime, timedelta
 from contextlib import asynccontextmanager
 from pathlib import Path
 from logging.handlers import RotatingFileHandler
@@ -52,9 +52,9 @@ from fastapi.staticfiles import StaticFiles
 from fastapi.responses import FileResponse
 
 from backend.app.core.database import init_db, async_session
-from sqlalchemy import select, or_
+from sqlalchemy import select, or_, delete
 from backend.app.core.websocket import ws_manager
-from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links
+from backend.app.api.routes import printers, archives, websocket, filaments, cloud, smart_plugs, print_queue, kprofiles, notifications, notification_templates, spoolman, updates, maintenance, camera, external_links, projects, api_keys, webhook, ams_history, system
 from backend.app.api.routes import settings as settings_routes
 from backend.app.services.notification_service import notification_service
 from backend.app.services.printer_manager import (
@@ -71,6 +71,7 @@ from backend.app.services.tasmota import tasmota_service
 from backend.app.models.smart_plug import SmartPlug
 from backend.app.services.spoolman import get_spoolman_client, init_spoolman_client, close_spoolman_client
 from backend.app.api.routes.maintenance import _get_printer_maintenance_internal, ensure_default_types
+from backend.app.services.telemetry import start_telemetry_loop
 
 
 # Track active prints: {(printer_id, filename): archive_id}
@@ -304,6 +305,26 @@ async def on_print_start(printer_id: int, data: dict):
 
     await ws_manager.send_print_start(printer_id, data)
 
+    # Send print start notifications FIRST (before any early returns)
+    try:
+        async with async_session() as db:
+            from backend.app.models.printer import Printer
+            result = await db.execute(
+                select(Printer).where(Printer.id == printer_id)
+            )
+            printer = result.scalar_one_or_none()
+            printer_name = printer.name if printer else f"Printer {printer_id}"
+            await notification_service.on_print_start(printer_id, printer_name, data, db)
+    except Exception as e:
+        logger.warning(f"Notification on_print_start failed: {e}")
+
+    # Smart plug automation: turn on plug when print starts
+    try:
+        async with async_session() as db:
+            await smart_plug_manager.on_print_start(printer_id, db)
+    except Exception as e:
+        logger.warning(f"Smart plug on_print_start failed: {e}")
+
     async with async_session() as db:
         from backend.app.models.printer import Printer
         from backend.app.services.bambu_ftp import list_files_async
@@ -396,12 +417,6 @@ async def on_print_start(printer_id: int, data: dict):
                     "status": "printing",
                 })
 
-            # Smart plug automation for expected prints too
-            try:
-                await smart_plug_manager.on_print_start(printer_id, db)
-            except Exception as e:
-                logger.warning(f"Smart plug on_print_start failed: {e}")
-
             return  # Skip creating a new archive
 
         # Check if there's already a "printing" archive for this printer/file
@@ -580,28 +595,6 @@ async def on_print_start(printer_id: int, data: dict):
             if temp_path and temp_path.exists():
                 temp_path.unlink()
 
-    # Smart plug automation: turn on plug when print starts
-    try:
-        async with async_session() as db:
-            await smart_plug_manager.on_print_start(printer_id, db)
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning(f"Smart plug on_print_start failed: {e}")
-
-    # Send print start notifications
-    try:
-        async with async_session() as db:
-            from backend.app.models.printer import Printer
-            result = await db.execute(
-                select(Printer).where(Printer.id == printer_id)
-            )
-            printer = result.scalar_one_or_none()
-            printer_name = printer.name if printer else f"Printer {printer_id}"
-            await notification_service.on_print_start(printer_id, printer_name, data, db)
-    except Exception as e:
-        import logging
-        logging.getLogger(__name__).warning(f"Notification on_print_start failed: {e}")
-
 
 async def on_print_complete(printer_id: int, data: dict):
     """Handle print completion - update the archive status."""
@@ -957,6 +950,196 @@ async def on_print_complete(printer_id: int, data: dict):
         logging.getLogger(__name__).warning(f"Queue item update failed: {e}")
 
 
+# AMS sensor history recording
+_ams_history_task: asyncio.Task | None = None
+AMS_HISTORY_INTERVAL = 300  # Record every 5 minutes
+AMS_HISTORY_RETENTION_DAYS = 30  # Keep data for 30 days
+_ams_cleanup_counter = 0  # Track recordings to trigger periodic cleanup
+_ams_alarm_cooldown: dict[str, datetime] = {}  # Track alarm cooldowns (printer_id:ams_id:type -> last_alarm_time)
+AMS_ALARM_COOLDOWN_MINUTES = 60  # Don't send same alarm more than once per hour
+
+
+async def record_ams_history():
+    """Background task to record AMS humidity and temperature data."""
+    import logging
+    logger = logging.getLogger(__name__)
+
+    # Wait a short time for MQTT connections to establish on startup
+    await asyncio.sleep(10)
+
+    while True:
+        try:
+            from backend.app.models.ams_history import AMSSensorHistory
+            from backend.app.models.printer import Printer
+            from backend.app.models.settings import Settings
+
+            async with async_session() as db:
+                # Get all active printers
+                result = await db.execute(
+                    select(Printer).where(Printer.is_active == True)
+                )
+                printers = result.scalars().all()
+
+                # Get alarm thresholds from settings
+                humidity_threshold = 60.0  # Default: fair threshold
+                temp_threshold = 35.0  # Default: fair threshold
+                result = await db.execute(select(Settings).where(Settings.key == "ams_humidity_fair"))
+                setting = result.scalar_one_or_none()
+                if setting:
+                    try:
+                        humidity_threshold = float(setting.value)
+                    except (ValueError, TypeError):
+                        pass
+                result = await db.execute(select(Settings).where(Settings.key == "ams_temp_fair"))
+                setting = result.scalar_one_or_none()
+                if setting:
+                    try:
+                        temp_threshold = float(setting.value)
+                    except (ValueError, TypeError):
+                        pass
+
+                recorded_count = 0
+                for printer in printers:
+                    # Get current state from printer manager
+                    state = printer_manager.get_status(printer.id)
+                    if not state or not state.raw_data:
+                        continue
+
+                    raw_data = state.raw_data
+                    if "ams" not in raw_data or not isinstance(raw_data["ams"], list):
+                        continue
+
+                    # Record data for each AMS unit
+                    for ams_data in raw_data["ams"]:
+                        ams_id = int(ams_data.get("id", 0))
+
+                        # Get humidity (prefer humidity_raw)
+                        humidity_raw = ams_data.get("humidity_raw")
+                        humidity_idx = ams_data.get("humidity")
+                        humidity = None
+                        if humidity_raw is not None:
+                            try:
+                                humidity = float(humidity_raw)
+                            except (ValueError, TypeError):
+                                pass
+                        if humidity is None and humidity_idx is not None:
+                            try:
+                                humidity = float(humidity_idx)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Get temperature
+                        temperature = None
+                        temp_str = ams_data.get("temp")
+                        if temp_str is not None:
+                            try:
+                                temperature = float(temp_str)
+                            except (ValueError, TypeError):
+                                pass
+
+                        # Skip if no data
+                        if humidity is None and temperature is None:
+                            continue
+
+                        # Record the data point
+                        history = AMSSensorHistory(
+                            printer_id=printer.id,
+                            ams_id=ams_id,
+                            humidity=humidity,
+                            humidity_raw=float(humidity_raw) if humidity_raw else None,
+                            temperature=temperature,
+                        )
+                        db.add(history)
+                        recorded_count += 1
+
+                        # Generate AMS label (A, B, C, D or HT-A for AMS-Lite/Hub)
+                        if ams_id >= 128:
+                            ams_label = f"HT-{chr(65 + (ams_id - 128))}"
+                        else:
+                            ams_label = f"AMS-{chr(65 + ams_id)}"
+
+                        # Check humidity alarm (only if above threshold)
+                        if humidity is not None and humidity > humidity_threshold:
+                            cooldown_key = f"{printer.id}:{ams_id}:humidity"
+                            last_alarm = _ams_alarm_cooldown.get(cooldown_key)
+                            now = datetime.now()
+                            if last_alarm is None or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60:
+                                _ams_alarm_cooldown[cooldown_key] = now
+                                logger.info(f"Sending humidity alarm for {printer.name} {ams_label}: {humidity}% > {humidity_threshold}%")
+                                try:
+                                    await notification_service.on_ams_humidity_high(
+                                        printer.id, printer.name, ams_label, humidity, humidity_threshold, db
+                                    )
+                                except Exception as e:
+                                    logger.warning(f"Failed to send humidity alarm: {e}")
+
+                        # Check temperature alarm (only if above threshold)
+                        if temperature is not None and temperature > temp_threshold:
+                            cooldown_key = f"{printer.id}:{ams_id}:temperature"
+                            last_alarm = _ams_alarm_cooldown.get(cooldown_key)
+                            now = datetime.now()
+                            if last_alarm is None or (now - last_alarm).total_seconds() >= AMS_ALARM_COOLDOWN_MINUTES * 60:
+                                _ams_alarm_cooldown[cooldown_key] = now
+                                logger.info(f"Sending temperature alarm for {printer.name} {ams_label}: {temperature}°C > {temp_threshold}°C")
+                                try:
+                                    await notification_service.on_ams_temperature_high(
+                                        printer.id, printer.name, ams_label, temperature, temp_threshold, db
+                                    )
+                                except Exception as e:
+                                    logger.warning(f"Failed to send temperature alarm: {e}")
+
+                await db.commit()
+                if recorded_count > 0:
+                    logger.info(f"Recorded {recorded_count} AMS sensor history entries")
+
+                # Periodic cleanup of old data (every ~288 recordings = ~24 hours at 5min interval)
+                global _ams_cleanup_counter
+                _ams_cleanup_counter += 1
+                if _ams_cleanup_counter >= 288:
+                    _ams_cleanup_counter = 0
+                    # Get retention days from settings
+                    from backend.app.models.settings import Settings
+                    result = await db.execute(
+                        select(Settings).where(Settings.key == "ams_history_retention_days")
+                    )
+                    setting = result.scalar_one_or_none()
+                    retention_days = int(setting.value) if setting else AMS_HISTORY_RETENTION_DAYS
+
+                    cutoff = datetime.now() - timedelta(days=retention_days)
+                    result = await db.execute(
+                        delete(AMSSensorHistory).where(AMSSensorHistory.recorded_at < cutoff)
+                    )
+                    await db.commit()
+                    if result.rowcount > 0:
+                        logger.info(f"Cleaned up {result.rowcount} old AMS sensor history entries (older than {retention_days} days)")
+
+            # Wait until next recording interval
+            await asyncio.sleep(AMS_HISTORY_INTERVAL)
+
+        except asyncio.CancelledError:
+            break
+        except Exception as e:
+            logger.warning(f"AMS history recording failed: {e}")
+            await asyncio.sleep(60)  # Wait a bit before retrying
+
+
+def start_ams_history_recording():
+    """Start the AMS history recording background task."""
+    global _ams_history_task
+    if _ams_history_task is None:
+        _ams_history_task = asyncio.create_task(record_ams_history())
+        logging.getLogger(__name__).info("AMS history recording started")
+
+
+def stop_ams_history_recording():
+    """Stop the AMS history recording background task."""
+    global _ams_history_task
+    if _ams_history_task:
+        _ams_history_task.cancel()
+        _ams_history_task = None
+        logging.getLogger(__name__).info("AMS history recording stopped")
+
+
 @asynccontextmanager
 async def lifespan(app: FastAPI):
     # Startup
@@ -996,15 +1179,25 @@ async def lifespan(app: FastAPI):
     # Start the smart plug scheduler for time-based on/off
     smart_plug_manager.start_scheduler()
 
+    # Resume any pending auto-offs that were interrupted by restart
+    await smart_plug_manager.resume_pending_auto_offs()
+
     # Start the notification digest scheduler
     notification_service.start_digest_scheduler()
 
+    # Start AMS history recording
+    start_ams_history_recording()
+
+    # Start anonymous telemetry (opt-out via settings)
+    asyncio.create_task(start_telemetry_loop(async_session))
+
     yield
 
     # Shutdown
     print_scheduler.stop()
     smart_plug_manager.stop_scheduler()
     notification_service.stop_digest_scheduler()
+    stop_ams_history_recording()
     printer_manager.disconnect_all()
     await close_spoolman_client()
 
@@ -1032,6 +1225,11 @@ app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(external_links.router, prefix=app_settings.api_prefix)
+app.include_router(projects.router, prefix=app_settings.api_prefix)
+app.include_router(api_keys.router, prefix=app_settings.api_prefix)
+app.include_router(webhook.router, prefix=app_settings.api_prefix)
+app.include_router(ams_history.router, prefix=app_settings.api_prefix)
+app.include_router(system.router, prefix=app_settings.api_prefix)
 app.include_router(websocket.router, prefix=app_settings.api_prefix)
 
 
@@ -1075,6 +1273,24 @@ async def health_check():
     return {"status": "healthy"}
 
 
+@app.get("/manifest.json")
+async def serve_manifest():
+    """Serve PWA manifest."""
+    manifest_file = app_settings.static_dir / "manifest.json"
+    if manifest_file.exists():
+        return FileResponse(manifest_file, media_type="application/manifest+json")
+    return {"error": "Manifest not found"}
+
+
+@app.get("/sw.js")
+async def serve_service_worker():
+    """Serve service worker."""
+    sw_file = app_settings.static_dir / "sw.js"
+    if sw_file.exists():
+        return FileResponse(sw_file, media_type="application/javascript")
+    return {"error": "Service worker not found"}
+
+
 # Catch-all route for React Router (must be last)
 @app.get("/{full_path:path}")
 async def serve_spa(full_path: str):

+ 6 - 0
backend/app/models/__init__.py

@@ -7,6 +7,9 @@ from backend.app.models.maintenance import MaintenanceType, PrinterMaintenance,
 from backend.app.models.kprofile_note import KProfileNote
 from backend.app.models.notification_template import NotificationTemplate
 from backend.app.models.notification import NotificationLog
+from backend.app.models.project import Project
+from backend.app.models.api_key import APIKey
+from backend.app.models.ams_history import AMSSensorHistory
 
 __all__ = [
     "Printer",
@@ -20,4 +23,7 @@ __all__ = [
     "KProfileNote",
     "NotificationTemplate",
     "NotificationLog",
+    "Project",
+    "APIKey",
+    "AMSSensorHistory",
 ]

+ 31 - 0
backend/app/models/ams_history.py

@@ -0,0 +1,31 @@
+from datetime import datetime
+from sqlalchemy import Integer, Float, DateTime, ForeignKey, String, func, Index
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class AMSSensorHistory(Base):
+    """Historical sensor data from AMS units (humidity and temperature)."""
+    __tablename__ = "ams_sensor_history"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    printer_id: Mapped[int] = mapped_column(ForeignKey("printers.id", ondelete="CASCADE"))
+    ams_id: Mapped[int] = mapped_column(Integer)  # AMS unit index (0, 1, 2, 3)
+    humidity: Mapped[float | None] = mapped_column(Float)  # Humidity percentage
+    humidity_raw: Mapped[float | None] = mapped_column(Float)  # Raw humidity value
+    temperature: Mapped[float | None] = mapped_column(Float)  # Temperature in Celsius
+    recorded_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), index=True
+    )
+
+    # Indexes for efficient querying
+    __table_args__ = (
+        Index('ix_ams_history_printer_ams_time', 'printer_id', 'ams_id', 'recorded_at'),
+    )
+
+    # Relationship
+    printer: Mapped["Printer"] = relationship(back_populates="ams_history")
+
+
+from backend.app.models.printer import Printer  # noqa: E402

+ 29 - 0
backend/app/models/api_key.py

@@ -0,0 +1,29 @@
+from datetime import datetime
+from sqlalchemy import String, Boolean, DateTime, Text, JSON, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class APIKey(Base):
+    """API key for external webhook access."""
+
+    __tablename__ = "api_keys"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))  # User-friendly name
+    key_hash: Mapped[str] = mapped_column(String(64))  # SHA256 hash of the key
+    key_prefix: Mapped[str] = mapped_column(String(8))  # First 8 chars for identification
+
+    # Permissions
+    can_queue: Mapped[bool] = mapped_column(Boolean, default=True)  # Add to queue
+    can_control_printer: Mapped[bool] = mapped_column(Boolean, default=False)  # Start/stop/cancel
+    can_read_status: Mapped[bool] = mapped_column(Boolean, default=True)  # Query status
+
+    # Optional scope limits
+    printer_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)  # null = all printers
+
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    last_used: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # Optional expiry

+ 5 - 0
backend/app/models/archive.py

@@ -10,6 +10,9 @@ class PrintArchive(Base):
 
     id: Mapped[int] = mapped_column(primary_key=True)
     printer_id: Mapped[int | None] = mapped_column(ForeignKey("printers.id"), nullable=True)
+    project_id: Mapped[int | None] = mapped_column(
+        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
+    )
 
     # File info
     filename: Mapped[str] = mapped_column(String(255))
@@ -63,6 +66,8 @@ class PrintArchive(Base):
 
     # Relationships
     printer: Mapped["Printer | None"] = relationship(back_populates="archives")
+    project: Mapped["Project | None"] = relationship(back_populates="archives")
 
 
 from backend.app.models.printer import Printer  # noqa: E402, F811
+from backend.app.models.project import Project  # noqa: E402, F811

+ 4 - 0
backend/app/models/notification.py

@@ -72,6 +72,10 @@ class NotificationProvider(Base):
     on_filament_low = Column(Boolean, default=False)
     on_maintenance_due = Column(Boolean, default=False)  # Maintenance reminder
 
+    # Event triggers - AMS environmental alarms
+    on_ams_humidity_high = Column(Boolean, default=False)  # Humidity above threshold
+    on_ams_temperature_high = Column(Boolean, default=False)  # Temperature above threshold
+
     # Quiet hours (do not disturb)
     quiet_hours_enabled = Column(Boolean, default=False)
     quiet_hours_start = Column(String(5), nullable=True)  # HH:MM format, e.g., "22:00"

+ 12 - 0
backend/app/models/notification_template.py

@@ -81,6 +81,18 @@ DEFAULT_TEMPLATES = [
         "title_template": "Maintenance Due",
         "body_template": "{printer}:\n{items}",
     },
+    {
+        "event_type": "ams_humidity_high",
+        "name": "AMS Humidity High",
+        "title_template": "AMS Humidity Alert",
+        "body_template": "{printer} {ams_label}: Humidity {humidity}% exceeds {threshold}% threshold",
+    },
+    {
+        "event_type": "ams_temperature_high",
+        "name": "AMS Temperature High",
+        "title_template": "AMS Temperature Alert",
+        "body_template": "{printer} {ams_label}: Temperature {temperature}°C exceeds {threshold}°C threshold",
+    },
     {
         "event_type": "test",
         "name": "Test Notification",

+ 5 - 0
backend/app/models/print_queue.py

@@ -19,6 +19,9 @@ class PrintQueueItem(Base):
     archive_id: Mapped[int] = mapped_column(
         ForeignKey("print_archives.id", ondelete="CASCADE")
     )
+    project_id: Mapped[int | None] = mapped_column(
+        ForeignKey("projects.id", ondelete="SET NULL"), nullable=True
+    )
 
     # Scheduling
     position: Mapped[int] = mapped_column(Integer, default=0)  # Queue order
@@ -44,7 +47,9 @@ class PrintQueueItem(Base):
     # Relationships
     printer: Mapped["Printer"] = relationship()
     archive: Mapped["PrintArchive"] = relationship()
+    project: Mapped["Project | None"] = relationship(back_populates="queue_items")
 
 
 from backend.app.models.printer import Printer  # noqa: E402
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.project import Project  # noqa: E402

+ 4 - 0
backend/app/models/printer.py

@@ -42,9 +42,13 @@ class Printer(Base):
     kprofile_notes: Mapped[list["KProfileNote"]] = relationship(
         back_populates="printer", cascade="all, delete-orphan"
     )
+    ams_history: Mapped[list["AMSSensorHistory"]] = relationship(
+        back_populates="printer", cascade="all, delete-orphan"
+    )
 
 
 from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.ams_history import AMSSensorHistory  # noqa: E402
 from backend.app.models.kprofile_note import KProfileNote  # noqa: E402
 from backend.app.models.smart_plug import SmartPlug  # noqa: E402
 from backend.app.models.notification import NotificationProvider  # noqa: E402

+ 32 - 0
backend/app/models/project.py

@@ -0,0 +1,32 @@
+from datetime import datetime
+from sqlalchemy import String, Integer, DateTime, Text, func
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from backend.app.core.database import Base
+
+
+class Project(Base):
+    """Project to group related prints (e.g., 'Voron Build' with multiple parts)."""
+
+    __tablename__ = "projects"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(255))
+    description: Mapped[str | None] = mapped_column(Text, nullable=True)
+    color: Mapped[str | None] = mapped_column(String(20), nullable=True)  # Hex color for UI
+    status: Mapped[str] = mapped_column(String(20), default="active")  # active, completed, archived
+    target_count: Mapped[int | None] = mapped_column(Integer, nullable=True)  # Optional target number of prints
+
+    # Timestamps
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime, server_default=func.now(), onupdate=func.now()
+    )
+
+    # Relationships
+    archives: Mapped[list["PrintArchive"]] = relationship(back_populates="project")
+    queue_items: Mapped[list["PrintQueueItem"]] = relationship(back_populates="project")
+
+
+from backend.app.models.archive import PrintArchive  # noqa: E402
+from backend.app.models.print_queue import PrintQueueItem  # noqa: E402

+ 2 - 0
backend/app/models/smart_plug.py

@@ -48,6 +48,8 @@ class SmartPlug(Base):
     last_state: Mapped[str | None] = mapped_column(String(10), nullable=True)  # "ON"/"OFF"
     last_checked: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     auto_off_executed: Mapped[bool] = mapped_column(Boolean, default=False)  # True when auto-off was triggered
+    auto_off_pending: Mapped[bool] = mapped_column(Boolean, default=False)  # True when waiting for cooldown
+    auto_off_pending_since: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)  # When auto-off was scheduled
 
     # Timestamps
     created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 46 - 0
backend/app/schemas/api_key.py

@@ -0,0 +1,46 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class APIKeyCreate(BaseModel):
+    """Schema for creating a new API key."""
+    name: str
+    can_queue: bool = True
+    can_control_printer: bool = False
+    can_read_status: bool = True
+    printer_ids: list[int] | None = None  # null = all printers
+    expires_at: datetime | None = None
+
+
+class APIKeyUpdate(BaseModel):
+    """Schema for updating an API key."""
+    name: str | None = None
+    can_queue: bool | None = None
+    can_control_printer: bool | None = None
+    can_read_status: bool | None = None
+    printer_ids: list[int] | None = None
+    enabled: bool | None = None
+    expires_at: datetime | None = None
+
+
+class APIKeyResponse(BaseModel):
+    """Schema for API key response (without full key)."""
+    id: int
+    name: str
+    key_prefix: str  # First 8 chars for identification
+    can_queue: bool
+    can_control_printer: bool
+    can_read_status: bool
+    printer_ids: list[int] | None
+    enabled: bool
+    last_used: datetime | None
+    created_at: datetime
+    expires_at: datetime | None
+
+    class Config:
+        from_attributes = True
+
+
+class APIKeyCreateResponse(APIKeyResponse):
+    """Response when creating a key - includes full key (shown only once)."""
+    key: str  # Full API key, only shown on creation

+ 3 - 0
backend/app/schemas/archive.py

@@ -13,6 +13,7 @@ class ArchiveBase(BaseModel):
 
 class ArchiveUpdate(ArchiveBase):
     printer_id: int | None = None
+    project_id: int | None = None
 
 
 class ArchiveDuplicate(BaseModel):
@@ -26,6 +27,8 @@ class ArchiveDuplicate(BaseModel):
 class ArchiveResponse(BaseModel):
     id: int
     printer_id: int | None
+    project_id: int | None = None
+    project_name: str | None = None  # Included for convenience
     filename: str
     file_path: str
     file_size: int

+ 10 - 0
backend/app/schemas/notification.py

@@ -15,6 +15,8 @@ class ProviderType(str, Enum):
     PUSHOVER = "pushover"
     TELEGRAM = "telegram"
     EMAIL = "email"
+    DISCORD = "discord"
+    WEBHOOK = "webhook"
 
 
 class NotificationProviderBase(BaseModel):
@@ -38,6 +40,10 @@ class NotificationProviderBase(BaseModel):
     on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
     on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
 
+    # Event triggers - AMS environmental alarms
+    on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
+    on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
+
     # Quiet hours
     quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
     quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
@@ -94,6 +100,10 @@ class NotificationProviderUpdate(BaseModel):
     on_filament_low: bool | None = None
     on_maintenance_due: bool | None = None
 
+    # Event triggers - AMS environmental alarms
+    on_ams_humidity_high: bool | None = None
+    on_ams_temperature_high: bool | None = None
+
     # Quiet hours
     quiet_hours_enabled: bool | None = None
     quiet_hours_start: str | None = None

+ 20 - 0
backend/app/schemas/notification_template.py

@@ -18,6 +18,8 @@ class EventType(str, Enum):
     PRINTER_ERROR = "printer_error"
     FILAMENT_LOW = "filament_low"
     MAINTENANCE_DUE = "maintenance_due"
+    AMS_HUMIDITY_HIGH = "ams_humidity_high"
+    AMS_TEMPERATURE_HIGH = "ams_temperature_high"
     TEST = "test"
 
 
@@ -32,6 +34,8 @@ EVENT_VARIABLES: dict[str, list[str]] = {
     "printer_error": ["printer", "error_type", "error_detail", "timestamp", "app_name"],
     "filament_low": ["printer", "slot", "remaining_percent", "color", "timestamp", "app_name"],
     "maintenance_due": ["printer", "items", "timestamp", "app_name"],
+    "ams_humidity_high": ["printer", "ams_label", "humidity", "threshold", "timestamp", "app_name"],
+    "ams_temperature_high": ["printer", "ams_label", "temperature", "threshold", "timestamp", "app_name"],
     "test": ["app_name", "timestamp"],
 }
 
@@ -101,6 +105,22 @@ SAMPLE_DATA: dict[str, dict[str, str]] = {
         "timestamp": "2024-01-15 14:30",
         "app_name": "Bambuddy",
     },
+    "ams_humidity_high": {
+        "printer": "Bambu X1C",
+        "ams_label": "AMS-A",
+        "humidity": "75",
+        "threshold": "60",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
+    "ams_temperature_high": {
+        "printer": "Bambu X1C",
+        "ams_label": "AMS-A",
+        "temperature": "42",
+        "threshold": "35",
+        "timestamp": "2024-01-15 14:30",
+        "app_name": "Bambuddy",
+    },
     "test": {
         "app_name": "Bambuddy",
         "timestamp": "2024-01-15 14:30",

+ 85 - 0
backend/app/schemas/project.py

@@ -0,0 +1,85 @@
+from datetime import datetime
+from pydantic import BaseModel
+
+
+class ProjectCreate(BaseModel):
+    """Schema for creating a new project."""
+    name: str
+    description: str | None = None
+    color: str | None = None
+    target_count: int | None = None
+
+
+class ProjectUpdate(BaseModel):
+    """Schema for updating a project."""
+    name: str | None = None
+    description: str | None = None
+    color: str | None = None
+    status: str | None = None  # active, completed, archived
+    target_count: int | None = None
+
+
+class ProjectStats(BaseModel):
+    """Statistics for a project."""
+    total_archives: int = 0
+    completed_prints: int = 0
+    failed_prints: int = 0
+    queued_prints: int = 0
+    in_progress_prints: int = 0
+    total_print_time_hours: float = 0.0
+    total_filament_grams: float = 0.0
+    progress_percent: float | None = None  # Based on target_count
+
+
+class ProjectResponse(BaseModel):
+    """Schema for project response."""
+    id: int
+    name: str
+    description: str | None
+    color: str | None
+    status: str
+    target_count: int | None
+    created_at: datetime
+    updated_at: datetime
+    stats: ProjectStats | None = None
+
+    class Config:
+        from_attributes = True
+
+
+class ArchivePreview(BaseModel):
+    """Minimal archive data for project preview."""
+    id: int
+    print_name: str | None
+    thumbnail_path: str | None
+    status: str
+
+
+class ProjectListResponse(BaseModel):
+    """Schema for project list item (lighter weight)."""
+    id: int
+    name: str
+    description: str | None
+    color: str | None
+    status: str
+    target_count: int | None
+    created_at: datetime
+    # Quick stats
+    archive_count: int = 0
+    queue_count: int = 0
+    progress_percent: float | None = None
+    # Preview of archives (up to 5)
+    archives: list[ArchivePreview] = []
+
+    class Config:
+        from_attributes = True
+
+
+class BatchAddArchives(BaseModel):
+    """Schema for batch adding archives to a project."""
+    archive_ids: list[int]
+
+
+class BatchAddQueueItems(BaseModel):
+    """Schema for batch adding queue items to a project."""
+    queue_item_ids: list[int]

+ 6 - 0
backend/app/schemas/settings.py

@@ -28,6 +28,7 @@ class AppSettings(BaseModel):
     ams_humidity_fair: int = Field(default=60, description="Humidity threshold for fair (orange): <= this value, > is red")
     ams_temp_good: float = Field(default=28.0, description="Temperature threshold for good (blue): <= this value")
     ams_temp_fair: float = Field(default=35.0, description="Temperature threshold for fair (orange): <= this value, > is red")
+    ams_history_retention_days: int = Field(default=30, description="Number of days to keep AMS sensor history data")
 
     # Date/time display format
     date_format: str = Field(default="system", description="Date format: system, us, eu, iso")
@@ -36,6 +37,9 @@ class AppSettings(BaseModel):
     # Default printer for operations
     default_printer_id: int | None = Field(default=None, description="Default printer ID for uploads, reprints, etc.")
 
+    # Telemetry
+    telemetry_enabled: bool = Field(default=True, description="Send anonymous usage data to help improve BamBuddy")
+
 
 class AppSettingsUpdate(BaseModel):
     """Schema for updating settings (all fields optional)."""
@@ -56,6 +60,8 @@ class AppSettingsUpdate(BaseModel):
     ams_humidity_fair: int | None = None
     ams_temp_good: float | None = None
     ams_temp_fair: float | None = None
+    ams_history_retention_days: int | None = None
     date_format: str | None = None
     time_format: str | None = None
     default_printer_id: int | None = None
+    telemetry_enabled: bool | None = None

+ 37 - 6
backend/app/services/archive.py

@@ -1,5 +1,6 @@
 import hashlib
 import json
+import re
 import zipfile
 import shutil
 from datetime import datetime
@@ -18,8 +19,9 @@ from backend.app.models.filament import Filament
 class ThreeMFParser:
     """Parser for Bambu Lab 3MF files."""
 
-    def __init__(self, file_path: Path):
+    def __init__(self, file_path: Path, plate_number: int | None = None):
         self.file_path = file_path
+        self.plate_number = plate_number  # Which plate was printed (1, 2, 3, etc.)
         self.metadata: dict = {}
 
     def parse(self) -> dict:
@@ -285,12 +287,23 @@ class ThreeMFParser:
             pass
 
     def _extract_thumbnail(self, zf: zipfile.ZipFile):
-        """Extract thumbnail image from 3MF."""
-        thumbnail_paths = [
+        """Extract thumbnail image from 3MF.
+
+        If a plate_number was specified, try to use that plate's thumbnail first.
+        """
+        thumbnail_paths = []
+
+        # If a specific plate was printed, try that thumbnail first
+        if self.plate_number:
+            thumbnail_paths.append(f"Metadata/plate_{self.plate_number}.png")
+
+        # Fallback to default paths
+        thumbnail_paths.extend([
             "Metadata/plate_1.png",
             "Metadata/thumbnail.png",
             "Metadata/model_thumbnail.png",
-        ]
+        ])
+
         for thumb_path in thumbnail_paths:
             if thumb_path in zf.namelist():
                 self.metadata["_thumbnail_data"] = zf.read(thumb_path)
@@ -631,8 +644,16 @@ class ArchiveService:
         # Compute content hash for duplicate detection
         content_hash = self.compute_file_hash(dest_file)
 
+        # Extract plate number from filename (e.g., "plate_5" from "/data/Metadata/plate_5.gcode")
+        plate_number = None
+        if print_data:
+            filename = print_data.get("filename", "")
+            match = re.search(r'plate_(\d+)', filename)
+            if match:
+                plate_number = int(match.group(1))
+
         # Parse 3MF metadata
-        parser = ThreeMFParser(dest_file)
+        parser = ThreeMFParser(dest_file, plate_number=plate_number)
         metadata = parser.parse()
 
         # Save thumbnail if present
@@ -733,15 +754,25 @@ class ArchiveService:
     async def list_archives(
         self,
         printer_id: int | None = None,
+        project_id: int | None = None,
         limit: int = 50,
         offset: int = 0,
     ) -> list[PrintArchive]:
         """List archives with optional filtering."""
-        query = select(PrintArchive).order_by(PrintArchive.created_at.desc())
+        from sqlalchemy.orm import selectinload
+
+        query = (
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project))
+            .order_by(PrintArchive.created_at.desc())
+        )
 
         if printer_id:
             query = query.where(PrintArchive.printer_id == printer_id)
 
+        if project_id:
+            query = query.where(PrintArchive.project_id == project_id)
+
         query = query.limit(limit).offset(offset)
         result = await self.db.execute(query)
         return list(result.scalars().all())

+ 278 - 0
backend/app/services/archive_comparison.py

@@ -0,0 +1,278 @@
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.archive import PrintArchive
+
+
+class ArchiveComparisonService:
+    """Service for comparing print archives."""
+
+    # Fields to compare
+    COMPARABLE_FIELDS = [
+        ("layer_height", "Layer Height", "mm"),
+        ("nozzle_diameter", "Nozzle Diameter", "mm"),
+        ("bed_temperature", "Bed Temperature", "°C"),
+        ("nozzle_temperature", "Nozzle Temperature", "°C"),
+        ("filament_type", "Filament Type", None),
+        ("filament_used_grams", "Filament Used", "g"),
+        ("print_time_seconds", "Print Time", "s"),
+        ("total_layers", "Total Layers", None),
+        ("status", "Status", None),
+    ]
+
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def compare_archives(self, archive_ids: list[int]) -> dict:
+        """Compare multiple archives side by side.
+
+        Args:
+            archive_ids: List of 2-5 archive IDs to compare
+
+        Returns:
+            Dictionary with comparison results
+        """
+        if len(archive_ids) < 2:
+            raise ValueError("At least 2 archives required for comparison")
+        if len(archive_ids) > 5:
+            raise ValueError("Maximum 5 archives can be compared at once")
+
+        # Fetch archives
+        result = await self.db.execute(
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project))
+            .where(PrintArchive.id.in_(archive_ids))
+        )
+        archives = {a.id: a for a in result.scalars().all()}
+
+        if len(archives) != len(archive_ids):
+            missing = set(archive_ids) - set(archives.keys())
+            raise ValueError(f"Archives not found: {missing}")
+
+        # Preserve order from input
+        ordered_archives = [archives[id] for id in archive_ids]
+
+        # Build basic info for each archive
+        archive_info = [
+            {
+                "id": a.id,
+                "print_name": a.print_name or a.filename,
+                "status": a.status,
+                "created_at": a.created_at.isoformat() if a.created_at else None,
+                "printer_id": a.printer_id,
+                "project_name": a.project.name if a.project else None,
+            }
+            for a in ordered_archives
+        ]
+
+        # Build field comparison
+        comparison = []
+        differences = []
+
+        for field_name, display_name, unit in self.COMPARABLE_FIELDS:
+            values = [getattr(a, field_name) for a in ordered_archives]
+
+            # Format values for display
+            formatted_values = []
+            for v in values:
+                if v is None:
+                    formatted_values.append(None)
+                elif field_name == "print_time_seconds":
+                    # Format as human-readable time
+                    hours = int(v) // 3600
+                    minutes = (int(v) % 3600) // 60
+                    formatted_values.append(f"{hours}h {minutes}m" if hours else f"{minutes}m")
+                elif isinstance(v, float):
+                    formatted_values.append(round(v, 2))
+                else:
+                    formatted_values.append(v)
+
+            # Check if values differ
+            non_none_values = [v for v in values if v is not None]
+            has_difference = len(set(str(v) for v in non_none_values)) > 1 if non_none_values else False
+
+            field_data = {
+                "field": field_name,
+                "label": display_name,
+                "unit": unit,
+                "values": formatted_values,
+                "raw_values": values,
+                "has_difference": has_difference,
+            }
+
+            comparison.append(field_data)
+
+            if has_difference:
+                differences.append(field_data)
+
+        # Analyze success/failure correlation
+        success_correlation = self._analyze_success_correlation(ordered_archives)
+
+        return {
+            "archives": archive_info,
+            "comparison": comparison,
+            "differences": differences,
+            "success_correlation": success_correlation,
+        }
+
+    def _analyze_success_correlation(self, archives: list[PrintArchive]) -> dict:
+        """Analyze what settings correlate with success/failure."""
+        successful = [a for a in archives if a.status == "completed"]
+        failed = [a for a in archives if a.status == "failed"]
+
+        if not successful or not failed:
+            return {
+                "has_both_outcomes": False,
+                "message": "Need both successful and failed prints to analyze correlation",
+            }
+
+        # Find settings that differ between successful and failed
+        insights = []
+
+        for field_name, display_name, unit in self.COMPARABLE_FIELDS:
+            if field_name == "status":
+                continue
+
+            success_values = [getattr(a, field_name) for a in successful if getattr(a, field_name) is not None]
+            failed_values = [getattr(a, field_name) for a in failed if getattr(a, field_name) is not None]
+
+            if not success_values or not failed_values:
+                continue
+
+            # For numeric fields, compare averages
+            if isinstance(success_values[0], (int, float)):
+                success_avg = sum(success_values) / len(success_values)
+                failed_avg = sum(failed_values) / len(failed_values)
+
+                if abs(success_avg - failed_avg) > 0.1 * max(abs(success_avg), abs(failed_avg), 0.01):
+                    direction = "higher" if success_avg > failed_avg else "lower"
+                    insights.append({
+                        "field": field_name,
+                        "label": display_name,
+                        "success_avg": round(success_avg, 2),
+                        "failed_avg": round(failed_avg, 2),
+                        "insight": f"Successful prints had {direction} {display_name}",
+                    })
+            else:
+                # For categorical fields, check if success uses different values
+                success_set = set(str(v) for v in success_values)
+                failed_set = set(str(v) for v in failed_values)
+
+                if success_set != failed_set:
+                    insights.append({
+                        "field": field_name,
+                        "label": display_name,
+                        "success_values": list(success_set),
+                        "failed_values": list(failed_set),
+                        "insight": f"Different {display_name} used in successful vs failed prints",
+                    })
+
+        return {
+            "has_both_outcomes": True,
+            "successful_count": len(successful),
+            "failed_count": len(failed),
+            "insights": insights,
+        }
+
+    async def find_similar_archives(
+        self,
+        archive_id: int,
+        limit: int = 10,
+    ) -> list[dict]:
+        """Find archives with similar settings for comparison.
+
+        Args:
+            archive_id: The archive to find similar ones for
+            limit: Maximum number of results
+
+        Returns:
+            List of similar archives with match reasons
+        """
+        # Get the reference archive
+        result = await self.db.execute(
+            select(PrintArchive).where(PrintArchive.id == archive_id)
+        )
+        reference = result.scalar_one_or_none()
+
+        if not reference:
+            raise ValueError("Archive not found")
+
+        # Find similar archives
+        similar = []
+
+        # By same print name
+        if reference.print_name:
+            result = await self.db.execute(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.id != archive_id,
+                    PrintArchive.print_name == reference.print_name,
+                )
+                .order_by(PrintArchive.created_at.desc())
+                .limit(limit)
+            )
+            for a in result.scalars().all():
+                similar.append({
+                    "archive": {
+                        "id": a.id,
+                        "print_name": a.print_name or a.filename,
+                        "status": a.status,
+                        "created_at": a.created_at.isoformat() if a.created_at else None,
+                    },
+                    "match_reason": "Same print name",
+                    "match_score": 100,
+                })
+
+        # By content hash
+        if reference.content_hash and len(similar) < limit:
+            result = await self.db.execute(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.id != archive_id,
+                    PrintArchive.content_hash == reference.content_hash,
+                )
+                .order_by(PrintArchive.created_at.desc())
+                .limit(limit - len(similar))
+            )
+            for a in result.scalars().all():
+                if not any(s["archive"]["id"] == a.id for s in similar):
+                    similar.append({
+                        "archive": {
+                            "id": a.id,
+                            "print_name": a.print_name or a.filename,
+                            "status": a.status,
+                            "created_at": a.created_at.isoformat() if a.created_at else None,
+                        },
+                        "match_reason": "Same file content",
+                        "match_score": 95,
+                    })
+
+        # By same filament type
+        if reference.filament_type and len(similar) < limit:
+            result = await self.db.execute(
+                select(PrintArchive)
+                .where(
+                    PrintArchive.id != archive_id,
+                    PrintArchive.filament_type == reference.filament_type,
+                )
+                .order_by(PrintArchive.created_at.desc())
+                .limit(limit - len(similar))
+            )
+            for a in result.scalars().all():
+                if not any(s["archive"]["id"] == a.id for s in similar):
+                    similar.append({
+                        "archive": {
+                            "id": a.id,
+                            "print_name": a.print_name or a.filename,
+                            "status": a.status,
+                            "created_at": a.created_at.isoformat() if a.created_at else None,
+                        },
+                        "match_reason": f"Same filament type ({reference.filament_type})",
+                        "match_score": 50,
+                    })
+
+        # Sort by match score
+        similar.sort(key=lambda x: x["match_score"], reverse=True)
+
+        return similar[:limit]

+ 2 - 0
backend/app/services/bambu_mqtt.py

@@ -1354,6 +1354,7 @@ class BambuMQTTClient:
             self.state.state == "RUNNING"
             and self._previous_gcode_state != "RUNNING"
             and current_file
+            and not self._was_running  # Prevent duplicates when resuming from PAUSE
         )
         # Also detect if file changed while running (new print started)
         is_file_change = (
@@ -1385,6 +1386,7 @@ class BambuMQTTClient:
             self.on_print_start({
                 "filename": current_file,
                 "subtask_name": self.state.subtask_name,
+                "remaining_time": self.state.remaining_time * 60 if self.state.remaining_time > 0 else None,  # Convert minutes to seconds
                 "raw_data": data,
             })
 

+ 335 - 0
backend/app/services/export.py

@@ -0,0 +1,335 @@
+import csv
+import io
+from datetime import datetime
+from typing import Any
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from sqlalchemy.orm import selectinload
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.project import Project
+
+
+class ExportService:
+    """Service for exporting archive data to CSV/Excel formats."""
+
+    # Default fields to export
+    DEFAULT_FIELDS = [
+        "id",
+        "print_name",
+        "filename",
+        "status",
+        "printer_id",
+        "project_name",
+        "filament_type",
+        "filament_used_grams",
+        "print_time_seconds",
+        "layer_height",
+        "nozzle_diameter",
+        "bed_temperature",
+        "nozzle_temperature",
+        "total_layers",
+        "cost",
+        "designer",
+        "tags",
+        "notes",
+        "failure_reason",
+        "started_at",
+        "completed_at",
+        "created_at",
+    ]
+
+    # Field labels for headers
+    FIELD_LABELS = {
+        "id": "ID",
+        "print_name": "Print Name",
+        "filename": "Filename",
+        "status": "Status",
+        "printer_id": "Printer ID",
+        "project_name": "Project",
+        "filament_type": "Filament Type",
+        "filament_used_grams": "Filament (g)",
+        "print_time_seconds": "Print Time (s)",
+        "layer_height": "Layer Height (mm)",
+        "nozzle_diameter": "Nozzle (mm)",
+        "bed_temperature": "Bed Temp (°C)",
+        "nozzle_temperature": "Nozzle Temp (°C)",
+        "total_layers": "Total Layers",
+        "cost": "Cost",
+        "designer": "Designer",
+        "tags": "Tags",
+        "notes": "Notes",
+        "failure_reason": "Failure Reason",
+        "started_at": "Started At",
+        "completed_at": "Completed At",
+        "created_at": "Created At",
+    }
+
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def export_archives(
+        self,
+        format: str = "csv",
+        fields: list[str] | None = None,
+        printer_id: int | None = None,
+        project_id: int | None = None,
+        status: str | None = None,
+        date_from: datetime | None = None,
+        date_to: datetime | None = None,
+        search: str | None = None,
+    ) -> tuple[bytes, str, str]:
+        """Export archives to CSV or Excel format.
+
+        Args:
+            format: Export format ('csv' or 'xlsx')
+            fields: List of fields to include (None = all default fields)
+            printer_id: Filter by printer
+            project_id: Filter by project
+            status: Filter by status
+            date_from: Filter by start date
+            date_to: Filter by end date
+            search: Search filter
+
+        Returns:
+            Tuple of (file_bytes, filename, content_type)
+        """
+        # Build query
+        query = (
+            select(PrintArchive)
+            .options(selectinload(PrintArchive.project))
+            .order_by(PrintArchive.created_at.desc())
+        )
+
+        # Apply filters
+        if printer_id:
+            query = query.where(PrintArchive.printer_id == printer_id)
+        if project_id:
+            query = query.where(PrintArchive.project_id == project_id)
+        if status:
+            query = query.where(PrintArchive.status == status)
+        if date_from:
+            query = query.where(PrintArchive.created_at >= date_from)
+        if date_to:
+            query = query.where(PrintArchive.created_at <= date_to)
+        if search:
+            like_pattern = f"%{search}%"
+            query = query.where(
+                (PrintArchive.print_name.ilike(like_pattern)) |
+                (PrintArchive.filename.ilike(like_pattern)) |
+                (PrintArchive.tags.ilike(like_pattern)) |
+                (PrintArchive.notes.ilike(like_pattern)) |
+                (PrintArchive.designer.ilike(like_pattern))
+            )
+
+        # Execute query
+        result = await self.db.execute(query)
+        archives = list(result.scalars().all())
+
+        # Determine fields to export
+        export_fields = fields if fields else self.DEFAULT_FIELDS
+
+        # Convert to rows
+        rows = []
+        for archive in archives:
+            row = self._archive_to_row(archive, export_fields)
+            rows.append(row)
+
+        # Generate headers
+        headers = [self.FIELD_LABELS.get(f, f) for f in export_fields]
+
+        # Generate file
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+        if format == "xlsx":
+            file_bytes = self._generate_xlsx(headers, rows, export_fields)
+            filename = f"archives_export_{timestamp}.xlsx"
+            content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+        else:
+            file_bytes = self._generate_csv(headers, rows)
+            filename = f"archives_export_{timestamp}.csv"
+            content_type = "text/csv"
+
+        return file_bytes, filename, content_type
+
+    async def export_stats(
+        self,
+        format: str = "csv",
+        days: int = 30,
+        printer_id: int | None = None,
+        project_id: int | None = None,
+    ) -> tuple[bytes, str, str]:
+        """Export statistics summary to CSV or Excel format.
+
+        Args:
+            format: Export format ('csv' or 'xlsx')
+            days: Number of days to include in stats
+            printer_id: Filter by printer
+            project_id: Filter by project
+
+        Returns:
+            Tuple of (file_bytes, filename, content_type)
+        """
+        from backend.app.services.failure_analysis import FailureAnalysisService
+
+        # Get failure analysis data (includes stats)
+        analysis_service = FailureAnalysisService(self.db)
+        analysis = await analysis_service.analyze_failures(
+            days=days,
+            printer_id=printer_id,
+            project_id=project_id,
+        )
+
+        # Build stats rows
+        rows = [
+            ["Metric", "Value"],
+            ["Period (days)", analysis["period_days"]],
+            ["Total Prints", analysis["total_prints"]],
+            ["Failed Prints", analysis["failed_prints"]],
+            ["Failure Rate (%)", analysis["failure_rate"]],
+            [""],
+            ["Failures by Reason", ""],
+        ]
+
+        for reason, count in analysis["failures_by_reason"].items():
+            rows.append([reason, count])
+
+        rows.append([""])
+        rows.append(["Failures by Filament", ""])
+
+        for filament, count in analysis["failures_by_filament"].items():
+            rows.append([filament, count])
+
+        rows.append([""])
+        rows.append(["Failures by Printer", ""])
+
+        for printer, count in analysis["failures_by_printer"].items():
+            rows.append([printer, count])
+
+        rows.append([""])
+        rows.append(["Weekly Trend", ""])
+        rows.append(["Week", "Total", "Failed", "Rate (%)"])
+
+        for week in analysis["trend"]:
+            rows.append([
+                week["week_start"],
+                week["total_prints"],
+                week["failed_prints"],
+                week["failure_rate"],
+            ])
+
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+
+        if format == "xlsx":
+            file_bytes = self._generate_xlsx_simple(rows)
+            filename = f"stats_export_{timestamp}.xlsx"
+            content_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
+        else:
+            file_bytes = self._generate_csv_simple(rows)
+            filename = f"stats_export_{timestamp}.csv"
+            content_type = "text/csv"
+
+        return file_bytes, filename, content_type
+
+    def _archive_to_row(self, archive: PrintArchive, fields: list[str]) -> list[Any]:
+        """Convert an archive to a row of values."""
+        row = []
+        for field in fields:
+            if field == "project_name":
+                value = archive.project.name if archive.project else None
+            elif field in ("started_at", "completed_at", "created_at"):
+                value = getattr(archive, field)
+                if value:
+                    value = value.isoformat()
+            else:
+                value = getattr(archive, field, None)
+            row.append(value)
+        return row
+
+    def _generate_csv(self, headers: list[str], rows: list[list]) -> bytes:
+        """Generate CSV file content."""
+        output = io.StringIO()
+        writer = csv.writer(output)
+        writer.writerow(headers)
+        writer.writerows(rows)
+        return output.getvalue().encode("utf-8")
+
+    def _generate_csv_simple(self, rows: list[list]) -> bytes:
+        """Generate CSV file content from simple rows (no separate headers)."""
+        output = io.StringIO()
+        writer = csv.writer(output)
+        writer.writerows(rows)
+        return output.getvalue().encode("utf-8")
+
+    def _generate_xlsx(self, headers: list[str], rows: list[list], fields: list[str]) -> bytes:
+        """Generate Excel file content."""
+        try:
+            from openpyxl import Workbook
+            from openpyxl.styles import Font, PatternFill, Alignment
+            from openpyxl.utils import get_column_letter
+        except ImportError:
+            raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
+
+        wb = Workbook()
+        ws = wb.active
+        ws.title = "Archives"
+
+        # Header style
+        header_font = Font(bold=True, color="FFFFFF")
+        header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
+        header_alignment = Alignment(horizontal="center")
+
+        # Write headers
+        for col, header in enumerate(headers, 1):
+            cell = ws.cell(row=1, column=col, value=header)
+            cell.font = header_font
+            cell.fill = header_fill
+            cell.alignment = header_alignment
+
+        # Write data
+        for row_idx, row in enumerate(rows, 2):
+            for col_idx, value in enumerate(row, 1):
+                ws.cell(row=row_idx, column=col_idx, value=value)
+
+        # Auto-adjust column widths
+        for col_idx, field in enumerate(fields, 1):
+            column_letter = get_column_letter(col_idx)
+            max_length = len(headers[col_idx - 1])
+            for row in rows:
+                cell_value = row[col_idx - 1]
+                if cell_value is not None:
+                    max_length = max(max_length, len(str(cell_value)))
+            ws.column_dimensions[column_letter].width = min(max_length + 2, 50)
+
+        # Freeze header row
+        ws.freeze_panes = "A2"
+
+        output = io.BytesIO()
+        wb.save(output)
+        return output.getvalue()
+
+    def _generate_xlsx_simple(self, rows: list[list]) -> bytes:
+        """Generate Excel file content from simple rows."""
+        try:
+            from openpyxl import Workbook
+            from openpyxl.styles import Font
+        except ImportError:
+            raise ImportError("openpyxl is required for Excel export. Install with: pip install openpyxl")
+
+        wb = Workbook()
+        ws = wb.active
+        ws.title = "Statistics"
+
+        bold_font = Font(bold=True)
+
+        for row_idx, row in enumerate(rows, 1):
+            for col_idx, value in enumerate(row, 1):
+                cell = ws.cell(row=row_idx, column=col_idx, value=value)
+                # Bold section headers
+                if col_idx == 1 and value and isinstance(value, str) and value.endswith(":"):
+                    cell.font = bold_font
+
+        output = io.BytesIO()
+        wb.save(output)
+        return output.getvalue()

+ 198 - 0
backend/app/services/failure_analysis.py

@@ -0,0 +1,198 @@
+from datetime import datetime, timedelta
+from collections import defaultdict
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select, func, and_
+
+from backend.app.models.archive import PrintArchive
+from backend.app.models.printer import Printer
+
+
+class FailureAnalysisService:
+    """Service for analyzing print failure patterns."""
+
+    def __init__(self, db: AsyncSession):
+        self.db = db
+
+    async def analyze_failures(
+        self,
+        days: int = 30,
+        printer_id: int | None = None,
+        project_id: int | None = None,
+    ) -> dict:
+        """Analyze failure patterns across archives.
+
+        Args:
+            days: Number of days to analyze
+            printer_id: Optional filter by printer
+            project_id: Optional filter by project
+
+        Returns:
+            Dictionary with failure analysis results
+        """
+        cutoff_date = datetime.utcnow() - timedelta(days=days)
+
+        # Build base query
+        base_filter = [PrintArchive.created_at >= cutoff_date]
+        if printer_id:
+            base_filter.append(PrintArchive.printer_id == printer_id)
+        if project_id:
+            base_filter.append(PrintArchive.project_id == project_id)
+
+        # Total counts
+        total_result = await self.db.execute(
+            select(func.count(PrintArchive.id)).where(and_(*base_filter))
+        )
+        total_prints = total_result.scalar() or 0
+
+        failed_result = await self.db.execute(
+            select(func.count(PrintArchive.id)).where(
+                and_(*base_filter, PrintArchive.status == "failed")
+            )
+        )
+        failed_prints = failed_result.scalar() or 0
+
+        failure_rate = (failed_prints / total_prints * 100) if total_prints > 0 else 0
+
+        # Failures by reason
+        reason_result = await self.db.execute(
+            select(
+                PrintArchive.failure_reason,
+                func.count(PrintArchive.id).label("count"),
+            )
+            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .group_by(PrintArchive.failure_reason)
+            .order_by(func.count(PrintArchive.id).desc())
+        )
+        failures_by_reason = {
+            (row[0] or "Unknown"): row[1]
+            for row in reason_result.fetchall()
+        }
+
+        # Failures by filament type
+        filament_result = await self.db.execute(
+            select(
+                PrintArchive.filament_type,
+                func.count(PrintArchive.id).label("count"),
+            )
+            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .group_by(PrintArchive.filament_type)
+            .order_by(func.count(PrintArchive.id).desc())
+        )
+        failures_by_filament = {
+            (row[0] or "Unknown"): row[1]
+            for row in filament_result.fetchall()
+        }
+
+        # Failures by printer
+        printer_result = await self.db.execute(
+            select(
+                PrintArchive.printer_id,
+                func.count(PrintArchive.id).label("count"),
+            )
+            .where(
+                and_(*base_filter, PrintArchive.status == "failed", PrintArchive.printer_id.isnot(None))
+            )
+            .group_by(PrintArchive.printer_id)
+            .order_by(func.count(PrintArchive.id).desc())
+        )
+        failures_by_printer_id = {row[0]: row[1] for row in printer_result.fetchall()}
+
+        # Get printer names
+        if failures_by_printer_id:
+            printers_result = await self.db.execute(
+                select(Printer.id, Printer.name).where(
+                    Printer.id.in_(failures_by_printer_id.keys())
+                )
+            )
+            printer_names = {row[0]: row[1] for row in printers_result.fetchall()}
+            failures_by_printer = {
+                printer_names.get(pid, f"Printer {pid}"): count
+                for pid, count in failures_by_printer_id.items()
+            }
+        else:
+            failures_by_printer = {}
+
+        # Failures by hour of day
+        failed_archives_result = await self.db.execute(
+            select(PrintArchive.started_at)
+            .where(
+                and_(
+                    *base_filter,
+                    PrintArchive.status == "failed",
+                    PrintArchive.started_at.isnot(None),
+                )
+            )
+        )
+        failures_by_hour = defaultdict(int)
+        for (started_at,) in failed_archives_result.fetchall():
+            if started_at:
+                hour = started_at.hour
+                failures_by_hour[hour] += 1
+        # Convert to dict with all 24 hours
+        failures_by_hour_complete = {h: failures_by_hour.get(h, 0) for h in range(24)}
+
+        # Recent failures
+        recent_result = await self.db.execute(
+            select(PrintArchive)
+            .where(and_(*base_filter, PrintArchive.status == "failed"))
+            .order_by(PrintArchive.created_at.desc())
+            .limit(10)
+        )
+        recent_failures = [
+            {
+                "id": a.id,
+                "print_name": a.print_name or a.filename,
+                "failure_reason": a.failure_reason,
+                "filament_type": a.filament_type,
+                "printer_id": a.printer_id,
+                "created_at": a.created_at.isoformat() if a.created_at else None,
+            }
+            for a in recent_result.scalars().all()
+        ]
+
+        # Failure rate trend (by week)
+        trend_data = []
+        for i in range(min(days // 7, 12)):  # Up to 12 weeks
+            week_end = datetime.utcnow() - timedelta(weeks=i)
+            week_start = week_end - timedelta(weeks=1)
+
+            week_filter = base_filter.copy()
+            week_filter[0] = and_(
+                PrintArchive.created_at >= week_start,
+                PrintArchive.created_at < week_end,
+            )
+
+            week_total = await self.db.execute(
+                select(func.count(PrintArchive.id)).where(and_(*week_filter))
+            )
+            week_failed = await self.db.execute(
+                select(func.count(PrintArchive.id)).where(
+                    and_(*week_filter, PrintArchive.status == "failed")
+                )
+            )
+
+            total = week_total.scalar() or 0
+            failed = week_failed.scalar() or 0
+            rate = (failed / total * 100) if total > 0 else 0
+
+            trend_data.append({
+                "week_start": week_start.date().isoformat(),
+                "total_prints": total,
+                "failed_prints": failed,
+                "failure_rate": round(rate, 1),
+            })
+
+        trend_data.reverse()  # Oldest first
+
+        return {
+            "period_days": days,
+            "total_prints": total_prints,
+            "failed_prints": failed_prints,
+            "failure_rate": round(failure_rate, 1),
+            "failures_by_reason": failures_by_reason,
+            "failures_by_filament": failures_by_filament,
+            "failures_by_printer": failures_by_printer,
+            "failures_by_hour": failures_by_hour_complete,
+            "recent_failures": recent_failures,
+            "trend": trend_data,
+        }

+ 91 - 12
backend/app/services/notification_service.py

@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 
 from backend.app.models.notification import NotificationLog, NotificationProvider, NotificationDigestQueue
 from backend.app.models.notification_template import NotificationTemplate
+from backend.app.models.settings import Settings
 
 logger = logging.getLogger(__name__)
 
@@ -106,9 +107,15 @@ class NotificationService:
         return f"{minutes}m"
 
     def _clean_filename(self, filename: str) -> str:
-        """Remove file extensions from filename."""
+        """Extract filename and remove file extensions."""
+        import os
+        # Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
+        filename = os.path.basename(filename)
+        # Remove common extensions
         if filename.endswith(".gcode.3mf"):
             return filename[:-10]
+        elif filename.endswith(".gcode"):
+            return filename[:-6]
         elif filename.endswith(".3mf"):
             return filename[:-4]
         return filename
@@ -380,7 +387,7 @@ class NotificationService:
             return False, f"Webhook error: {str(e)}"
 
     async def _send_to_provider(
-        self, provider: NotificationProvider, title: str, message: str
+        self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
     ) -> tuple[bool, str]:
         """Send notification to a specific provider."""
         # Check quiet hours
@@ -487,11 +494,19 @@ class NotificationService:
         event_type: str = "unknown",
         printer_id: int | None = None,
         printer_name: str | None = None,
+        force_immediate: bool = False,
     ):
-        """Send notification to multiple providers and log the results."""
+        """Send notification to multiple providers and log the results.
+
+        All notifications are always sent immediately. If digest mode is enabled,
+        the notification is ALSO queued for the daily digest summary.
+        """
         for provider in providers:
             try:
-                # Check if provider wants digest mode
+                # Always send notification immediately
+                success, error = await self._send_to_provider(provider, title, message, db)
+
+                # Also queue for digest if enabled (digest is a summary, not a queue)
                 if provider.daily_digest_enabled and provider.daily_digest_time:
                     await self._queue_for_digest(
                         provider=provider,
@@ -502,9 +517,6 @@ class NotificationService:
                         printer_id=printer_id,
                         printer_name=printer_name,
                     )
-                    continue
-
-                success, error = await self._send_to_provider(provider, title, message)
                 await self._update_provider_status(db, provider.id, success, error if not success else None)
                 await self._log_notification(
                     db=db,
@@ -546,9 +558,21 @@ class NotificationService:
             logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
             return
 
-        filename = self._clean_filename(data.get("filename", "Unknown"))
-        estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
-        time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
+        # Use subtask_name (project name) if available, otherwise use filename
+        subtask_name = data.get("subtask_name")
+        if subtask_name:
+            # Replace underscores with spaces for readability
+            filename = subtask_name.replace("_", " ")
+        else:
+            filename = self._clean_filename(data.get("filename", "Unknown"))
+
+        # remaining_time can be passed directly, or look in raw_data at top level
+        # mc_remaining_time is in minutes in MQTT data
+        estimated_time = data.get("remaining_time")
+        if estimated_time is None:
+            raw_time = data.get("raw_data", {}).get("mc_remaining_time")
+            estimated_time = raw_time * 60 if raw_time else None
+        time_str = self._format_duration(estimated_time)
 
         variables = {
             "printer": printer_name,
@@ -592,7 +616,12 @@ class NotificationService:
             logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
             return
 
-        filename = self._clean_filename(data.get("filename", "Unknown"))
+        # Use subtask_name (project name) if available, otherwise use filename
+        subtask_name = data.get("subtask_name")
+        if subtask_name:
+            filename = subtask_name.replace("_", " ")
+        else:
+            filename = self._clean_filename(data.get("filename", "Unknown"))
 
         variables = {
             "printer": printer_name,
@@ -729,6 +758,56 @@ class NotificationService:
         title, message = await self._build_message_from_template(db, "maintenance_due", variables)
         await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
 
+    async def on_ams_humidity_high(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        humidity: float,
+        threshold: float,
+        db: AsyncSession,
+    ):
+        """Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
+        providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "humidity": f"{humidity:.0f}",
+            "threshold": f"{threshold:.0f}",
+        }
+
+        title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
+        # Alarms always send immediately, bypassing digest mode
+        await self._send_to_providers(providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True)
+
+    async def on_ams_temperature_high(
+        self,
+        printer_id: int,
+        printer_name: str,
+        ams_label: str,
+        temperature: float,
+        threshold: float,
+        db: AsyncSession,
+    ):
+        """Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
+        providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
+        if not providers:
+            return
+
+        variables = {
+            "printer": printer_name,
+            "ams_label": ams_label,
+            "temperature": f"{temperature:.1f}",
+            "threshold": f"{threshold:.1f}",
+        }
+
+        title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
+        # Alarms always send immediately, bypassing digest mode
+        await self._send_to_providers(providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True)
+
     def clear_template_cache(self):
         """Clear the template cache. Call this when templates are updated."""
         self._template_cache.clear()
@@ -809,7 +888,7 @@ class NotificationService:
             body = "\n".join(body_parts)
 
             # Send the digest
-            success, error = await self._send_to_provider(provider, title, body)
+            success, error = await self._send_to_provider(provider, title, body, db)
 
             # Log the digest
             await self._log_notification(

+ 93 - 0
backend/app/services/smart_plug_manager.py

@@ -187,6 +187,9 @@ class SmartPlugManager:
             f"Scheduling turn-off for plug '{plug.name}' in {delay_seconds} seconds"
         )
 
+        # Mark as pending in database (survives restarts)
+        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+
         task = asyncio.create_task(
             self._delayed_off(plug.id, plug.ip_address, plug.username, plug.password, printer_id, delay_seconds)
         )
@@ -240,6 +243,9 @@ class SmartPlugManager:
             f"(threshold: {temp_threshold}°C)"
         )
 
+        # Mark as pending in database (survives restarts)
+        asyncio.create_task(self._mark_auto_off_pending(plug.id, True))
+
         task = asyncio.create_task(
             self._temp_based_off(
                 plug.id,
@@ -331,6 +337,25 @@ class SmartPlugManager:
         finally:
             self._pending_off.pop(plug_id, None)
 
+    async def _mark_auto_off_pending(self, plug_id: int, pending: bool):
+        """Mark a plug as having a pending auto-off (survives restarts)."""
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.models.smart_plug import SmartPlug
+
+            async with async_session() as db:
+                result = await db.execute(
+                    select(SmartPlug).where(SmartPlug.id == plug_id)
+                )
+                plug = result.scalar_one_or_none()
+                if plug:
+                    plug.auto_off_pending = pending
+                    plug.auto_off_pending_since = datetime.utcnow() if pending else None
+                    await db.commit()
+                    logger.debug(f"Marked plug {plug_id} auto_off_pending={pending}")
+        except Exception as e:
+            logger.warning(f"Failed to update plug {plug_id} pending state: {e}")
+
     async def _mark_auto_off_executed(self, plug_id: int):
         """Disable auto-off after it was executed (one-shot behavior)."""
         try:
@@ -345,6 +370,8 @@ class SmartPlugManager:
                 if plug:
                     plug.auto_off = False  # Disable auto-off (one-shot behavior)
                     plug.auto_off_executed = False  # Reset the flag
+                    plug.auto_off_pending = False  # Clear pending state
+                    plug.auto_off_pending_since = None
                     plug.last_state = "OFF"
                     plug.last_checked = datetime.utcnow()
                     await db.commit()
@@ -358,12 +385,78 @@ class SmartPlugManager:
             logger.debug(f"Cancelling pending turn-off for plug {plug_id}")
             self._pending_off[plug_id].cancel()
             del self._pending_off[plug_id]
+            # Clear pending state in database
+            asyncio.create_task(self._mark_auto_off_pending(plug_id, False))
 
     def cancel_all_pending(self):
         """Cancel all pending turn-off tasks."""
         for plug_id in list(self._pending_off.keys()):
             self._cancel_pending_off(plug_id)
 
+    async def resume_pending_auto_offs(self):
+        """Resume any pending auto-offs that were interrupted by a restart.
+
+        Called on startup to check for plugs that had auto-off pending but
+        never completed (e.g., due to service restart).
+        """
+        try:
+            from backend.app.core.database import async_session
+            from backend.app.models.smart_plug import SmartPlug
+
+            async with async_session() as db:
+                # Find all plugs with pending auto-off
+                result = await db.execute(
+                    select(SmartPlug).where(
+                        SmartPlug.auto_off_pending == True,
+                        SmartPlug.printer_id != None,
+                    )
+                )
+                pending_plugs = result.scalars().all()
+
+                for plug in pending_plugs:
+                    # Check how long it's been pending (timeout after 2 hours)
+                    if plug.auto_off_pending_since:
+                        elapsed = (datetime.utcnow() - plug.auto_off_pending_since).total_seconds()
+                        if elapsed > 7200:  # 2 hours
+                            logger.warning(
+                                f"Auto-off for plug '{plug.name}' was pending for {elapsed/60:.0f} minutes, "
+                                f"clearing stale pending state"
+                            )
+                            plug.auto_off_pending = False
+                            plug.auto_off_pending_since = None
+                            await db.commit()
+                            continue
+
+                    logger.info(
+                        f"Resuming pending auto-off for plug '{plug.name}' "
+                        f"(printer {plug.printer_id})"
+                    )
+
+                    # Resume the appropriate off mode
+                    if plug.off_delay_mode == "temperature":
+                        self._schedule_temp_based_off(plug, plug.printer_id, plug.off_temp_threshold)
+                    else:
+                        # For time mode, just turn off immediately since delay already passed
+                        logger.info(f"Time-based auto-off was pending, turning off plug '{plug.name}' now")
+
+                        class PlugInfo:
+                            def __init__(self, p):
+                                self.ip_address = p.ip_address
+                                self.username = p.username
+                                self.password = p.password
+                                self.name = p.name
+
+                        success = await tasmota_service.turn_off(PlugInfo(plug))
+                        if success:
+                            await self._mark_auto_off_executed(plug.id)
+                            printer_manager.mark_printer_offline(plug.printer_id)
+
+                if pending_plugs:
+                    logger.info(f"Resumed {len(pending_plugs)} pending auto-off(s)")
+
+        except Exception as e:
+            logger.warning(f"Failed to resume pending auto-offs: {e}")
+
 
 # Global singleton
 smart_plug_manager = SmartPlugManager()

+ 2 - 2
backend/app/services/spoolman.py

@@ -439,9 +439,9 @@ class SpoolmanClient:
         if not tray_type or tray_type.strip() == "":
             return None
 
-        # Also need valid color to create filament (000000FF = unset/empty)
+        # Need valid color to create filament
         tray_color = tray_data.get("tray_color", "")
-        if not tray_color or tray_color in ("", "000000FF", "00000000"):
+        if not tray_color or tray_color in ("", "00000000"):
             logger.debug(f"Skipping tray with invalid color: {tray_color}")
             return None
 

+ 125 - 0
backend/app/services/telemetry.py

@@ -0,0 +1,125 @@
+"""Anonymous telemetry service for BamBuddy."""
+
+import asyncio
+import logging
+import uuid
+from datetime import datetime, timedelta
+
+import httpx
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+# Default telemetry server URL (can be overridden via settings)
+DEFAULT_TELEMETRY_URL = "https://telemetry.bambuddy.cool"
+
+# How often to send heartbeats (once per day)
+HEARTBEAT_INTERVAL = timedelta(hours=24)
+
+_last_heartbeat: datetime | None = None
+
+
+async def get_or_create_installation_id(db: AsyncSession) -> str:
+    """Get existing installation ID or create a new one."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "installation_id")
+    )
+    setting = result.scalar_one_or_none()
+
+    if setting:
+        return setting.value
+
+    # Generate new UUID
+    installation_id = str(uuid.uuid4())
+
+    # Save to database
+    new_setting = Settings(key="installation_id", value=installation_id)
+    db.add(new_setting)
+    await db.commit()
+
+    logger.info(f"Generated new installation ID: {installation_id[:8]}...")
+    return installation_id
+
+
+async def is_telemetry_enabled(db: AsyncSession) -> bool:
+    """Check if telemetry is enabled (opt-out model)."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "telemetry_enabled")
+    )
+    setting = result.scalar_one_or_none()
+
+    # Default to enabled (opt-out model)
+    if not setting:
+        return True
+
+    return setting.value.lower() == "true"
+
+
+async def get_telemetry_url(db: AsyncSession) -> str:
+    """Get telemetry server URL from settings."""
+    result = await db.execute(
+        select(Settings).where(Settings.key == "telemetry_url")
+    )
+    setting = result.scalar_one_or_none()
+
+    return setting.value if setting else DEFAULT_TELEMETRY_URL
+
+
+async def send_heartbeat(db: AsyncSession) -> bool:
+    """Send anonymous heartbeat to telemetry server."""
+    global _last_heartbeat
+
+    try:
+        # Check if telemetry is enabled
+        if not await is_telemetry_enabled(db):
+            logger.debug("Telemetry disabled, skipping heartbeat")
+            return False
+
+        # Rate limit: only send once per day
+        if _last_heartbeat and datetime.now() - _last_heartbeat < HEARTBEAT_INTERVAL:
+            logger.debug("Heartbeat already sent recently, skipping")
+            return True
+
+        installation_id = await get_or_create_installation_id(db)
+        telemetry_url = await get_telemetry_url(db)
+
+        async with httpx.AsyncClient(timeout=10.0) as client:
+            response = await client.post(
+                f"{telemetry_url}/heartbeat",
+                json={
+                    "installation_id": installation_id,
+                    "version": APP_VERSION,
+                },
+            )
+            response.raise_for_status()
+
+        _last_heartbeat = datetime.now()
+        logger.info(f"Telemetry heartbeat sent to {telemetry_url}")
+        return True
+
+    except httpx.HTTPError as e:
+        logger.debug(f"Telemetry heartbeat failed (network): {e}")
+        return False
+    except Exception as e:
+        logger.debug(f"Telemetry heartbeat failed: {e}")
+        return False
+
+
+async def start_telemetry_loop(get_session):
+    """Background task to send periodic heartbeats."""
+    # Wait a bit before first heartbeat to let app initialize
+    await asyncio.sleep(30)
+
+    while True:
+        try:
+            async with get_session() as db:
+                await send_heartbeat(db)
+        except Exception as e:
+            logger.debug(f"Telemetry loop error: {e}")
+
+        # Check daily
+        await asyncio.sleep(HEARTBEAT_INTERVAL.total_seconds())

+ 1 - 0
backend/tests/__init__.py

@@ -0,0 +1 @@
+"""BamBuddy backend tests."""

+ 380 - 0
backend/tests/conftest.py

@@ -0,0 +1,380 @@
+"""Shared test fixtures for BamBuddy backend tests."""
+
+import asyncio
+import json
+import os
+import sys
+import pytest
+from typing import AsyncGenerator
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+# IMPORTANT: Set environment variables BEFORE any app imports
+# This must happen before settings/config are loaded
+os.environ["LOG_TO_FILE"] = "false"
+os.environ["DEBUG"] = "false"
+
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
+from httpx import AsyncClient, ASGITransport
+
+# Ensure settings use our env vars - import and override before database import
+from backend.app.core.config import settings
+settings.log_to_file = False
+
+from backend.app.core.database import Base
+
+
+# Use in-memory SQLite for tests
+TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
+
+
+@pytest.fixture(scope="session")
+def event_loop():
+    """Create an instance of the default event loop for each test session."""
+    loop = asyncio.get_event_loop_policy().new_event_loop()
+    yield loop
+    loop.close()
+
+
+@pytest.fixture
+async def test_engine():
+    """Create a test database engine."""
+    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
+
+    # Import all models to register them
+    from backend.app.models import (
+        printer, archive, filament, settings, smart_plug,
+        print_queue, notification, maintenance, kprofile_note,
+        notification_template, external_link, project, api_key,
+        ams_history
+    )
+
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+
+    yield engine
+
+    async with engine.begin() as conn:
+        await conn.run_sync(Base.metadata.drop_all)
+    await engine.dispose()
+
+
+@pytest.fixture
+async def db_session(test_engine) -> AsyncGenerator[AsyncSession, None]:
+    """Create a test database session."""
+    async_session_maker = async_sessionmaker(
+        test_engine, class_=AsyncSession, expire_on_commit=False
+    )
+    async with async_session_maker() as session:
+        yield session
+
+
+@pytest.fixture
+async def async_client(test_engine, db_session) -> AsyncGenerator[AsyncClient, None]:
+    """Create an async test client."""
+    from backend.app.main import app
+    from backend.app.core.database import get_db, async_session
+
+    # Create a new session maker for the test engine
+    test_async_session = async_sessionmaker(
+        test_engine, class_=AsyncSession, expire_on_commit=False
+    )
+
+    async def override_get_db():
+        async with test_async_session() as session:
+            yield session
+
+    app.dependency_overrides[get_db] = override_get_db
+
+    # Also patch the module-level async_session used by services
+    with patch('backend.app.core.database.async_session', test_async_session):
+        async with AsyncClient(
+            transport=ASGITransport(app=app),
+            base_url="http://test"
+        ) as client:
+            yield client
+
+    app.dependency_overrides.clear()
+
+
+# ============================================================================
+# Mock External Services
+# ============================================================================
+
+@pytest.fixture
+def mock_tasmota_service():
+    """Mock the Tasmota service for smart plug tests."""
+    # Patch both the module where it's defined and where it's imported
+    with patch('backend.app.services.tasmota.tasmota_service') as mock, \
+         patch('backend.app.api.routes.smart_plugs.tasmota_service') as mock2:
+        mock.turn_on = AsyncMock(return_value=True)
+        mock.turn_off = AsyncMock(return_value=True)
+        mock.toggle = AsyncMock(return_value=True)
+        mock.get_status = AsyncMock(return_value={
+            "state": "ON",
+            "reachable": True,
+            "device_name": "Test Plug"
+        })
+        mock.get_energy = AsyncMock(return_value={
+            "power": 150.5,
+            "voltage": 120.0,
+            "current": 1.25,
+            "today": 2.5,
+            "total": 100.0,
+            "factor": 0.95,
+        })
+        mock.test_connection = AsyncMock(return_value={
+            "success": True,
+            "state": "ON",
+            "device_name": "Test Plug"
+        })
+        # Copy mocks to second patch target
+        mock2.turn_on = mock.turn_on
+        mock2.turn_off = mock.turn_off
+        mock2.toggle = mock.toggle
+        mock2.get_status = mock.get_status
+        mock2.get_energy = mock.get_energy
+        mock2.test_connection = mock.test_connection
+        yield mock
+
+
+@pytest.fixture
+def mock_mqtt_client():
+    """Mock the MQTT client for printer communication tests."""
+    with patch('backend.app.services.bambu_mqtt.BambuMQTTClient') as mock:
+        instance = MagicMock()
+        instance.state = MagicMock(
+            connected=True,
+            state="IDLE",
+            progress=0,
+            temperatures={"nozzle": 25, "bed": 25}
+        )
+        instance.connect = MagicMock()
+        instance.disconnect = MagicMock()
+        mock.return_value = instance
+        yield mock
+
+
+@pytest.fixture
+def mock_ftp_client():
+    """Mock the FTP client for file transfer tests."""
+    with patch('backend.app.services.bambu_ftp.download_file_async') as download_mock, \
+         patch('backend.app.services.bambu_ftp.list_files_async') as list_mock:
+        download_mock.return_value = True
+        list_mock.return_value = []
+        yield {"download": download_mock, "list": list_mock}
+
+
+@pytest.fixture
+def mock_httpx_client():
+    """Mock httpx for webhook/notification HTTP calls."""
+    with patch('httpx.AsyncClient') as mock_class:
+        mock_instance = AsyncMock()
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+        mock_response.text = "OK"
+        mock_response.json.return_value = {}
+
+        mock_instance.get = AsyncMock(return_value=mock_response)
+        mock_instance.post = AsyncMock(return_value=mock_response)
+        mock_instance.__aenter__ = AsyncMock(return_value=mock_instance)
+        mock_instance.__aexit__ = AsyncMock()
+
+        mock_class.return_value = mock_instance
+        yield mock_instance
+
+
+@pytest.fixture
+def mock_printer_manager():
+    """Mock the printer manager for status checks."""
+    with patch('backend.app.services.printer_manager.printer_manager') as mock:
+        mock.get_status = MagicMock(return_value=MagicMock(
+            connected=True,
+            state="IDLE",
+            progress=0,
+            temperatures={"nozzle": 25, "bed": 25, "chamber": 25},
+            raw_data={}
+        ))
+        mock.mark_printer_offline = MagicMock()
+        yield mock
+
+
+# ============================================================================
+# Factory Fixtures for Test Data
+# ============================================================================
+
+@pytest.fixture
+def smart_plug_factory(db_session):
+    """Factory to create test smart plugs."""
+    async def _create_plug(**kwargs):
+        from backend.app.models.smart_plug import SmartPlug
+
+        defaults = {
+            "name": "Test Plug",
+            "ip_address": "192.168.1.100",
+            "enabled": True,
+            "auto_on": True,
+            "auto_off": True,
+            "off_delay_mode": "time",
+            "off_delay_minutes": 5,
+            "off_temp_threshold": 70,
+            "schedule_enabled": False,
+            "power_alert_enabled": False,
+        }
+        defaults.update(kwargs)
+
+        plug = SmartPlug(**defaults)
+        db_session.add(plug)
+        await db_session.commit()
+        await db_session.refresh(plug)
+        return plug
+
+    return _create_plug
+
+
+@pytest.fixture
+def printer_factory(db_session):
+    """Factory to create test printers."""
+    _counter = [0]  # Use list to allow mutation in nested function
+
+    async def _create_printer(**kwargs):
+        from backend.app.models.printer import Printer
+
+        _counter[0] += 1
+        counter = _counter[0]
+
+        defaults = {
+            "name": "Test Printer",
+            "serial_number": f"00M09A{counter:09d}",  # Unique serial per printer
+            "ip_address": f"192.168.1.{100 + counter}",  # Unique IP per printer
+            "access_code": "12345678",
+            "is_active": True,
+            "auto_archive": True,
+            "model": "X1C",
+        }
+        defaults.update(kwargs)
+
+        printer = Printer(**defaults)
+        db_session.add(printer)
+        await db_session.commit()
+        await db_session.refresh(printer)
+        return printer
+
+    return _create_printer
+
+
+@pytest.fixture
+def notification_provider_factory(db_session):
+    """Factory to create test notification providers."""
+    async def _create_provider(**kwargs):
+        from backend.app.models.notification import NotificationProvider
+
+        config = kwargs.pop("config", {"server": "https://ntfy.sh", "topic": "test-topic"})
+        if isinstance(config, dict):
+            config = json.dumps(config)
+
+        defaults = {
+            "name": "Test Provider",
+            "provider_type": "ntfy",
+            "enabled": True,
+            "config": config,
+            "on_print_start": True,
+            "on_print_complete": True,
+            "on_print_failed": True,
+            "on_print_stopped": True,
+            "on_print_progress": False,
+            "on_printer_offline": False,
+            "on_printer_error": False,
+            "on_filament_low": False,
+            "on_maintenance_due": False,
+            "on_ams_humidity_high": False,
+            "on_ams_temperature_high": False,
+            "quiet_hours_enabled": False,
+            "daily_digest_enabled": False,
+        }
+        defaults.update(kwargs)
+
+        provider = NotificationProvider(**defaults)
+        db_session.add(provider)
+        await db_session.commit()
+        await db_session.refresh(provider)
+        return provider
+
+    return _create_provider
+
+
+@pytest.fixture
+def archive_factory(db_session):
+    """Factory to create test archives."""
+    async def _create_archive(printer_id: int, **kwargs):
+        from backend.app.models.archive import PrintArchive
+
+        defaults = {
+            "printer_id": printer_id,
+            "filename": "test_print.gcode.3mf",
+            "print_name": "Test Print",
+            "file_path": "archives/test/test_print.gcode.3mf",
+            "file_size": 1024000,
+            "status": "completed",
+            "filament_type": "PLA",
+            "filament_used_grams": 50.0,
+            "print_time_seconds": 3600,
+        }
+        defaults.update(kwargs)
+
+        archive = PrintArchive(**defaults)
+        db_session.add(archive)
+        await db_session.commit()
+        await db_session.refresh(archive)
+        return archive
+
+    return _create_archive
+
+
+# ============================================================================
+# Sample Data Fixtures
+# ============================================================================
+
+@pytest.fixture
+def sample_mqtt_print_start():
+    """Sample MQTT message for print start."""
+    return {
+        "print": {
+            "command": "project_file",
+            "param": "/sdcard/test.gcode.3mf",
+            "subtask_name": "test_print",
+            "gcode_state": "RUNNING",
+            "mc_percent": 0,
+        }
+    }
+
+
+@pytest.fixture
+def sample_mqtt_print_complete():
+    """Sample MQTT message for print complete."""
+    return {
+        "print": {
+            "gcode_state": "FINISH",
+            "mc_percent": 100,
+            "subtask_name": "test_print",
+        }
+    }
+
+
+@pytest.fixture
+def sample_printer_status():
+    """Sample printer status data."""
+    return {
+        "connected": True,
+        "state": "IDLE",
+        "progress": 0,
+        "layer_num": 0,
+        "total_layers": 0,
+        "temperatures": {
+            "nozzle": 25.0,
+            "bed": 25.0,
+            "chamber": 25.0,
+        },
+        "remaining_time": 0,
+        "filename": None,
+    }

+ 1 - 0
backend/tests/integration/__init__.py

@@ -0,0 +1 @@
+"""Integration tests for BamBuddy API endpoints."""

+ 189 - 0
backend/tests/integration/test_ams_history_api.py

@@ -0,0 +1,189 @@
+"""Integration tests for AMS History API endpoints."""
+
+import pytest
+from datetime import datetime, timedelta
+from httpx import AsyncClient
+
+
+class TestAMSHistoryAPI:
+    """Integration tests for /api/v1/ams-history endpoints."""
+
+    @pytest.fixture
+    async def ams_history_factory(self, db_session, printer_factory):
+        """Factory to create test AMS history records."""
+        async def _create_history(printer_id=None, ams_id=0, **kwargs):
+            from backend.app.models.ams_history import AMSSensorHistory
+
+            if printer_id is None:
+                printer = await printer_factory()
+                printer_id = printer.id
+
+            defaults = {
+                "printer_id": printer_id,
+                "ams_id": ams_id,
+                "humidity": 45.0,
+                "humidity_raw": 4500,
+                "temperature": 25.0,
+                "recorded_at": datetime.now(),
+            }
+            defaults.update(kwargs)
+
+            history = AMSSensorHistory(**defaults)
+            db_session.add(history)
+            await db_session.commit()
+            await db_session.refresh(history)
+            return history
+
+        return _create_history
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_empty(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify empty history returns empty data array."""
+        printer = await printer_factory()
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["printer_id"] == printer.id
+        assert data["ams_id"] == 0
+        assert data["data"] == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_with_data(
+        self, async_client: AsyncClient, ams_history_factory, db_session
+    ):
+        """Verify history returns recorded data."""
+        # Create history records
+        history = await ams_history_factory()
+        printer_id = history.printer_id
+
+        response = await async_client.get(f"/api/v1/ams-history/{printer_id}/0")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data["data"]) >= 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_with_stats(
+        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
+    ):
+        """Verify history includes statistics."""
+        printer = await printer_factory()
+        # Create multiple records with different values
+        await ams_history_factory(printer_id=printer.id, humidity=40.0, temperature=24.0)
+        await ams_history_factory(printer_id=printer.id, humidity=50.0, temperature=26.0)
+        await ams_history_factory(printer_id=printer.id, humidity=45.0, temperature=25.0)
+
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data = response.json()
+
+        # Check statistics
+        assert data["min_humidity"] == 40.0
+        assert data["max_humidity"] == 50.0
+        assert data["min_temperature"] == 24.0
+        assert data["max_temperature"] == 26.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_with_hours_filter(
+        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
+    ):
+        """Verify hours parameter filters data."""
+        printer = await printer_factory()
+        # Create a recent record
+        await ams_history_factory(
+            printer_id=printer.id,
+            recorded_at=datetime.now()
+        )
+        # Create an old record (outside default 24h)
+        await ams_history_factory(
+            printer_id=printer.id,
+            recorded_at=datetime.now() - timedelta(hours=48)
+        )
+
+        # Request only last 24 hours (default)
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data = response.json()
+        # Should only get the recent record
+        assert len(data["data"]) == 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_custom_hours(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify custom hours parameter works."""
+        printer = await printer_factory()
+        response = await async_client.get(
+            f"/api/v1/ams-history/{printer.id}/0",
+            params={"hours": 48}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_ams_history_different_ams_units(
+        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
+    ):
+        """Verify filtering by AMS unit ID."""
+        printer = await printer_factory()
+        await ams_history_factory(printer_id=printer.id, ams_id=0, humidity=40.0)
+        await ams_history_factory(printer_id=printer.id, ams_id=1, humidity=50.0)
+
+        # Get AMS unit 0
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/0")
+        assert response.status_code == 200
+        data0 = response.json()
+        assert len(data0["data"]) == 1
+        assert data0["data"][0]["humidity"] == 40.0
+
+        # Get AMS unit 1
+        response = await async_client.get(f"/api/v1/ams-history/{printer.id}/1")
+        assert response.status_code == 200
+        data1 = response.json()
+        assert len(data1["data"]) == 1
+        assert data1["data"][0]["humidity"] == 50.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_old_history(
+        self, async_client: AsyncClient, ams_history_factory, printer_factory, db_session
+    ):
+        """Verify old history can be deleted."""
+        printer = await printer_factory()
+        # Create an old record
+        await ams_history_factory(
+            printer_id=printer.id,
+            recorded_at=datetime.now() - timedelta(days=60)
+        )
+
+        # Delete records older than 30 days
+        response = await async_client.delete(
+            f"/api/v1/ams-history/{printer.id}",
+            params={"days": 30}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["deleted"] >= 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_old_history_no_records(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify delete with no old records returns 0."""
+        printer = await printer_factory()
+        response = await async_client.delete(
+            f"/api/v1/ams-history/{printer.id}",
+            params={"days": 30}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["deleted"] == 0

+ 294 - 0
backend/tests/integration/test_archives_api.py

@@ -0,0 +1,294 @@
+"""Integration tests for Archives API endpoints.
+
+Tests the full request/response cycle for /api/v1/archives/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestArchivesAPI:
+    """Integration tests for /api/v1/archives/ endpoints."""
+
+    # ========================================================================
+    # List endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_archives_empty(self, async_client: AsyncClient):
+        """Verify empty list is returned when no archives exist."""
+        response = await async_client.get("/api/v1/archives/")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_archives_with_data(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify list returns existing archives."""
+        printer = await printer_factory()
+        await archive_factory(printer.id, print_name="Test Archive")
+
+        response = await async_client.get("/api/v1/archives/")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) >= 1
+        assert any(a["print_name"] == "Test Archive" for a in data)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_archives_pagination(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify pagination works correctly."""
+        printer = await printer_factory()
+        # Create 5 archives
+        for i in range(5):
+            await archive_factory(printer.id, print_name=f"Archive {i}")
+
+        # Get first page with limit 2
+        response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 2
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_archives_filter_by_printer(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify filtering by printer_id works."""
+        printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
+        printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
+        await archive_factory(printer1.id, print_name="Printer 1 Archive")
+        await archive_factory(printer2.id, print_name="Printer 2 Archive")
+
+        response = await async_client.get(
+            f"/api/v1/archives/?printer_id={printer1.id}"
+        )
+
+        assert response.status_code == 200
+        data = response.json()
+        assert all(a["printer_id"] == printer1.id for a in data)
+
+    # ========================================================================
+    # Get single endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_archive(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify single archive can be retrieved."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Get Test Archive")
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == archive.id
+        assert result["print_name"] == "Get Test Archive"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_archive_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent archive."""
+        response = await async_client.get("/api/v1/archives/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Update endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_name(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive name can be updated."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, print_name="Original Name")
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"print_name": "Updated Name"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["print_name"] == "Updated Name"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_notes(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive notes can be updated."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"notes": "Great print!"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["notes"] == "Great print!"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_archive_favorite(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive favorite status can be updated."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"is_favorite": True}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["is_favorite"] is True
+
+    # ========================================================================
+    # Delete endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_archive(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive can be deleted."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id)
+        archive_id = archive.id
+
+        response = await async_client.delete(f"/api/v1/archives/{archive_id}")
+
+        assert response.status_code == 200
+
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/archives/{archive_id}")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
+        """Verify deleting non-existent archive returns 404."""
+        response = await async_client.delete("/api/v1/archives/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Statistics endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_archive_stats(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive statistics can be retrieved."""
+        printer = await printer_factory()
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=3600,
+            filament_used_grams=50.0,
+        )
+        await archive_factory(
+            printer.id,
+            status="completed",
+            print_time_seconds=7200,
+            filament_used_grams=100.0,
+        )
+
+        response = await async_client.get("/api/v1/archives/stats")
+
+        assert response.status_code == 200
+        result = response.json()
+        # Check for actual stats fields
+        assert "total_prints" in result
+        assert "successful_prints" in result
+
+
+class TestArchiveDataIntegrity:
+    """Tests for archive data integrity."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_linked_to_printer(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive is properly linked to printer."""
+        printer = await printer_factory(name="My Printer")
+        archive = await archive_factory(printer.id)
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_stores_print_data(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """Verify archive stores all print data correctly."""
+        printer = await printer_factory()
+        archive = await archive_factory(
+            printer.id,
+            print_name="Test Print",
+            filename="test.3mf",
+            status="completed",
+            filament_type="PLA",
+            filament_used_grams=75.5,
+            print_time_seconds=5400,
+        )
+
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["print_name"] == "Test Print"
+        assert result["filename"] == "test.3mf"
+        assert result["status"] == "completed"
+        assert result["filament_type"] == "PLA"
+        assert result["filament_used_grams"] == 75.5
+        assert result["print_time_seconds"] == 5400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_archive_update_persists(
+        self, async_client: AsyncClient, archive_factory, printer_factory, db_session
+    ):
+        """CRITICAL: Verify archive updates persist."""
+        printer = await printer_factory()
+        archive = await archive_factory(printer.id, notes="Original notes")
+
+        # Update
+        await async_client.patch(
+            f"/api/v1/archives/{archive.id}",
+            json={"notes": "Updated notes", "is_favorite": True}
+        )
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/archives/{archive.id}")
+        result = response.json()
+        assert result["notes"] == "Updated notes"
+        assert result["is_favorite"] is True

+ 184 - 0
backend/tests/integration/test_external_links_api.py

@@ -0,0 +1,184 @@
+"""Integration tests for External Links API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestExternalLinksAPI:
+    """Integration tests for /api/v1/external-links endpoints."""
+
+    @pytest.fixture
+    async def link_factory(self, db_session):
+        """Factory to create test external links."""
+        _counter = [0]
+
+        async def _create_link(**kwargs):
+            from backend.app.models.external_link import ExternalLink
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Link {counter}",
+                "url": f"https://example.com/{counter}",
+                "icon": "Link",
+                "sort_order": counter,
+            }
+            defaults.update(kwargs)
+
+            link = ExternalLink(**defaults)
+            db_session.add(link)
+            await db_session.commit()
+            await db_session.refresh(link)
+            return link
+
+        return _create_link
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_external_links_empty(self, async_client: AsyncClient):
+        """Verify empty list when no links exist."""
+        response = await async_client.get("/api/v1/external-links/")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_external_links_with_data(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify list returns existing links."""
+        await link_factory(name="My Link")
+        response = await async_client.get("/api/v1/external-links/")
+        assert response.status_code == 200
+        data = response.json()
+        assert any(link["name"] == "My Link" for link in data)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_external_link(self, async_client: AsyncClient):
+        """Verify external link can be created."""
+        data = {
+            "name": "New Link",
+            "url": "https://new-link.example.com",
+            "icon": "ExternalLink",
+        }
+        response = await async_client.post("/api/v1/external-links/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New Link"
+        assert result["url"] == "https://new-link.example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_external_link(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify single link can be retrieved."""
+        link = await link_factory(name="Get Test Link")
+        response = await async_client.get(f"/api/v1/external-links/{link.id}")
+        assert response.status_code == 200
+        assert response.json()["name"] == "Get Test Link"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_external_link_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent link."""
+        response = await async_client.get("/api/v1/external-links/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_external_link(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify link can be updated."""
+        link = await link_factory(name="Original")
+        response = await async_client.patch(
+            f"/api/v1/external-links/{link.id}",
+            json={"name": "Updated", "url": "https://updated.example.com"}
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Updated"
+        assert result["url"] == "https://updated.example.com"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_external_link(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify link can be deleted."""
+        link = await link_factory()
+        response = await async_client.delete(f"/api/v1/external-links/{link.id}")
+        assert response.status_code == 200
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/external-links/{link.id}")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reorder_external_links(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify links can be reordered."""
+        link1 = await link_factory(name="Link 1")
+        link2 = await link_factory(name="Link 2")
+        link3 = await link_factory(name="Link 3")
+
+        # Reorder: 3, 1, 2
+        response = await async_client.put(
+            "/api/v1/external-links/reorder",
+            json={"ids": [link3.id, link1.id, link2.id]}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        # First link should be link3
+        assert data[0]["id"] == link3.id
+        assert data[0]["sort_order"] == 0
+
+
+class TestExternalLinksIconAPI:
+    """Tests for external link icon upload/delete."""
+
+    @pytest.fixture
+    async def link_factory(self, db_session):
+        """Factory to create test external links."""
+        async def _create_link(**kwargs):
+            from backend.app.models.external_link import ExternalLink
+
+            defaults = {
+                "name": "Icon Test Link",
+                "url": "https://example.com",
+                "icon": "Link",
+                "sort_order": 0,
+            }
+            defaults.update(kwargs)
+
+            link = ExternalLink(**defaults)
+            db_session.add(link)
+            await db_session.commit()
+            await db_session.refresh(link)
+            return link
+
+        return _create_link
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_icon_not_set(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify 404 when no custom icon is set."""
+        link = await link_factory()
+        response = await async_client.get(f"/api/v1/external-links/{link.id}/icon")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_icon_when_none(
+        self, async_client: AsyncClient, link_factory, db_session
+    ):
+        """Verify deleting non-existent icon succeeds silently."""
+        link = await link_factory()
+        response = await async_client.delete(f"/api/v1/external-links/{link.id}/icon")
+        assert response.status_code == 200

+ 117 - 0
backend/tests/integration/test_filaments_api.py

@@ -0,0 +1,117 @@
+"""Integration tests for Filaments API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestFilamentsAPI:
+    """Integration tests for /api/v1/filaments/ endpoints."""
+
+    @pytest.fixture
+    async def filament_factory(self, db_session):
+        """Factory to create test filaments."""
+        async def _create_filament(**kwargs):
+            from backend.app.models.filament import Filament
+
+            defaults = {
+                "name": "Test PLA",
+                "type": "PLA",
+                "color": "Red",
+                "color_hex": "#FF0000",
+                "brand": "Generic",
+                "cost_per_kg": 25.0,
+            }
+            defaults.update(kwargs)
+
+            filament = Filament(**defaults)
+            db_session.add(filament)
+            await db_session.commit()
+            await db_session.refresh(filament)
+            return filament
+
+        return _create_filament
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_filaments_empty(self, async_client: AsyncClient):
+        """Verify empty list when no filaments exist."""
+        response = await async_client.get("/api/v1/filaments/")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_filaments_with_data(
+        self, async_client: AsyncClient, filament_factory, db_session
+    ):
+        """Verify list returns existing filaments."""
+        await filament_factory(name="Test Filament")
+        response = await async_client.get("/api/v1/filaments/")
+        assert response.status_code == 200
+        data = response.json()
+        assert any(f["name"] == "Test Filament" for f in data)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_filament(self, async_client: AsyncClient):
+        """Verify filament can be created."""
+        data = {
+            "name": "New PETG",
+            "type": "PETG",
+            "color": "Blue",
+            "color_hex": "#0000FF",
+            "brand": "Bambu",
+            "cost_per_kg": 30.0,
+        }
+        response = await async_client.post("/api/v1/filaments/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New PETG"
+        assert result["type"] == "PETG"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filament(
+        self, async_client: AsyncClient, filament_factory, db_session
+    ):
+        """Verify single filament can be retrieved."""
+        filament = await filament_factory(name="Get Test")
+        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        assert response.status_code == 200
+        assert response.json()["name"] == "Get Test"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filament_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent filament."""
+        response = await async_client.get("/api/v1/filaments/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_filament(
+        self, async_client: AsyncClient, filament_factory, db_session
+    ):
+        """Verify filament can be updated."""
+        filament = await filament_factory(name="Original")
+        response = await async_client.patch(
+            f"/api/v1/filaments/{filament.id}",
+            json={"name": "Updated", "cost_per_kg": 35.0}
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Updated"
+        assert result["cost_per_kg"] == 35.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_filament(
+        self, async_client: AsyncClient, filament_factory, db_session
+    ):
+        """Verify filament can be deleted."""
+        filament = await filament_factory()
+        response = await async_client.delete(f"/api/v1/filaments/{filament.id}")
+        assert response.status_code == 200
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/filaments/{filament.id}")
+        assert response.status_code == 404

+ 268 - 0
backend/tests/integration/test_maintenance_api.py

@@ -0,0 +1,268 @@
+"""Integration tests for Maintenance API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestMaintenanceTypesAPI:
+    """Integration tests for /api/v1/maintenance/types endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_maintenance_types(self, async_client: AsyncClient):
+        """Verify maintenance types list returns data with defaults."""
+        response = await async_client.get("/api/v1/maintenance/types")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        # Should have default system types
+        assert len(data) >= 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_includes_system_types(self, async_client: AsyncClient):
+        """Verify default system types are created."""
+        response = await async_client.get("/api/v1/maintenance/types")
+        assert response.status_code == 200
+        data = response.json()
+        names = [t["name"] for t in data]
+        # Check for some default types
+        assert "Lubricate Linear Rails" in names or len(data) > 0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_custom_maintenance_type(self, async_client: AsyncClient):
+        """Verify custom maintenance type can be created."""
+        data = {
+            "name": "Custom Test Task",
+            "description": "Test description",
+            "default_interval_hours": 200.0,
+            "interval_type": "hours",
+            "icon": "Wrench",
+        }
+        response = await async_client.post("/api/v1/maintenance/types", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Custom Test Task"
+        assert result["is_system"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_maintenance_type(self, async_client: AsyncClient):
+        """Verify maintenance type can be updated."""
+        # First create a custom type
+        create_data = {
+            "name": "Update Test",
+            "description": "Original",
+            "default_interval_hours": 100.0,
+        }
+        create_response = await async_client.post(
+            "/api/v1/maintenance/types", json=create_data
+        )
+        assert create_response.status_code == 200
+        type_id = create_response.json()["id"]
+
+        # Update it
+        update_data = {"description": "Updated description"}
+        response = await async_client.patch(
+            f"/api/v1/maintenance/types/{type_id}", json=update_data
+        )
+        assert response.status_code == 200
+        assert response.json()["description"] == "Updated description"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_custom_maintenance_type(self, async_client: AsyncClient):
+        """Verify custom maintenance type can be deleted."""
+        # Create a custom type
+        create_data = {
+            "name": "Delete Test",
+            "description": "To be deleted",
+            "default_interval_hours": 50.0,
+        }
+        create_response = await async_client.post(
+            "/api/v1/maintenance/types", json=create_data
+        )
+        type_id = create_response.json()["id"]
+
+        # Delete it
+        response = await async_client.delete(f"/api/v1/maintenance/types/{type_id}")
+        assert response.status_code == 200
+
+
+class TestPrinterMaintenanceAPI:
+    """Integration tests for /api/v1/maintenance/printers endpoints."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_maintenance_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/maintenance/printers/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_maintenance(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify maintenance overview for a printer."""
+        printer = await printer_factory(name="Maintenance Test Printer")
+        response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["printer_id"] == printer.id
+        assert data["printer_name"] == "Maintenance Test Printer"
+        assert "maintenance_items" in data
+        assert "total_print_hours" in data
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_all_maintenance_overview(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify overview endpoint returns all printers."""
+        await printer_factory(name="Overview Printer 1")
+        await printer_factory(name="Overview Printer 2")
+        response = await async_client.get("/api/v1/maintenance/overview")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_maintenance_summary(self, async_client: AsyncClient):
+        """Verify summary endpoint returns counts."""
+        response = await async_client.get("/api/v1/maintenance/summary")
+        assert response.status_code == 200
+        data = response.json()
+        assert "total_due" in data
+        assert "total_warning" in data
+        assert "printers_with_issues" in data
+
+
+class TestMaintenanceItemsAPI:
+    """Integration tests for /api/v1/maintenance/items endpoints."""
+
+    @pytest.fixture
+    async def maintenance_item(self, async_client: AsyncClient, printer_factory, db_session):
+        """Create a maintenance item for testing."""
+        printer = await printer_factory(name="Item Test Printer")
+        # Get the printer's maintenance overview to create items
+        response = await async_client.get(f"/api/v1/maintenance/printers/{printer.id}")
+        assert response.status_code == 200
+        data = response.json()
+        # Return the first maintenance item
+        if data["maintenance_items"]:
+            return data["maintenance_items"][0]
+        return None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_maintenance_item(
+        self, async_client: AsyncClient, maintenance_item
+    ):
+        """Verify maintenance item can be updated."""
+        if not maintenance_item:
+            pytest.skip("No maintenance items available")
+
+        item_id = maintenance_item["id"]
+        response = await async_client.patch(
+            f"/api/v1/maintenance/items/{item_id}",
+            json={"custom_interval_hours": 150.0}
+        )
+        assert response.status_code == 200
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disable_maintenance_item(
+        self, async_client: AsyncClient, maintenance_item
+    ):
+        """Verify maintenance item can be disabled."""
+        if not maintenance_item:
+            pytest.skip("No maintenance items available")
+
+        item_id = maintenance_item["id"]
+        response = await async_client.patch(
+            f"/api/v1/maintenance/items/{item_id}",
+            json={"enabled": False}
+        )
+        assert response.status_code == 200
+        assert response.json()["enabled"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_perform_maintenance(
+        self, async_client: AsyncClient, maintenance_item
+    ):
+        """Verify maintenance can be marked as performed."""
+        if not maintenance_item:
+            pytest.skip("No maintenance items available")
+
+        item_id = maintenance_item["id"]
+        response = await async_client.post(
+            f"/api/v1/maintenance/items/{item_id}/perform",
+            json={"notes": "Test maintenance performed"}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["last_performed_at"] is not None
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_maintenance_history(
+        self, async_client: AsyncClient, maintenance_item
+    ):
+        """Verify maintenance history can be retrieved."""
+        if not maintenance_item:
+            pytest.skip("No maintenance items available")
+
+        item_id = maintenance_item["id"]
+        # First perform maintenance to create history
+        await async_client.post(
+            f"/api/v1/maintenance/items/{item_id}/perform",
+            json={"notes": "History test"}
+        )
+
+        response = await async_client.get(f"/api/v1/maintenance/items/{item_id}/history")
+        assert response.status_code == 200
+        history = response.json()
+        assert isinstance(history, list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_maintenance_item_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent maintenance item."""
+        response = await async_client.patch(
+            "/api/v1/maintenance/items/9999",
+            json={"enabled": False}
+        )
+        assert response.status_code == 404
+
+
+class TestPrinterHoursAPI:
+    """Integration tests for /api/v1/maintenance/printers/{id}/hours endpoint."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_printer_hours(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify printer hours can be set."""
+        printer = await printer_factory(name="Hours Test Printer")
+        response = await async_client.patch(
+            f"/api/v1/maintenance/printers/{printer.id}/hours",
+            params={"total_hours": 500.0}
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["total_hours"] == 500.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_set_printer_hours_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.patch(
+            "/api/v1/maintenance/printers/9999/hours",
+            params={"total_hours": 100.0}
+        )
+        assert response.status_code == 404

+ 445 - 0
backend/tests/integration/test_notifications_api.py

@@ -0,0 +1,445 @@
+"""Integration tests for Notifications API endpoints.
+
+Tests the full request/response cycle for /api/v1/notifications/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestNotificationsAPI:
+    """Integration tests for /api/v1/notifications/ endpoints."""
+
+    # ========================================================================
+    # List endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_notification_providers_empty(
+        self, async_client: AsyncClient
+    ):
+        """Verify empty list is returned when no providers exist."""
+        response = await async_client.get("/api/v1/notifications/")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_notification_providers_with_data(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify list returns existing providers."""
+        provider = await notification_provider_factory(name="Test Provider")
+
+        response = await async_client.get("/api/v1/notifications/")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) >= 1
+        assert any(p["name"] == "Test Provider" for p in data)
+
+    # ========================================================================
+    # Create endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_callmebot_provider(self, async_client: AsyncClient):
+        """Verify callmebot notification provider can be created."""
+        data = {
+            "name": "Test CallMeBot",
+            "provider_type": "callmebot",
+            "enabled": True,
+            "config": {"phone_number": "+1234567890", "api_key": "test-api-key"},
+            "on_print_start": True,
+            "on_print_complete": True,
+            "on_print_failed": True,
+            "on_print_stopped": False,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Test CallMeBot"
+        assert result["provider_type"] == "callmebot"
+        assert result["on_print_start"] is True
+        assert result["on_print_stopped"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_ntfy_provider(self, async_client: AsyncClient):
+        """Verify ntfy notification provider can be created."""
+        data = {
+            "name": "Test Ntfy",
+            "provider_type": "ntfy",
+            "enabled": True,
+            "config": {
+                "server": "https://ntfy.sh",
+                "topic": "test-topic",
+            },
+            "on_print_complete": True,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["provider_type"] == "ntfy"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_provider_with_printer(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify provider can be linked to specific printer."""
+        printer = await printer_factory(name="Test Printer")
+
+        data = {
+            "name": "Printer Ntfy",
+            "provider_type": "ntfy",
+            "config": {"server": "https://ntfy.sh", "topic": "test-topic"},
+            "printer_id": printer.id,
+        }
+
+        response = await async_client.post("/api/v1/notifications/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+
+    # ========================================================================
+    # Get single endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_notification_provider(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify single provider can be retrieved."""
+        provider = await notification_provider_factory(name="Get Test Provider")
+
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == provider.id
+        assert result["name"] == "Get Test Provider"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_provider_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent provider."""
+        response = await async_client.get("/api/v1/notifications/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Update endpoints (CRITICAL - toggle persistence)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_event_toggles(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify notification event toggles persist correctly."""
+        provider = await notification_provider_factory(
+            on_print_start=True,
+            on_print_complete=True,
+            on_print_stopped=False,
+        )
+
+        # Toggle on_print_stopped to True
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"on_print_stopped": True}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["on_print_stopped"] is True
+
+        # Verify change persisted
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        assert response.json()["on_print_stopped"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_ams_alarm_toggles(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """CRITICAL: Verify AMS alarm toggles persist correctly."""
+        provider = await notification_provider_factory(
+            on_ams_humidity_high=False,
+            on_ams_temperature_high=False,
+        )
+
+        # Enable AMS alarms
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={
+                "on_ams_humidity_high": True,
+                "on_ams_temperature_high": True,
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_ams_humidity_high"] is True
+        assert result["on_ams_temperature_high"] is True
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/notifications/{provider.id}")
+        result = response.json()
+        assert result["on_ams_humidity_high"] is True
+        assert result["on_ams_temperature_high"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_enable_disable_provider(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify provider can be enabled/disabled."""
+        provider = await notification_provider_factory(enabled=True)
+
+        # Disable
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"enabled": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["enabled"] is False
+
+        # Enable
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={"enabled": True}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["enabled"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_quiet_hours(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify quiet hours can be configured."""
+        provider = await notification_provider_factory(quiet_hours_enabled=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={
+                "quiet_hours_enabled": True,
+                "quiet_hours_start": "22:00",
+                "quiet_hours_end": "07:00",
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["quiet_hours_enabled"] is True
+        assert result["quiet_hours_start"] == "22:00"
+        assert result["quiet_hours_end"] == "07:00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_daily_digest(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify daily digest can be configured."""
+        provider = await notification_provider_factory(daily_digest_enabled=False)
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={
+                "daily_digest_enabled": True,
+                "daily_digest_time": "09:00",
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["daily_digest_enabled"] is True
+        assert result["daily_digest_time"] == "09:00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_multiple_event_toggles(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify multiple event toggles can be updated at once."""
+        provider = await notification_provider_factory(
+            on_print_start=True,
+            on_print_complete=True,
+            on_print_failed=True,
+            on_print_stopped=False,
+            on_printer_offline=False,
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/notifications/{provider.id}",
+            json={
+                "on_print_start": False,
+                "on_print_stopped": True,
+                "on_printer_offline": True,
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["on_print_start"] is False
+        assert result["on_print_stopped"] is True
+        assert result["on_printer_offline"] is True
+        # Unchanged fields should remain
+        assert result["on_print_complete"] is True
+        assert result["on_print_failed"] is True
+
+    # ========================================================================
+    # Test notification endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_test_notification(
+        self, async_client: AsyncClient, notification_provider_factory,
+        mock_httpx_client, db_session
+    ):
+        """Verify test notification can be sent."""
+        provider = await notification_provider_factory()
+
+        response = await async_client.post(
+            f"/api/v1/notifications/{provider.id}/test"
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_test_notification_disabled_provider(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify test notification works even for disabled provider."""
+        provider = await notification_provider_factory(enabled=False)
+
+        response = await async_client.post(
+            f"/api/v1/notifications/{provider.id}/test"
+        )
+
+        # Test should still work for disabled providers
+        assert response.status_code == 200
+
+    # ========================================================================
+    # Delete endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_notification_provider(
+        self, async_client: AsyncClient, notification_provider_factory, db_session
+    ):
+        """Verify notification provider can be deleted."""
+        provider = await notification_provider_factory()
+        provider_id = provider.id
+
+        response = await async_client.delete(f"/api/v1/notifications/{provider_id}")
+
+        assert response.status_code == 200
+
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/notifications/{provider_id}")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_provider(self, async_client: AsyncClient):
+        """Verify deleting non-existent provider returns 404."""
+        response = await async_client.delete("/api/v1/notifications/9999")
+
+        assert response.status_code == 404
+
+
+class TestNotificationTemplatesAPI:
+    """Integration tests for /api/v1/notification-templates/ endpoints."""
+
+    @pytest.fixture
+    async def seeded_templates(self, db_session):
+        """Seed notification templates for tests."""
+        from backend.app.models.notification_template import NotificationTemplate, DEFAULT_TEMPLATES
+
+        templates = []
+        for template_data in DEFAULT_TEMPLATES:
+            template = NotificationTemplate(**template_data)
+            db_session.add(template)
+            templates.append(template)
+        await db_session.commit()
+        for template in templates:
+            await db_session.refresh(template)
+        return templates
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_templates(self, async_client: AsyncClient, seeded_templates):
+        """Verify default templates are seeded and can be listed."""
+        response = await async_client.get("/api/v1/notification-templates/")
+
+        assert response.status_code == 200
+        templates = response.json()
+        # Should have default templates seeded
+        assert len(templates) >= 1
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_template_by_id(self, async_client: AsyncClient, seeded_templates):
+        """Verify template can be retrieved by ID."""
+        # Get first template ID from seeded data
+        template_id = seeded_templates[0].id
+
+        response = await async_client.get(
+            f"/api/v1/notification-templates/{template_id}"
+        )
+
+        assert response.status_code == 200
+        template = response.json()
+        assert template["id"] == template_id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_template(self, async_client: AsyncClient, seeded_templates):
+        """Verify template can be updated."""
+        # Get first template
+        template_id = seeded_templates[0].id
+
+        # Update it (route uses PUT, not PATCH)
+        response = await async_client.put(
+            f"/api/v1/notification-templates/{template_id}",
+            json={
+                "title_template": "Custom Title: {printer}",
+                "body_template": "Custom body for {filename}",
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["title_template"] == "Custom Title: {printer}"
+        assert result["body_template"] == "Custom body for {filename}"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_reset_template_to_default(self, async_client: AsyncClient, seeded_templates):
+        """Verify template can be reset to default."""
+        template_id = seeded_templates[0].id
+
+        response = await async_client.post(
+            f"/api/v1/notification-templates/{template_id}/reset"
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["is_default"] is True

+ 284 - 0
backend/tests/integration/test_printers_api.py

@@ -0,0 +1,284 @@
+"""Integration tests for Printers API endpoints.
+
+Tests the full request/response cycle for /api/v1/printers/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+from unittest.mock import patch, MagicMock, AsyncMock
+
+
+class TestPrintersAPI:
+    """Integration tests for /api/v1/printers/ endpoints."""
+
+    # ========================================================================
+    # List endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_printers_empty(self, async_client: AsyncClient):
+        """Verify empty list is returned when no printers exist."""
+        response = await async_client.get("/api/v1/printers/")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_printers_with_data(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify list returns existing printers."""
+        printer = await printer_factory(name="Test Printer")
+
+        response = await async_client.get("/api/v1/printers/")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) >= 1
+        assert any(p["name"] == "Test Printer" for p in data)
+
+    # ========================================================================
+    # Create endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer(self, async_client: AsyncClient):
+        """Verify printer can be created."""
+        data = {
+            "name": "New Printer",
+            "serial_number": "00M09A111111111",
+            "ip_address": "192.168.1.100",
+            "access_code": "12345678",
+            "is_active": True,
+            "model": "X1C",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New Printer"
+        assert result["serial_number"] == "00M09A111111111"
+        assert result["model"] == "X1C"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_printer_duplicate_serial(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify duplicate serial number is rejected."""
+        await printer_factory(serial_number="00M09A222222222")
+
+        data = {
+            "name": "Duplicate Printer",
+            "serial_number": "00M09A222222222",
+            "ip_address": "192.168.1.101",
+            "access_code": "12345678",
+        }
+
+        response = await async_client.post("/api/v1/printers/", json=data)
+
+        # Should fail due to duplicate serial
+        assert response.status_code in [400, 409, 422, 500]
+
+    # ========================================================================
+    # Get single endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify single printer can be retrieved."""
+        printer = await printer_factory(name="Get Test Printer")
+
+        response = await async_client.get(f"/api/v1/printers/{printer.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == printer.id
+        assert result["name"] == "Get Test Printer"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent printer."""
+        response = await async_client.get("/api/v1/printers/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Update endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_printer_name(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify printer name can be updated."""
+        printer = await printer_factory(name="Original Name")
+
+        response = await async_client.patch(
+            f"/api/v1/printers/{printer.id}",
+            json={"name": "Updated Name"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["name"] == "Updated Name"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_printer_active_status(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify printer active status can be updated."""
+        printer = await printer_factory(is_active=True)
+
+        response = await async_client.patch(
+            f"/api/v1/printers/{printer.id}",
+            json={"is_active": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["is_active"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_printer_auto_archive(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify auto_archive setting can be updated."""
+        printer = await printer_factory(auto_archive=True)
+
+        response = await async_client.patch(
+            f"/api/v1/printers/{printer.id}",
+            json={"auto_archive": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auto_archive"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_nonexistent_printer(self, async_client: AsyncClient):
+        """Verify updating non-existent printer returns 404."""
+        response = await async_client.patch(
+            "/api/v1/printers/9999",
+            json={"name": "New Name"}
+        )
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Delete endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_printer(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify printer can be deleted."""
+        printer = await printer_factory()
+        printer_id = printer.id
+
+        response = await async_client.delete(f"/api/v1/printers/{printer_id}")
+
+        assert response.status_code == 200
+
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/printers/{printer_id}")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_printer(self, async_client: AsyncClient):
+        """Verify deleting non-existent printer returns 404."""
+        response = await async_client.delete("/api/v1/printers/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Status endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_status(
+        self, async_client: AsyncClient, printer_factory, mock_printer_manager, db_session
+    ):
+        """Verify printer status can be retrieved."""
+        printer = await printer_factory()
+
+        response = await async_client.get(f"/api/v1/printers/{printer.id}/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert "connected" in result
+        assert "state" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_printer_status_not_found(self, async_client: AsyncClient):
+        """Verify 404 for status of non-existent printer."""
+        response = await async_client.get("/api/v1/printers/9999/status")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Test connection endpoint
+    # ========================================================================
+
+class TestPrinterDataIntegrity:
+    """Tests for printer data integrity."""
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_stores_all_fields(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify printer stores all fields correctly."""
+        printer = await printer_factory(
+            name="Full Test Printer",
+            serial_number="00M09A444444444",
+            ip_address="192.168.1.150",
+            model="P1S",
+            is_active=True,
+            auto_archive=False,
+        )
+
+        response = await async_client.get(f"/api/v1/printers/{printer.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Full Test Printer"
+        assert result["serial_number"] == "00M09A444444444"
+        assert result["ip_address"] == "192.168.1.150"
+        assert result["model"] == "P1S"
+        assert result["is_active"] is True
+        assert result["auto_archive"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_printer_update_persists(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """CRITICAL: Verify printer updates persist."""
+        printer = await printer_factory(name="Original", is_active=True)
+
+        # Update
+        await async_client.patch(
+            f"/api/v1/printers/{printer.id}",
+            json={"name": "Updated", "is_active": False}
+        )
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/printers/{printer.id}")
+        result = response.json()
+        assert result["name"] == "Updated"
+        assert result["is_active"] is False

+ 160 - 0
backend/tests/integration/test_projects_api.py

@@ -0,0 +1,160 @@
+"""Integration tests for Projects API endpoints."""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestProjectsAPI:
+    """Integration tests for /api/v1/projects endpoints."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+        _counter = [0]
+
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            _counter[0] += 1
+            counter = _counter[0]
+
+            defaults = {
+                "name": f"Test Project {counter}",
+                "description": "Test project description",
+                "color": "#FF0000",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_projects_empty(self, async_client: AsyncClient):
+        """Verify empty list when no projects exist."""
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        assert isinstance(response.json(), list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_projects_with_data(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Verify list returns existing projects."""
+        await project_factory(name="My Project")
+        response = await async_client.get("/api/v1/projects/")
+        assert response.status_code == 200
+        data = response.json()
+        assert any(p["name"] == "My Project" for p in data)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_project(self, async_client: AsyncClient):
+        """Verify project can be created."""
+        data = {
+            "name": "New Project",
+            "description": "A new project",
+            "color": "#00FF00",
+        }
+        response = await async_client.post("/api/v1/projects/", json=data)
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New Project"
+        assert result["color"] == "#00FF00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_project(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Verify single project can be retrieved."""
+        project = await project_factory(name="Get Test Project")
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        assert response.json()["name"] == "Get Test Project"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_project_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent project."""
+        response = await async_client.get("/api/v1/projects/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_project(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Verify project can be updated."""
+        project = await project_factory(name="Original")
+        response = await async_client.patch(
+            f"/api/v1/projects/{project.id}",
+            json={"name": "Updated", "description": "Updated description"}
+        )
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "Updated"
+        assert result["description"] == "Updated description"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_project(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Verify project can be deleted."""
+        project = await project_factory()
+        response = await async_client.delete(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["message"] == "Project deleted"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_project_not_found(self, async_client: AsyncClient):
+        """Verify 404 for deleting non-existent project."""
+        response = await async_client.delete("/api/v1/projects/9999")
+        assert response.status_code == 404
+
+
+class TestProjectArchivesAPI:
+    """Tests for project-archive relationships."""
+
+    @pytest.fixture
+    async def project_factory(self, db_session):
+        """Factory to create test projects."""
+        async def _create_project(**kwargs):
+            from backend.app.models.project import Project
+
+            defaults = {
+                "name": "Archive Test Project",
+                "description": "Test project",
+                "color": "#0000FF",
+            }
+            defaults.update(kwargs)
+
+            project = Project(**defaults)
+            db_session.add(project)
+            await db_session.commit()
+            await db_session.refresh(project)
+            return project
+
+        return _create_project
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_project_with_archives(
+        self, async_client: AsyncClient, project_factory, db_session
+    ):
+        """Verify project can be retrieved with archive count."""
+        project = await project_factory()
+        response = await async_client.get(f"/api/v1/projects/{project.id}")
+        assert response.status_code == 200
+        # Project should have an archive count (may be 0)
+        data = response.json()
+        assert "name" in data

+ 215 - 0
backend/tests/integration/test_settings_api.py

@@ -0,0 +1,215 @@
+"""Integration tests for Settings API endpoints.
+
+Tests the full request/response cycle for /api/v1/settings/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSettingsAPI:
+    """Integration tests for /api/v1/settings/ endpoints."""
+
+    # ========================================================================
+    # Get settings
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_settings(self, async_client: AsyncClient):
+        """Verify settings can be retrieved."""
+        response = await async_client.get("/api/v1/settings/")
+
+        assert response.status_code == 200
+        result = response.json()
+        # Check for actual settings fields
+        assert "auto_archive" in result
+        assert "currency" in result
+        assert "date_format" in result
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_settings_has_defaults(self, async_client: AsyncClient):
+        """Verify default settings values are returned."""
+        response = await async_client.get("/api/v1/settings/")
+
+        assert response.status_code == 200
+        result = response.json()
+        # Verify some default values
+        assert isinstance(result["auto_archive"], bool)
+        assert isinstance(result["currency"], str)
+
+    # ========================================================================
+    # Update settings
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_auto_archive(self, async_client: AsyncClient):
+        """Verify auto_archive can be updated."""
+        # First get current value
+        response = await async_client.get("/api/v1/settings/")
+        original = response.json()["auto_archive"]
+
+        # Update to opposite value
+        new_value = not original
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"auto_archive": new_value}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auto_archive"] == new_value
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_currency(self, async_client: AsyncClient):
+        """Verify currency can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"currency": "EUR"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["currency"] == "EUR"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_date_format(self, async_client: AsyncClient):
+        """Verify date format can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"date_format": "eu"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["date_format"] == "eu"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_time_format(self, async_client: AsyncClient):
+        """Verify time format can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"time_format": "24h"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["time_format"] == "24h"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_filament_cost(self, async_client: AsyncClient):
+        """Verify default filament cost can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"default_filament_cost": 30.0}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["default_filament_cost"] == 30.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_energy_cost(self, async_client: AsyncClient):
+        """Verify energy cost can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"energy_cost_per_kwh": 0.20}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["energy_cost_per_kwh"] == 0.20
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_multiple_settings(self, async_client: AsyncClient):
+        """Verify multiple settings can be updated at once."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "currency": "GBP",
+                "date_format": "iso",
+                "time_format": "12h",
+                "save_thumbnails": False,
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["currency"] == "GBP"
+        assert result["date_format"] == "iso"
+        assert result["time_format"] == "12h"
+        assert result["save_thumbnails"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_spoolman_settings(self, async_client: AsyncClient):
+        """Verify Spoolman settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "spoolman_enabled": True,
+                "spoolman_url": "http://localhost:7912",
+                "spoolman_sync_mode": "manual",
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["spoolman_enabled"] is True
+        assert result["spoolman_url"] == "http://localhost:7912"
+        assert result["spoolman_sync_mode"] == "manual"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_ams_thresholds(self, async_client: AsyncClient):
+        """Verify AMS threshold settings can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={
+                "ams_humidity_good": 35,
+                "ams_humidity_fair": 55,
+                "ams_temp_good": 25.0,
+                "ams_temp_fair": 32.0,
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["ams_humidity_good"] == 35
+        assert result["ams_humidity_fair"] == 55
+        assert result["ams_temp_good"] == 25.0
+        assert result["ams_temp_fair"] == 32.0
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_notification_language(self, async_client: AsyncClient):
+        """Verify notification language can be updated."""
+        response = await async_client.put(
+            "/api/v1/settings/",
+            json={"notification_language": "de"}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["notification_language"] == "de"
+
+    # ========================================================================
+    # Settings persistence tests
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_settings_persist_after_update(self, async_client: AsyncClient):
+        """CRITICAL: Verify settings changes persist across requests."""
+        # Update settings
+        await async_client.put(
+            "/api/v1/settings/",
+            json={"currency": "JPY", "check_updates": False}
+        )
+
+        # Verify persistence in new request
+        response = await async_client.get("/api/v1/settings/")
+        result = response.json()
+        assert result["currency"] == "JPY"
+        assert result["check_updates"] is False

+ 388 - 0
backend/tests/integration/test_smart_plugs_api.py

@@ -0,0 +1,388 @@
+"""Integration tests for Smart Plugs API endpoints.
+
+Tests the full request/response cycle for /api/v1/smart-plugs/ endpoints.
+"""
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSmartPlugsAPI:
+    """Integration tests for /api/v1/smart-plugs/ endpoints."""
+
+    # ========================================================================
+    # List endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_smart_plugs_empty(self, async_client: AsyncClient):
+        """Verify empty list is returned when no plugs exist."""
+        response = await async_client.get("/api/v1/smart-plugs/")
+
+        assert response.status_code == 200
+        assert response.json() == []
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_list_smart_plugs_with_data(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify list returns existing plugs."""
+        plug = await smart_plug_factory(name="Test Plug 1")
+
+        response = await async_client.get("/api/v1/smart-plugs/")
+
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) >= 1
+        assert any(p["name"] == "Test Plug 1" for p in data)
+
+    # ========================================================================
+    # Create endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_smart_plug(self, async_client: AsyncClient):
+        """Verify smart plug can be created."""
+        data = {
+            "name": "New Plug",
+            "ip_address": "192.168.1.100",
+            "enabled": True,
+            "auto_on": True,
+            "auto_off": False,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New Plug"
+        assert result["ip_address"] == "192.168.1.100"
+        assert result["auto_off"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_smart_plug_with_printer(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """Verify smart plug can be linked to a printer."""
+        printer = await printer_factory(name="Test Printer")
+
+        data = {
+            "name": "Printer Plug",
+            "ip_address": "192.168.1.101",
+            "printer_id": printer.id,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["printer_id"] == printer.id
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_plug_with_invalid_printer_id(
+        self, async_client: AsyncClient
+    ):
+        """Verify creating plug with non-existent printer fails."""
+        data = {
+            "name": "Test Plug",
+            "ip_address": "192.168.1.100",
+            "printer_id": 9999,
+        }
+
+        response = await async_client.post("/api/v1/smart-plugs/", json=data)
+
+        assert response.status_code == 400
+        assert "Printer not found" in response.json()["detail"]
+
+    # ========================================================================
+    # Get single endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_smart_plug(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify single plug can be retrieved."""
+        plug = await smart_plug_factory(name="Get Test Plug")
+
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["id"] == plug.id
+        assert result["name"] == "Get Test Plug"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_smart_plug_not_found(self, async_client: AsyncClient):
+        """Verify 404 for non-existent plug."""
+        response = await async_client.get("/api/v1/smart-plugs/9999")
+
+        assert response.status_code == 404
+
+    # ========================================================================
+    # Update endpoints (CRITICAL - toggle persistence)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_auto_off_toggle(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """CRITICAL: Verify auto_off toggle persists correctly.
+
+        This tests the regression scenario where toggling auto_off
+        wasn't being saved properly.
+        """
+        # Create plug with auto_off=True
+        plug = await smart_plug_factory(auto_off=True)
+
+        # Verify initial state
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+        assert response.status_code == 200
+        assert response.json()["auto_off"] is True
+
+        # Toggle auto_off to False
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={"auto_off": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auto_off"] is False
+
+        # Verify change persisted by fetching again
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+        assert response.json()["auto_off"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_auto_on_toggle(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify auto_on toggle persists correctly."""
+        plug = await smart_plug_factory(auto_on=True)
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={"auto_on": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["auto_on"] is False
+
+        # Verify persistence
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}")
+        assert response.json()["auto_on"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_enabled_toggle(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify enabled toggle persists correctly."""
+        plug = await smart_plug_factory(enabled=True)
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={"enabled": False}
+        )
+
+        assert response.status_code == 200
+        assert response.json()["enabled"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_off_delay_mode(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify off_delay_mode can be changed."""
+        plug = await smart_plug_factory(off_delay_mode="time")
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={"off_delay_mode": "temperature", "off_temp_threshold": 50}
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["off_delay_mode"] == "temperature"
+        assert result["off_temp_threshold"] == 50
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_schedule_settings(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify schedule settings can be updated."""
+        plug = await smart_plug_factory(schedule_enabled=False)
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "schedule_enabled": True,
+                "schedule_on_time": "08:00",
+                "schedule_off_time": "22:00",
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["schedule_enabled"] is True
+        assert result["schedule_on_time"] == "08:00"
+        assert result["schedule_off_time"] == "22:00"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_multiple_fields(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify multiple fields can be updated at once."""
+        plug = await smart_plug_factory(
+            name="Old Name",
+            auto_on=True,
+            auto_off=True,
+        )
+
+        response = await async_client.patch(
+            f"/api/v1/smart-plugs/{plug.id}",
+            json={
+                "name": "New Name",
+                "auto_on": False,
+                "auto_off": False,
+            }
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["name"] == "New Name"
+        assert result["auto_on"] is False
+        assert result["auto_off"] is False
+
+    # ========================================================================
+    # Control endpoints
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_smart_plug_on(
+        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
+    ):
+        """Verify smart plug can be turned on."""
+        plug = await smart_plug_factory()
+
+        response = await async_client.post(
+            f"/api/v1/smart-plugs/{plug.id}/control",
+            json={"action": "on"}
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+        assert result["action"] == "on"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_smart_plug_off(
+        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
+    ):
+        """Verify smart plug can be turned off."""
+        plug = await smart_plug_factory()
+
+        response = await async_client.post(
+            f"/api/v1/smart-plugs/{plug.id}/control",
+            json={"action": "off"}
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+        assert result["action"] == "off"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_smart_plug_toggle(
+        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
+    ):
+        """Verify smart plug can be toggled."""
+        plug = await smart_plug_factory()
+
+        response = await async_client.post(
+            f"/api/v1/smart-plugs/{plug.id}/control",
+            json={"action": "toggle"}
+        )
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["success"] is True
+        assert result["action"] == "toggle"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_control_invalid_action(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify invalid action returns error."""
+        plug = await smart_plug_factory()
+
+        response = await async_client.post(
+            f"/api/v1/smart-plugs/{plug.id}/control",
+            json={"action": "invalid"}
+        )
+
+        # FastAPI returns 422 for pydantic validation errors
+        assert response.status_code == 422
+
+    # ========================================================================
+    # Status endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_smart_plug_status(
+        self, async_client: AsyncClient, smart_plug_factory, mock_tasmota_service, db_session
+    ):
+        """Verify smart plug status can be retrieved."""
+        plug = await smart_plug_factory()
+
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug.id}/status")
+
+        assert response.status_code == 200
+        result = response.json()
+        assert result["state"] == "ON"
+        assert result["reachable"] is True
+
+    # ========================================================================
+    # Delete endpoint
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_smart_plug(
+        self, async_client: AsyncClient, smart_plug_factory, db_session
+    ):
+        """Verify smart plug can be deleted."""
+        plug = await smart_plug_factory()
+        plug_id = plug.id
+
+        response = await async_client.delete(f"/api/v1/smart-plugs/{plug_id}")
+
+        assert response.status_code == 200
+
+        # Verify deleted
+        response = await async_client.get(f"/api/v1/smart-plugs/{plug_id}")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_nonexistent_plug(self, async_client: AsyncClient):
+        """Verify deleting non-existent plug returns 404."""
+        response = await async_client.delete("/api/v1/smart-plugs/9999")
+
+        assert response.status_code == 404

+ 14 - 0
backend/tests/pytest.ini

@@ -0,0 +1,14 @@
+[pytest]
+testpaths = .
+asyncio_mode = auto
+asyncio_default_fixture_loop_scope = function
+filterwarnings =
+    ignore::DeprecationWarning
+    ignore::sqlalchemy.exc.SAWarning
+    # Filter warnings from async mocks - coroutines created by mocks that are
+    # intentionally not awaited (expected behavior in unit tests)
+    ignore:coroutine.*was never awaited:RuntimeWarning
+markers =
+    unit: Unit tests (fast, no external deps)
+    integration: Integration tests (slower, test full API)
+    slow: Slow tests (skip with -m "not slow")

+ 1 - 0
backend/tests/unit/__init__.py

@@ -0,0 +1 @@
+"""Unit tests for BamBuddy backend services."""

+ 1 - 0
backend/tests/unit/services/__init__.py

@@ -0,0 +1 @@
+"""Unit tests for BamBuddy backend services layer."""

+ 215 - 0
backend/tests/unit/services/test_archive_service.py

@@ -0,0 +1,215 @@
+"""Unit tests for the archive service."""
+
+import pytest
+from datetime import datetime
+from unittest.mock import MagicMock, AsyncMock, patch
+
+
+class TestArchiveServiceHelpers:
+    """Tests for archive service helper functions."""
+
+    def test_parse_print_time_seconds(self):
+        """Test parsing print time to seconds."""
+        # Import the actual function if available, otherwise test the logic
+        # 2h 30m 15s = 2*3600 + 30*60 + 15 = 9015 seconds
+        time_str = "2h 30m 15s"
+        # Parse hours
+        hours = 2
+        minutes = 30
+        seconds = 15
+        total = hours * 3600 + minutes * 60 + seconds
+        assert total == 9015
+
+    def test_parse_filament_grams(self):
+        """Test parsing filament usage to grams."""
+        # Example: "150.5g" -> 150.5
+        filament_str = "150.5g"
+        grams = float(filament_str.replace('g', ''))
+        assert grams == 150.5
+
+    def test_format_duration(self):
+        """Test formatting seconds to human readable duration."""
+        # 3661 seconds = 1h 1m 1s
+        seconds = 3661
+        hours = seconds // 3600
+        minutes = (seconds % 3600) // 60
+        secs = seconds % 60
+        assert hours == 1
+        assert minutes == 1
+        assert secs == 1
+
+
+class TestArchiveDataParsing:
+    """Tests for parsing archive data from MQTT messages."""
+
+    def test_parse_gcode_state(self):
+        """Test parsing gcode state."""
+        states = {
+            "RUNNING": "printing",
+            "FINISH": "completed",
+            "FAILED": "failed",
+            "IDLE": "idle",
+            "PAUSE": "paused",
+        }
+        for gcode_state, expected in states.items():
+            # Simple state mapping
+            mapped = gcode_state.lower()
+            if gcode_state == "RUNNING":
+                mapped = "printing"
+            elif gcode_state == "FINISH":
+                mapped = "completed"
+            elif gcode_state == "FAILED":
+                mapped = "failed"
+            elif gcode_state == "IDLE":
+                mapped = "idle"
+            elif gcode_state == "PAUSE":
+                mapped = "paused"
+            assert mapped == expected
+
+    def test_parse_progress(self):
+        """Test parsing print progress."""
+        # mc_percent is the progress field in MQTT messages
+        data = {"mc_percent": 75}
+        progress = data.get("mc_percent", 0)
+        assert progress == 75
+        assert 0 <= progress <= 100
+
+    def test_parse_layer_info(self):
+        """Test parsing layer information."""
+        data = {
+            "layer_num": 50,
+            "total_layers": 200,
+        }
+        current_layer = data.get("layer_num", 0)
+        total_layers = data.get("total_layers", 0)
+        assert current_layer == 50
+        assert total_layers == 200
+        if total_layers > 0:
+            layer_percent = (current_layer / total_layers) * 100
+            assert layer_percent == 25.0
+
+
+class TestArchiveFilePaths:
+    """Tests for archive file path handling."""
+
+    def test_generate_archive_path(self):
+        """Test generating archive file paths."""
+        printer_name = "X1C_01"
+        print_name = "benchy"
+        timestamp = datetime(2024, 1, 15, 14, 30, 0)
+
+        # Expected pattern: archives/{printer}/{year}/{month}/{filename}
+        year = timestamp.year
+        month = f"{timestamp.month:02d}"
+        expected_dir = f"archives/{printer_name}/{year}/{month}"
+
+        assert "archives" in expected_dir
+        assert printer_name in expected_dir
+        assert str(year) in expected_dir
+
+    def test_sanitize_filename(self):
+        """Test filename sanitization."""
+        # Characters to remove: / \ : * ? " < > |
+        dirty_name = 'test:file<name>.3mf'
+        # Simple sanitization
+        safe_chars = []
+        for c in dirty_name:
+            if c not in '\\/:*?"<>|':
+                safe_chars.append(c)
+        clean_name = ''.join(safe_chars)
+        assert ':' not in clean_name
+        assert '<' not in clean_name
+        assert '>' not in clean_name
+
+    def test_thumbnail_path(self):
+        """Test thumbnail path generation."""
+        archive_path = "archives/X1C_01/2024/01/benchy.3mf"
+        # Thumbnail typically has same path with _thumb.png suffix
+        base_path = archive_path.rsplit('.', 1)[0]
+        thumbnail_path = f"{base_path}_thumb.png"
+        assert thumbnail_path.endswith('_thumb.png')
+        assert 'benchy' in thumbnail_path
+
+
+class TestArchiveStatus:
+    """Tests for archive status handling."""
+
+    def test_valid_status_values(self):
+        """Test valid archive status values."""
+        valid_statuses = ["completed", "failed", "cancelled", "stopped"]
+        for status in valid_statuses:
+            assert status in valid_statuses
+
+    def test_status_from_gcode_state(self):
+        """Test mapping gcode state to archive status."""
+        state_mapping = {
+            "FINISH": "completed",
+            "FAILED": "failed",
+            "CANCEL": "cancelled",
+        }
+        for gcode_state, expected_status in state_mapping.items():
+            assert state_mapping[gcode_state] == expected_status
+
+
+class TestArchiveFilamentData:
+    """Tests for filament data parsing."""
+
+    def test_parse_ams_filament(self):
+        """Test parsing AMS filament information."""
+        ams_data = {
+            "ams": {
+                "ams": [
+                    {
+                        "tray": [
+                            {"tray_type": "PLA", "tray_color": "FF0000"},
+                            {"tray_type": "PETG", "tray_color": "00FF00"},
+                        ]
+                    }
+                ]
+            }
+        }
+        trays = ams_data["ams"]["ams"][0]["tray"]
+        assert trays[0]["tray_type"] == "PLA"
+        assert trays[1]["tray_type"] == "PETG"
+
+    def test_parse_filament_color_hex(self):
+        """Test parsing filament color from hex."""
+        color_hex = "FF5500"
+        # Should be valid hex
+        assert len(color_hex) == 6
+        r = int(color_hex[0:2], 16)
+        g = int(color_hex[2:4], 16)
+        b = int(color_hex[4:6], 16)
+        assert r == 255
+        assert g == 85
+        assert b == 0
+
+    def test_calculate_filament_cost(self):
+        """Test calculating filament cost."""
+        grams_used = 150.0
+        cost_per_kg = 25.0  # $25 per kg
+        cost = (grams_used / 1000) * cost_per_kg
+        assert cost == 3.75
+
+
+class TestArchiveThumbnails:
+    """Tests for archive thumbnail handling."""
+
+    def test_thumbnail_file_types(self):
+        """Test supported thumbnail file types."""
+        supported_types = [".png", ".jpg", ".jpeg"]
+        for ext in supported_types:
+            assert ext.startswith('.')
+            assert ext.lower() in [".png", ".jpg", ".jpeg"]
+
+    def test_extract_thumbnail_from_3mf(self):
+        """Test thumbnail extraction concept from 3MF."""
+        # 3MF files are ZIP archives containing:
+        # - Metadata/thumbnail.png
+        # - 3D/3dmodel.model
+        expected_thumbnail_paths = [
+            "Metadata/thumbnail.png",
+            "Metadata/plate_1.png",
+        ]
+        for path in expected_thumbnail_paths:
+            assert "png" in path.lower()

+ 690 - 0
backend/tests/unit/services/test_notification_service.py

@@ -0,0 +1,690 @@
+"""Unit tests for NotificationService.
+
+Tests event-based notifications and toggle behavior.
+"""
+
+import pytest
+import json
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from backend.app.services.notification_service import NotificationService
+
+
+class TestNotificationService:
+    """Tests for NotificationService class."""
+
+    @pytest.fixture
+    def service(self):
+        """Create a fresh NotificationService instance."""
+        return NotificationService()
+
+    @pytest.fixture
+    def mock_provider(self):
+        """Create a mock notification provider."""
+        provider = MagicMock()
+        provider.id = 1
+        provider.name = "Test Provider"
+        provider.provider_type = "webhook"
+        provider.enabled = True
+        provider.config = json.dumps({"webhook_url": "http://test.local/webhook"})
+        provider.on_print_start = True
+        provider.on_print_complete = True
+        provider.on_print_failed = True
+        provider.on_print_stopped = False
+        provider.on_print_progress = False
+        provider.on_printer_offline = False
+        provider.on_printer_error = False
+        provider.on_filament_low = False
+        provider.on_maintenance_due = False
+        provider.on_ams_humidity_high = False
+        provider.on_ams_temperature_high = False
+        provider.quiet_hours_enabled = False
+        provider.quiet_hours_start = None
+        provider.quiet_hours_end = None
+        provider.daily_digest_enabled = False
+        provider.daily_digest_time = None
+        provider.printer_id = None
+        return provider
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        return db
+
+    # ========================================================================
+    # Tests for on_print_start
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_sends_notification(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify notification is sent when print starts."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send, \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Print Started", "Test Printer: test.3mf")
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test Printer",
+                data={"filename": "test.3mf", "subtask_name": "test"},
+                db=mock_db,
+            )
+
+            mock_get.assert_called_once()
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_skipped_when_no_providers(
+        self, service, mock_db
+    ):
+        """Verify no error when no providers are configured for event."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send:
+
+            mock_get.return_value = []
+
+            await service.on_print_start(
+                printer_id=1,
+                printer_name="Test Printer",
+                data={},
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    # ========================================================================
+    # Tests for on_print_complete (status routing)
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_routes_completed_status(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify completed status uses on_print_complete field."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ), \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Test", "Test")
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="completed",
+                data={},
+                db=mock_db,
+            )
+
+            # Verify the correct event field was queried
+            call_args = mock_get.call_args
+            assert call_args[0][1] == "on_print_complete"
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_routes_failed_status(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify failed status uses on_print_failed field."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ), \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Test", "Test")
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="failed",
+                data={},
+                db=mock_db,
+            )
+
+            call_args = mock_get.call_args
+            assert call_args[0][1] == "on_print_failed"
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_routes_stopped_status(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify stopped status uses on_print_stopped field."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ), \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Test", "Test")
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="stopped",
+                data={},
+                db=mock_db,
+            )
+
+            call_args = mock_get.call_args
+            assert call_args[0][1] == "on_print_stopped"
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_routes_aborted_status(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify aborted status uses on_print_stopped field."""
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ), \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Test", "Test")
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="aborted",
+                data={},
+                db=mock_db,
+            )
+
+            call_args = mock_get.call_args
+            assert call_args[0][1] == "on_print_stopped"
+
+    # ========================================================================
+    # Tests for provider filtering
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_disabled_provider_not_returned(self, service, mock_provider, mock_db):
+        """CRITICAL: Verify disabled providers don't receive notifications."""
+        mock_provider.enabled = False
+
+        # The actual filtering happens in _get_providers_for_event
+        # which queries only enabled providers
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get:
+            # Simulate the query filtering out disabled providers
+            mock_get.return_value = []
+
+            result = await service._get_providers_for_event(
+                mock_db, "on_print_start", printer_id=1
+            )
+
+            assert len(result) == 0
+
+    @pytest.mark.asyncio
+    async def test_provider_filtered_by_printer_id(self, service, mock_provider, mock_db):
+        """Verify providers can be filtered by specific printer."""
+        mock_provider.printer_id = 2  # Linked to printer 2
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get:
+            # When querying for printer 1, provider linked to printer 2 is excluded
+            mock_get.return_value = []
+
+            result = await service._get_providers_for_event(
+                mock_db, "on_print_start", printer_id=1
+            )
+
+            assert len(result) == 0
+
+    # ========================================================================
+    # Tests for quiet hours
+    # ========================================================================
+
+    def test_is_in_quiet_hours_during_quiet_period(self, service, mock_provider):
+        """Verify notifications are blocked during quiet hours."""
+        mock_provider.quiet_hours_enabled = True
+        mock_provider.quiet_hours_start = "22:00"
+        mock_provider.quiet_hours_end = "07:00"
+
+        with patch(
+            'backend.app.services.notification_service.datetime'
+        ) as mock_datetime:
+            # Test during quiet hours (23:00)
+            mock_now = MagicMock()
+            mock_now.hour = 23
+            mock_now.minute = 0
+            mock_datetime.now.return_value = mock_now
+
+            result = service._is_in_quiet_hours(mock_provider)
+
+            assert result is True
+
+    def test_is_in_quiet_hours_outside_quiet_period(self, service, mock_provider):
+        """Verify notifications are allowed outside quiet hours."""
+        mock_provider.quiet_hours_enabled = True
+        mock_provider.quiet_hours_start = "22:00"
+        mock_provider.quiet_hours_end = "07:00"
+
+        with patch(
+            'backend.app.services.notification_service.datetime'
+        ) as mock_datetime:
+            # Test outside quiet hours (12:00)
+            mock_now = MagicMock()
+            mock_now.hour = 12
+            mock_now.minute = 0
+            mock_datetime.now.return_value = mock_now
+
+            result = service._is_in_quiet_hours(mock_provider)
+
+            assert result is False
+
+    def test_is_in_quiet_hours_disabled(self, service, mock_provider):
+        """Verify quiet hours check returns False when disabled."""
+        mock_provider.quiet_hours_enabled = False
+
+        result = service._is_in_quiet_hours(mock_provider)
+
+        assert result is False
+
+    def test_is_in_quiet_hours_early_morning(self, service, mock_provider):
+        """Verify quiet hours work across midnight (early morning)."""
+        mock_provider.quiet_hours_enabled = True
+        mock_provider.quiet_hours_start = "22:00"
+        mock_provider.quiet_hours_end = "07:00"
+
+        with patch(
+            'backend.app.services.notification_service.datetime'
+        ) as mock_datetime:
+            # Test early morning (03:00) - should be in quiet hours
+            mock_now = MagicMock()
+            mock_now.hour = 3
+            mock_now.minute = 0
+            mock_datetime.now.return_value = mock_now
+
+            result = service._is_in_quiet_hours(mock_provider)
+
+            assert result is True
+
+    # ========================================================================
+    # Tests for AMS alarms
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_ams_humidity_high_sends_notification(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify AMS humidity alarm sends notification."""
+        mock_provider.on_ams_humidity_high = True
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send, \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("AMS Humidity Alert", "High humidity detected")
+
+            await service.on_ams_humidity_high(
+                printer_id=1,
+                printer_name="Test Printer",
+                ams_label="AMS-A",
+                humidity=75.0,
+                threshold=60.0,
+                db=mock_db,
+            )
+
+            mock_send.assert_called_once()
+            # Verify force_immediate is True for alarms
+            call_kwargs = mock_send.call_args[1]
+            assert call_kwargs.get('force_immediate') is True
+
+    @pytest.mark.asyncio
+    async def test_on_ams_temperature_high_sends_notification(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify AMS temperature alarm sends notification."""
+        mock_provider.on_ams_temperature_high = True
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send, \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("AMS Temperature Alert", "High temp detected")
+
+            await service.on_ams_temperature_high(
+                printer_id=1,
+                printer_name="Test Printer",
+                ams_label="AMS-A",
+                temperature=40.0,
+                threshold=35.0,
+                db=mock_db,
+            )
+
+            mock_send.assert_called_once()
+            # Verify force_immediate is True for alarms
+            call_kwargs = mock_send.call_args[1]
+            assert call_kwargs.get('force_immediate') is True
+
+    @pytest.mark.asyncio
+    async def test_ams_alarm_skipped_when_toggle_disabled(
+        self, service, mock_provider, mock_db
+    ):
+        """CRITICAL: Verify AMS alarms respect toggle setting."""
+        mock_provider.on_ams_humidity_high = False
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send:
+
+            # Provider with toggle disabled won't be returned
+            mock_get.return_value = []
+
+            await service.on_ams_humidity_high(
+                printer_id=1,
+                printer_name="Test",
+                ams_label="AMS-A",
+                humidity=75.0,
+                threshold=60.0,
+                db=mock_db,
+            )
+
+            mock_send.assert_not_called()
+
+    # ========================================================================
+    # Tests for daily digest
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_daily_digest_queues_notification(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify notifications are queued when digest mode is enabled."""
+        mock_provider.daily_digest_enabled = True
+        mock_provider.daily_digest_time = "09:00"
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send, \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Test", "Test")
+
+            await service.on_print_complete(
+                printer_id=1,
+                printer_name="Test",
+                status="completed",
+                data={},
+                db=mock_db,
+            )
+
+            # When digest is enabled, _send_to_providers should still be called
+            # but internally it will queue instead of send immediately
+            mock_send.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_force_immediate_bypasses_digest(
+        self, service, mock_provider, mock_db
+    ):
+        """Verify force_immediate=True bypasses digest mode."""
+        mock_provider.daily_digest_enabled = True
+        mock_provider.on_ams_humidity_high = True
+
+        with patch.object(
+            service, '_get_providers_for_event', new_callable=AsyncMock
+        ) as mock_get, \
+             patch.object(
+            service, '_send_to_providers', new_callable=AsyncMock
+        ) as mock_send, \
+             patch.object(
+            service, '_build_message_from_template', new_callable=AsyncMock
+        ) as mock_build:
+
+            mock_get.return_value = [mock_provider]
+            mock_build.return_value = ("Alert", "Alert message")
+
+            await service.on_ams_humidity_high(
+                printer_id=1,
+                printer_name="Test",
+                ams_label="AMS-A",
+                humidity=75.0,
+                threshold=60.0,
+                db=mock_db,
+            )
+
+            # Verify force_immediate is passed
+            call_kwargs = mock_send.call_args[1]
+            assert call_kwargs.get('force_immediate') is True
+
+
+class TestDigestModeAlwaysSendsImmediately:
+    """CRITICAL: Tests that notifications always send immediately regardless of digest setting."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_notification_sends_immediately_even_with_digest_enabled(self, service):
+        """CRITICAL: All notifications must be sent immediately, digest is just a summary."""
+        # Create a mock provider with digest enabled
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+        mock_provider.name = "Test Provider"
+        mock_provider.provider_type = "ntfy"
+        mock_provider.enabled = True
+        mock_provider.daily_digest_enabled = True  # Digest enabled
+        mock_provider.daily_digest_time = "23:59"
+        mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
+
+        mock_db = AsyncMock()
+
+        # Mock the _send_to_provider method
+        with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, None)
+
+            with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
+                with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
+                    with patch.object(service, '_log_notification', new_callable=AsyncMock):
+                        await service._send_to_providers(
+                            providers=[mock_provider],
+                            title="Print Started",
+                            message="Your print has started",
+                            db=mock_db,
+                            event_type="print_start",
+                        )
+
+                        # CRITICAL: _send_to_provider MUST be called (immediate send)
+                        mock_send.assert_called_once()
+
+                        # Digest queue should also be called (for daily summary)
+                        mock_queue.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_notification_sends_without_digest_queue_when_disabled(self, service):
+        """When digest is disabled, notification sends but no digest queue."""
+        mock_provider = MagicMock()
+        mock_provider.id = 1
+        mock_provider.name = "Test Provider"
+        mock_provider.provider_type = "ntfy"
+        mock_provider.enabled = True
+        mock_provider.daily_digest_enabled = False  # Digest disabled
+        mock_provider.daily_digest_time = None
+        mock_provider.config = '{"server": "https://ntfy.sh", "topic": "test"}'
+
+        mock_db = AsyncMock()
+
+        with patch.object(service, '_send_to_provider', new_callable=AsyncMock) as mock_send:
+            mock_send.return_value = (True, None)
+
+            with patch.object(service, '_queue_for_digest', new_callable=AsyncMock) as mock_queue:
+                with patch.object(service, '_update_provider_status', new_callable=AsyncMock):
+                    with patch.object(service, '_log_notification', new_callable=AsyncMock):
+                        await service._send_to_providers(
+                            providers=[mock_provider],
+                            title="Print Started",
+                            message="Your print has started",
+                            db=mock_db,
+                            event_type="print_start",
+                        )
+
+                        # Notification must still be sent immediately
+                        mock_send.assert_called_once()
+
+                        # Digest queue should NOT be called when digest is disabled
+                        mock_queue.assert_not_called()
+
+
+class TestNotificationProviderTypes:
+    """Tests for different notification provider types."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_webhook_provider_sends_request(self, service):
+        """Verify webhook provider sends HTTP request."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+            "field_title": "title",
+            "field_message": "message",
+        }
+
+        # Create a mock response
+        mock_response = MagicMock()
+        mock_response.status_code = 200
+
+        # Mock the _get_client method
+        mock_client = AsyncMock()
+        mock_client.post = AsyncMock(return_value=mock_response)
+
+        with patch.object(service, '_get_client', new_callable=AsyncMock) as mock_get_client:
+            mock_get_client.return_value = mock_client
+
+            success, message = await service._send_webhook(
+                config, "Test Title", "Test Message"
+            )
+
+            assert success is True
+            mock_client.post.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_webhook_handles_failure(self, service):
+        """Verify webhook gracefully handles HTTP errors."""
+        config = {
+            "webhook_url": "http://test.local/webhook",
+        }
+
+        with patch('httpx.AsyncClient') as mock_client_class:
+            mock_instance = AsyncMock()
+            mock_instance.post.side_effect = Exception("Connection failed")
+            mock_client_class.return_value.__aenter__ = AsyncMock(
+                return_value=mock_instance
+            )
+            mock_client_class.return_value.__aexit__ = AsyncMock()
+
+            success, message = await service._send_webhook(
+                config, "Test", "Test"
+            )
+
+            assert success is False
+            assert "Connection failed" in message or "error" in message.lower()
+
+
+class TestNotificationTemplates:
+    """Tests for notification message template rendering."""
+
+    @pytest.fixture
+    def service(self):
+        return NotificationService()
+
+    @pytest.mark.asyncio
+    async def test_template_renders_variables(self, service):
+        """Verify template variables are replaced correctly."""
+        template_title = "Print {progress}% Complete"
+        template_body = "{printer}: {filename}\nRemaining: {remaining_time}"
+
+        variables = {
+            "printer": "Test Printer",
+            "filename": "test.3mf",
+            "progress": "50",
+            "remaining_time": "1h 30m",
+        }
+
+        title = template_title.format(**variables)
+        body = template_body.format(**variables)
+
+        assert title == "Print 50% Complete"
+        assert "Test Printer" in body
+        assert "test.3mf" in body
+        assert "1h 30m" in body
+
+    @pytest.mark.asyncio
+    async def test_template_handles_missing_variables(self, service):
+        """Verify missing template variables don't cause crashes."""
+        template = "{printer}: {unknown_var}"
+        variables = {"printer": "Test"}
+
+        # Should handle gracefully - either leave placeholder or skip
+        try:
+            result = template.format_map(
+                {**variables, "unknown_var": "{unknown_var}"}
+            )
+            assert "Test" in result
+        except KeyError:
+            pytest.fail("Template should handle missing variables gracefully")

+ 778 - 0
backend/tests/unit/services/test_printer_manager.py

@@ -0,0 +1,778 @@
+"""Unit tests for PrinterManager service.
+
+Tests printer connection management, status tracking, and print control.
+"""
+
+import asyncio
+import pytest
+from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
+from datetime import datetime
+
+from backend.app.services.printer_manager import (
+    PrinterManager,
+    printer_state_to_dict,
+    init_printer_connections,
+)
+
+
+class TestPrinterManager:
+    """Tests for PrinterManager class."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a fresh PrinterManager instance."""
+        return PrinterManager()
+
+    @pytest.fixture
+    def mock_printer(self):
+        """Create a mock Printer object."""
+        printer = MagicMock()
+        printer.id = 1
+        printer.ip_address = "192.168.1.100"
+        printer.serial_number = "00M09A123456789"
+        printer.access_code = "12345678"
+        printer.is_active = True
+        return printer
+
+    @pytest.fixture
+    def mock_client(self):
+        """Create a mock BambuMQTTClient."""
+        client = MagicMock()
+        client.state = MagicMock()
+        client.state.connected = True
+        client.state.state = "IDLE"
+        client.state.progress = 0
+        client.state.temperatures = {"nozzle": 25, "bed": 25}
+        client.state.raw_data = {}
+        client.logging_enabled = False
+        return client
+
+    # ========================================================================
+    # Tests for initialization
+    # ========================================================================
+
+    def test_init_creates_empty_clients_dict(self, manager):
+        """Verify manager initializes with empty clients dict."""
+        assert manager._clients == {}
+
+    def test_init_callbacks_are_none(self, manager):
+        """Verify all callbacks are initially None."""
+        assert manager._on_print_start is None
+        assert manager._on_print_complete is None
+        assert manager._on_status_change is None
+        assert manager._on_ams_change is None
+
+    def test_init_loop_is_none(self, manager):
+        """Verify event loop is initially None."""
+        assert manager._loop is None
+
+    # ========================================================================
+    # Tests for callback setters
+    # ========================================================================
+
+    def test_set_event_loop(self, manager):
+        """Verify event loop can be set."""
+        mock_loop = MagicMock()
+        manager.set_event_loop(mock_loop)
+        assert manager._loop == mock_loop
+
+    def test_set_print_start_callback(self, manager):
+        """Verify print start callback can be set."""
+        callback = MagicMock()
+        manager.set_print_start_callback(callback)
+        assert manager._on_print_start == callback
+
+    def test_set_print_complete_callback(self, manager):
+        """Verify print complete callback can be set."""
+        callback = MagicMock()
+        manager.set_print_complete_callback(callback)
+        assert manager._on_print_complete == callback
+
+    def test_set_status_change_callback(self, manager):
+        """Verify status change callback can be set."""
+        callback = MagicMock()
+        manager.set_status_change_callback(callback)
+        assert manager._on_status_change == callback
+
+    def test_set_ams_change_callback(self, manager):
+        """Verify AMS change callback can be set."""
+        callback = MagicMock()
+        manager.set_ams_change_callback(callback)
+        assert manager._on_ams_change == callback
+
+    # ========================================================================
+    # Tests for _schedule_async
+    # ========================================================================
+
+    def test_schedule_async_with_running_loop(self, manager):
+        """Verify async coroutine is scheduled when loop is running."""
+        mock_loop = MagicMock()
+        mock_loop.is_running.return_value = True
+        manager._loop = mock_loop
+
+        async def dummy_coro():
+            pass
+
+        coro = dummy_coro()
+        manager._schedule_async(coro)
+
+        mock_loop.is_running.assert_called_once()
+        # Clean up the coroutine
+        coro.close()
+
+    def test_schedule_async_without_loop(self, manager):
+        """Verify nothing happens when no loop is set."""
+        async def dummy_coro():
+            pass
+
+        coro = dummy_coro()
+        # Should not raise
+        manager._schedule_async(coro)
+        coro.close()
+
+    def test_schedule_async_with_stopped_loop(self, manager):
+        """Verify nothing happens when loop is not running."""
+        mock_loop = MagicMock()
+        mock_loop.is_running.return_value = False
+        manager._loop = mock_loop
+
+        async def dummy_coro():
+            pass
+
+        coro = dummy_coro()
+        manager._schedule_async(coro)
+        coro.close()
+
+    # ========================================================================
+    # Tests for connect_printer
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_connect_printer_creates_client(self, manager, mock_printer):
+        """Verify connecting creates an MQTT client."""
+        with patch(
+            'backend.app.services.printer_manager.BambuMQTTClient'
+        ) as MockClient:
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = True
+            MockClient.return_value = mock_instance
+
+            result = await manager.connect_printer(mock_printer)
+
+            MockClient.assert_called_once()
+            mock_instance.connect.assert_called_once()
+            assert mock_printer.id in manager._clients
+            assert result is True
+
+    @pytest.mark.asyncio
+    async def test_connect_printer_disconnects_existing(self, manager, mock_printer, mock_client):
+        """Verify connecting disconnects existing client first."""
+        manager._clients[mock_printer.id] = mock_client
+
+        with patch(
+            'backend.app.services.printer_manager.BambuMQTTClient'
+        ) as MockClient:
+            new_client = MagicMock()
+            new_client.state = MagicMock()
+            new_client.state.connected = True
+            MockClient.return_value = new_client
+
+            await manager.connect_printer(mock_printer)
+
+            mock_client.disconnect.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_connect_printer_returns_false_on_failure(self, manager, mock_printer):
+        """Verify returns False when connection fails."""
+        with patch(
+            'backend.app.services.printer_manager.BambuMQTTClient'
+        ) as MockClient:
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = False
+            MockClient.return_value = mock_instance
+
+            result = await manager.connect_printer(mock_printer)
+
+            assert result is False
+
+    # ========================================================================
+    # Tests for disconnect_printer
+    # ========================================================================
+
+    def test_disconnect_printer_removes_client(self, manager, mock_client):
+        """Verify disconnecting removes and disconnects client."""
+        manager._clients[1] = mock_client
+
+        manager.disconnect_printer(1)
+
+        mock_client.disconnect.assert_called_once()
+        assert 1 not in manager._clients
+
+    def test_disconnect_printer_handles_missing(self, manager):
+        """Verify disconnecting non-existent printer doesn't raise."""
+        manager.disconnect_printer(999)  # Should not raise
+
+    # ========================================================================
+    # Tests for disconnect_all
+    # ========================================================================
+
+    def test_disconnect_all_disconnects_all_clients(self, manager):
+        """Verify all clients are disconnected."""
+        client1 = MagicMock()
+        client2 = MagicMock()
+        manager._clients[1] = client1
+        manager._clients[2] = client2
+
+        manager.disconnect_all()
+
+        client1.disconnect.assert_called_once()
+        client2.disconnect.assert_called_once()
+        assert len(manager._clients) == 0
+
+    # ========================================================================
+    # Tests for get_status
+    # ========================================================================
+
+    def test_get_status_returns_state(self, manager, mock_client):
+        """Verify get_status returns client state."""
+        manager._clients[1] = mock_client
+
+        result = manager.get_status(1)
+
+        mock_client.check_staleness.assert_called_once()
+        assert result == mock_client.state
+
+    def test_get_status_returns_none_for_unknown(self, manager):
+        """Verify get_status returns None for unknown printer."""
+        result = manager.get_status(999)
+        assert result is None
+
+    # ========================================================================
+    # Tests for get_all_statuses
+    # ========================================================================
+
+    def test_get_all_statuses_returns_all(self, manager):
+        """Verify all statuses are returned."""
+        client1 = MagicMock()
+        client1.state = MagicMock(connected=True)
+        client2 = MagicMock()
+        client2.state = MagicMock(connected=False)
+        manager._clients[1] = client1
+        manager._clients[2] = client2
+
+        result = manager.get_all_statuses()
+
+        assert len(result) == 2
+        assert 1 in result
+        assert 2 in result
+        client1.check_staleness.assert_called_once()
+        client2.check_staleness.assert_called_once()
+
+    # ========================================================================
+    # Tests for is_connected
+    # ========================================================================
+
+    def test_is_connected_returns_true(self, manager, mock_client):
+        """Verify is_connected returns True for connected printer."""
+        mock_client.check_staleness.return_value = True
+        manager._clients[1] = mock_client
+
+        result = manager.is_connected(1)
+
+        assert result is True
+
+    def test_is_connected_returns_false_for_unknown(self, manager):
+        """Verify is_connected returns False for unknown printer."""
+        result = manager.is_connected(999)
+        assert result is False
+
+    # ========================================================================
+    # Tests for get_client
+    # ========================================================================
+
+    def test_get_client_returns_client(self, manager, mock_client):
+        """Verify get_client returns the client."""
+        manager._clients[1] = mock_client
+
+        result = manager.get_client(1)
+
+        assert result == mock_client
+
+    def test_get_client_returns_none_for_unknown(self, manager):
+        """Verify get_client returns None for unknown printer."""
+        result = manager.get_client(999)
+        assert result is None
+
+    # ========================================================================
+    # Tests for mark_printer_offline
+    # ========================================================================
+
+    def test_mark_printer_offline_updates_state(self, manager, mock_client):
+        """Verify mark_printer_offline updates client state."""
+        mock_client.state.connected = True
+        manager._clients[1] = mock_client
+
+        manager.mark_printer_offline(1)
+
+        assert mock_client.state.connected is False
+        assert mock_client.state.state == "unknown"
+
+    def test_mark_printer_offline_triggers_callback(self, manager, mock_client):
+        """Verify mark_printer_offline triggers status callback."""
+        mock_client.state.connected = True
+        manager._clients[1] = mock_client
+
+        # Callback must return a coroutine
+        async def async_callback(printer_id, state):
+            pass
+
+        manager._on_status_change = async_callback
+
+        # Need a running loop for callback
+        mock_loop = MagicMock()
+        mock_loop.is_running.return_value = True
+        manager._loop = mock_loop
+
+        manager.mark_printer_offline(1)
+
+        # Callback should be scheduled via run_coroutine_threadsafe
+        mock_loop.is_running.assert_called()
+        # State should be updated
+        assert mock_client.state.connected is False
+
+    def test_mark_printer_offline_handles_unknown(self, manager):
+        """Verify mark_printer_offline handles unknown printer."""
+        manager.mark_printer_offline(999)  # Should not raise
+
+    def test_mark_printer_offline_skips_already_offline(self, manager, mock_client):
+        """Verify mark_printer_offline skips already offline printer."""
+        mock_client.state.connected = False
+        manager._clients[1] = mock_client
+
+        manager.mark_printer_offline(1)
+
+        # State should remain unchanged
+        assert mock_client.state.connected is False
+
+    # ========================================================================
+    # Tests for start_print
+    # ========================================================================
+
+    def test_start_print_calls_client(self, manager, mock_client):
+        """Verify start_print calls client method."""
+        mock_client.start_print.return_value = True
+        manager._clients[1] = mock_client
+
+        result = manager.start_print(1, "test.gcode")
+
+        mock_client.start_print.assert_called_once_with("test.gcode")
+        assert result is True
+
+    def test_start_print_returns_false_for_unknown(self, manager):
+        """Verify start_print returns False for unknown printer."""
+        result = manager.start_print(999, "test.gcode")
+        assert result is False
+
+    # ========================================================================
+    # Tests for stop_print
+    # ========================================================================
+
+    def test_stop_print_calls_client(self, manager, mock_client):
+        """Verify stop_print calls client method."""
+        mock_client.stop_print.return_value = True
+        manager._clients[1] = mock_client
+
+        result = manager.stop_print(1)
+
+        mock_client.stop_print.assert_called_once()
+        assert result is True
+
+    def test_stop_print_returns_false_for_unknown(self, manager):
+        """Verify stop_print returns False for unknown printer."""
+        result = manager.stop_print(999)
+        assert result is False
+
+    # ========================================================================
+    # Tests for wait_for_cooldown
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_wait_for_cooldown_returns_true_when_cool(self, manager, mock_client):
+        """Verify wait_for_cooldown returns True when printer is cool."""
+        mock_client.state.connected = True
+        mock_client.state.temperatures = {"nozzle": 40, "bed": 30}
+        mock_client.check_staleness.return_value = True
+        manager._clients[1] = mock_client
+
+        result = await manager.wait_for_cooldown(1, target_temp=50)
+
+        assert result is True
+
+    @pytest.mark.asyncio
+    async def test_wait_for_cooldown_returns_false_on_disconnect(self, manager, mock_client):
+        """Verify wait_for_cooldown returns False when printer disconnects."""
+        mock_client.state.connected = False
+        mock_client.check_staleness.return_value = False
+        manager._clients[1] = mock_client
+
+        result = await manager.wait_for_cooldown(1, target_temp=50, timeout=1)
+
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_wait_for_cooldown_returns_false_for_unknown(self, manager):
+        """Verify wait_for_cooldown returns False for unknown printer."""
+        result = await manager.wait_for_cooldown(999, target_temp=50, timeout=1)
+        assert result is False
+
+    @pytest.mark.asyncio
+    async def test_wait_for_cooldown_checks_both_nozzles(self, manager, mock_client):
+        """Verify wait_for_cooldown checks both nozzles for dual extruders."""
+        mock_client.state.connected = True
+        mock_client.state.temperatures = {"nozzle": 40, "nozzle_2": 45, "bed": 30}
+        mock_client.check_staleness.return_value = True
+        manager._clients[1] = mock_client
+
+        result = await manager.wait_for_cooldown(1, target_temp=50)
+
+        assert result is True
+
+    # ========================================================================
+    # Tests for logging methods
+    # ========================================================================
+
+    def test_enable_logging_calls_client(self, manager, mock_client):
+        """Verify enable_logging calls client method."""
+        manager._clients[1] = mock_client
+
+        result = manager.enable_logging(1, True)
+
+        mock_client.enable_logging.assert_called_once_with(True)
+        assert result is True
+
+    def test_enable_logging_returns_false_for_unknown(self, manager):
+        """Verify enable_logging returns False for unknown printer."""
+        result = manager.enable_logging(999, True)
+        assert result is False
+
+    def test_get_logs_returns_logs(self, manager, mock_client):
+        """Verify get_logs returns client logs."""
+        mock_logs = [MagicMock(), MagicMock()]
+        mock_client.get_logs.return_value = mock_logs
+        manager._clients[1] = mock_client
+
+        result = manager.get_logs(1)
+
+        assert result == mock_logs
+
+    def test_get_logs_returns_empty_for_unknown(self, manager):
+        """Verify get_logs returns empty list for unknown printer."""
+        result = manager.get_logs(999)
+        assert result == []
+
+    def test_clear_logs_calls_client(self, manager, mock_client):
+        """Verify clear_logs calls client method."""
+        manager._clients[1] = mock_client
+
+        result = manager.clear_logs(1)
+
+        mock_client.clear_logs.assert_called_once()
+        assert result is True
+
+    def test_clear_logs_returns_false_for_unknown(self, manager):
+        """Verify clear_logs returns False for unknown printer."""
+        result = manager.clear_logs(999)
+        assert result is False
+
+    def test_is_logging_enabled_returns_status(self, manager, mock_client):
+        """Verify is_logging_enabled returns client status."""
+        mock_client.logging_enabled = True
+        manager._clients[1] = mock_client
+
+        result = manager.is_logging_enabled(1)
+
+        assert result is True
+
+    def test_is_logging_enabled_returns_false_for_unknown(self, manager):
+        """Verify is_logging_enabled returns False for unknown printer."""
+        result = manager.is_logging_enabled(999)
+        assert result is False
+
+    # ========================================================================
+    # Tests for request_status_update
+    # ========================================================================
+
+    def test_request_status_update_calls_client(self, manager, mock_client):
+        """Verify request_status_update calls client method."""
+        mock_client.request_status_update.return_value = True
+        manager._clients[1] = mock_client
+
+        result = manager.request_status_update(1)
+
+        mock_client.request_status_update.assert_called_once()
+        assert result is True
+
+    def test_request_status_update_returns_false_for_unknown(self, manager):
+        """Verify request_status_update returns False for unknown printer."""
+        result = manager.request_status_update(999)
+        assert result is False
+
+    # ========================================================================
+    # Tests for test_connection
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_test_connection_success(self, manager):
+        """Verify test_connection returns success on connection."""
+        with patch(
+            'backend.app.services.printer_manager.BambuMQTTClient'
+        ) as MockClient:
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = True
+            mock_instance.state.state = "IDLE"
+            mock_instance.state.raw_data = {"device_model": "X1C"}
+            MockClient.return_value = mock_instance
+
+            result = await manager.test_connection(
+                "192.168.1.100", "00M09A123456789", "12345678"
+            )
+
+            assert result["success"] is True
+            assert result["state"] == "IDLE"
+            assert result["model"] == "X1C"
+            mock_instance.disconnect.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_test_connection_failure(self, manager):
+        """Verify test_connection returns failure on connection error."""
+        with patch(
+            'backend.app.services.printer_manager.BambuMQTTClient'
+        ) as MockClient:
+            mock_instance = MagicMock()
+            mock_instance.state = MagicMock()
+            mock_instance.state.connected = False
+            MockClient.return_value = mock_instance
+
+            result = await manager.test_connection(
+                "192.168.1.100", "00M09A123456789", "12345678"
+            )
+
+            assert result["success"] is False
+            assert result["state"] is None
+            mock_instance.disconnect.assert_called_once()
+
+
+class TestPrinterStateToDict:
+    """Tests for printer_state_to_dict helper function."""
+
+    @pytest.fixture
+    def mock_state(self):
+        """Create a mock PrinterState."""
+        state = MagicMock()
+        state.connected = True
+        state.state = "RUNNING"
+        state.current_print = "test.3mf"
+        state.subtask_name = "Test Print"
+        state.gcode_file = "/sdcard/test.gcode"
+        state.progress = 50
+        state.remaining_time = 3600
+        state.layer_num = 10
+        state.total_layers = 20
+        state.temperatures = {"nozzle": 200, "bed": 60}
+        state.hms_errors = []
+        state.ams_status_main = 0
+        state.ams_status_sub = 0
+        state.tray_now = "1"
+        state.wifi_signal = -50
+        state.raw_data = {}
+        return state
+
+    def test_basic_conversion(self, mock_state):
+        """Verify basic state fields are converted."""
+        result = printer_state_to_dict(mock_state)
+
+        assert result["connected"] is True
+        assert result["state"] == "RUNNING"
+        assert result["progress"] == 50
+        assert result["temperatures"] == {"nozzle": 200, "bed": 60}
+
+    def test_ams_data_parsing(self, mock_state):
+        """Verify AMS data is parsed correctly."""
+        mock_state.raw_data = {
+            "ams": [{
+                "id": 0,
+                "humidity_raw": 45,
+                "temp": 25,
+                "tray": [
+                    {
+                        "id": 0,
+                        "tray_color": "FF0000",
+                        "tray_type": "PLA",
+                        "tray_sub_brands": "Generic",
+                        "remain": 80,
+                        "k": 0.5,
+                        "tag_uid": "ABC123",
+                        "tray_uuid": "uuid-123",
+                    }
+                ]
+            }]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"] is not None
+        assert len(result["ams"]) == 1
+        assert result["ams"][0]["humidity"] == 45
+        assert len(result["ams"][0]["tray"]) == 1
+        assert result["ams"][0]["tray"][0]["tray_color"] == "FF0000"
+
+    def test_empty_tag_uid_becomes_none(self, mock_state):
+        """Verify empty tag_uid is converted to None."""
+        mock_state.raw_data = {
+            "ams": [{
+                "id": 0,
+                "tray": [{
+                    "id": 0,
+                    "tag_uid": "",
+                    "tray_uuid": "00000000000000000000000000000000",
+                }]
+            }]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"][0]["tray"][0]["tag_uid"] is None
+        assert result["ams"][0]["tray"][0]["tray_uuid"] is None
+
+    def test_zero_tag_uid_becomes_none(self, mock_state):
+        """Verify zero tag_uid is converted to None."""
+        mock_state.raw_data = {
+            "ams": [{
+                "id": 0,
+                "tray": [{
+                    "id": 0,
+                    "tag_uid": "0000000000000000",
+                }]
+            }]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"][0]["tray"][0]["tag_uid"] is None
+
+    def test_vt_tray_parsing(self, mock_state):
+        """Verify virtual tray is parsed correctly."""
+        mock_state.raw_data = {
+            "vt_tray": {
+                "tray_color": "00FF00",
+                "tray_type": "PETG",
+                "tray_sub_brands": "Generic",
+                "remain": 60,
+                "tag_uid": "VT123",
+            }
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["vt_tray"] is not None
+        assert result["vt_tray"]["id"] == 254
+        assert result["vt_tray"]["tray_color"] == "00FF00"
+        assert result["vt_tray"]["tray_type"] == "PETG"
+
+    def test_hms_errors_conversion(self, mock_state):
+        """Verify HMS errors are converted correctly."""
+        error = MagicMock()
+        error.code = "0700_0100"
+        error.attr = 1
+        error.module = "AMS"
+        error.severity = 2
+        mock_state.hms_errors = [error]
+
+        result = printer_state_to_dict(mock_state)
+
+        assert len(result["hms_errors"]) == 1
+        assert result["hms_errors"][0]["code"] == "0700_0100"
+        assert result["hms_errors"][0]["module"] == "AMS"
+
+    def test_cover_url_added_for_running_print(self, mock_state):
+        """Verify cover_url is added for running prints."""
+        result = printer_state_to_dict(mock_state, printer_id=1)
+
+        assert result["cover_url"] == "/api/v1/printers/1/cover"
+
+    def test_cover_url_none_when_not_running(self, mock_state):
+        """Verify cover_url is None when not printing."""
+        mock_state.state = "IDLE"
+
+        result = printer_state_to_dict(mock_state, printer_id=1)
+
+        assert result["cover_url"] is None
+
+    def test_ams_ht_detection(self, mock_state):
+        """Verify AMS-HT is detected (1 tray vs 4)."""
+        mock_state.raw_data = {
+            "ams": [{
+                "id": 0,
+                "tray": [{"id": 0}]  # Only 1 tray = AMS-HT
+            }]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"][0]["is_ams_ht"] is True
+
+    def test_regular_ams_detection(self, mock_state):
+        """Verify regular AMS is detected (4 trays)."""
+        mock_state.raw_data = {
+            "ams": [{
+                "id": 0,
+                "tray": [{"id": 0}, {"id": 1}, {"id": 2}, {"id": 3}]
+            }]
+        }
+
+        result = printer_state_to_dict(mock_state)
+
+        assert result["ams"][0]["is_ams_ht"] is False
+
+
+class TestInitPrinterConnections:
+    """Tests for init_printer_connections function."""
+
+    @pytest.mark.asyncio
+    async def test_connects_all_active_printers(self):
+        """Verify all active printers are connected."""
+        mock_db = AsyncMock()
+        mock_printer1 = MagicMock(id=1, is_active=True)
+        mock_printer2 = MagicMock(id=2, is_active=True)
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = [mock_printer1, mock_printer2]
+        mock_db.execute.return_value = mock_result
+
+        with patch(
+            'backend.app.services.printer_manager.printer_manager'
+        ) as mock_manager:
+            mock_manager.connect_printer = AsyncMock()
+
+            await init_printer_connections(mock_db)
+
+            assert mock_manager.connect_printer.call_count == 2
+
+    @pytest.mark.asyncio
+    async def test_handles_empty_printer_list(self):
+        """Verify empty printer list is handled."""
+        mock_db = AsyncMock()
+        mock_result = MagicMock()
+        mock_result.scalars.return_value.all.return_value = []
+        mock_db.execute.return_value = mock_result
+
+        with patch(
+            'backend.app.services.printer_manager.printer_manager'
+        ) as mock_manager:
+            mock_manager.connect_printer = AsyncMock()
+
+            await init_printer_connections(mock_db)
+
+            mock_manager.connect_printer.assert_not_called()

+ 603 - 0
backend/tests/unit/services/test_smart_plug_manager.py

@@ -0,0 +1,603 @@
+"""Unit tests for SmartPlugManager service.
+
+These tests specifically target the auto-off behavior and toggle functionality
+that were identified as common regression points.
+"""
+
+import pytest
+import asyncio
+from datetime import datetime
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from backend.app.services.smart_plug_manager import SmartPlugManager
+
+
+class TestSmartPlugManager:
+    """Tests for SmartPlugManager class."""
+
+    @pytest.fixture
+    def manager(self):
+        """Create a fresh SmartPlugManager instance."""
+        return SmartPlugManager()
+
+    @pytest.fixture
+    def mock_plug(self):
+        """Create a mock SmartPlug object."""
+        plug = MagicMock()
+        plug.id = 1
+        plug.name = "Test Plug"
+        plug.ip_address = "192.168.1.100"
+        plug.username = None
+        plug.password = None
+        plug.enabled = True
+        plug.auto_on = True
+        plug.auto_off = True
+        plug.off_delay_mode = "time"
+        plug.off_delay_minutes = 5
+        plug.off_temp_threshold = 70
+        plug.printer_id = 1
+        plug.auto_off_executed = False
+        plug.auto_off_pending = False
+        plug.last_state = "ON"
+        plug.last_checked = None
+        return plug
+
+    @pytest.fixture
+    def mock_db(self):
+        """Create a mock database session."""
+        db = AsyncMock()
+        db.commit = AsyncMock()
+        db.refresh = AsyncMock()
+        return db
+
+    # ========================================================================
+    # Tests for on_print_start
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_turns_on_plug(self, manager, mock_plug, mock_db):
+        """Verify plug is turned ON when print starts with auto_on enabled."""
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = mock_plug
+            mock_tasmota.turn_on = AsyncMock(return_value=True)
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            mock_tasmota.turn_on.assert_called_once_with(mock_plug)
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_skipped_when_auto_on_disabled(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify plug is NOT turned on when auto_on is disabled."""
+        mock_plug.auto_on = False
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = mock_plug
+            mock_tasmota.turn_on = AsyncMock()
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            mock_tasmota.turn_on.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_skipped_when_plug_disabled(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify plug is NOT turned on when plug.enabled is False."""
+        mock_plug.enabled = False
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = mock_plug
+            mock_tasmota.turn_on = AsyncMock()
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            mock_tasmota.turn_on.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_skipped_when_no_plug_found(
+        self, manager, mock_db
+    ):
+        """Verify graceful handling when no plug is linked to printer."""
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = None
+            mock_tasmota.turn_on = AsyncMock()
+
+            # Should not raise any exception
+            await manager.on_print_start(printer_id=999, db=mock_db)
+
+            mock_tasmota.turn_on.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_cancels_pending_off(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify starting a new print cancels any pending auto-off."""
+        # Set up a pending task
+        mock_task = MagicMock()
+        manager._pending_off[mock_plug.id] = mock_task
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(
+            manager, '_mark_auto_off_pending', new_callable=AsyncMock
+        ), \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = mock_plug
+            mock_tasmota.turn_on = AsyncMock(return_value=True)
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            mock_task.cancel.assert_called_once()
+            assert mock_plug.id not in manager._pending_off
+
+    @pytest.mark.asyncio
+    async def test_on_print_start_resets_auto_off_executed_flag(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify auto_off_executed flag is reset when turning on."""
+        mock_plug.auto_off_executed = True
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch('backend.app.services.smart_plug_manager.tasmota_service') as mock_tasmota:
+
+            mock_get_plug.return_value = mock_plug
+            mock_tasmota.turn_on = AsyncMock(return_value=True)
+
+            await manager.on_print_start(printer_id=1, db=mock_db)
+
+            assert mock_plug.auto_off_executed is False
+
+    # ========================================================================
+    # Tests for on_print_complete
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_schedules_time_based_off(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify time-based auto-off is scheduled when print completes."""
+        mock_plug.off_delay_mode = "time"
+        mock_plug.off_delay_minutes = 5
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="completed", db=mock_db
+            )
+
+            mock_schedule.assert_called_once_with(mock_plug, 1, 300)  # 5 min * 60 sec
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_schedules_temp_based_off(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify temperature-based auto-off is scheduled when print completes."""
+        mock_plug.off_delay_mode = "temperature"
+        mock_plug.off_temp_threshold = 70
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_temp_based_off') as mock_schedule:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="completed", db=mock_db
+            )
+
+            mock_schedule.assert_called_once_with(mock_plug, 1, 70)
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_skipped_when_auto_off_disabled(
+        self, manager, mock_plug, mock_db
+    ):
+        """CRITICAL: Verify auto-off does NOT trigger when auto_off is False.
+
+        This is a key regression test - the toggle must respect the setting.
+        """
+        mock_plug.auto_off = False
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_delayed_off') as mock_schedule, \
+             patch.object(manager, '_schedule_temp_based_off') as mock_temp:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="completed", db=mock_db
+            )
+
+            mock_schedule.assert_not_called()
+            mock_temp.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_skipped_when_plug_disabled(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify auto-off does NOT trigger when plug is disabled."""
+        mock_plug.enabled = False
+
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="completed", db=mock_db
+            )
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_skipped_on_failed_print(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify auto-off does NOT trigger on failed prints for investigation."""
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="failed", db=mock_db
+            )
+
+            mock_schedule.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_on_print_complete_skipped_on_aborted_print(
+        self, manager, mock_plug, mock_db
+    ):
+        """Verify auto-off does NOT trigger on aborted prints."""
+        with patch.object(
+            manager, '_get_plug_for_printer', new_callable=AsyncMock
+        ) as mock_get_plug, \
+             patch.object(manager, '_schedule_delayed_off') as mock_schedule:
+
+            mock_get_plug.return_value = mock_plug
+
+            await manager.on_print_complete(
+                printer_id=1, status="aborted", db=mock_db
+            )
+
+            mock_schedule.assert_not_called()
+
+    # ========================================================================
+    # Tests for _cancel_pending_off
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_cancel_pending_off_removes_task(self, manager, mock_plug):
+        """Verify pending off tasks can be cancelled."""
+        mock_task = MagicMock()
+        manager._pending_off[mock_plug.id] = mock_task
+
+        with patch.object(
+            manager, '_mark_auto_off_pending', new_callable=AsyncMock
+        ):
+            manager._cancel_pending_off(mock_plug.id)
+
+        assert mock_plug.id not in manager._pending_off
+        mock_task.cancel.assert_called_once()
+
+    @pytest.mark.asyncio
+    async def test_cancel_pending_off_handles_missing_task(self, manager):
+        """Verify no error when cancelling non-existent task."""
+        # Should not raise any exception
+        with patch.object(
+            manager, '_mark_auto_off_pending', new_callable=AsyncMock
+        ):
+            manager._cancel_pending_off(999)  # Non-existent plug ID
+
+    @pytest.mark.asyncio
+    async def test_cancel_all_pending(self, manager, mock_plug):
+        """Verify all pending tasks can be cancelled."""
+        mock_task1 = MagicMock()
+        mock_task2 = MagicMock()
+        manager._pending_off[1] = mock_task1
+        manager._pending_off[2] = mock_task2
+
+        with patch('asyncio.create_task') as mock_create:
+            manager.cancel_all_pending()
+
+        assert len(manager._pending_off) == 0
+        mock_task1.cancel.assert_called_once()
+        mock_task2.cancel.assert_called_once()
+
+    # ========================================================================
+    # Tests for scheduler
+    # ========================================================================
+
+    def test_start_scheduler(self, manager):
+        """Verify scheduler can be started."""
+        assert manager._scheduler_task is None
+
+        # Mock _schedule_loop to return a mock coroutine to avoid unawaited coroutine warning
+        with patch.object(manager, '_schedule_loop') as mock_loop, \
+             patch('asyncio.create_task') as mock_create:
+            mock_create.return_value = MagicMock()
+            manager.start_scheduler()
+
+            assert manager._scheduler_task is not None
+            mock_loop.assert_called_once()
+
+    def test_stop_scheduler(self, manager):
+        """Verify scheduler can be stopped."""
+        mock_task = MagicMock()
+        manager._scheduler_task = mock_task
+
+        manager.stop_scheduler()
+
+        mock_task.cancel.assert_called_once()
+        assert manager._scheduler_task is None
+
+    def test_start_scheduler_idempotent(self, manager):
+        """Verify starting scheduler twice doesn't create multiple tasks."""
+        mock_task = MagicMock()
+        manager._scheduler_task = mock_task
+
+        # Mock _schedule_loop to avoid unawaited coroutine warning (in case it's called)
+        with patch.object(manager, '_schedule_loop') as mock_loop, \
+             patch('asyncio.create_task') as mock_create:
+            manager.start_scheduler()
+
+            mock_create.assert_not_called()  # Should not create new task
+            mock_loop.assert_not_called()  # Should not call _schedule_loop
+
+
+class TestScheduleLoop:
+    """Tests for the schedule-based plug control."""
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.mark.asyncio
+    async def test_check_schedules_turns_on_at_scheduled_time(self, manager):
+        """Verify scheduled on-time turns plug on."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.name = "Test Plug"
+        mock_plug.enabled = True
+        mock_plug.schedule_enabled = True
+        mock_plug.schedule_on_time = "08:00"
+        mock_plug.schedule_off_time = "22:00"
+        mock_plug.printer_id = None
+        mock_plug.last_state = "OFF"
+
+        with patch(
+            'backend.app.services.smart_plug_manager.datetime'
+        ) as mock_datetime, \
+             patch(
+            'backend.app.core.database.async_session'
+        ) as mock_session_ctx, \
+             patch(
+            'backend.app.services.smart_plug_manager.tasmota_service'
+        ) as mock_tasmota:
+
+            # Set current time to 08:00
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "08:00"
+            mock_datetime.now.return_value = mock_now
+            mock_datetime.utcnow.return_value = datetime.utcnow()
+
+            # Set up async session mock
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [mock_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_on = AsyncMock(return_value=True)
+
+            await manager._check_schedules()
+
+            mock_tasmota.turn_on.assert_called_once_with(mock_plug)
+
+    @pytest.mark.asyncio
+    async def test_check_schedules_turns_off_at_scheduled_time(self, manager):
+        """Verify scheduled off-time turns plug off."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.name = "Test Plug"
+        mock_plug.enabled = True
+        mock_plug.schedule_enabled = True
+        mock_plug.schedule_on_time = "08:00"
+        mock_plug.schedule_off_time = "22:00"
+        mock_plug.printer_id = 1
+        mock_plug.last_state = "ON"
+
+        with patch(
+            'backend.app.services.smart_plug_manager.datetime'
+        ) as mock_datetime, \
+             patch(
+            'backend.app.core.database.async_session'
+        ) as mock_session_ctx, \
+             patch(
+            'backend.app.services.smart_plug_manager.tasmota_service'
+        ) as mock_tasmota, \
+             patch(
+            'backend.app.services.smart_plug_manager.printer_manager'
+        ) as mock_pm:
+
+            # Set current time to 22:00
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "22:00"
+            mock_datetime.now.return_value = mock_now
+            mock_datetime.utcnow.return_value = datetime.utcnow()
+
+            # Set up async session mock
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [mock_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+            mock_pm.mark_printer_offline = MagicMock()
+
+            await manager._check_schedules()
+
+            mock_tasmota.turn_off.assert_called_once_with(mock_plug)
+
+    @pytest.mark.asyncio
+    async def test_check_schedules_skipped_when_disabled(self, manager):
+        """Verify schedule is skipped when schedule_enabled is False."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.enabled = True
+        mock_plug.schedule_enabled = False  # Disabled
+
+        with patch(
+            'backend.app.services.smart_plug_manager.datetime'
+        ) as mock_datetime, \
+             patch(
+            'backend.app.core.database.async_session'
+        ) as mock_session_ctx, \
+             patch(
+            'backend.app.services.smart_plug_manager.tasmota_service'
+        ) as mock_tasmota:
+
+            mock_now = MagicMock()
+            mock_now.strftime.return_value = "08:00"
+            mock_datetime.now.return_value = mock_now
+
+            # Set up async session mock - returns no plugs (filtered by schedule_enabled)
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = []
+            mock_db.execute = AsyncMock(return_value=mock_result)
+            mock_db.commit = AsyncMock()
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_on = AsyncMock()
+
+            await manager._check_schedules()
+
+            mock_tasmota.turn_on.assert_not_called()
+
+
+class TestPendingAutoOffPersistence:
+    """Tests for auto-off pending state persistence (restart recovery)."""
+
+    @pytest.fixture
+    def manager(self):
+        return SmartPlugManager()
+
+    @pytest.mark.asyncio
+    async def test_resume_pending_auto_offs_temperature_mode(self, manager):
+        """Verify temperature-based pending auto-offs are resumed on startup."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.name = "Test Plug"
+        mock_plug.ip_address = "192.168.1.100"
+        mock_plug.username = None
+        mock_plug.password = None
+        mock_plug.printer_id = 1
+        mock_plug.auto_off_pending = True
+        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.off_delay_mode = "temperature"
+        mock_plug.off_temp_threshold = 70
+
+        with patch(
+            'backend.app.core.database.async_session'
+        ) as mock_session_ctx, \
+             patch.object(
+            manager, '_schedule_temp_based_off'
+        ) as mock_schedule:
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [mock_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            await manager.resume_pending_auto_offs()
+
+            mock_schedule.assert_called_once_with(mock_plug, 1, 70)
+
+    @pytest.mark.asyncio
+    async def test_resume_pending_auto_offs_time_mode_immediate_off(self, manager):
+        """Verify time-based pending auto-offs turn off immediately on resume."""
+        mock_plug = MagicMock()
+        mock_plug.id = 1
+        mock_plug.name = "Test Plug"
+        mock_plug.ip_address = "192.168.1.100"
+        mock_plug.username = None
+        mock_plug.password = None
+        mock_plug.printer_id = 1
+        mock_plug.auto_off_pending = True
+        mock_plug.auto_off_pending_since = datetime.utcnow()
+        mock_plug.off_delay_mode = "time"
+
+        with patch(
+            'backend.app.core.database.async_session'
+        ) as mock_session_ctx, \
+             patch(
+            'backend.app.services.smart_plug_manager.tasmota_service'
+        ) as mock_tasmota, \
+             patch.object(
+            manager, '_mark_auto_off_executed', new_callable=AsyncMock
+        ) as mock_mark, \
+             patch(
+            'backend.app.services.smart_plug_manager.printer_manager'
+        ) as mock_pm:
+
+            mock_db = AsyncMock()
+            mock_result = MagicMock()
+            mock_result.scalars.return_value.all.return_value = [mock_plug]
+            mock_db.execute = AsyncMock(return_value=mock_result)
+
+            mock_session_ctx.return_value.__aenter__ = AsyncMock(return_value=mock_db)
+            mock_session_ctx.return_value.__aexit__ = AsyncMock()
+
+            mock_tasmota.turn_off = AsyncMock(return_value=True)
+
+            await manager.resume_pending_auto_offs()
+
+            mock_tasmota.turn_off.assert_called_once()
+            mock_mark.assert_called_once_with(1)

+ 383 - 0
backend/tests/unit/services/test_tasmota.py

@@ -0,0 +1,383 @@
+"""Unit tests for TasmotaService.
+
+Tests smart plug HTTP communication and error handling.
+"""
+
+import pytest
+from unittest.mock import AsyncMock, MagicMock, patch
+import httpx
+
+from backend.app.services.tasmota import TasmotaService
+
+
+class TestTasmotaService:
+    """Tests for TasmotaService class."""
+
+    @pytest.fixture
+    def service(self):
+        """Create a TasmotaService instance."""
+        return TasmotaService(timeout=5.0)
+
+    @pytest.fixture
+    def mock_plug(self):
+        """Create a mock SmartPlug object."""
+        plug = MagicMock()
+        plug.ip_address = "192.168.1.100"
+        plug.username = None
+        plug.password = None
+        plug.name = "Test Plug"
+        return plug
+
+    # ========================================================================
+    # Tests for URL building
+    # ========================================================================
+
+    def test_build_url_without_auth(self, service):
+        """Verify URL is built correctly without auth."""
+        url = service._build_url("192.168.1.100", "Power On")
+        assert url == "http://192.168.1.100/cm?cmnd=Power%20On"
+
+    def test_build_url_with_auth(self, service):
+        """Verify URL includes credentials when provided."""
+        url = service._build_url(
+            "192.168.1.100", "Power On", username="admin", password="secret"
+        )
+        assert url == "http://admin:secret@192.168.1.100/cm?cmnd=Power%20On"
+
+    def test_build_url_encodes_special_characters(self, service):
+        """Verify special characters in commands are encoded."""
+        url = service._build_url("192.168.1.100", "Backlog Power On; Delay 100")
+        assert "Backlog%20Power%20On" in url
+
+    # ========================================================================
+    # Tests for turn_on
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_turn_on_success(self, service, mock_plug):
+        """Verify turn_on returns True on success."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {"POWER": "ON"}
+
+            result = await service.turn_on(mock_plug)
+
+            assert result is True
+            mock_send.assert_called_once_with(
+                "192.168.1.100", "Power On", None, None
+            )
+
+    @pytest.mark.asyncio
+    async def test_turn_on_failure(self, service, mock_plug):
+        """Verify turn_on returns False on failure."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = None
+
+            result = await service.turn_on(mock_plug)
+
+            assert result is False
+
+    @pytest.mark.asyncio
+    async def test_turn_on_with_auth(self, service, mock_plug):
+        """Verify turn_on passes credentials when provided."""
+        mock_plug.username = "admin"
+        mock_plug.password = "secret"
+
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {"POWER": "ON"}
+
+            await service.turn_on(mock_plug)
+
+            mock_send.assert_called_once_with(
+                "192.168.1.100", "Power On", "admin", "secret"
+            )
+
+    # ========================================================================
+    # Tests for turn_off
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_turn_off_success(self, service, mock_plug):
+        """Verify turn_off returns True on success."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {"POWER": "OFF"}
+
+            result = await service.turn_off(mock_plug)
+
+            assert result is True
+
+    @pytest.mark.asyncio
+    async def test_turn_off_failure(self, service, mock_plug):
+        """Verify turn_off returns False on failure."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = None
+
+            result = await service.turn_off(mock_plug)
+
+            assert result is False
+
+    # ========================================================================
+    # Tests for toggle
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_toggle_success(self, service, mock_plug):
+        """Verify toggle returns True on success."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {"POWER": "ON"}
+
+            result = await service.toggle(mock_plug)
+
+            assert result is True
+            mock_send.assert_called_once_with(
+                "192.168.1.100", "Power Toggle", None, None
+            )
+
+    # ========================================================================
+    # Tests for get_status
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_get_status_returns_on(self, service, mock_plug):
+        """Verify get_status returns correct state when ON."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            # Tasmota returns {"POWER": "ON"} for Power command
+            mock_send.return_value = {"POWER": "ON"}
+
+            result = await service.get_status(mock_plug)
+
+            assert result is not None
+            assert result["state"] == "ON"
+            assert result["reachable"] is True
+
+    @pytest.mark.asyncio
+    async def test_get_status_returns_off(self, service, mock_plug):
+        """Verify get_status returns correct state when OFF."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            # Tasmota returns {"POWER": "OFF"} for Power command
+            mock_send.return_value = {"POWER": "OFF"}
+
+            result = await service.get_status(mock_plug)
+
+            assert result is not None
+            assert result["state"] == "OFF"
+
+    @pytest.mark.asyncio
+    async def test_get_status_unreachable(self, service, mock_plug):
+        """Verify get_status handles unreachable device."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = None
+
+            result = await service.get_status(mock_plug)
+
+            assert result is not None
+            assert result["reachable"] is False
+
+    # ========================================================================
+    # Tests for get_energy
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_get_energy_returns_data(self, service, mock_plug):
+        """Verify get_energy parses energy data correctly."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {
+                "StatusSNS": {
+                    "ENERGY": {
+                        "Power": 150.5,
+                        "Voltage": 120.0,
+                        "Current": 1.25,
+                        "Today": 2.5,
+                        "Total": 100.0,
+                        "Factor": 0.95,
+                    }
+                }
+            }
+
+            result = await service.get_energy(mock_plug)
+
+            assert result is not None
+            assert result["power"] == 150.5
+            assert result["voltage"] == 120.0
+            assert result["current"] == 1.25
+            assert result["today"] == 2.5
+            assert result["total"] == 100.0
+            assert result["factor"] == 0.95
+
+    @pytest.mark.asyncio
+    async def test_get_energy_handles_missing_data(self, service, mock_plug):
+        """Verify get_energy handles devices without energy monitoring."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {"StatusSNS": {}}
+
+            result = await service.get_energy(mock_plug)
+
+            assert result is None
+
+    @pytest.mark.asyncio
+    async def test_get_energy_handles_unreachable(self, service, mock_plug):
+        """Verify get_energy handles unreachable device."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = None
+
+            result = await service.get_energy(mock_plug)
+
+            assert result is None
+
+    @pytest.mark.asyncio
+    async def test_get_energy_handles_partial_data(self, service, mock_plug):
+        """Verify get_energy handles partial energy data."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = {
+                "StatusSNS": {
+                    "ENERGY": {
+                        "Power": 150.5,
+                        # Missing other fields
+                    }
+                }
+            }
+
+            result = await service.get_energy(mock_plug)
+
+            assert result is not None
+            assert result["power"] == 150.5
+            # Missing fields should be None or 0
+            assert result.get("voltage") is None or result.get("voltage") == 0
+
+    # ========================================================================
+    # Tests for test_connection
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_test_connection_success(self, service):
+        """Verify test_connection returns success on reachable device."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            # First call (Power) returns state, second call (Status 0) returns device info
+            mock_send.side_effect = [
+                {"POWER": "ON"},  # Power command response
+                {"Status": {"DeviceName": "Test Plug"}}  # Status 0 response
+            ]
+
+            result = await service.test_connection("192.168.1.100")
+
+            assert result["success"] is True
+            assert result["state"] == "ON"
+            assert result["device_name"] == "Test Plug"
+
+    @pytest.mark.asyncio
+    async def test_test_connection_failure(self, service):
+        """Verify test_connection returns failure on unreachable device."""
+        with patch.object(
+            service, '_send_command', new_callable=AsyncMock
+        ) as mock_send:
+            mock_send.return_value = None
+
+            result = await service.test_connection("192.168.1.100")
+
+            assert result["success"] is False
+
+    # ========================================================================
+    # Tests for _send_command
+    # ========================================================================
+
+    @pytest.mark.asyncio
+    async def test_send_command_handles_timeout(self, service):
+        """Verify timeout is handled gracefully."""
+        with patch('httpx.AsyncClient') as mock_client_class:
+            mock_client = AsyncMock()
+            mock_client.get.side_effect = httpx.TimeoutException("Timeout")
+            mock_client_class.return_value.__aenter__ = AsyncMock(
+                return_value=mock_client
+            )
+            mock_client_class.return_value.__aexit__ = AsyncMock()
+
+            result = await service._send_command("192.168.1.100", "Power")
+
+            assert result is None
+
+    @pytest.mark.asyncio
+    async def test_send_command_handles_connection_error(self, service):
+        """Verify connection error is handled gracefully."""
+        with patch('httpx.AsyncClient') as mock_client_class:
+            mock_client = AsyncMock()
+            mock_client.get.side_effect = httpx.ConnectError("Connection refused")
+            mock_client_class.return_value.__aenter__ = AsyncMock(
+                return_value=mock_client
+            )
+            mock_client_class.return_value.__aexit__ = AsyncMock()
+
+            result = await service._send_command("192.168.1.100", "Power")
+
+            assert result is None
+
+    @pytest.mark.asyncio
+    async def test_send_command_handles_invalid_json(self, service):
+        """Verify invalid JSON response is handled gracefully."""
+        with patch('httpx.AsyncClient') as mock_client_class:
+            mock_client = AsyncMock()
+            mock_response = MagicMock()
+            mock_response.json.side_effect = ValueError("Invalid JSON")
+            mock_client.get.return_value = mock_response
+            mock_client_class.return_value.__aenter__ = AsyncMock(
+                return_value=mock_client
+            )
+            mock_client_class.return_value.__aexit__ = AsyncMock()
+
+            result = await service._send_command("192.168.1.100", "Power")
+
+            assert result is None
+
+    @pytest.mark.asyncio
+    async def test_send_command_success(self, service):
+        """Verify successful command returns parsed JSON."""
+        with patch('httpx.AsyncClient') as mock_client_class:
+            mock_client = AsyncMock()
+            mock_response = MagicMock()
+            mock_response.json.return_value = {"POWER": "ON"}
+            mock_client.get.return_value = mock_response
+            mock_client_class.return_value.__aenter__ = AsyncMock(
+                return_value=mock_client
+            )
+            mock_client_class.return_value.__aexit__ = AsyncMock()
+
+            result = await service._send_command("192.168.1.100", "Power")
+
+            assert result == {"POWER": "ON"}
+
+
+class TestTasmotaServiceSingleton:
+    """Tests for the global tasmota_service singleton."""
+
+    def test_singleton_exists(self):
+        """Verify global tasmota_service instance exists."""
+        from backend.app.services.tasmota import tasmota_service
+
+        assert tasmota_service is not None
+        assert isinstance(tasmota_service, TasmotaService)

BIN
docs/screenshots/api_keys.png


BIN
docs/screenshots/archives.png


BIN
docs/screenshots/maintenance_overdue.png


BIN
docs/screenshots/maintenance_settings.png


BIN
docs/screenshots/maintenance_status.png


BIN
docs/screenshots/notifications.png


BIN
docs/screenshots/printers.png


BIN
docs/screenshots/profiles_create.png


BIN
docs/screenshots/profiles_edit.png


BIN
docs/screenshots/profiles_k.png


BIN
docs/screenshots/projects.png


BIN
docs/screenshots/queue.png


BIN
docs/screenshots/settings.png


BIN
docs/screenshots/smart_plugs.png


+ 224 - 0
frontend/coverage/base.css

@@ -0,0 +1,224 @@
+body, html {
+  margin:0; padding: 0;
+  height: 100%;
+}
+body {
+    font-family: Helvetica Neue, Helvetica, Arial;
+    font-size: 14px;
+    color:#333;
+}
+.small { font-size: 12px; }
+*, *:after, *:before {
+  -webkit-box-sizing:border-box;
+     -moz-box-sizing:border-box;
+          box-sizing:border-box;
+  }
+h1 { font-size: 20px; margin: 0;}
+h2 { font-size: 14px; }
+pre {
+    font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
+    margin: 0;
+    padding: 0;
+    -moz-tab-size: 2;
+    -o-tab-size:  2;
+    tab-size: 2;
+}
+a { color:#0074D9; text-decoration:none; }
+a:hover { text-decoration:underline; }
+.strong { font-weight: bold; }
+.space-top1 { padding: 10px 0 0 0; }
+.pad2y { padding: 20px 0; }
+.pad1y { padding: 10px 0; }
+.pad2x { padding: 0 20px; }
+.pad2 { padding: 20px; }
+.pad1 { padding: 10px; }
+.space-left2 { padding-left:55px; }
+.space-right2 { padding-right:20px; }
+.center { text-align:center; }
+.clearfix { display:block; }
+.clearfix:after {
+  content:'';
+  display:block;
+  height:0;
+  clear:both;
+  visibility:hidden;
+  }
+.fl { float: left; }
+@media only screen and (max-width:640px) {
+  .col3 { width:100%; max-width:100%; }
+  .hide-mobile { display:none!important; }
+}
+
+.quiet {
+  color: #7f7f7f;
+  color: rgba(0,0,0,0.5);
+}
+.quiet a { opacity: 0.7; }
+
+.fraction {
+  font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
+  font-size: 10px;
+  color: #555;
+  background: #E8E8E8;
+  padding: 4px 5px;
+  border-radius: 3px;
+  vertical-align: middle;
+}
+
+div.path a:link, div.path a:visited { color: #333; }
+table.coverage {
+  border-collapse: collapse;
+  margin: 10px 0 0 0;
+  padding: 0;
+}
+
+table.coverage td {
+  margin: 0;
+  padding: 0;
+  vertical-align: top;
+}
+table.coverage td.line-count {
+    text-align: right;
+    padding: 0 5px 0 20px;
+}
+table.coverage td.line-coverage {
+    text-align: right;
+    padding-right: 10px;
+    min-width:20px;
+}
+
+table.coverage td span.cline-any {
+    display: inline-block;
+    padding: 0 5px;
+    width: 100%;
+}
+.missing-if-branch {
+    display: inline-block;
+    margin-right: 5px;
+    border-radius: 3px;
+    position: relative;
+    padding: 0 4px;
+    background: #333;
+    color: yellow;
+}
+
+.skip-if-branch {
+    display: none;
+    margin-right: 10px;
+    position: relative;
+    padding: 0 4px;
+    background: #ccc;
+    color: white;
+}
+.missing-if-branch .typ, .skip-if-branch .typ {
+    color: inherit !important;
+}
+.coverage-summary {
+  border-collapse: collapse;
+  width: 100%;
+}
+.coverage-summary tr { border-bottom: 1px solid #bbb; }
+.keyline-all { border: 1px solid #ddd; }
+.coverage-summary td, .coverage-summary th { padding: 10px; }
+.coverage-summary tbody { border: 1px solid #bbb; }
+.coverage-summary td { border-right: 1px solid #bbb; }
+.coverage-summary td:last-child { border-right: none; }
+.coverage-summary th {
+  text-align: left;
+  font-weight: normal;
+  white-space: nowrap;
+}
+.coverage-summary th.file { border-right: none !important; }
+.coverage-summary th.pct { }
+.coverage-summary th.pic,
+.coverage-summary th.abs,
+.coverage-summary td.pct,
+.coverage-summary td.abs { text-align: right; }
+.coverage-summary td.file { white-space: nowrap;  }
+.coverage-summary td.pic { min-width: 120px !important;  }
+.coverage-summary tfoot td { }
+
+.coverage-summary .sorter {
+    height: 10px;
+    width: 7px;
+    display: inline-block;
+    margin-left: 0.5em;
+    background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
+}
+.coverage-summary .sorted .sorter {
+    background-position: 0 -20px;
+}
+.coverage-summary .sorted-desc .sorter {
+    background-position: 0 -10px;
+}
+.status-line {  height: 10px; }
+/* yellow */
+.cbranch-no { background: yellow !important; color: #111; }
+/* dark red */
+.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
+.low .chart { border:1px solid #C21F39 }
+.highlighted,
+.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
+  background: #C21F39 !important;
+}
+/* medium red */
+.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
+/* light red */
+.low, .cline-no { background:#FCE1E5 }
+/* light green */
+.high, .cline-yes { background:rgb(230,245,208) }
+/* medium green */
+.cstat-yes { background:rgb(161,215,106) }
+/* dark green */
+.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
+.high .chart { border:1px solid rgb(77,146,33) }
+/* dark yellow (gold) */
+.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
+.medium .chart { border:1px solid #f9cd0b; }
+/* light yellow */
+.medium { background: #fff4c2; }
+
+.cstat-skip { background: #ddd; color: #111; }
+.fstat-skip { background: #ddd; color: #111 !important; }
+.cbranch-skip { background: #ddd !important; color: #111; }
+
+span.cline-neutral { background: #eaeaea; }
+
+.coverage-summary td.empty {
+    opacity: .5;
+    padding-top: 4px;
+    padding-bottom: 4px;
+    line-height: 1;
+    color: #888;
+}
+
+.cover-fill, .cover-empty {
+  display:inline-block;
+  height: 12px;
+}
+.chart {
+  line-height: 0;
+}
+.cover-empty {
+    background: white;
+}
+.cover-full {
+    border-right: none !important;
+}
+pre.prettyprint {
+    border: none !important;
+    padding: 0 !important;
+    margin: 0 !important;
+}
+.com { color: #999 !important; }
+.ignore-none { color: #999; font-weight: normal; }
+
+.wrapper {
+  min-height: 100%;
+  height: auto !important;
+  height: 100%;
+  margin: 0 auto -48px;
+}
+.footer, .push {
+  height: 48px;
+}

+ 87 - 0
frontend/coverage/block-navigation.js

@@ -0,0 +1,87 @@
+/* eslint-disable */
+var jumpToCode = (function init() {
+    // Classes of code we would like to highlight in the file view
+    var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
+
+    // Elements to highlight in the file listing view
+    var fileListingElements = ['td.pct.low'];
+
+    // We don't want to select elements that are direct descendants of another match
+    var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
+
+    // Selector that finds elements on the page to which we can jump
+    var selector =
+        fileListingElements.join(', ') +
+        ', ' +
+        notSelector +
+        missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
+
+    // The NodeList of matching elements
+    var missingCoverageElements = document.querySelectorAll(selector);
+
+    var currentIndex;
+
+    function toggleClass(index) {
+        missingCoverageElements
+            .item(currentIndex)
+            .classList.remove('highlighted');
+        missingCoverageElements.item(index).classList.add('highlighted');
+    }
+
+    function makeCurrent(index) {
+        toggleClass(index);
+        currentIndex = index;
+        missingCoverageElements.item(index).scrollIntoView({
+            behavior: 'smooth',
+            block: 'center',
+            inline: 'center'
+        });
+    }
+
+    function goToPrevious() {
+        var nextIndex = 0;
+        if (typeof currentIndex !== 'number' || currentIndex === 0) {
+            nextIndex = missingCoverageElements.length - 1;
+        } else if (missingCoverageElements.length > 1) {
+            nextIndex = currentIndex - 1;
+        }
+
+        makeCurrent(nextIndex);
+    }
+
+    function goToNext() {
+        var nextIndex = 0;
+
+        if (
+            typeof currentIndex === 'number' &&
+            currentIndex < missingCoverageElements.length - 1
+        ) {
+            nextIndex = currentIndex + 1;
+        }
+
+        makeCurrent(nextIndex);
+    }
+
+    return function jump(event) {
+        if (
+            document.getElementById('fileSearch') === document.activeElement &&
+            document.activeElement != null
+        ) {
+            // if we're currently focused on the search input, we don't want to navigate
+            return;
+        }
+
+        switch (event.which) {
+            case 78: // n
+            case 74: // j
+                goToNext();
+                break;
+            case 66: // b
+            case 75: // k
+            case 80: // p
+                goToPrevious();
+                break;
+        }
+    };
+})();
+window.addEventListener('keydown', jumpToCode);

File diff suppressed because it is too large
+ 0 - 0
frontend/coverage/coverage-final.json


BIN
frontend/coverage/favicon.png


+ 236 - 0
frontend/coverage/index.html

@@ -0,0 +1,236 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for All files</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="prettify.css" />
+    <link rel="stylesheet" href="base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1>All files</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">2.88% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>594/20569</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">41.73% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>53/127</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">7.06% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>19/269</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">2.88% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>594/20569</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <div class="pad1">
+<table class="coverage-summary">
+<thead>
+<tr>
+   <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
+   <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
+   <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
+   <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
+   <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
+   <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
+   <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
+</tr>
+</thead>
+<tbody><tr>
+	<td class="file low" data-value="src"><a href="src/index.html">src</a></td>
+	<td data-value="0" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+	</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="56" class="abs low">0/56</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="56" class="abs low">0/56</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/api"><a href="src/api/index.html">src/api</a></td>
+	<td data-value="27.54" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 27%"></div><div class="cover-empty" style="width: 73%"></div></div>
+	</td>
+	<td data-value="27.54" class="pct low">27.54%</td>
+	<td data-value="679" class="abs low">187/679</td>
+	<td data-value="75" class="pct medium">75%</td>
+	<td data-value="4" class="abs medium">3/4</td>
+	<td data-value="1.76" class="pct low">1.76%</td>
+	<td data-value="170" class="abs low">3/170</td>
+	<td data-value="27.54" class="pct low">27.54%</td>
+	<td data-value="679" class="abs low">187/679</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/components"><a href="src/components/index.html">src/components</a></td>
+	<td data-value="3.65" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 3%"></div><div class="cover-empty" style="width: 97%"></div></div>
+	</td>
+	<td data-value="3.65" class="pct low">3.65%</td>
+	<td data-value="9626" class="abs low">352/9626</td>
+	<td data-value="46" class="pct low">46%</td>
+	<td data-value="100" class="abs low">46/100</td>
+	<td data-value="18.66" class="pct low">18.66%</td>
+	<td data-value="75" class="abs low">14/75</td>
+	<td data-value="3.65" class="pct low">3.65%</td>
+	<td data-value="9626" class="abs low">352/9626</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/components/icons"><a href="src/components/icons/index.html">src/components/icons</a></td>
+	<td data-value="0" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+	</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="44" class="abs low">0/44</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="44" class="abs low">0/44</td>
+	</tr>
+
+<tr>
+	<td class="file medium" data-value="src/contexts"><a href="src/contexts/index.html">src/contexts</a></td>
+	<td data-value="60.22" class="pic medium">
+	<div class="chart"><div class="cover-fill" style="width: 60%"></div><div class="cover-empty" style="width: 40%"></div></div>
+	</td>
+	<td data-value="60.22" class="pct medium">60.22%</td>
+	<td data-value="88" class="abs medium">53/88</td>
+	<td data-value="66.66" class="pct medium">66.66%</td>
+	<td data-value="6" class="abs medium">4/6</td>
+	<td data-value="33.33" class="pct low">33.33%</td>
+	<td data-value="6" class="abs low">2/6</td>
+	<td data-value="60.22" class="pct medium">60.22%</td>
+	<td data-value="88" class="abs medium">53/88</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/hooks"><a href="src/hooks/index.html">src/hooks</a></td>
+	<td data-value="1.33" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 1%"></div><div class="cover-empty" style="width: 99%"></div></div>
+	</td>
+	<td data-value="1.33" class="pct low">1.33%</td>
+	<td data-value="150" class="abs low">2/150</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="2" class="abs low">0/2</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="3" class="abs low">0/3</td>
+	<td data-value="1.33" class="pct low">1.33%</td>
+	<td data-value="150" class="abs low">2/150</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/i18n"><a href="src/i18n/index.html">src/i18n</a></td>
+	<td data-value="0" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+	</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="29" class="abs low">0/29</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="1" class="abs low">0/1</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="29" class="abs low">0/29</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/i18n/locales"><a href="src/i18n/locales/index.html">src/i18n/locales</a></td>
+	<td data-value="0" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+	</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="666" class="abs low">0/666</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="2" class="abs low">0/2</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="2" class="abs low">0/2</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="666" class="abs low">0/666</td>
+	</tr>
+
+<tr>
+	<td class="file low" data-value="src/pages"><a href="src/pages/index.html">src/pages</a></td>
+	<td data-value="0" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 0%"></div><div class="cover-empty" style="width: 100%"></div></div>
+	</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="9231" class="abs low">0/9231</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="10" class="abs low">0/10</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="10" class="abs low">0/10</td>
+	<td data-value="0" class="pct low">0%</td>
+	<td data-value="9231" class="abs low">0/9231</td>
+	</tr>
+
+</tbody>
+</table>
+</div>
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="sorter.js"></script>
+        <script src="block-navigation.js"></script>
+    </body>
+</html>
+    

+ 1 - 0
frontend/coverage/prettify.css

@@ -0,0 +1 @@
+.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}

File diff suppressed because it is too large
+ 1 - 0
frontend/coverage/prettify.js


BIN
frontend/coverage/sort-arrow-sprite.png


+ 210 - 0
frontend/coverage/sorter.js

@@ -0,0 +1,210 @@
+/* eslint-disable */
+var addSorting = (function() {
+    'use strict';
+    var cols,
+        currentSort = {
+            index: 0,
+            desc: false
+        };
+
+    // returns the summary table element
+    function getTable() {
+        return document.querySelector('.coverage-summary');
+    }
+    // returns the thead element of the summary table
+    function getTableHeader() {
+        return getTable().querySelector('thead tr');
+    }
+    // returns the tbody element of the summary table
+    function getTableBody() {
+        return getTable().querySelector('tbody');
+    }
+    // returns the th element for nth column
+    function getNthColumn(n) {
+        return getTableHeader().querySelectorAll('th')[n];
+    }
+
+    function onFilterInput() {
+        const searchValue = document.getElementById('fileSearch').value;
+        const rows = document.getElementsByTagName('tbody')[0].children;
+
+        // Try to create a RegExp from the searchValue. If it fails (invalid regex),
+        // it will be treated as a plain text search
+        let searchRegex;
+        try {
+            searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
+        } catch (error) {
+            searchRegex = null;
+        }
+
+        for (let i = 0; i < rows.length; i++) {
+            const row = rows[i];
+            let isMatch = false;
+
+            if (searchRegex) {
+                // If a valid regex was created, use it for matching
+                isMatch = searchRegex.test(row.textContent);
+            } else {
+                // Otherwise, fall back to the original plain text search
+                isMatch = row.textContent
+                    .toLowerCase()
+                    .includes(searchValue.toLowerCase());
+            }
+
+            row.style.display = isMatch ? '' : 'none';
+        }
+    }
+
+    // loads the search box
+    function addSearchBox() {
+        var template = document.getElementById('filterTemplate');
+        var templateClone = template.content.cloneNode(true);
+        templateClone.getElementById('fileSearch').oninput = onFilterInput;
+        template.parentElement.appendChild(templateClone);
+    }
+
+    // loads all columns
+    function loadColumns() {
+        var colNodes = getTableHeader().querySelectorAll('th'),
+            colNode,
+            cols = [],
+            col,
+            i;
+
+        for (i = 0; i < colNodes.length; i += 1) {
+            colNode = colNodes[i];
+            col = {
+                key: colNode.getAttribute('data-col'),
+                sortable: !colNode.getAttribute('data-nosort'),
+                type: colNode.getAttribute('data-type') || 'string'
+            };
+            cols.push(col);
+            if (col.sortable) {
+                col.defaultDescSort = col.type === 'number';
+                colNode.innerHTML =
+                    colNode.innerHTML + '<span class="sorter"></span>';
+            }
+        }
+        return cols;
+    }
+    // attaches a data attribute to every tr element with an object
+    // of data values keyed by column name
+    function loadRowData(tableRow) {
+        var tableCols = tableRow.querySelectorAll('td'),
+            colNode,
+            col,
+            data = {},
+            i,
+            val;
+        for (i = 0; i < tableCols.length; i += 1) {
+            colNode = tableCols[i];
+            col = cols[i];
+            val = colNode.getAttribute('data-value');
+            if (col.type === 'number') {
+                val = Number(val);
+            }
+            data[col.key] = val;
+        }
+        return data;
+    }
+    // loads all row data
+    function loadData() {
+        var rows = getTableBody().querySelectorAll('tr'),
+            i;
+
+        for (i = 0; i < rows.length; i += 1) {
+            rows[i].data = loadRowData(rows[i]);
+        }
+    }
+    // sorts the table using the data for the ith column
+    function sortByIndex(index, desc) {
+        var key = cols[index].key,
+            sorter = function(a, b) {
+                a = a.data[key];
+                b = b.data[key];
+                return a < b ? -1 : a > b ? 1 : 0;
+            },
+            finalSorter = sorter,
+            tableBody = document.querySelector('.coverage-summary tbody'),
+            rowNodes = tableBody.querySelectorAll('tr'),
+            rows = [],
+            i;
+
+        if (desc) {
+            finalSorter = function(a, b) {
+                return -1 * sorter(a, b);
+            };
+        }
+
+        for (i = 0; i < rowNodes.length; i += 1) {
+            rows.push(rowNodes[i]);
+            tableBody.removeChild(rowNodes[i]);
+        }
+
+        rows.sort(finalSorter);
+
+        for (i = 0; i < rows.length; i += 1) {
+            tableBody.appendChild(rows[i]);
+        }
+    }
+    // removes sort indicators for current column being sorted
+    function removeSortIndicators() {
+        var col = getNthColumn(currentSort.index),
+            cls = col.className;
+
+        cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
+        col.className = cls;
+    }
+    // adds sort indicators for current column being sorted
+    function addSortIndicators() {
+        getNthColumn(currentSort.index).className += currentSort.desc
+            ? ' sorted-desc'
+            : ' sorted';
+    }
+    // adds event listeners for all sorter widgets
+    function enableUI() {
+        var i,
+            el,
+            ithSorter = function ithSorter(i) {
+                var col = cols[i];
+
+                return function() {
+                    var desc = col.defaultDescSort;
+
+                    if (currentSort.index === i) {
+                        desc = !currentSort.desc;
+                    }
+                    sortByIndex(i, desc);
+                    removeSortIndicators();
+                    currentSort.index = i;
+                    currentSort.desc = desc;
+                    addSortIndicators();
+                };
+            };
+        for (i = 0; i < cols.length; i += 1) {
+            if (cols[i].sortable) {
+                // add the click event handler on the th so users
+                // dont have to click on those tiny arrows
+                el = getNthColumn(i).querySelector('.sorter').parentElement;
+                if (el.addEventListener) {
+                    el.addEventListener('click', ithSorter(i));
+                } else {
+                    el.attachEvent('onclick', ithSorter(i));
+                }
+            }
+        }
+    }
+    // adds sorting functionality to the UI
+    return function() {
+        if (!getTable()) {
+            return;
+        }
+        cols = loadColumns();
+        loadData();
+        addSearchBox();
+        addSortIndicators();
+        enableUI();
+    };
+})();
+
+window.addEventListener('load', addSorting);

+ 274 - 0
frontend/coverage/src/App.tsx.html

@@ -0,0 +1,274 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/App.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../prettify.css" />
+    <link rel="stylesheet" href="../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../index.html">All files</a> / <a href="index.html">src</a> App.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/56</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/56</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { BrowserRouter, Routes, Route } from 'react-router-dom';</span></span></span>
+<span class="cstat-no" title="statement not covered" >import { QueryClient, QueryClientProvider } from '@tanstack/react-query';</span>
+<span class="cstat-no" title="statement not covered" >import { Layout } from './components/Layout';</span>
+<span class="cstat-no" title="statement not covered" >import { PrintersPage } from './pages/PrintersPage';</span>
+<span class="cstat-no" title="statement not covered" >import { ArchivesPage } from './pages/ArchivesPage';</span>
+<span class="cstat-no" title="statement not covered" >import { QueuePage } from './pages/QueuePage';</span>
+<span class="cstat-no" title="statement not covered" >import { StatsPage } from './pages/StatsPage';</span>
+<span class="cstat-no" title="statement not covered" >import { SettingsPage } from './pages/SettingsPage';</span>
+<span class="cstat-no" title="statement not covered" >import { ProfilesPage } from './pages/ProfilesPage';</span>
+<span class="cstat-no" title="statement not covered" >import { MaintenancePage } from './pages/MaintenancePage';</span>
+<span class="cstat-no" title="statement not covered" >import { ProjectsPage } from './pages/ProjectsPage';</span>
+<span class="cstat-no" title="statement not covered" >import { CameraPage } from './pages/CameraPage';</span>
+<span class="cstat-no" title="statement not covered" >import { ExternalLinkPage } from './pages/ExternalLinkPage';</span>
+<span class="cstat-no" title="statement not covered" >import { useWebSocket } from './hooks/useWebSocket';</span>
+<span class="cstat-no" title="statement not covered" >import { ThemeProvider } from './contexts/ThemeContext';</span>
+<span class="cstat-no" title="statement not covered" >import { ToastProvider } from './contexts/ToastContext';</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const queryClient = new QueryClient({</span>
+<span class="cstat-no" title="statement not covered" >  defaultOptions: {</span>
+<span class="cstat-no" title="statement not covered" >    queries: {</span>
+<span class="cstat-no" title="statement not covered" >      staleTime: 1000 * 60,</span>
+<span class="cstat-no" title="statement not covered" >      retry: 1,</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >});</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >function WebSocketProvider({ children }: { children: React.ReactNode }) {</span>
+<span class="cstat-no" title="statement not covered" >  useWebSocket();</span>
+<span class="cstat-no" title="statement not covered" >  return &lt;&gt;{children}&lt;/&gt;;</span>
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >function App() {</span>
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;ThemeProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;ToastProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;QueryClientProvider client={queryClient}&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;WebSocketProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;BrowserRouter&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Routes&gt;</span>
+                {/* Camera page - standalone, no layout */}
+<span class="cstat-no" title="statement not covered" >                &lt;Route path="/camera/:printerId" element={&lt;CameraPage /&gt;} /&gt;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >                &lt;Route path="/" element={&lt;Layout /&gt;}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route index element={&lt;PrintersPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="archives" element={&lt;ArchivesPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="queue" element={&lt;QueuePage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="stats" element={&lt;StatsPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="profiles" element={&lt;ProfilesPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="maintenance" element={&lt;MaintenancePage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="projects" element={&lt;ProjectsPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="settings" element={&lt;SettingsPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Route path="external/:id" element={&lt;ExternalLinkPage /&gt;} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/Route&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/Routes&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/BrowserRouter&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/WebSocketProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/QueryClientProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/ToastProvider&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/ThemeProvider&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export default App;</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../sorter.js"></script>
+        <script src="../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 5875 - 0
frontend/coverage/src/api/client.ts.html

@@ -0,0 +1,5875 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/api/client.ts</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/api</a> client.ts</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">27.54% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>187/679</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">75% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>3/4</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">1.76% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>3/170</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">27.54% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>187/679</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a>
+<a name='L297'></a><a href='#L297'>297</a>
+<a name='L298'></a><a href='#L298'>298</a>
+<a name='L299'></a><a href='#L299'>299</a>
+<a name='L300'></a><a href='#L300'>300</a>
+<a name='L301'></a><a href='#L301'>301</a>
+<a name='L302'></a><a href='#L302'>302</a>
+<a name='L303'></a><a href='#L303'>303</a>
+<a name='L304'></a><a href='#L304'>304</a>
+<a name='L305'></a><a href='#L305'>305</a>
+<a name='L306'></a><a href='#L306'>306</a>
+<a name='L307'></a><a href='#L307'>307</a>
+<a name='L308'></a><a href='#L308'>308</a>
+<a name='L309'></a><a href='#L309'>309</a>
+<a name='L310'></a><a href='#L310'>310</a>
+<a name='L311'></a><a href='#L311'>311</a>
+<a name='L312'></a><a href='#L312'>312</a>
+<a name='L313'></a><a href='#L313'>313</a>
+<a name='L314'></a><a href='#L314'>314</a>
+<a name='L315'></a><a href='#L315'>315</a>
+<a name='L316'></a><a href='#L316'>316</a>
+<a name='L317'></a><a href='#L317'>317</a>
+<a name='L318'></a><a href='#L318'>318</a>
+<a name='L319'></a><a href='#L319'>319</a>
+<a name='L320'></a><a href='#L320'>320</a>
+<a name='L321'></a><a href='#L321'>321</a>
+<a name='L322'></a><a href='#L322'>322</a>
+<a name='L323'></a><a href='#L323'>323</a>
+<a name='L324'></a><a href='#L324'>324</a>
+<a name='L325'></a><a href='#L325'>325</a>
+<a name='L326'></a><a href='#L326'>326</a>
+<a name='L327'></a><a href='#L327'>327</a>
+<a name='L328'></a><a href='#L328'>328</a>
+<a name='L329'></a><a href='#L329'>329</a>
+<a name='L330'></a><a href='#L330'>330</a>
+<a name='L331'></a><a href='#L331'>331</a>
+<a name='L332'></a><a href='#L332'>332</a>
+<a name='L333'></a><a href='#L333'>333</a>
+<a name='L334'></a><a href='#L334'>334</a>
+<a name='L335'></a><a href='#L335'>335</a>
+<a name='L336'></a><a href='#L336'>336</a>
+<a name='L337'></a><a href='#L337'>337</a>
+<a name='L338'></a><a href='#L338'>338</a>
+<a name='L339'></a><a href='#L339'>339</a>
+<a name='L340'></a><a href='#L340'>340</a>
+<a name='L341'></a><a href='#L341'>341</a>
+<a name='L342'></a><a href='#L342'>342</a>
+<a name='L343'></a><a href='#L343'>343</a>
+<a name='L344'></a><a href='#L344'>344</a>
+<a name='L345'></a><a href='#L345'>345</a>
+<a name='L346'></a><a href='#L346'>346</a>
+<a name='L347'></a><a href='#L347'>347</a>
+<a name='L348'></a><a href='#L348'>348</a>
+<a name='L349'></a><a href='#L349'>349</a>
+<a name='L350'></a><a href='#L350'>350</a>
+<a name='L351'></a><a href='#L351'>351</a>
+<a name='L352'></a><a href='#L352'>352</a>
+<a name='L353'></a><a href='#L353'>353</a>
+<a name='L354'></a><a href='#L354'>354</a>
+<a name='L355'></a><a href='#L355'>355</a>
+<a name='L356'></a><a href='#L356'>356</a>
+<a name='L357'></a><a href='#L357'>357</a>
+<a name='L358'></a><a href='#L358'>358</a>
+<a name='L359'></a><a href='#L359'>359</a>
+<a name='L360'></a><a href='#L360'>360</a>
+<a name='L361'></a><a href='#L361'>361</a>
+<a name='L362'></a><a href='#L362'>362</a>
+<a name='L363'></a><a href='#L363'>363</a>
+<a name='L364'></a><a href='#L364'>364</a>
+<a name='L365'></a><a href='#L365'>365</a>
+<a name='L366'></a><a href='#L366'>366</a>
+<a name='L367'></a><a href='#L367'>367</a>
+<a name='L368'></a><a href='#L368'>368</a>
+<a name='L369'></a><a href='#L369'>369</a>
+<a name='L370'></a><a href='#L370'>370</a>
+<a name='L371'></a><a href='#L371'>371</a>
+<a name='L372'></a><a href='#L372'>372</a>
+<a name='L373'></a><a href='#L373'>373</a>
+<a name='L374'></a><a href='#L374'>374</a>
+<a name='L375'></a><a href='#L375'>375</a>
+<a name='L376'></a><a href='#L376'>376</a>
+<a name='L377'></a><a href='#L377'>377</a>
+<a name='L378'></a><a href='#L378'>378</a>
+<a name='L379'></a><a href='#L379'>379</a>
+<a name='L380'></a><a href='#L380'>380</a>
+<a name='L381'></a><a href='#L381'>381</a>
+<a name='L382'></a><a href='#L382'>382</a>
+<a name='L383'></a><a href='#L383'>383</a>
+<a name='L384'></a><a href='#L384'>384</a>
+<a name='L385'></a><a href='#L385'>385</a>
+<a name='L386'></a><a href='#L386'>386</a>
+<a name='L387'></a><a href='#L387'>387</a>
+<a name='L388'></a><a href='#L388'>388</a>
+<a name='L389'></a><a href='#L389'>389</a>
+<a name='L390'></a><a href='#L390'>390</a>
+<a name='L391'></a><a href='#L391'>391</a>
+<a name='L392'></a><a href='#L392'>392</a>
+<a name='L393'></a><a href='#L393'>393</a>
+<a name='L394'></a><a href='#L394'>394</a>
+<a name='L395'></a><a href='#L395'>395</a>
+<a name='L396'></a><a href='#L396'>396</a>
+<a name='L397'></a><a href='#L397'>397</a>
+<a name='L398'></a><a href='#L398'>398</a>
+<a name='L399'></a><a href='#L399'>399</a>
+<a name='L400'></a><a href='#L400'>400</a>
+<a name='L401'></a><a href='#L401'>401</a>
+<a name='L402'></a><a href='#L402'>402</a>
+<a name='L403'></a><a href='#L403'>403</a>
+<a name='L404'></a><a href='#L404'>404</a>
+<a name='L405'></a><a href='#L405'>405</a>
+<a name='L406'></a><a href='#L406'>406</a>
+<a name='L407'></a><a href='#L407'>407</a>
+<a name='L408'></a><a href='#L408'>408</a>
+<a name='L409'></a><a href='#L409'>409</a>
+<a name='L410'></a><a href='#L410'>410</a>
+<a name='L411'></a><a href='#L411'>411</a>
+<a name='L412'></a><a href='#L412'>412</a>
+<a name='L413'></a><a href='#L413'>413</a>
+<a name='L414'></a><a href='#L414'>414</a>
+<a name='L415'></a><a href='#L415'>415</a>
+<a name='L416'></a><a href='#L416'>416</a>
+<a name='L417'></a><a href='#L417'>417</a>
+<a name='L418'></a><a href='#L418'>418</a>
+<a name='L419'></a><a href='#L419'>419</a>
+<a name='L420'></a><a href='#L420'>420</a>
+<a name='L421'></a><a href='#L421'>421</a>
+<a name='L422'></a><a href='#L422'>422</a>
+<a name='L423'></a><a href='#L423'>423</a>
+<a name='L424'></a><a href='#L424'>424</a>
+<a name='L425'></a><a href='#L425'>425</a>
+<a name='L426'></a><a href='#L426'>426</a>
+<a name='L427'></a><a href='#L427'>427</a>
+<a name='L428'></a><a href='#L428'>428</a>
+<a name='L429'></a><a href='#L429'>429</a>
+<a name='L430'></a><a href='#L430'>430</a>
+<a name='L431'></a><a href='#L431'>431</a>
+<a name='L432'></a><a href='#L432'>432</a>
+<a name='L433'></a><a href='#L433'>433</a>
+<a name='L434'></a><a href='#L434'>434</a>
+<a name='L435'></a><a href='#L435'>435</a>
+<a name='L436'></a><a href='#L436'>436</a>
+<a name='L437'></a><a href='#L437'>437</a>
+<a name='L438'></a><a href='#L438'>438</a>
+<a name='L439'></a><a href='#L439'>439</a>
+<a name='L440'></a><a href='#L440'>440</a>
+<a name='L441'></a><a href='#L441'>441</a>
+<a name='L442'></a><a href='#L442'>442</a>
+<a name='L443'></a><a href='#L443'>443</a>
+<a name='L444'></a><a href='#L444'>444</a>
+<a name='L445'></a><a href='#L445'>445</a>
+<a name='L446'></a><a href='#L446'>446</a>
+<a name='L447'></a><a href='#L447'>447</a>
+<a name='L448'></a><a href='#L448'>448</a>
+<a name='L449'></a><a href='#L449'>449</a>
+<a name='L450'></a><a href='#L450'>450</a>
+<a name='L451'></a><a href='#L451'>451</a>
+<a name='L452'></a><a href='#L452'>452</a>
+<a name='L453'></a><a href='#L453'>453</a>
+<a name='L454'></a><a href='#L454'>454</a>
+<a name='L455'></a><a href='#L455'>455</a>
+<a name='L456'></a><a href='#L456'>456</a>
+<a name='L457'></a><a href='#L457'>457</a>
+<a name='L458'></a><a href='#L458'>458</a>
+<a name='L459'></a><a href='#L459'>459</a>
+<a name='L460'></a><a href='#L460'>460</a>
+<a name='L461'></a><a href='#L461'>461</a>
+<a name='L462'></a><a href='#L462'>462</a>
+<a name='L463'></a><a href='#L463'>463</a>
+<a name='L464'></a><a href='#L464'>464</a>
+<a name='L465'></a><a href='#L465'>465</a>
+<a name='L466'></a><a href='#L466'>466</a>
+<a name='L467'></a><a href='#L467'>467</a>
+<a name='L468'></a><a href='#L468'>468</a>
+<a name='L469'></a><a href='#L469'>469</a>
+<a name='L470'></a><a href='#L470'>470</a>
+<a name='L471'></a><a href='#L471'>471</a>
+<a name='L472'></a><a href='#L472'>472</a>
+<a name='L473'></a><a href='#L473'>473</a>
+<a name='L474'></a><a href='#L474'>474</a>
+<a name='L475'></a><a href='#L475'>475</a>
+<a name='L476'></a><a href='#L476'>476</a>
+<a name='L477'></a><a href='#L477'>477</a>
+<a name='L478'></a><a href='#L478'>478</a>
+<a name='L479'></a><a href='#L479'>479</a>
+<a name='L480'></a><a href='#L480'>480</a>
+<a name='L481'></a><a href='#L481'>481</a>
+<a name='L482'></a><a href='#L482'>482</a>
+<a name='L483'></a><a href='#L483'>483</a>
+<a name='L484'></a><a href='#L484'>484</a>
+<a name='L485'></a><a href='#L485'>485</a>
+<a name='L486'></a><a href='#L486'>486</a>
+<a name='L487'></a><a href='#L487'>487</a>
+<a name='L488'></a><a href='#L488'>488</a>
+<a name='L489'></a><a href='#L489'>489</a>
+<a name='L490'></a><a href='#L490'>490</a>
+<a name='L491'></a><a href='#L491'>491</a>
+<a name='L492'></a><a href='#L492'>492</a>
+<a name='L493'></a><a href='#L493'>493</a>
+<a name='L494'></a><a href='#L494'>494</a>
+<a name='L495'></a><a href='#L495'>495</a>
+<a name='L496'></a><a href='#L496'>496</a>
+<a name='L497'></a><a href='#L497'>497</a>
+<a name='L498'></a><a href='#L498'>498</a>
+<a name='L499'></a><a href='#L499'>499</a>
+<a name='L500'></a><a href='#L500'>500</a>
+<a name='L501'></a><a href='#L501'>501</a>
+<a name='L502'></a><a href='#L502'>502</a>
+<a name='L503'></a><a href='#L503'>503</a>
+<a name='L504'></a><a href='#L504'>504</a>
+<a name='L505'></a><a href='#L505'>505</a>
+<a name='L506'></a><a href='#L506'>506</a>
+<a name='L507'></a><a href='#L507'>507</a>
+<a name='L508'></a><a href='#L508'>508</a>
+<a name='L509'></a><a href='#L509'>509</a>
+<a name='L510'></a><a href='#L510'>510</a>
+<a name='L511'></a><a href='#L511'>511</a>
+<a name='L512'></a><a href='#L512'>512</a>
+<a name='L513'></a><a href='#L513'>513</a>
+<a name='L514'></a><a href='#L514'>514</a>
+<a name='L515'></a><a href='#L515'>515</a>
+<a name='L516'></a><a href='#L516'>516</a>
+<a name='L517'></a><a href='#L517'>517</a>
+<a name='L518'></a><a href='#L518'>518</a>
+<a name='L519'></a><a href='#L519'>519</a>
+<a name='L520'></a><a href='#L520'>520</a>
+<a name='L521'></a><a href='#L521'>521</a>
+<a name='L522'></a><a href='#L522'>522</a>
+<a name='L523'></a><a href='#L523'>523</a>
+<a name='L524'></a><a href='#L524'>524</a>
+<a name='L525'></a><a href='#L525'>525</a>
+<a name='L526'></a><a href='#L526'>526</a>
+<a name='L527'></a><a href='#L527'>527</a>
+<a name='L528'></a><a href='#L528'>528</a>
+<a name='L529'></a><a href='#L529'>529</a>
+<a name='L530'></a><a href='#L530'>530</a>
+<a name='L531'></a><a href='#L531'>531</a>
+<a name='L532'></a><a href='#L532'>532</a>
+<a name='L533'></a><a href='#L533'>533</a>
+<a name='L534'></a><a href='#L534'>534</a>
+<a name='L535'></a><a href='#L535'>535</a>
+<a name='L536'></a><a href='#L536'>536</a>
+<a name='L537'></a><a href='#L537'>537</a>
+<a name='L538'></a><a href='#L538'>538</a>
+<a name='L539'></a><a href='#L539'>539</a>
+<a name='L540'></a><a href='#L540'>540</a>
+<a name='L541'></a><a href='#L541'>541</a>
+<a name='L542'></a><a href='#L542'>542</a>
+<a name='L543'></a><a href='#L543'>543</a>
+<a name='L544'></a><a href='#L544'>544</a>
+<a name='L545'></a><a href='#L545'>545</a>
+<a name='L546'></a><a href='#L546'>546</a>
+<a name='L547'></a><a href='#L547'>547</a>
+<a name='L548'></a><a href='#L548'>548</a>
+<a name='L549'></a><a href='#L549'>549</a>
+<a name='L550'></a><a href='#L550'>550</a>
+<a name='L551'></a><a href='#L551'>551</a>
+<a name='L552'></a><a href='#L552'>552</a>
+<a name='L553'></a><a href='#L553'>553</a>
+<a name='L554'></a><a href='#L554'>554</a>
+<a name='L555'></a><a href='#L555'>555</a>
+<a name='L556'></a><a href='#L556'>556</a>
+<a name='L557'></a><a href='#L557'>557</a>
+<a name='L558'></a><a href='#L558'>558</a>
+<a name='L559'></a><a href='#L559'>559</a>
+<a name='L560'></a><a href='#L560'>560</a>
+<a name='L561'></a><a href='#L561'>561</a>
+<a name='L562'></a><a href='#L562'>562</a>
+<a name='L563'></a><a href='#L563'>563</a>
+<a name='L564'></a><a href='#L564'>564</a>
+<a name='L565'></a><a href='#L565'>565</a>
+<a name='L566'></a><a href='#L566'>566</a>
+<a name='L567'></a><a href='#L567'>567</a>
+<a name='L568'></a><a href='#L568'>568</a>
+<a name='L569'></a><a href='#L569'>569</a>
+<a name='L570'></a><a href='#L570'>570</a>
+<a name='L571'></a><a href='#L571'>571</a>
+<a name='L572'></a><a href='#L572'>572</a>
+<a name='L573'></a><a href='#L573'>573</a>
+<a name='L574'></a><a href='#L574'>574</a>
+<a name='L575'></a><a href='#L575'>575</a>
+<a name='L576'></a><a href='#L576'>576</a>
+<a name='L577'></a><a href='#L577'>577</a>
+<a name='L578'></a><a href='#L578'>578</a>
+<a name='L579'></a><a href='#L579'>579</a>
+<a name='L580'></a><a href='#L580'>580</a>
+<a name='L581'></a><a href='#L581'>581</a>
+<a name='L582'></a><a href='#L582'>582</a>
+<a name='L583'></a><a href='#L583'>583</a>
+<a name='L584'></a><a href='#L584'>584</a>
+<a name='L585'></a><a href='#L585'>585</a>
+<a name='L586'></a><a href='#L586'>586</a>
+<a name='L587'></a><a href='#L587'>587</a>
+<a name='L588'></a><a href='#L588'>588</a>
+<a name='L589'></a><a href='#L589'>589</a>
+<a name='L590'></a><a href='#L590'>590</a>
+<a name='L591'></a><a href='#L591'>591</a>
+<a name='L592'></a><a href='#L592'>592</a>
+<a name='L593'></a><a href='#L593'>593</a>
+<a name='L594'></a><a href='#L594'>594</a>
+<a name='L595'></a><a href='#L595'>595</a>
+<a name='L596'></a><a href='#L596'>596</a>
+<a name='L597'></a><a href='#L597'>597</a>
+<a name='L598'></a><a href='#L598'>598</a>
+<a name='L599'></a><a href='#L599'>599</a>
+<a name='L600'></a><a href='#L600'>600</a>
+<a name='L601'></a><a href='#L601'>601</a>
+<a name='L602'></a><a href='#L602'>602</a>
+<a name='L603'></a><a href='#L603'>603</a>
+<a name='L604'></a><a href='#L604'>604</a>
+<a name='L605'></a><a href='#L605'>605</a>
+<a name='L606'></a><a href='#L606'>606</a>
+<a name='L607'></a><a href='#L607'>607</a>
+<a name='L608'></a><a href='#L608'>608</a>
+<a name='L609'></a><a href='#L609'>609</a>
+<a name='L610'></a><a href='#L610'>610</a>
+<a name='L611'></a><a href='#L611'>611</a>
+<a name='L612'></a><a href='#L612'>612</a>
+<a name='L613'></a><a href='#L613'>613</a>
+<a name='L614'></a><a href='#L614'>614</a>
+<a name='L615'></a><a href='#L615'>615</a>
+<a name='L616'></a><a href='#L616'>616</a>
+<a name='L617'></a><a href='#L617'>617</a>
+<a name='L618'></a><a href='#L618'>618</a>
+<a name='L619'></a><a href='#L619'>619</a>
+<a name='L620'></a><a href='#L620'>620</a>
+<a name='L621'></a><a href='#L621'>621</a>
+<a name='L622'></a><a href='#L622'>622</a>
+<a name='L623'></a><a href='#L623'>623</a>
+<a name='L624'></a><a href='#L624'>624</a>
+<a name='L625'></a><a href='#L625'>625</a>
+<a name='L626'></a><a href='#L626'>626</a>
+<a name='L627'></a><a href='#L627'>627</a>
+<a name='L628'></a><a href='#L628'>628</a>
+<a name='L629'></a><a href='#L629'>629</a>
+<a name='L630'></a><a href='#L630'>630</a>
+<a name='L631'></a><a href='#L631'>631</a>
+<a name='L632'></a><a href='#L632'>632</a>
+<a name='L633'></a><a href='#L633'>633</a>
+<a name='L634'></a><a href='#L634'>634</a>
+<a name='L635'></a><a href='#L635'>635</a>
+<a name='L636'></a><a href='#L636'>636</a>
+<a name='L637'></a><a href='#L637'>637</a>
+<a name='L638'></a><a href='#L638'>638</a>
+<a name='L639'></a><a href='#L639'>639</a>
+<a name='L640'></a><a href='#L640'>640</a>
+<a name='L641'></a><a href='#L641'>641</a>
+<a name='L642'></a><a href='#L642'>642</a>
+<a name='L643'></a><a href='#L643'>643</a>
+<a name='L644'></a><a href='#L644'>644</a>
+<a name='L645'></a><a href='#L645'>645</a>
+<a name='L646'></a><a href='#L646'>646</a>
+<a name='L647'></a><a href='#L647'>647</a>
+<a name='L648'></a><a href='#L648'>648</a>
+<a name='L649'></a><a href='#L649'>649</a>
+<a name='L650'></a><a href='#L650'>650</a>
+<a name='L651'></a><a href='#L651'>651</a>
+<a name='L652'></a><a href='#L652'>652</a>
+<a name='L653'></a><a href='#L653'>653</a>
+<a name='L654'></a><a href='#L654'>654</a>
+<a name='L655'></a><a href='#L655'>655</a>
+<a name='L656'></a><a href='#L656'>656</a>
+<a name='L657'></a><a href='#L657'>657</a>
+<a name='L658'></a><a href='#L658'>658</a>
+<a name='L659'></a><a href='#L659'>659</a>
+<a name='L660'></a><a href='#L660'>660</a>
+<a name='L661'></a><a href='#L661'>661</a>
+<a name='L662'></a><a href='#L662'>662</a>
+<a name='L663'></a><a href='#L663'>663</a>
+<a name='L664'></a><a href='#L664'>664</a>
+<a name='L665'></a><a href='#L665'>665</a>
+<a name='L666'></a><a href='#L666'>666</a>
+<a name='L667'></a><a href='#L667'>667</a>
+<a name='L668'></a><a href='#L668'>668</a>
+<a name='L669'></a><a href='#L669'>669</a>
+<a name='L670'></a><a href='#L670'>670</a>
+<a name='L671'></a><a href='#L671'>671</a>
+<a name='L672'></a><a href='#L672'>672</a>
+<a name='L673'></a><a href='#L673'>673</a>
+<a name='L674'></a><a href='#L674'>674</a>
+<a name='L675'></a><a href='#L675'>675</a>
+<a name='L676'></a><a href='#L676'>676</a>
+<a name='L677'></a><a href='#L677'>677</a>
+<a name='L678'></a><a href='#L678'>678</a>
+<a name='L679'></a><a href='#L679'>679</a>
+<a name='L680'></a><a href='#L680'>680</a>
+<a name='L681'></a><a href='#L681'>681</a>
+<a name='L682'></a><a href='#L682'>682</a>
+<a name='L683'></a><a href='#L683'>683</a>
+<a name='L684'></a><a href='#L684'>684</a>
+<a name='L685'></a><a href='#L685'>685</a>
+<a name='L686'></a><a href='#L686'>686</a>
+<a name='L687'></a><a href='#L687'>687</a>
+<a name='L688'></a><a href='#L688'>688</a>
+<a name='L689'></a><a href='#L689'>689</a>
+<a name='L690'></a><a href='#L690'>690</a>
+<a name='L691'></a><a href='#L691'>691</a>
+<a name='L692'></a><a href='#L692'>692</a>
+<a name='L693'></a><a href='#L693'>693</a>
+<a name='L694'></a><a href='#L694'>694</a>
+<a name='L695'></a><a href='#L695'>695</a>
+<a name='L696'></a><a href='#L696'>696</a>
+<a name='L697'></a><a href='#L697'>697</a>
+<a name='L698'></a><a href='#L698'>698</a>
+<a name='L699'></a><a href='#L699'>699</a>
+<a name='L700'></a><a href='#L700'>700</a>
+<a name='L701'></a><a href='#L701'>701</a>
+<a name='L702'></a><a href='#L702'>702</a>
+<a name='L703'></a><a href='#L703'>703</a>
+<a name='L704'></a><a href='#L704'>704</a>
+<a name='L705'></a><a href='#L705'>705</a>
+<a name='L706'></a><a href='#L706'>706</a>
+<a name='L707'></a><a href='#L707'>707</a>
+<a name='L708'></a><a href='#L708'>708</a>
+<a name='L709'></a><a href='#L709'>709</a>
+<a name='L710'></a><a href='#L710'>710</a>
+<a name='L711'></a><a href='#L711'>711</a>
+<a name='L712'></a><a href='#L712'>712</a>
+<a name='L713'></a><a href='#L713'>713</a>
+<a name='L714'></a><a href='#L714'>714</a>
+<a name='L715'></a><a href='#L715'>715</a>
+<a name='L716'></a><a href='#L716'>716</a>
+<a name='L717'></a><a href='#L717'>717</a>
+<a name='L718'></a><a href='#L718'>718</a>
+<a name='L719'></a><a href='#L719'>719</a>
+<a name='L720'></a><a href='#L720'>720</a>
+<a name='L721'></a><a href='#L721'>721</a>
+<a name='L722'></a><a href='#L722'>722</a>
+<a name='L723'></a><a href='#L723'>723</a>
+<a name='L724'></a><a href='#L724'>724</a>
+<a name='L725'></a><a href='#L725'>725</a>
+<a name='L726'></a><a href='#L726'>726</a>
+<a name='L727'></a><a href='#L727'>727</a>
+<a name='L728'></a><a href='#L728'>728</a>
+<a name='L729'></a><a href='#L729'>729</a>
+<a name='L730'></a><a href='#L730'>730</a>
+<a name='L731'></a><a href='#L731'>731</a>
+<a name='L732'></a><a href='#L732'>732</a>
+<a name='L733'></a><a href='#L733'>733</a>
+<a name='L734'></a><a href='#L734'>734</a>
+<a name='L735'></a><a href='#L735'>735</a>
+<a name='L736'></a><a href='#L736'>736</a>
+<a name='L737'></a><a href='#L737'>737</a>
+<a name='L738'></a><a href='#L738'>738</a>
+<a name='L739'></a><a href='#L739'>739</a>
+<a name='L740'></a><a href='#L740'>740</a>
+<a name='L741'></a><a href='#L741'>741</a>
+<a name='L742'></a><a href='#L742'>742</a>
+<a name='L743'></a><a href='#L743'>743</a>
+<a name='L744'></a><a href='#L744'>744</a>
+<a name='L745'></a><a href='#L745'>745</a>
+<a name='L746'></a><a href='#L746'>746</a>
+<a name='L747'></a><a href='#L747'>747</a>
+<a name='L748'></a><a href='#L748'>748</a>
+<a name='L749'></a><a href='#L749'>749</a>
+<a name='L750'></a><a href='#L750'>750</a>
+<a name='L751'></a><a href='#L751'>751</a>
+<a name='L752'></a><a href='#L752'>752</a>
+<a name='L753'></a><a href='#L753'>753</a>
+<a name='L754'></a><a href='#L754'>754</a>
+<a name='L755'></a><a href='#L755'>755</a>
+<a name='L756'></a><a href='#L756'>756</a>
+<a name='L757'></a><a href='#L757'>757</a>
+<a name='L758'></a><a href='#L758'>758</a>
+<a name='L759'></a><a href='#L759'>759</a>
+<a name='L760'></a><a href='#L760'>760</a>
+<a name='L761'></a><a href='#L761'>761</a>
+<a name='L762'></a><a href='#L762'>762</a>
+<a name='L763'></a><a href='#L763'>763</a>
+<a name='L764'></a><a href='#L764'>764</a>
+<a name='L765'></a><a href='#L765'>765</a>
+<a name='L766'></a><a href='#L766'>766</a>
+<a name='L767'></a><a href='#L767'>767</a>
+<a name='L768'></a><a href='#L768'>768</a>
+<a name='L769'></a><a href='#L769'>769</a>
+<a name='L770'></a><a href='#L770'>770</a>
+<a name='L771'></a><a href='#L771'>771</a>
+<a name='L772'></a><a href='#L772'>772</a>
+<a name='L773'></a><a href='#L773'>773</a>
+<a name='L774'></a><a href='#L774'>774</a>
+<a name='L775'></a><a href='#L775'>775</a>
+<a name='L776'></a><a href='#L776'>776</a>
+<a name='L777'></a><a href='#L777'>777</a>
+<a name='L778'></a><a href='#L778'>778</a>
+<a name='L779'></a><a href='#L779'>779</a>
+<a name='L780'></a><a href='#L780'>780</a>
+<a name='L781'></a><a href='#L781'>781</a>
+<a name='L782'></a><a href='#L782'>782</a>
+<a name='L783'></a><a href='#L783'>783</a>
+<a name='L784'></a><a href='#L784'>784</a>
+<a name='L785'></a><a href='#L785'>785</a>
+<a name='L786'></a><a href='#L786'>786</a>
+<a name='L787'></a><a href='#L787'>787</a>
+<a name='L788'></a><a href='#L788'>788</a>
+<a name='L789'></a><a href='#L789'>789</a>
+<a name='L790'></a><a href='#L790'>790</a>
+<a name='L791'></a><a href='#L791'>791</a>
+<a name='L792'></a><a href='#L792'>792</a>
+<a name='L793'></a><a href='#L793'>793</a>
+<a name='L794'></a><a href='#L794'>794</a>
+<a name='L795'></a><a href='#L795'>795</a>
+<a name='L796'></a><a href='#L796'>796</a>
+<a name='L797'></a><a href='#L797'>797</a>
+<a name='L798'></a><a href='#L798'>798</a>
+<a name='L799'></a><a href='#L799'>799</a>
+<a name='L800'></a><a href='#L800'>800</a>
+<a name='L801'></a><a href='#L801'>801</a>
+<a name='L802'></a><a href='#L802'>802</a>
+<a name='L803'></a><a href='#L803'>803</a>
+<a name='L804'></a><a href='#L804'>804</a>
+<a name='L805'></a><a href='#L805'>805</a>
+<a name='L806'></a><a href='#L806'>806</a>
+<a name='L807'></a><a href='#L807'>807</a>
+<a name='L808'></a><a href='#L808'>808</a>
+<a name='L809'></a><a href='#L809'>809</a>
+<a name='L810'></a><a href='#L810'>810</a>
+<a name='L811'></a><a href='#L811'>811</a>
+<a name='L812'></a><a href='#L812'>812</a>
+<a name='L813'></a><a href='#L813'>813</a>
+<a name='L814'></a><a href='#L814'>814</a>
+<a name='L815'></a><a href='#L815'>815</a>
+<a name='L816'></a><a href='#L816'>816</a>
+<a name='L817'></a><a href='#L817'>817</a>
+<a name='L818'></a><a href='#L818'>818</a>
+<a name='L819'></a><a href='#L819'>819</a>
+<a name='L820'></a><a href='#L820'>820</a>
+<a name='L821'></a><a href='#L821'>821</a>
+<a name='L822'></a><a href='#L822'>822</a>
+<a name='L823'></a><a href='#L823'>823</a>
+<a name='L824'></a><a href='#L824'>824</a>
+<a name='L825'></a><a href='#L825'>825</a>
+<a name='L826'></a><a href='#L826'>826</a>
+<a name='L827'></a><a href='#L827'>827</a>
+<a name='L828'></a><a href='#L828'>828</a>
+<a name='L829'></a><a href='#L829'>829</a>
+<a name='L830'></a><a href='#L830'>830</a>
+<a name='L831'></a><a href='#L831'>831</a>
+<a name='L832'></a><a href='#L832'>832</a>
+<a name='L833'></a><a href='#L833'>833</a>
+<a name='L834'></a><a href='#L834'>834</a>
+<a name='L835'></a><a href='#L835'>835</a>
+<a name='L836'></a><a href='#L836'>836</a>
+<a name='L837'></a><a href='#L837'>837</a>
+<a name='L838'></a><a href='#L838'>838</a>
+<a name='L839'></a><a href='#L839'>839</a>
+<a name='L840'></a><a href='#L840'>840</a>
+<a name='L841'></a><a href='#L841'>841</a>
+<a name='L842'></a><a href='#L842'>842</a>
+<a name='L843'></a><a href='#L843'>843</a>
+<a name='L844'></a><a href='#L844'>844</a>
+<a name='L845'></a><a href='#L845'>845</a>
+<a name='L846'></a><a href='#L846'>846</a>
+<a name='L847'></a><a href='#L847'>847</a>
+<a name='L848'></a><a href='#L848'>848</a>
+<a name='L849'></a><a href='#L849'>849</a>
+<a name='L850'></a><a href='#L850'>850</a>
+<a name='L851'></a><a href='#L851'>851</a>
+<a name='L852'></a><a href='#L852'>852</a>
+<a name='L853'></a><a href='#L853'>853</a>
+<a name='L854'></a><a href='#L854'>854</a>
+<a name='L855'></a><a href='#L855'>855</a>
+<a name='L856'></a><a href='#L856'>856</a>
+<a name='L857'></a><a href='#L857'>857</a>
+<a name='L858'></a><a href='#L858'>858</a>
+<a name='L859'></a><a href='#L859'>859</a>
+<a name='L860'></a><a href='#L860'>860</a>
+<a name='L861'></a><a href='#L861'>861</a>
+<a name='L862'></a><a href='#L862'>862</a>
+<a name='L863'></a><a href='#L863'>863</a>
+<a name='L864'></a><a href='#L864'>864</a>
+<a name='L865'></a><a href='#L865'>865</a>
+<a name='L866'></a><a href='#L866'>866</a>
+<a name='L867'></a><a href='#L867'>867</a>
+<a name='L868'></a><a href='#L868'>868</a>
+<a name='L869'></a><a href='#L869'>869</a>
+<a name='L870'></a><a href='#L870'>870</a>
+<a name='L871'></a><a href='#L871'>871</a>
+<a name='L872'></a><a href='#L872'>872</a>
+<a name='L873'></a><a href='#L873'>873</a>
+<a name='L874'></a><a href='#L874'>874</a>
+<a name='L875'></a><a href='#L875'>875</a>
+<a name='L876'></a><a href='#L876'>876</a>
+<a name='L877'></a><a href='#L877'>877</a>
+<a name='L878'></a><a href='#L878'>878</a>
+<a name='L879'></a><a href='#L879'>879</a>
+<a name='L880'></a><a href='#L880'>880</a>
+<a name='L881'></a><a href='#L881'>881</a>
+<a name='L882'></a><a href='#L882'>882</a>
+<a name='L883'></a><a href='#L883'>883</a>
+<a name='L884'></a><a href='#L884'>884</a>
+<a name='L885'></a><a href='#L885'>885</a>
+<a name='L886'></a><a href='#L886'>886</a>
+<a name='L887'></a><a href='#L887'>887</a>
+<a name='L888'></a><a href='#L888'>888</a>
+<a name='L889'></a><a href='#L889'>889</a>
+<a name='L890'></a><a href='#L890'>890</a>
+<a name='L891'></a><a href='#L891'>891</a>
+<a name='L892'></a><a href='#L892'>892</a>
+<a name='L893'></a><a href='#L893'>893</a>
+<a name='L894'></a><a href='#L894'>894</a>
+<a name='L895'></a><a href='#L895'>895</a>
+<a name='L896'></a><a href='#L896'>896</a>
+<a name='L897'></a><a href='#L897'>897</a>
+<a name='L898'></a><a href='#L898'>898</a>
+<a name='L899'></a><a href='#L899'>899</a>
+<a name='L900'></a><a href='#L900'>900</a>
+<a name='L901'></a><a href='#L901'>901</a>
+<a name='L902'></a><a href='#L902'>902</a>
+<a name='L903'></a><a href='#L903'>903</a>
+<a name='L904'></a><a href='#L904'>904</a>
+<a name='L905'></a><a href='#L905'>905</a>
+<a name='L906'></a><a href='#L906'>906</a>
+<a name='L907'></a><a href='#L907'>907</a>
+<a name='L908'></a><a href='#L908'>908</a>
+<a name='L909'></a><a href='#L909'>909</a>
+<a name='L910'></a><a href='#L910'>910</a>
+<a name='L911'></a><a href='#L911'>911</a>
+<a name='L912'></a><a href='#L912'>912</a>
+<a name='L913'></a><a href='#L913'>913</a>
+<a name='L914'></a><a href='#L914'>914</a>
+<a name='L915'></a><a href='#L915'>915</a>
+<a name='L916'></a><a href='#L916'>916</a>
+<a name='L917'></a><a href='#L917'>917</a>
+<a name='L918'></a><a href='#L918'>918</a>
+<a name='L919'></a><a href='#L919'>919</a>
+<a name='L920'></a><a href='#L920'>920</a>
+<a name='L921'></a><a href='#L921'>921</a>
+<a name='L922'></a><a href='#L922'>922</a>
+<a name='L923'></a><a href='#L923'>923</a>
+<a name='L924'></a><a href='#L924'>924</a>
+<a name='L925'></a><a href='#L925'>925</a>
+<a name='L926'></a><a href='#L926'>926</a>
+<a name='L927'></a><a href='#L927'>927</a>
+<a name='L928'></a><a href='#L928'>928</a>
+<a name='L929'></a><a href='#L929'>929</a>
+<a name='L930'></a><a href='#L930'>930</a>
+<a name='L931'></a><a href='#L931'>931</a>
+<a name='L932'></a><a href='#L932'>932</a>
+<a name='L933'></a><a href='#L933'>933</a>
+<a name='L934'></a><a href='#L934'>934</a>
+<a name='L935'></a><a href='#L935'>935</a>
+<a name='L936'></a><a href='#L936'>936</a>
+<a name='L937'></a><a href='#L937'>937</a>
+<a name='L938'></a><a href='#L938'>938</a>
+<a name='L939'></a><a href='#L939'>939</a>
+<a name='L940'></a><a href='#L940'>940</a>
+<a name='L941'></a><a href='#L941'>941</a>
+<a name='L942'></a><a href='#L942'>942</a>
+<a name='L943'></a><a href='#L943'>943</a>
+<a name='L944'></a><a href='#L944'>944</a>
+<a name='L945'></a><a href='#L945'>945</a>
+<a name='L946'></a><a href='#L946'>946</a>
+<a name='L947'></a><a href='#L947'>947</a>
+<a name='L948'></a><a href='#L948'>948</a>
+<a name='L949'></a><a href='#L949'>949</a>
+<a name='L950'></a><a href='#L950'>950</a>
+<a name='L951'></a><a href='#L951'>951</a>
+<a name='L952'></a><a href='#L952'>952</a>
+<a name='L953'></a><a href='#L953'>953</a>
+<a name='L954'></a><a href='#L954'>954</a>
+<a name='L955'></a><a href='#L955'>955</a>
+<a name='L956'></a><a href='#L956'>956</a>
+<a name='L957'></a><a href='#L957'>957</a>
+<a name='L958'></a><a href='#L958'>958</a>
+<a name='L959'></a><a href='#L959'>959</a>
+<a name='L960'></a><a href='#L960'>960</a>
+<a name='L961'></a><a href='#L961'>961</a>
+<a name='L962'></a><a href='#L962'>962</a>
+<a name='L963'></a><a href='#L963'>963</a>
+<a name='L964'></a><a href='#L964'>964</a>
+<a name='L965'></a><a href='#L965'>965</a>
+<a name='L966'></a><a href='#L966'>966</a>
+<a name='L967'></a><a href='#L967'>967</a>
+<a name='L968'></a><a href='#L968'>968</a>
+<a name='L969'></a><a href='#L969'>969</a>
+<a name='L970'></a><a href='#L970'>970</a>
+<a name='L971'></a><a href='#L971'>971</a>
+<a name='L972'></a><a href='#L972'>972</a>
+<a name='L973'></a><a href='#L973'>973</a>
+<a name='L974'></a><a href='#L974'>974</a>
+<a name='L975'></a><a href='#L975'>975</a>
+<a name='L976'></a><a href='#L976'>976</a>
+<a name='L977'></a><a href='#L977'>977</a>
+<a name='L978'></a><a href='#L978'>978</a>
+<a name='L979'></a><a href='#L979'>979</a>
+<a name='L980'></a><a href='#L980'>980</a>
+<a name='L981'></a><a href='#L981'>981</a>
+<a name='L982'></a><a href='#L982'>982</a>
+<a name='L983'></a><a href='#L983'>983</a>
+<a name='L984'></a><a href='#L984'>984</a>
+<a name='L985'></a><a href='#L985'>985</a>
+<a name='L986'></a><a href='#L986'>986</a>
+<a name='L987'></a><a href='#L987'>987</a>
+<a name='L988'></a><a href='#L988'>988</a>
+<a name='L989'></a><a href='#L989'>989</a>
+<a name='L990'></a><a href='#L990'>990</a>
+<a name='L991'></a><a href='#L991'>991</a>
+<a name='L992'></a><a href='#L992'>992</a>
+<a name='L993'></a><a href='#L993'>993</a>
+<a name='L994'></a><a href='#L994'>994</a>
+<a name='L995'></a><a href='#L995'>995</a>
+<a name='L996'></a><a href='#L996'>996</a>
+<a name='L997'></a><a href='#L997'>997</a>
+<a name='L998'></a><a href='#L998'>998</a>
+<a name='L999'></a><a href='#L999'>999</a>
+<a name='L1000'></a><a href='#L1000'>1000</a>
+<a name='L1001'></a><a href='#L1001'>1001</a>
+<a name='L1002'></a><a href='#L1002'>1002</a>
+<a name='L1003'></a><a href='#L1003'>1003</a>
+<a name='L1004'></a><a href='#L1004'>1004</a>
+<a name='L1005'></a><a href='#L1005'>1005</a>
+<a name='L1006'></a><a href='#L1006'>1006</a>
+<a name='L1007'></a><a href='#L1007'>1007</a>
+<a name='L1008'></a><a href='#L1008'>1008</a>
+<a name='L1009'></a><a href='#L1009'>1009</a>
+<a name='L1010'></a><a href='#L1010'>1010</a>
+<a name='L1011'></a><a href='#L1011'>1011</a>
+<a name='L1012'></a><a href='#L1012'>1012</a>
+<a name='L1013'></a><a href='#L1013'>1013</a>
+<a name='L1014'></a><a href='#L1014'>1014</a>
+<a name='L1015'></a><a href='#L1015'>1015</a>
+<a name='L1016'></a><a href='#L1016'>1016</a>
+<a name='L1017'></a><a href='#L1017'>1017</a>
+<a name='L1018'></a><a href='#L1018'>1018</a>
+<a name='L1019'></a><a href='#L1019'>1019</a>
+<a name='L1020'></a><a href='#L1020'>1020</a>
+<a name='L1021'></a><a href='#L1021'>1021</a>
+<a name='L1022'></a><a href='#L1022'>1022</a>
+<a name='L1023'></a><a href='#L1023'>1023</a>
+<a name='L1024'></a><a href='#L1024'>1024</a>
+<a name='L1025'></a><a href='#L1025'>1025</a>
+<a name='L1026'></a><a href='#L1026'>1026</a>
+<a name='L1027'></a><a href='#L1027'>1027</a>
+<a name='L1028'></a><a href='#L1028'>1028</a>
+<a name='L1029'></a><a href='#L1029'>1029</a>
+<a name='L1030'></a><a href='#L1030'>1030</a>
+<a name='L1031'></a><a href='#L1031'>1031</a>
+<a name='L1032'></a><a href='#L1032'>1032</a>
+<a name='L1033'></a><a href='#L1033'>1033</a>
+<a name='L1034'></a><a href='#L1034'>1034</a>
+<a name='L1035'></a><a href='#L1035'>1035</a>
+<a name='L1036'></a><a href='#L1036'>1036</a>
+<a name='L1037'></a><a href='#L1037'>1037</a>
+<a name='L1038'></a><a href='#L1038'>1038</a>
+<a name='L1039'></a><a href='#L1039'>1039</a>
+<a name='L1040'></a><a href='#L1040'>1040</a>
+<a name='L1041'></a><a href='#L1041'>1041</a>
+<a name='L1042'></a><a href='#L1042'>1042</a>
+<a name='L1043'></a><a href='#L1043'>1043</a>
+<a name='L1044'></a><a href='#L1044'>1044</a>
+<a name='L1045'></a><a href='#L1045'>1045</a>
+<a name='L1046'></a><a href='#L1046'>1046</a>
+<a name='L1047'></a><a href='#L1047'>1047</a>
+<a name='L1048'></a><a href='#L1048'>1048</a>
+<a name='L1049'></a><a href='#L1049'>1049</a>
+<a name='L1050'></a><a href='#L1050'>1050</a>
+<a name='L1051'></a><a href='#L1051'>1051</a>
+<a name='L1052'></a><a href='#L1052'>1052</a>
+<a name='L1053'></a><a href='#L1053'>1053</a>
+<a name='L1054'></a><a href='#L1054'>1054</a>
+<a name='L1055'></a><a href='#L1055'>1055</a>
+<a name='L1056'></a><a href='#L1056'>1056</a>
+<a name='L1057'></a><a href='#L1057'>1057</a>
+<a name='L1058'></a><a href='#L1058'>1058</a>
+<a name='L1059'></a><a href='#L1059'>1059</a>
+<a name='L1060'></a><a href='#L1060'>1060</a>
+<a name='L1061'></a><a href='#L1061'>1061</a>
+<a name='L1062'></a><a href='#L1062'>1062</a>
+<a name='L1063'></a><a href='#L1063'>1063</a>
+<a name='L1064'></a><a href='#L1064'>1064</a>
+<a name='L1065'></a><a href='#L1065'>1065</a>
+<a name='L1066'></a><a href='#L1066'>1066</a>
+<a name='L1067'></a><a href='#L1067'>1067</a>
+<a name='L1068'></a><a href='#L1068'>1068</a>
+<a name='L1069'></a><a href='#L1069'>1069</a>
+<a name='L1070'></a><a href='#L1070'>1070</a>
+<a name='L1071'></a><a href='#L1071'>1071</a>
+<a name='L1072'></a><a href='#L1072'>1072</a>
+<a name='L1073'></a><a href='#L1073'>1073</a>
+<a name='L1074'></a><a href='#L1074'>1074</a>
+<a name='L1075'></a><a href='#L1075'>1075</a>
+<a name='L1076'></a><a href='#L1076'>1076</a>
+<a name='L1077'></a><a href='#L1077'>1077</a>
+<a name='L1078'></a><a href='#L1078'>1078</a>
+<a name='L1079'></a><a href='#L1079'>1079</a>
+<a name='L1080'></a><a href='#L1080'>1080</a>
+<a name='L1081'></a><a href='#L1081'>1081</a>
+<a name='L1082'></a><a href='#L1082'>1082</a>
+<a name='L1083'></a><a href='#L1083'>1083</a>
+<a name='L1084'></a><a href='#L1084'>1084</a>
+<a name='L1085'></a><a href='#L1085'>1085</a>
+<a name='L1086'></a><a href='#L1086'>1086</a>
+<a name='L1087'></a><a href='#L1087'>1087</a>
+<a name='L1088'></a><a href='#L1088'>1088</a>
+<a name='L1089'></a><a href='#L1089'>1089</a>
+<a name='L1090'></a><a href='#L1090'>1090</a>
+<a name='L1091'></a><a href='#L1091'>1091</a>
+<a name='L1092'></a><a href='#L1092'>1092</a>
+<a name='L1093'></a><a href='#L1093'>1093</a>
+<a name='L1094'></a><a href='#L1094'>1094</a>
+<a name='L1095'></a><a href='#L1095'>1095</a>
+<a name='L1096'></a><a href='#L1096'>1096</a>
+<a name='L1097'></a><a href='#L1097'>1097</a>
+<a name='L1098'></a><a href='#L1098'>1098</a>
+<a name='L1099'></a><a href='#L1099'>1099</a>
+<a name='L1100'></a><a href='#L1100'>1100</a>
+<a name='L1101'></a><a href='#L1101'>1101</a>
+<a name='L1102'></a><a href='#L1102'>1102</a>
+<a name='L1103'></a><a href='#L1103'>1103</a>
+<a name='L1104'></a><a href='#L1104'>1104</a>
+<a name='L1105'></a><a href='#L1105'>1105</a>
+<a name='L1106'></a><a href='#L1106'>1106</a>
+<a name='L1107'></a><a href='#L1107'>1107</a>
+<a name='L1108'></a><a href='#L1108'>1108</a>
+<a name='L1109'></a><a href='#L1109'>1109</a>
+<a name='L1110'></a><a href='#L1110'>1110</a>
+<a name='L1111'></a><a href='#L1111'>1111</a>
+<a name='L1112'></a><a href='#L1112'>1112</a>
+<a name='L1113'></a><a href='#L1113'>1113</a>
+<a name='L1114'></a><a href='#L1114'>1114</a>
+<a name='L1115'></a><a href='#L1115'>1115</a>
+<a name='L1116'></a><a href='#L1116'>1116</a>
+<a name='L1117'></a><a href='#L1117'>1117</a>
+<a name='L1118'></a><a href='#L1118'>1118</a>
+<a name='L1119'></a><a href='#L1119'>1119</a>
+<a name='L1120'></a><a href='#L1120'>1120</a>
+<a name='L1121'></a><a href='#L1121'>1121</a>
+<a name='L1122'></a><a href='#L1122'>1122</a>
+<a name='L1123'></a><a href='#L1123'>1123</a>
+<a name='L1124'></a><a href='#L1124'>1124</a>
+<a name='L1125'></a><a href='#L1125'>1125</a>
+<a name='L1126'></a><a href='#L1126'>1126</a>
+<a name='L1127'></a><a href='#L1127'>1127</a>
+<a name='L1128'></a><a href='#L1128'>1128</a>
+<a name='L1129'></a><a href='#L1129'>1129</a>
+<a name='L1130'></a><a href='#L1130'>1130</a>
+<a name='L1131'></a><a href='#L1131'>1131</a>
+<a name='L1132'></a><a href='#L1132'>1132</a>
+<a name='L1133'></a><a href='#L1133'>1133</a>
+<a name='L1134'></a><a href='#L1134'>1134</a>
+<a name='L1135'></a><a href='#L1135'>1135</a>
+<a name='L1136'></a><a href='#L1136'>1136</a>
+<a name='L1137'></a><a href='#L1137'>1137</a>
+<a name='L1138'></a><a href='#L1138'>1138</a>
+<a name='L1139'></a><a href='#L1139'>1139</a>
+<a name='L1140'></a><a href='#L1140'>1140</a>
+<a name='L1141'></a><a href='#L1141'>1141</a>
+<a name='L1142'></a><a href='#L1142'>1142</a>
+<a name='L1143'></a><a href='#L1143'>1143</a>
+<a name='L1144'></a><a href='#L1144'>1144</a>
+<a name='L1145'></a><a href='#L1145'>1145</a>
+<a name='L1146'></a><a href='#L1146'>1146</a>
+<a name='L1147'></a><a href='#L1147'>1147</a>
+<a name='L1148'></a><a href='#L1148'>1148</a>
+<a name='L1149'></a><a href='#L1149'>1149</a>
+<a name='L1150'></a><a href='#L1150'>1150</a>
+<a name='L1151'></a><a href='#L1151'>1151</a>
+<a name='L1152'></a><a href='#L1152'>1152</a>
+<a name='L1153'></a><a href='#L1153'>1153</a>
+<a name='L1154'></a><a href='#L1154'>1154</a>
+<a name='L1155'></a><a href='#L1155'>1155</a>
+<a name='L1156'></a><a href='#L1156'>1156</a>
+<a name='L1157'></a><a href='#L1157'>1157</a>
+<a name='L1158'></a><a href='#L1158'>1158</a>
+<a name='L1159'></a><a href='#L1159'>1159</a>
+<a name='L1160'></a><a href='#L1160'>1160</a>
+<a name='L1161'></a><a href='#L1161'>1161</a>
+<a name='L1162'></a><a href='#L1162'>1162</a>
+<a name='L1163'></a><a href='#L1163'>1163</a>
+<a name='L1164'></a><a href='#L1164'>1164</a>
+<a name='L1165'></a><a href='#L1165'>1165</a>
+<a name='L1166'></a><a href='#L1166'>1166</a>
+<a name='L1167'></a><a href='#L1167'>1167</a>
+<a name='L1168'></a><a href='#L1168'>1168</a>
+<a name='L1169'></a><a href='#L1169'>1169</a>
+<a name='L1170'></a><a href='#L1170'>1170</a>
+<a name='L1171'></a><a href='#L1171'>1171</a>
+<a name='L1172'></a><a href='#L1172'>1172</a>
+<a name='L1173'></a><a href='#L1173'>1173</a>
+<a name='L1174'></a><a href='#L1174'>1174</a>
+<a name='L1175'></a><a href='#L1175'>1175</a>
+<a name='L1176'></a><a href='#L1176'>1176</a>
+<a name='L1177'></a><a href='#L1177'>1177</a>
+<a name='L1178'></a><a href='#L1178'>1178</a>
+<a name='L1179'></a><a href='#L1179'>1179</a>
+<a name='L1180'></a><a href='#L1180'>1180</a>
+<a name='L1181'></a><a href='#L1181'>1181</a>
+<a name='L1182'></a><a href='#L1182'>1182</a>
+<a name='L1183'></a><a href='#L1183'>1183</a>
+<a name='L1184'></a><a href='#L1184'>1184</a>
+<a name='L1185'></a><a href='#L1185'>1185</a>
+<a name='L1186'></a><a href='#L1186'>1186</a>
+<a name='L1187'></a><a href='#L1187'>1187</a>
+<a name='L1188'></a><a href='#L1188'>1188</a>
+<a name='L1189'></a><a href='#L1189'>1189</a>
+<a name='L1190'></a><a href='#L1190'>1190</a>
+<a name='L1191'></a><a href='#L1191'>1191</a>
+<a name='L1192'></a><a href='#L1192'>1192</a>
+<a name='L1193'></a><a href='#L1193'>1193</a>
+<a name='L1194'></a><a href='#L1194'>1194</a>
+<a name='L1195'></a><a href='#L1195'>1195</a>
+<a name='L1196'></a><a href='#L1196'>1196</a>
+<a name='L1197'></a><a href='#L1197'>1197</a>
+<a name='L1198'></a><a href='#L1198'>1198</a>
+<a name='L1199'></a><a href='#L1199'>1199</a>
+<a name='L1200'></a><a href='#L1200'>1200</a>
+<a name='L1201'></a><a href='#L1201'>1201</a>
+<a name='L1202'></a><a href='#L1202'>1202</a>
+<a name='L1203'></a><a href='#L1203'>1203</a>
+<a name='L1204'></a><a href='#L1204'>1204</a>
+<a name='L1205'></a><a href='#L1205'>1205</a>
+<a name='L1206'></a><a href='#L1206'>1206</a>
+<a name='L1207'></a><a href='#L1207'>1207</a>
+<a name='L1208'></a><a href='#L1208'>1208</a>
+<a name='L1209'></a><a href='#L1209'>1209</a>
+<a name='L1210'></a><a href='#L1210'>1210</a>
+<a name='L1211'></a><a href='#L1211'>1211</a>
+<a name='L1212'></a><a href='#L1212'>1212</a>
+<a name='L1213'></a><a href='#L1213'>1213</a>
+<a name='L1214'></a><a href='#L1214'>1214</a>
+<a name='L1215'></a><a href='#L1215'>1215</a>
+<a name='L1216'></a><a href='#L1216'>1216</a>
+<a name='L1217'></a><a href='#L1217'>1217</a>
+<a name='L1218'></a><a href='#L1218'>1218</a>
+<a name='L1219'></a><a href='#L1219'>1219</a>
+<a name='L1220'></a><a href='#L1220'>1220</a>
+<a name='L1221'></a><a href='#L1221'>1221</a>
+<a name='L1222'></a><a href='#L1222'>1222</a>
+<a name='L1223'></a><a href='#L1223'>1223</a>
+<a name='L1224'></a><a href='#L1224'>1224</a>
+<a name='L1225'></a><a href='#L1225'>1225</a>
+<a name='L1226'></a><a href='#L1226'>1226</a>
+<a name='L1227'></a><a href='#L1227'>1227</a>
+<a name='L1228'></a><a href='#L1228'>1228</a>
+<a name='L1229'></a><a href='#L1229'>1229</a>
+<a name='L1230'></a><a href='#L1230'>1230</a>
+<a name='L1231'></a><a href='#L1231'>1231</a>
+<a name='L1232'></a><a href='#L1232'>1232</a>
+<a name='L1233'></a><a href='#L1233'>1233</a>
+<a name='L1234'></a><a href='#L1234'>1234</a>
+<a name='L1235'></a><a href='#L1235'>1235</a>
+<a name='L1236'></a><a href='#L1236'>1236</a>
+<a name='L1237'></a><a href='#L1237'>1237</a>
+<a name='L1238'></a><a href='#L1238'>1238</a>
+<a name='L1239'></a><a href='#L1239'>1239</a>
+<a name='L1240'></a><a href='#L1240'>1240</a>
+<a name='L1241'></a><a href='#L1241'>1241</a>
+<a name='L1242'></a><a href='#L1242'>1242</a>
+<a name='L1243'></a><a href='#L1243'>1243</a>
+<a name='L1244'></a><a href='#L1244'>1244</a>
+<a name='L1245'></a><a href='#L1245'>1245</a>
+<a name='L1246'></a><a href='#L1246'>1246</a>
+<a name='L1247'></a><a href='#L1247'>1247</a>
+<a name='L1248'></a><a href='#L1248'>1248</a>
+<a name='L1249'></a><a href='#L1249'>1249</a>
+<a name='L1250'></a><a href='#L1250'>1250</a>
+<a name='L1251'></a><a href='#L1251'>1251</a>
+<a name='L1252'></a><a href='#L1252'>1252</a>
+<a name='L1253'></a><a href='#L1253'>1253</a>
+<a name='L1254'></a><a href='#L1254'>1254</a>
+<a name='L1255'></a><a href='#L1255'>1255</a>
+<a name='L1256'></a><a href='#L1256'>1256</a>
+<a name='L1257'></a><a href='#L1257'>1257</a>
+<a name='L1258'></a><a href='#L1258'>1258</a>
+<a name='L1259'></a><a href='#L1259'>1259</a>
+<a name='L1260'></a><a href='#L1260'>1260</a>
+<a name='L1261'></a><a href='#L1261'>1261</a>
+<a name='L1262'></a><a href='#L1262'>1262</a>
+<a name='L1263'></a><a href='#L1263'>1263</a>
+<a name='L1264'></a><a href='#L1264'>1264</a>
+<a name='L1265'></a><a href='#L1265'>1265</a>
+<a name='L1266'></a><a href='#L1266'>1266</a>
+<a name='L1267'></a><a href='#L1267'>1267</a>
+<a name='L1268'></a><a href='#L1268'>1268</a>
+<a name='L1269'></a><a href='#L1269'>1269</a>
+<a name='L1270'></a><a href='#L1270'>1270</a>
+<a name='L1271'></a><a href='#L1271'>1271</a>
+<a name='L1272'></a><a href='#L1272'>1272</a>
+<a name='L1273'></a><a href='#L1273'>1273</a>
+<a name='L1274'></a><a href='#L1274'>1274</a>
+<a name='L1275'></a><a href='#L1275'>1275</a>
+<a name='L1276'></a><a href='#L1276'>1276</a>
+<a name='L1277'></a><a href='#L1277'>1277</a>
+<a name='L1278'></a><a href='#L1278'>1278</a>
+<a name='L1279'></a><a href='#L1279'>1279</a>
+<a name='L1280'></a><a href='#L1280'>1280</a>
+<a name='L1281'></a><a href='#L1281'>1281</a>
+<a name='L1282'></a><a href='#L1282'>1282</a>
+<a name='L1283'></a><a href='#L1283'>1283</a>
+<a name='L1284'></a><a href='#L1284'>1284</a>
+<a name='L1285'></a><a href='#L1285'>1285</a>
+<a name='L1286'></a><a href='#L1286'>1286</a>
+<a name='L1287'></a><a href='#L1287'>1287</a>
+<a name='L1288'></a><a href='#L1288'>1288</a>
+<a name='L1289'></a><a href='#L1289'>1289</a>
+<a name='L1290'></a><a href='#L1290'>1290</a>
+<a name='L1291'></a><a href='#L1291'>1291</a>
+<a name='L1292'></a><a href='#L1292'>1292</a>
+<a name='L1293'></a><a href='#L1293'>1293</a>
+<a name='L1294'></a><a href='#L1294'>1294</a>
+<a name='L1295'></a><a href='#L1295'>1295</a>
+<a name='L1296'></a><a href='#L1296'>1296</a>
+<a name='L1297'></a><a href='#L1297'>1297</a>
+<a name='L1298'></a><a href='#L1298'>1298</a>
+<a name='L1299'></a><a href='#L1299'>1299</a>
+<a name='L1300'></a><a href='#L1300'>1300</a>
+<a name='L1301'></a><a href='#L1301'>1301</a>
+<a name='L1302'></a><a href='#L1302'>1302</a>
+<a name='L1303'></a><a href='#L1303'>1303</a>
+<a name='L1304'></a><a href='#L1304'>1304</a>
+<a name='L1305'></a><a href='#L1305'>1305</a>
+<a name='L1306'></a><a href='#L1306'>1306</a>
+<a name='L1307'></a><a href='#L1307'>1307</a>
+<a name='L1308'></a><a href='#L1308'>1308</a>
+<a name='L1309'></a><a href='#L1309'>1309</a>
+<a name='L1310'></a><a href='#L1310'>1310</a>
+<a name='L1311'></a><a href='#L1311'>1311</a>
+<a name='L1312'></a><a href='#L1312'>1312</a>
+<a name='L1313'></a><a href='#L1313'>1313</a>
+<a name='L1314'></a><a href='#L1314'>1314</a>
+<a name='L1315'></a><a href='#L1315'>1315</a>
+<a name='L1316'></a><a href='#L1316'>1316</a>
+<a name='L1317'></a><a href='#L1317'>1317</a>
+<a name='L1318'></a><a href='#L1318'>1318</a>
+<a name='L1319'></a><a href='#L1319'>1319</a>
+<a name='L1320'></a><a href='#L1320'>1320</a>
+<a name='L1321'></a><a href='#L1321'>1321</a>
+<a name='L1322'></a><a href='#L1322'>1322</a>
+<a name='L1323'></a><a href='#L1323'>1323</a>
+<a name='L1324'></a><a href='#L1324'>1324</a>
+<a name='L1325'></a><a href='#L1325'>1325</a>
+<a name='L1326'></a><a href='#L1326'>1326</a>
+<a name='L1327'></a><a href='#L1327'>1327</a>
+<a name='L1328'></a><a href='#L1328'>1328</a>
+<a name='L1329'></a><a href='#L1329'>1329</a>
+<a name='L1330'></a><a href='#L1330'>1330</a>
+<a name='L1331'></a><a href='#L1331'>1331</a>
+<a name='L1332'></a><a href='#L1332'>1332</a>
+<a name='L1333'></a><a href='#L1333'>1333</a>
+<a name='L1334'></a><a href='#L1334'>1334</a>
+<a name='L1335'></a><a href='#L1335'>1335</a>
+<a name='L1336'></a><a href='#L1336'>1336</a>
+<a name='L1337'></a><a href='#L1337'>1337</a>
+<a name='L1338'></a><a href='#L1338'>1338</a>
+<a name='L1339'></a><a href='#L1339'>1339</a>
+<a name='L1340'></a><a href='#L1340'>1340</a>
+<a name='L1341'></a><a href='#L1341'>1341</a>
+<a name='L1342'></a><a href='#L1342'>1342</a>
+<a name='L1343'></a><a href='#L1343'>1343</a>
+<a name='L1344'></a><a href='#L1344'>1344</a>
+<a name='L1345'></a><a href='#L1345'>1345</a>
+<a name='L1346'></a><a href='#L1346'>1346</a>
+<a name='L1347'></a><a href='#L1347'>1347</a>
+<a name='L1348'></a><a href='#L1348'>1348</a>
+<a name='L1349'></a><a href='#L1349'>1349</a>
+<a name='L1350'></a><a href='#L1350'>1350</a>
+<a name='L1351'></a><a href='#L1351'>1351</a>
+<a name='L1352'></a><a href='#L1352'>1352</a>
+<a name='L1353'></a><a href='#L1353'>1353</a>
+<a name='L1354'></a><a href='#L1354'>1354</a>
+<a name='L1355'></a><a href='#L1355'>1355</a>
+<a name='L1356'></a><a href='#L1356'>1356</a>
+<a name='L1357'></a><a href='#L1357'>1357</a>
+<a name='L1358'></a><a href='#L1358'>1358</a>
+<a name='L1359'></a><a href='#L1359'>1359</a>
+<a name='L1360'></a><a href='#L1360'>1360</a>
+<a name='L1361'></a><a href='#L1361'>1361</a>
+<a name='L1362'></a><a href='#L1362'>1362</a>
+<a name='L1363'></a><a href='#L1363'>1363</a>
+<a name='L1364'></a><a href='#L1364'>1364</a>
+<a name='L1365'></a><a href='#L1365'>1365</a>
+<a name='L1366'></a><a href='#L1366'>1366</a>
+<a name='L1367'></a><a href='#L1367'>1367</a>
+<a name='L1368'></a><a href='#L1368'>1368</a>
+<a name='L1369'></a><a href='#L1369'>1369</a>
+<a name='L1370'></a><a href='#L1370'>1370</a>
+<a name='L1371'></a><a href='#L1371'>1371</a>
+<a name='L1372'></a><a href='#L1372'>1372</a>
+<a name='L1373'></a><a href='#L1373'>1373</a>
+<a name='L1374'></a><a href='#L1374'>1374</a>
+<a name='L1375'></a><a href='#L1375'>1375</a>
+<a name='L1376'></a><a href='#L1376'>1376</a>
+<a name='L1377'></a><a href='#L1377'>1377</a>
+<a name='L1378'></a><a href='#L1378'>1378</a>
+<a name='L1379'></a><a href='#L1379'>1379</a>
+<a name='L1380'></a><a href='#L1380'>1380</a>
+<a name='L1381'></a><a href='#L1381'>1381</a>
+<a name='L1382'></a><a href='#L1382'>1382</a>
+<a name='L1383'></a><a href='#L1383'>1383</a>
+<a name='L1384'></a><a href='#L1384'>1384</a>
+<a name='L1385'></a><a href='#L1385'>1385</a>
+<a name='L1386'></a><a href='#L1386'>1386</a>
+<a name='L1387'></a><a href='#L1387'>1387</a>
+<a name='L1388'></a><a href='#L1388'>1388</a>
+<a name='L1389'></a><a href='#L1389'>1389</a>
+<a name='L1390'></a><a href='#L1390'>1390</a>
+<a name='L1391'></a><a href='#L1391'>1391</a>
+<a name='L1392'></a><a href='#L1392'>1392</a>
+<a name='L1393'></a><a href='#L1393'>1393</a>
+<a name='L1394'></a><a href='#L1394'>1394</a>
+<a name='L1395'></a><a href='#L1395'>1395</a>
+<a name='L1396'></a><a href='#L1396'>1396</a>
+<a name='L1397'></a><a href='#L1397'>1397</a>
+<a name='L1398'></a><a href='#L1398'>1398</a>
+<a name='L1399'></a><a href='#L1399'>1399</a>
+<a name='L1400'></a><a href='#L1400'>1400</a>
+<a name='L1401'></a><a href='#L1401'>1401</a>
+<a name='L1402'></a><a href='#L1402'>1402</a>
+<a name='L1403'></a><a href='#L1403'>1403</a>
+<a name='L1404'></a><a href='#L1404'>1404</a>
+<a name='L1405'></a><a href='#L1405'>1405</a>
+<a name='L1406'></a><a href='#L1406'>1406</a>
+<a name='L1407'></a><a href='#L1407'>1407</a>
+<a name='L1408'></a><a href='#L1408'>1408</a>
+<a name='L1409'></a><a href='#L1409'>1409</a>
+<a name='L1410'></a><a href='#L1410'>1410</a>
+<a name='L1411'></a><a href='#L1411'>1411</a>
+<a name='L1412'></a><a href='#L1412'>1412</a>
+<a name='L1413'></a><a href='#L1413'>1413</a>
+<a name='L1414'></a><a href='#L1414'>1414</a>
+<a name='L1415'></a><a href='#L1415'>1415</a>
+<a name='L1416'></a><a href='#L1416'>1416</a>
+<a name='L1417'></a><a href='#L1417'>1417</a>
+<a name='L1418'></a><a href='#L1418'>1418</a>
+<a name='L1419'></a><a href='#L1419'>1419</a>
+<a name='L1420'></a><a href='#L1420'>1420</a>
+<a name='L1421'></a><a href='#L1421'>1421</a>
+<a name='L1422'></a><a href='#L1422'>1422</a>
+<a name='L1423'></a><a href='#L1423'>1423</a>
+<a name='L1424'></a><a href='#L1424'>1424</a>
+<a name='L1425'></a><a href='#L1425'>1425</a>
+<a name='L1426'></a><a href='#L1426'>1426</a>
+<a name='L1427'></a><a href='#L1427'>1427</a>
+<a name='L1428'></a><a href='#L1428'>1428</a>
+<a name='L1429'></a><a href='#L1429'>1429</a>
+<a name='L1430'></a><a href='#L1430'>1430</a>
+<a name='L1431'></a><a href='#L1431'>1431</a>
+<a name='L1432'></a><a href='#L1432'>1432</a>
+<a name='L1433'></a><a href='#L1433'>1433</a>
+<a name='L1434'></a><a href='#L1434'>1434</a>
+<a name='L1435'></a><a href='#L1435'>1435</a>
+<a name='L1436'></a><a href='#L1436'>1436</a>
+<a name='L1437'></a><a href='#L1437'>1437</a>
+<a name='L1438'></a><a href='#L1438'>1438</a>
+<a name='L1439'></a><a href='#L1439'>1439</a>
+<a name='L1440'></a><a href='#L1440'>1440</a>
+<a name='L1441'></a><a href='#L1441'>1441</a>
+<a name='L1442'></a><a href='#L1442'>1442</a>
+<a name='L1443'></a><a href='#L1443'>1443</a>
+<a name='L1444'></a><a href='#L1444'>1444</a>
+<a name='L1445'></a><a href='#L1445'>1445</a>
+<a name='L1446'></a><a href='#L1446'>1446</a>
+<a name='L1447'></a><a href='#L1447'>1447</a>
+<a name='L1448'></a><a href='#L1448'>1448</a>
+<a name='L1449'></a><a href='#L1449'>1449</a>
+<a name='L1450'></a><a href='#L1450'>1450</a>
+<a name='L1451'></a><a href='#L1451'>1451</a>
+<a name='L1452'></a><a href='#L1452'>1452</a>
+<a name='L1453'></a><a href='#L1453'>1453</a>
+<a name='L1454'></a><a href='#L1454'>1454</a>
+<a name='L1455'></a><a href='#L1455'>1455</a>
+<a name='L1456'></a><a href='#L1456'>1456</a>
+<a name='L1457'></a><a href='#L1457'>1457</a>
+<a name='L1458'></a><a href='#L1458'>1458</a>
+<a name='L1459'></a><a href='#L1459'>1459</a>
+<a name='L1460'></a><a href='#L1460'>1460</a>
+<a name='L1461'></a><a href='#L1461'>1461</a>
+<a name='L1462'></a><a href='#L1462'>1462</a>
+<a name='L1463'></a><a href='#L1463'>1463</a>
+<a name='L1464'></a><a href='#L1464'>1464</a>
+<a name='L1465'></a><a href='#L1465'>1465</a>
+<a name='L1466'></a><a href='#L1466'>1466</a>
+<a name='L1467'></a><a href='#L1467'>1467</a>
+<a name='L1468'></a><a href='#L1468'>1468</a>
+<a name='L1469'></a><a href='#L1469'>1469</a>
+<a name='L1470'></a><a href='#L1470'>1470</a>
+<a name='L1471'></a><a href='#L1471'>1471</a>
+<a name='L1472'></a><a href='#L1472'>1472</a>
+<a name='L1473'></a><a href='#L1473'>1473</a>
+<a name='L1474'></a><a href='#L1474'>1474</a>
+<a name='L1475'></a><a href='#L1475'>1475</a>
+<a name='L1476'></a><a href='#L1476'>1476</a>
+<a name='L1477'></a><a href='#L1477'>1477</a>
+<a name='L1478'></a><a href='#L1478'>1478</a>
+<a name='L1479'></a><a href='#L1479'>1479</a>
+<a name='L1480'></a><a href='#L1480'>1480</a>
+<a name='L1481'></a><a href='#L1481'>1481</a>
+<a name='L1482'></a><a href='#L1482'>1482</a>
+<a name='L1483'></a><a href='#L1483'>1483</a>
+<a name='L1484'></a><a href='#L1484'>1484</a>
+<a name='L1485'></a><a href='#L1485'>1485</a>
+<a name='L1486'></a><a href='#L1486'>1486</a>
+<a name='L1487'></a><a href='#L1487'>1487</a>
+<a name='L1488'></a><a href='#L1488'>1488</a>
+<a name='L1489'></a><a href='#L1489'>1489</a>
+<a name='L1490'></a><a href='#L1490'>1490</a>
+<a name='L1491'></a><a href='#L1491'>1491</a>
+<a name='L1492'></a><a href='#L1492'>1492</a>
+<a name='L1493'></a><a href='#L1493'>1493</a>
+<a name='L1494'></a><a href='#L1494'>1494</a>
+<a name='L1495'></a><a href='#L1495'>1495</a>
+<a name='L1496'></a><a href='#L1496'>1496</a>
+<a name='L1497'></a><a href='#L1497'>1497</a>
+<a name='L1498'></a><a href='#L1498'>1498</a>
+<a name='L1499'></a><a href='#L1499'>1499</a>
+<a name='L1500'></a><a href='#L1500'>1500</a>
+<a name='L1501'></a><a href='#L1501'>1501</a>
+<a name='L1502'></a><a href='#L1502'>1502</a>
+<a name='L1503'></a><a href='#L1503'>1503</a>
+<a name='L1504'></a><a href='#L1504'>1504</a>
+<a name='L1505'></a><a href='#L1505'>1505</a>
+<a name='L1506'></a><a href='#L1506'>1506</a>
+<a name='L1507'></a><a href='#L1507'>1507</a>
+<a name='L1508'></a><a href='#L1508'>1508</a>
+<a name='L1509'></a><a href='#L1509'>1509</a>
+<a name='L1510'></a><a href='#L1510'>1510</a>
+<a name='L1511'></a><a href='#L1511'>1511</a>
+<a name='L1512'></a><a href='#L1512'>1512</a>
+<a name='L1513'></a><a href='#L1513'>1513</a>
+<a name='L1514'></a><a href='#L1514'>1514</a>
+<a name='L1515'></a><a href='#L1515'>1515</a>
+<a name='L1516'></a><a href='#L1516'>1516</a>
+<a name='L1517'></a><a href='#L1517'>1517</a>
+<a name='L1518'></a><a href='#L1518'>1518</a>
+<a name='L1519'></a><a href='#L1519'>1519</a>
+<a name='L1520'></a><a href='#L1520'>1520</a>
+<a name='L1521'></a><a href='#L1521'>1521</a>
+<a name='L1522'></a><a href='#L1522'>1522</a>
+<a name='L1523'></a><a href='#L1523'>1523</a>
+<a name='L1524'></a><a href='#L1524'>1524</a>
+<a name='L1525'></a><a href='#L1525'>1525</a>
+<a name='L1526'></a><a href='#L1526'>1526</a>
+<a name='L1527'></a><a href='#L1527'>1527</a>
+<a name='L1528'></a><a href='#L1528'>1528</a>
+<a name='L1529'></a><a href='#L1529'>1529</a>
+<a name='L1530'></a><a href='#L1530'>1530</a>
+<a name='L1531'></a><a href='#L1531'>1531</a>
+<a name='L1532'></a><a href='#L1532'>1532</a>
+<a name='L1533'></a><a href='#L1533'>1533</a>
+<a name='L1534'></a><a href='#L1534'>1534</a>
+<a name='L1535'></a><a href='#L1535'>1535</a>
+<a name='L1536'></a><a href='#L1536'>1536</a>
+<a name='L1537'></a><a href='#L1537'>1537</a>
+<a name='L1538'></a><a href='#L1538'>1538</a>
+<a name='L1539'></a><a href='#L1539'>1539</a>
+<a name='L1540'></a><a href='#L1540'>1540</a>
+<a name='L1541'></a><a href='#L1541'>1541</a>
+<a name='L1542'></a><a href='#L1542'>1542</a>
+<a name='L1543'></a><a href='#L1543'>1543</a>
+<a name='L1544'></a><a href='#L1544'>1544</a>
+<a name='L1545'></a><a href='#L1545'>1545</a>
+<a name='L1546'></a><a href='#L1546'>1546</a>
+<a name='L1547'></a><a href='#L1547'>1547</a>
+<a name='L1548'></a><a href='#L1548'>1548</a>
+<a name='L1549'></a><a href='#L1549'>1549</a>
+<a name='L1550'></a><a href='#L1550'>1550</a>
+<a name='L1551'></a><a href='#L1551'>1551</a>
+<a name='L1552'></a><a href='#L1552'>1552</a>
+<a name='L1553'></a><a href='#L1553'>1553</a>
+<a name='L1554'></a><a href='#L1554'>1554</a>
+<a name='L1555'></a><a href='#L1555'>1555</a>
+<a name='L1556'></a><a href='#L1556'>1556</a>
+<a name='L1557'></a><a href='#L1557'>1557</a>
+<a name='L1558'></a><a href='#L1558'>1558</a>
+<a name='L1559'></a><a href='#L1559'>1559</a>
+<a name='L1560'></a><a href='#L1560'>1560</a>
+<a name='L1561'></a><a href='#L1561'>1561</a>
+<a name='L1562'></a><a href='#L1562'>1562</a>
+<a name='L1563'></a><a href='#L1563'>1563</a>
+<a name='L1564'></a><a href='#L1564'>1564</a>
+<a name='L1565'></a><a href='#L1565'>1565</a>
+<a name='L1566'></a><a href='#L1566'>1566</a>
+<a name='L1567'></a><a href='#L1567'>1567</a>
+<a name='L1568'></a><a href='#L1568'>1568</a>
+<a name='L1569'></a><a href='#L1569'>1569</a>
+<a name='L1570'></a><a href='#L1570'>1570</a>
+<a name='L1571'></a><a href='#L1571'>1571</a>
+<a name='L1572'></a><a href='#L1572'>1572</a>
+<a name='L1573'></a><a href='#L1573'>1573</a>
+<a name='L1574'></a><a href='#L1574'>1574</a>
+<a name='L1575'></a><a href='#L1575'>1575</a>
+<a name='L1576'></a><a href='#L1576'>1576</a>
+<a name='L1577'></a><a href='#L1577'>1577</a>
+<a name='L1578'></a><a href='#L1578'>1578</a>
+<a name='L1579'></a><a href='#L1579'>1579</a>
+<a name='L1580'></a><a href='#L1580'>1580</a>
+<a name='L1581'></a><a href='#L1581'>1581</a>
+<a name='L1582'></a><a href='#L1582'>1582</a>
+<a name='L1583'></a><a href='#L1583'>1583</a>
+<a name='L1584'></a><a href='#L1584'>1584</a>
+<a name='L1585'></a><a href='#L1585'>1585</a>
+<a name='L1586'></a><a href='#L1586'>1586</a>
+<a name='L1587'></a><a href='#L1587'>1587</a>
+<a name='L1588'></a><a href='#L1588'>1588</a>
+<a name='L1589'></a><a href='#L1589'>1589</a>
+<a name='L1590'></a><a href='#L1590'>1590</a>
+<a name='L1591'></a><a href='#L1591'>1591</a>
+<a name='L1592'></a><a href='#L1592'>1592</a>
+<a name='L1593'></a><a href='#L1593'>1593</a>
+<a name='L1594'></a><a href='#L1594'>1594</a>
+<a name='L1595'></a><a href='#L1595'>1595</a>
+<a name='L1596'></a><a href='#L1596'>1596</a>
+<a name='L1597'></a><a href='#L1597'>1597</a>
+<a name='L1598'></a><a href='#L1598'>1598</a>
+<a name='L1599'></a><a href='#L1599'>1599</a>
+<a name='L1600'></a><a href='#L1600'>1600</a>
+<a name='L1601'></a><a href='#L1601'>1601</a>
+<a name='L1602'></a><a href='#L1602'>1602</a>
+<a name='L1603'></a><a href='#L1603'>1603</a>
+<a name='L1604'></a><a href='#L1604'>1604</a>
+<a name='L1605'></a><a href='#L1605'>1605</a>
+<a name='L1606'></a><a href='#L1606'>1606</a>
+<a name='L1607'></a><a href='#L1607'>1607</a>
+<a name='L1608'></a><a href='#L1608'>1608</a>
+<a name='L1609'></a><a href='#L1609'>1609</a>
+<a name='L1610'></a><a href='#L1610'>1610</a>
+<a name='L1611'></a><a href='#L1611'>1611</a>
+<a name='L1612'></a><a href='#L1612'>1612</a>
+<a name='L1613'></a><a href='#L1613'>1613</a>
+<a name='L1614'></a><a href='#L1614'>1614</a>
+<a name='L1615'></a><a href='#L1615'>1615</a>
+<a name='L1616'></a><a href='#L1616'>1616</a>
+<a name='L1617'></a><a href='#L1617'>1617</a>
+<a name='L1618'></a><a href='#L1618'>1618</a>
+<a name='L1619'></a><a href='#L1619'>1619</a>
+<a name='L1620'></a><a href='#L1620'>1620</a>
+<a name='L1621'></a><a href='#L1621'>1621</a>
+<a name='L1622'></a><a href='#L1622'>1622</a>
+<a name='L1623'></a><a href='#L1623'>1623</a>
+<a name='L1624'></a><a href='#L1624'>1624</a>
+<a name='L1625'></a><a href='#L1625'>1625</a>
+<a name='L1626'></a><a href='#L1626'>1626</a>
+<a name='L1627'></a><a href='#L1627'>1627</a>
+<a name='L1628'></a><a href='#L1628'>1628</a>
+<a name='L1629'></a><a href='#L1629'>1629</a>
+<a name='L1630'></a><a href='#L1630'>1630</a>
+<a name='L1631'></a><a href='#L1631'>1631</a>
+<a name='L1632'></a><a href='#L1632'>1632</a>
+<a name='L1633'></a><a href='#L1633'>1633</a>
+<a name='L1634'></a><a href='#L1634'>1634</a>
+<a name='L1635'></a><a href='#L1635'>1635</a>
+<a name='L1636'></a><a href='#L1636'>1636</a>
+<a name='L1637'></a><a href='#L1637'>1637</a>
+<a name='L1638'></a><a href='#L1638'>1638</a>
+<a name='L1639'></a><a href='#L1639'>1639</a>
+<a name='L1640'></a><a href='#L1640'>1640</a>
+<a name='L1641'></a><a href='#L1641'>1641</a>
+<a name='L1642'></a><a href='#L1642'>1642</a>
+<a name='L1643'></a><a href='#L1643'>1643</a>
+<a name='L1644'></a><a href='#L1644'>1644</a>
+<a name='L1645'></a><a href='#L1645'>1645</a>
+<a name='L1646'></a><a href='#L1646'>1646</a>
+<a name='L1647'></a><a href='#L1647'>1647</a>
+<a name='L1648'></a><a href='#L1648'>1648</a>
+<a name='L1649'></a><a href='#L1649'>1649</a>
+<a name='L1650'></a><a href='#L1650'>1650</a>
+<a name='L1651'></a><a href='#L1651'>1651</a>
+<a name='L1652'></a><a href='#L1652'>1652</a>
+<a name='L1653'></a><a href='#L1653'>1653</a>
+<a name='L1654'></a><a href='#L1654'>1654</a>
+<a name='L1655'></a><a href='#L1655'>1655</a>
+<a name='L1656'></a><a href='#L1656'>1656</a>
+<a name='L1657'></a><a href='#L1657'>1657</a>
+<a name='L1658'></a><a href='#L1658'>1658</a>
+<a name='L1659'></a><a href='#L1659'>1659</a>
+<a name='L1660'></a><a href='#L1660'>1660</a>
+<a name='L1661'></a><a href='#L1661'>1661</a>
+<a name='L1662'></a><a href='#L1662'>1662</a>
+<a name='L1663'></a><a href='#L1663'>1663</a>
+<a name='L1664'></a><a href='#L1664'>1664</a>
+<a name='L1665'></a><a href='#L1665'>1665</a>
+<a name='L1666'></a><a href='#L1666'>1666</a>
+<a name='L1667'></a><a href='#L1667'>1667</a>
+<a name='L1668'></a><a href='#L1668'>1668</a>
+<a name='L1669'></a><a href='#L1669'>1669</a>
+<a name='L1670'></a><a href='#L1670'>1670</a>
+<a name='L1671'></a><a href='#L1671'>1671</a>
+<a name='L1672'></a><a href='#L1672'>1672</a>
+<a name='L1673'></a><a href='#L1673'>1673</a>
+<a name='L1674'></a><a href='#L1674'>1674</a>
+<a name='L1675'></a><a href='#L1675'>1675</a>
+<a name='L1676'></a><a href='#L1676'>1676</a>
+<a name='L1677'></a><a href='#L1677'>1677</a>
+<a name='L1678'></a><a href='#L1678'>1678</a>
+<a name='L1679'></a><a href='#L1679'>1679</a>
+<a name='L1680'></a><a href='#L1680'>1680</a>
+<a name='L1681'></a><a href='#L1681'>1681</a>
+<a name='L1682'></a><a href='#L1682'>1682</a>
+<a name='L1683'></a><a href='#L1683'>1683</a>
+<a name='L1684'></a><a href='#L1684'>1684</a>
+<a name='L1685'></a><a href='#L1685'>1685</a>
+<a name='L1686'></a><a href='#L1686'>1686</a>
+<a name='L1687'></a><a href='#L1687'>1687</a>
+<a name='L1688'></a><a href='#L1688'>1688</a>
+<a name='L1689'></a><a href='#L1689'>1689</a>
+<a name='L1690'></a><a href='#L1690'>1690</a>
+<a name='L1691'></a><a href='#L1691'>1691</a>
+<a name='L1692'></a><a href='#L1692'>1692</a>
+<a name='L1693'></a><a href='#L1693'>1693</a>
+<a name='L1694'></a><a href='#L1694'>1694</a>
+<a name='L1695'></a><a href='#L1695'>1695</a>
+<a name='L1696'></a><a href='#L1696'>1696</a>
+<a name='L1697'></a><a href='#L1697'>1697</a>
+<a name='L1698'></a><a href='#L1698'>1698</a>
+<a name='L1699'></a><a href='#L1699'>1699</a>
+<a name='L1700'></a><a href='#L1700'>1700</a>
+<a name='L1701'></a><a href='#L1701'>1701</a>
+<a name='L1702'></a><a href='#L1702'>1702</a>
+<a name='L1703'></a><a href='#L1703'>1703</a>
+<a name='L1704'></a><a href='#L1704'>1704</a>
+<a name='L1705'></a><a href='#L1705'>1705</a>
+<a name='L1706'></a><a href='#L1706'>1706</a>
+<a name='L1707'></a><a href='#L1707'>1707</a>
+<a name='L1708'></a><a href='#L1708'>1708</a>
+<a name='L1709'></a><a href='#L1709'>1709</a>
+<a name='L1710'></a><a href='#L1710'>1710</a>
+<a name='L1711'></a><a href='#L1711'>1711</a>
+<a name='L1712'></a><a href='#L1712'>1712</a>
+<a name='L1713'></a><a href='#L1713'>1713</a>
+<a name='L1714'></a><a href='#L1714'>1714</a>
+<a name='L1715'></a><a href='#L1715'>1715</a>
+<a name='L1716'></a><a href='#L1716'>1716</a>
+<a name='L1717'></a><a href='#L1717'>1717</a>
+<a name='L1718'></a><a href='#L1718'>1718</a>
+<a name='L1719'></a><a href='#L1719'>1719</a>
+<a name='L1720'></a><a href='#L1720'>1720</a>
+<a name='L1721'></a><a href='#L1721'>1721</a>
+<a name='L1722'></a><a href='#L1722'>1722</a>
+<a name='L1723'></a><a href='#L1723'>1723</a>
+<a name='L1724'></a><a href='#L1724'>1724</a>
+<a name='L1725'></a><a href='#L1725'>1725</a>
+<a name='L1726'></a><a href='#L1726'>1726</a>
+<a name='L1727'></a><a href='#L1727'>1727</a>
+<a name='L1728'></a><a href='#L1728'>1728</a>
+<a name='L1729'></a><a href='#L1729'>1729</a>
+<a name='L1730'></a><a href='#L1730'>1730</a>
+<a name='L1731'></a><a href='#L1731'>1731</a>
+<a name='L1732'></a><a href='#L1732'>1732</a>
+<a name='L1733'></a><a href='#L1733'>1733</a>
+<a name='L1734'></a><a href='#L1734'>1734</a>
+<a name='L1735'></a><a href='#L1735'>1735</a>
+<a name='L1736'></a><a href='#L1736'>1736</a>
+<a name='L1737'></a><a href='#L1737'>1737</a>
+<a name='L1738'></a><a href='#L1738'>1738</a>
+<a name='L1739'></a><a href='#L1739'>1739</a>
+<a name='L1740'></a><a href='#L1740'>1740</a>
+<a name='L1741'></a><a href='#L1741'>1741</a>
+<a name='L1742'></a><a href='#L1742'>1742</a>
+<a name='L1743'></a><a href='#L1743'>1743</a>
+<a name='L1744'></a><a href='#L1744'>1744</a>
+<a name='L1745'></a><a href='#L1745'>1745</a>
+<a name='L1746'></a><a href='#L1746'>1746</a>
+<a name='L1747'></a><a href='#L1747'>1747</a>
+<a name='L1748'></a><a href='#L1748'>1748</a>
+<a name='L1749'></a><a href='#L1749'>1749</a>
+<a name='L1750'></a><a href='#L1750'>1750</a>
+<a name='L1751'></a><a href='#L1751'>1751</a>
+<a name='L1752'></a><a href='#L1752'>1752</a>
+<a name='L1753'></a><a href='#L1753'>1753</a>
+<a name='L1754'></a><a href='#L1754'>1754</a>
+<a name='L1755'></a><a href='#L1755'>1755</a>
+<a name='L1756'></a><a href='#L1756'>1756</a>
+<a name='L1757'></a><a href='#L1757'>1757</a>
+<a name='L1758'></a><a href='#L1758'>1758</a>
+<a name='L1759'></a><a href='#L1759'>1759</a>
+<a name='L1760'></a><a href='#L1760'>1760</a>
+<a name='L1761'></a><a href='#L1761'>1761</a>
+<a name='L1762'></a><a href='#L1762'>1762</a>
+<a name='L1763'></a><a href='#L1763'>1763</a>
+<a name='L1764'></a><a href='#L1764'>1764</a>
+<a name='L1765'></a><a href='#L1765'>1765</a>
+<a name='L1766'></a><a href='#L1766'>1766</a>
+<a name='L1767'></a><a href='#L1767'>1767</a>
+<a name='L1768'></a><a href='#L1768'>1768</a>
+<a name='L1769'></a><a href='#L1769'>1769</a>
+<a name='L1770'></a><a href='#L1770'>1770</a>
+<a name='L1771'></a><a href='#L1771'>1771</a>
+<a name='L1772'></a><a href='#L1772'>1772</a>
+<a name='L1773'></a><a href='#L1773'>1773</a>
+<a name='L1774'></a><a href='#L1774'>1774</a>
+<a name='L1775'></a><a href='#L1775'>1775</a>
+<a name='L1776'></a><a href='#L1776'>1776</a>
+<a name='L1777'></a><a href='#L1777'>1777</a>
+<a name='L1778'></a><a href='#L1778'>1778</a>
+<a name='L1779'></a><a href='#L1779'>1779</a>
+<a name='L1780'></a><a href='#L1780'>1780</a>
+<a name='L1781'></a><a href='#L1781'>1781</a>
+<a name='L1782'></a><a href='#L1782'>1782</a>
+<a name='L1783'></a><a href='#L1783'>1783</a>
+<a name='L1784'></a><a href='#L1784'>1784</a>
+<a name='L1785'></a><a href='#L1785'>1785</a>
+<a name='L1786'></a><a href='#L1786'>1786</a>
+<a name='L1787'></a><a href='#L1787'>1787</a>
+<a name='L1788'></a><a href='#L1788'>1788</a>
+<a name='L1789'></a><a href='#L1789'>1789</a>
+<a name='L1790'></a><a href='#L1790'>1790</a>
+<a name='L1791'></a><a href='#L1791'>1791</a>
+<a name='L1792'></a><a href='#L1792'>1792</a>
+<a name='L1793'></a><a href='#L1793'>1793</a>
+<a name='L1794'></a><a href='#L1794'>1794</a>
+<a name='L1795'></a><a href='#L1795'>1795</a>
+<a name='L1796'></a><a href='#L1796'>1796</a>
+<a name='L1797'></a><a href='#L1797'>1797</a>
+<a name='L1798'></a><a href='#L1798'>1798</a>
+<a name='L1799'></a><a href='#L1799'>1799</a>
+<a name='L1800'></a><a href='#L1800'>1800</a>
+<a name='L1801'></a><a href='#L1801'>1801</a>
+<a name='L1802'></a><a href='#L1802'>1802</a>
+<a name='L1803'></a><a href='#L1803'>1803</a>
+<a name='L1804'></a><a href='#L1804'>1804</a>
+<a name='L1805'></a><a href='#L1805'>1805</a>
+<a name='L1806'></a><a href='#L1806'>1806</a>
+<a name='L1807'></a><a href='#L1807'>1807</a>
+<a name='L1808'></a><a href='#L1808'>1808</a>
+<a name='L1809'></a><a href='#L1809'>1809</a>
+<a name='L1810'></a><a href='#L1810'>1810</a>
+<a name='L1811'></a><a href='#L1811'>1811</a>
+<a name='L1812'></a><a href='#L1812'>1812</a>
+<a name='L1813'></a><a href='#L1813'>1813</a>
+<a name='L1814'></a><a href='#L1814'>1814</a>
+<a name='L1815'></a><a href='#L1815'>1815</a>
+<a name='L1816'></a><a href='#L1816'>1816</a>
+<a name='L1817'></a><a href='#L1817'>1817</a>
+<a name='L1818'></a><a href='#L1818'>1818</a>
+<a name='L1819'></a><a href='#L1819'>1819</a>
+<a name='L1820'></a><a href='#L1820'>1820</a>
+<a name='L1821'></a><a href='#L1821'>1821</a>
+<a name='L1822'></a><a href='#L1822'>1822</a>
+<a name='L1823'></a><a href='#L1823'>1823</a>
+<a name='L1824'></a><a href='#L1824'>1824</a>
+<a name='L1825'></a><a href='#L1825'>1825</a>
+<a name='L1826'></a><a href='#L1826'>1826</a>
+<a name='L1827'></a><a href='#L1827'>1827</a>
+<a name='L1828'></a><a href='#L1828'>1828</a>
+<a name='L1829'></a><a href='#L1829'>1829</a>
+<a name='L1830'></a><a href='#L1830'>1830</a>
+<a name='L1831'></a><a href='#L1831'>1831</a>
+<a name='L1832'></a><a href='#L1832'>1832</a>
+<a name='L1833'></a><a href='#L1833'>1833</a>
+<a name='L1834'></a><a href='#L1834'>1834</a>
+<a name='L1835'></a><a href='#L1835'>1835</a>
+<a name='L1836'></a><a href='#L1836'>1836</a>
+<a name='L1837'></a><a href='#L1837'>1837</a>
+<a name='L1838'></a><a href='#L1838'>1838</a>
+<a name='L1839'></a><a href='#L1839'>1839</a>
+<a name='L1840'></a><a href='#L1840'>1840</a>
+<a name='L1841'></a><a href='#L1841'>1841</a>
+<a name='L1842'></a><a href='#L1842'>1842</a>
+<a name='L1843'></a><a href='#L1843'>1843</a>
+<a name='L1844'></a><a href='#L1844'>1844</a>
+<a name='L1845'></a><a href='#L1845'>1845</a>
+<a name='L1846'></a><a href='#L1846'>1846</a>
+<a name='L1847'></a><a href='#L1847'>1847</a>
+<a name='L1848'></a><a href='#L1848'>1848</a>
+<a name='L1849'></a><a href='#L1849'>1849</a>
+<a name='L1850'></a><a href='#L1850'>1850</a>
+<a name='L1851'></a><a href='#L1851'>1851</a>
+<a name='L1852'></a><a href='#L1852'>1852</a>
+<a name='L1853'></a><a href='#L1853'>1853</a>
+<a name='L1854'></a><a href='#L1854'>1854</a>
+<a name='L1855'></a><a href='#L1855'>1855</a>
+<a name='L1856'></a><a href='#L1856'>1856</a>
+<a name='L1857'></a><a href='#L1857'>1857</a>
+<a name='L1858'></a><a href='#L1858'>1858</a>
+<a name='L1859'></a><a href='#L1859'>1859</a>
+<a name='L1860'></a><a href='#L1860'>1860</a>
+<a name='L1861'></a><a href='#L1861'>1861</a>
+<a name='L1862'></a><a href='#L1862'>1862</a>
+<a name='L1863'></a><a href='#L1863'>1863</a>
+<a name='L1864'></a><a href='#L1864'>1864</a>
+<a name='L1865'></a><a href='#L1865'>1865</a>
+<a name='L1866'></a><a href='#L1866'>1866</a>
+<a name='L1867'></a><a href='#L1867'>1867</a>
+<a name='L1868'></a><a href='#L1868'>1868</a>
+<a name='L1869'></a><a href='#L1869'>1869</a>
+<a name='L1870'></a><a href='#L1870'>1870</a>
+<a name='L1871'></a><a href='#L1871'>1871</a>
+<a name='L1872'></a><a href='#L1872'>1872</a>
+<a name='L1873'></a><a href='#L1873'>1873</a>
+<a name='L1874'></a><a href='#L1874'>1874</a>
+<a name='L1875'></a><a href='#L1875'>1875</a>
+<a name='L1876'></a><a href='#L1876'>1876</a>
+<a name='L1877'></a><a href='#L1877'>1877</a>
+<a name='L1878'></a><a href='#L1878'>1878</a>
+<a name='L1879'></a><a href='#L1879'>1879</a>
+<a name='L1880'></a><a href='#L1880'>1880</a>
+<a name='L1881'></a><a href='#L1881'>1881</a>
+<a name='L1882'></a><a href='#L1882'>1882</a>
+<a name='L1883'></a><a href='#L1883'>1883</a>
+<a name='L1884'></a><a href='#L1884'>1884</a>
+<a name='L1885'></a><a href='#L1885'>1885</a>
+<a name='L1886'></a><a href='#L1886'>1886</a>
+<a name='L1887'></a><a href='#L1887'>1887</a>
+<a name='L1888'></a><a href='#L1888'>1888</a>
+<a name='L1889'></a><a href='#L1889'>1889</a>
+<a name='L1890'></a><a href='#L1890'>1890</a>
+<a name='L1891'></a><a href='#L1891'>1891</a>
+<a name='L1892'></a><a href='#L1892'>1892</a>
+<a name='L1893'></a><a href='#L1893'>1893</a>
+<a name='L1894'></a><a href='#L1894'>1894</a>
+<a name='L1895'></a><a href='#L1895'>1895</a>
+<a name='L1896'></a><a href='#L1896'>1896</a>
+<a name='L1897'></a><a href='#L1897'>1897</a>
+<a name='L1898'></a><a href='#L1898'>1898</a>
+<a name='L1899'></a><a href='#L1899'>1899</a>
+<a name='L1900'></a><a href='#L1900'>1900</a>
+<a name='L1901'></a><a href='#L1901'>1901</a>
+<a name='L1902'></a><a href='#L1902'>1902</a>
+<a name='L1903'></a><a href='#L1903'>1903</a>
+<a name='L1904'></a><a href='#L1904'>1904</a>
+<a name='L1905'></a><a href='#L1905'>1905</a>
+<a name='L1906'></a><a href='#L1906'>1906</a>
+<a name='L1907'></a><a href='#L1907'>1907</a>
+<a name='L1908'></a><a href='#L1908'>1908</a>
+<a name='L1909'></a><a href='#L1909'>1909</a>
+<a name='L1910'></a><a href='#L1910'>1910</a>
+<a name='L1911'></a><a href='#L1911'>1911</a>
+<a name='L1912'></a><a href='#L1912'>1912</a>
+<a name='L1913'></a><a href='#L1913'>1913</a>
+<a name='L1914'></a><a href='#L1914'>1914</a>
+<a name='L1915'></a><a href='#L1915'>1915</a>
+<a name='L1916'></a><a href='#L1916'>1916</a>
+<a name='L1917'></a><a href='#L1917'>1917</a>
+<a name='L1918'></a><a href='#L1918'>1918</a>
+<a name='L1919'></a><a href='#L1919'>1919</a>
+<a name='L1920'></a><a href='#L1920'>1920</a>
+<a name='L1921'></a><a href='#L1921'>1921</a>
+<a name='L1922'></a><a href='#L1922'>1922</a>
+<a name='L1923'></a><a href='#L1923'>1923</a>
+<a name='L1924'></a><a href='#L1924'>1924</a>
+<a name='L1925'></a><a href='#L1925'>1925</a>
+<a name='L1926'></a><a href='#L1926'>1926</a>
+<a name='L1927'></a><a href='#L1927'>1927</a>
+<a name='L1928'></a><a href='#L1928'>1928</a>
+<a name='L1929'></a><a href='#L1929'>1929</a>
+<a name='L1930'></a><a href='#L1930'>1930</a>
+<a name='L1931'></a><a href='#L1931'>1931</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-yes">26x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">13x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">const API_BASE = '/api/v1';
+&nbsp;
+async function request&lt;T&gt;(
+  endpoint: string,
+  options: RequestInit = {}
+): Promise&lt;T&gt; {
+  const response = await fetch(`${API_BASE}${endpoint}`, {
+    ...options,
+    headers: {
+      'Content-Type': 'application/json',
+      ...options.headers,
+    },
+  });
+&nbsp;
+  if (!response.ok) <span class="branch-0 cbranch-no" title="branch not covered" >{</span>
+<span class="cstat-no" title="statement not covered" >    const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >    throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >  }</span>
+&nbsp;
+  return response.json();
+}
+&nbsp;
+// Printer types
+export interface Printer {
+  id: number;
+  name: string;
+  serial_number: string;
+  ip_address: string;
+  access_code: string;
+  model: string | null;
+  location: string | null;  // Group/location name
+  nozzle_count: number;  // 1 or 2, auto-detected from MQTT
+  is_active: boolean;
+  auto_archive: boolean;
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+export interface HMSError {
+  code: string;
+  attr: number;  // Attribute value for constructing wiki URL
+  module: number;
+  severity: number;  // 1=fatal, 2=serious, 3=common, 4=info
+}
+&nbsp;
+export interface AMSTray {
+  id: number;
+  tray_color: string | null;
+  tray_type: string | null;
+  tray_sub_brands: string | null;  // Full name like "PLA Basic", "PETG HF"
+  tray_id_name: string | null;  // Bambu filament ID like "A00-Y2" (can decode to color)
+  tray_info_idx: string | null;  // Filament preset ID like "GFA00" - maps to cloud setting_id
+  remain: number;
+  k: number | null;  // Pressure advance value
+  tag_uid: string | null;  // RFID tag UID (any tag)
+  tray_uuid: string | null;  // Bambu Lab spool UUID (32-char hex, only valid for Bambu Lab spools)
+  nozzle_temp_min: number | null;  // Min nozzle temperature
+  nozzle_temp_max: number | null;  // Max nozzle temperature
+}
+&nbsp;
+export interface AMSUnit {
+  id: number;
+  humidity: number | null;
+  temp: number | null;
+  is_ams_ht: boolean;  // True for AMS-HT (single spool), False for regular AMS (4 spools)
+  tray: AMSTray[];
+}
+&nbsp;
+export interface NozzleInfo {
+  nozzle_type: string;  // "stainless_steel" or "hardened_steel"
+  nozzle_diameter: string;  // e.g., "0.4"
+}
+&nbsp;
+export interface PrintOptions {
+  // Core AI detectors
+  spaghetti_detector: boolean;
+  print_halt: boolean;
+  halt_print_sensitivity: string;  // "low", "medium", "high" - spaghetti sensitivity
+  first_layer_inspector: boolean;
+  printing_monitor: boolean;
+  buildplate_marker_detector: boolean;
+  allow_skip_parts: boolean;
+  // Additional AI detectors (decoded from cfg bitmask)
+  nozzle_clumping_detector: boolean;
+  nozzle_clumping_sensitivity: string;  // "low", "medium", "high"
+  pileup_detector: boolean;
+  pileup_sensitivity: string;  // "low", "medium", "high"
+  airprint_detector: boolean;
+  airprint_sensitivity: string;  // "low", "medium", "high"
+  auto_recovery_step_loss: boolean;
+  filament_tangle_detect: boolean;
+}
+&nbsp;
+export interface PrinterStatus {
+  id: number;
+  name: string;
+  connected: boolean;
+  state: string | null;
+  current_print: string | null;
+  subtask_name: string | null;
+  gcode_file: string | null;
+  progress: number | null;
+  remaining_time: number | null;
+  layer_num: number | null;
+  total_layers: number | null;
+  temperatures: {
+    bed?: number;
+    bed_target?: number;
+    nozzle?: number;
+    nozzle_target?: number;
+    nozzle_2?: number;  // Second nozzle for H2 series (dual nozzle)
+    nozzle_2_target?: number;
+    chamber?: number;
+  } | null;
+  cover_url: string | null;
+  hms_errors: HMSError[];
+  ams: AMSUnit[];
+  ams_exists: boolean;
+  vt_tray: AMSTray | null;  // Virtual tray / external spool
+  sdcard: boolean;  // SD card inserted
+  store_to_sdcard: boolean;  // Store sent files on SD card
+  timelapse: boolean;  // Timelapse recording active
+  ipcam: boolean;  // Live view enabled
+  wifi_signal: number | null;  // WiFi signal strength in dBm
+  nozzles: NozzleInfo[];  // Nozzle hardware info (index 0=left/primary, 1=right)
+  print_options: PrintOptions | null;  // AI detection and print options
+  // Calibration stage tracking
+  stg_cur: number;  // Current stage number (-1 = not calibrating)
+  stg_cur_name: string | null;  // Human-readable current stage name
+  stg: number[];  // List of stage numbers in calibration sequence
+  // Air conditioning mode (0=cooling, 1=heating)
+  airduct_mode: number;
+  // Print speed level (1=silent, 2=standard, 3=sport, 4=ludicrous)
+  speed_level: number;
+  // Chamber light on/off
+  chamber_light: boolean;
+  // Active extruder for dual nozzle (0=right, 1=left)
+  active_extruder: number;
+  // AMS mapping - which AMS is connected to which nozzle
+  // Format: [ams_id_for_nozzle0, ams_id_for_nozzle1, ...] where -1 means no AMS
+  ams_mapping: number[];
+  // Per-AMS extruder mapping - extracted from each AMS unit's info field
+  // Format: {ams_id: extruder_id} where extruder 0=right, 1=left
+  // Note: JSON keys are always strings
+  ams_extruder_map: Record&lt;string, number&gt;;
+  // Currently loaded tray (global tray ID, 255 = no filament loaded, 254 = external spool)
+  tray_now: number;
+  // AMS status for filament change tracking (0=idle, 1=filament_change, 2=rfid_identifying, 3=assist, 4=calibration)
+  ams_status_main: number;
+  // AMS sub-status for filament change step (when main=1): 4=retraction, 6=load verification, 7=purge
+  ams_status_sub: number;
+  // mc_print_sub_stage - filament change step indicator used by OrcaSlicer/BambuStudio
+  mc_print_sub_stage: number;
+  // Timestamp of last AMS data update (for RFID refresh detection)
+  last_ams_update: number;
+}
+&nbsp;
+export interface PrinterCreate {
+  name: string;
+  serial_number: string;
+  ip_address: string;
+  access_code: string;
+  model?: string;
+  location?: string;
+  auto_archive?: boolean;
+}
+&nbsp;
+// Archive types
+export interface ArchiveDuplicate {
+  id: number;
+  print_name: string | null;
+  created_at: string;
+  match_type: 'exact' | 'similar';  // 'exact' = hash match, 'similar' = name match
+}
+&nbsp;
+export interface Archive {
+  id: number;
+  printer_id: number | null;
+  project_id: number | null;
+  project_name: string | null;
+  filename: string;
+  file_path: string;
+  file_size: number;
+  content_hash: string | null;
+  thumbnail_path: string | null;
+  timelapse_path: string | null;
+  source_3mf_path: string | null;
+  duplicates: ArchiveDuplicate[] | null;
+  duplicate_count: number;
+  print_name: string | null;
+  print_time_seconds: number | null;
+  actual_time_seconds: number | null;  // Computed from started_at/completed_at
+  time_accuracy: number | null;  // Percentage: 100 = perfect, &gt;100 = faster than estimated
+  filament_used_grams: number | null;
+  filament_type: string | null;
+  filament_color: string | null;
+  layer_height: number | null;
+  total_layers: number | null;
+  nozzle_diameter: number | null;
+  bed_temperature: number | null;
+  nozzle_temperature: number | null;
+  status: string;
+  started_at: string | null;
+  completed_at: string | null;
+  extra_data: Record&lt;string, unknown&gt; | null;
+  makerworld_url: string | null;
+  designer: string | null;
+  is_favorite: boolean;
+  tags: string | null;
+  notes: string | null;
+  cost: number | null;
+  photos: string[] | null;
+  failure_reason: string | null;
+  energy_kwh: number | null;
+  energy_cost: number | null;
+  created_at: string;
+}
+&nbsp;
+export interface ArchiveStats {
+  total_prints: number;
+  successful_prints: number;
+  failed_prints: number;
+  total_print_time_hours: number;
+  total_filament_grams: number;
+  total_cost: number;
+  prints_by_filament_type: Record&lt;string, number&gt;;
+  prints_by_printer: Record&lt;string, number&gt;;
+  average_time_accuracy: number | null;
+  time_accuracy_by_printer: Record&lt;string, number&gt; | null;
+  total_energy_kwh: number;
+  total_energy_cost: number;
+}
+&nbsp;
+export interface FailureAnalysis {
+  period_days: number;
+  total_prints: number;
+  failed_prints: number;
+  failure_rate: number;
+  failures_by_reason: Record&lt;string, number&gt;;
+  failures_by_filament: Record&lt;string, number&gt;;
+  failures_by_printer: Record&lt;string, number&gt;;
+  failures_by_hour: Record&lt;number, number&gt;;
+  recent_failures: Array&lt;{
+    id: number;
+    print_name: string;
+    failure_reason: string | null;
+    filament_type: string | null;
+    printer_id: number | null;
+    created_at: string | null;
+  }&gt;;
+  trend: Array&lt;{
+    week_start: string;
+    total_prints: number;
+    failed_prints: number;
+    failure_rate: number;
+  }&gt;;
+}
+&nbsp;
+export interface BulkUploadResult {
+  uploaded: number;
+  failed: number;
+  results: Array&lt;{ filename: string; id: number; status: string }&gt;;
+  errors: Array&lt;{ filename: string; error: string }&gt;;
+}
+&nbsp;
+// Archive Comparison types
+export interface ComparisonArchiveInfo {
+  id: number;
+  print_name: string;
+  status: string;
+  created_at: string | null;
+  printer_id: number | null;
+  project_name: string | null;
+}
+&nbsp;
+export interface ComparisonField {
+  field: string;
+  label: string;
+  unit: string | null;
+  values: (string | number | null)[];
+  raw_values: (string | number | null)[];
+  has_difference: boolean;
+}
+&nbsp;
+export interface SuccessCorrelationInsight {
+  field: string;
+  label: string;
+  insight: string;
+  success_avg?: number;
+  failed_avg?: number;
+  success_values?: string[];
+  failed_values?: string[];
+}
+&nbsp;
+export interface SuccessCorrelation {
+  has_both_outcomes: boolean;
+  message?: string;
+  successful_count?: number;
+  failed_count?: number;
+  insights?: SuccessCorrelationInsight[];
+}
+&nbsp;
+export interface ArchiveComparison {
+  archives: ComparisonArchiveInfo[];
+  comparison: ComparisonField[];
+  differences: ComparisonField[];
+  success_correlation: SuccessCorrelation;
+}
+&nbsp;
+export interface SimilarArchive {
+  archive: {
+    id: number;
+    print_name: string;
+    status: string;
+    created_at: string | null;
+  };
+  match_reason: string;
+  match_score: number;
+}
+&nbsp;
+// Project types
+export interface ProjectStats {
+  total_archives: number;
+  completed_prints: number;
+  failed_prints: number;
+  queued_prints: number;
+  in_progress_prints: number;
+  total_print_time_hours: number;
+  total_filament_grams: number;
+  progress_percent: number | null;
+}
+&nbsp;
+export interface Project {
+  id: number;
+  name: string;
+  description: string | null;
+  color: string | null;
+  status: string;  // active, completed, archived
+  target_count: number | null;
+  created_at: string;
+  updated_at: string;
+  stats?: ProjectStats;
+}
+&nbsp;
+export interface ArchivePreview {
+  id: number;
+  print_name: string | null;
+  thumbnail_path: string | null;
+  status: string;
+}
+&nbsp;
+export interface ProjectListItem {
+  id: number;
+  name: string;
+  description: string | null;
+  color: string | null;
+  status: string;
+  target_count: number | null;
+  created_at: string;
+  archive_count: number;
+  queue_count: number;
+  progress_percent: number | null;
+  archives: ArchivePreview[];
+}
+&nbsp;
+export interface ProjectCreate {
+  name: string;
+  description?: string;
+  color?: string;
+  target_count?: number;
+}
+&nbsp;
+export interface ProjectUpdate {
+  name?: string;
+  description?: string;
+  color?: string;
+  status?: string;
+  target_count?: number;
+}
+&nbsp;
+// API Key types
+export interface APIKey {
+  id: number;
+  name: string;
+  key_prefix: string;
+  can_queue: boolean;
+  can_control_printer: boolean;
+  can_read_status: boolean;
+  printer_ids: number[] | null;
+  enabled: boolean;
+  last_used: string | null;
+  created_at: string;
+  expires_at: string | null;
+}
+&nbsp;
+export interface APIKeyCreate {
+  name: string;
+  can_queue?: boolean;
+  can_control_printer?: boolean;
+  can_read_status?: boolean;
+  printer_ids?: number[] | null;
+  expires_at?: string | null;
+}
+&nbsp;
+export interface APIKeyCreateResponse extends APIKey {
+  key: string;  // Full key, only shown on creation
+}
+&nbsp;
+export interface APIKeyUpdate {
+  name?: string;
+  can_queue?: boolean;
+  can_control_printer?: boolean;
+  can_read_status?: boolean;
+  printer_ids?: number[] | null;
+  enabled?: boolean;
+  expires_at?: string | null;
+}
+&nbsp;
+// Settings types
+export interface AppSettings {
+  auto_archive: boolean;
+  save_thumbnails: boolean;
+  capture_finish_photo: boolean;
+  default_filament_cost: number;
+  currency: string;
+  energy_cost_per_kwh: number;
+  energy_tracking_mode: 'print' | 'total';
+  check_updates: boolean;
+  notification_language: string;
+  // AMS threshold settings
+  ams_humidity_good: number;  // &lt;= this is green
+  ams_humidity_fair: number;  // &lt;= this is orange, &gt; is red
+  ams_temp_good: number;      // &lt;= this is green/blue
+  ams_temp_fair: number;      // &lt;= this is orange, &gt; is red
+  ams_history_retention_days: number;  // days to keep AMS sensor history
+  // Date/time format settings
+  date_format: 'system' | 'us' | 'eu' | 'iso';
+  time_format: 'system' | '12h' | '24h';
+  // Default printer
+  default_printer_id: number | null;
+}
+&nbsp;
+export type AppSettingsUpdate = Partial&lt;AppSettings&gt;;
+&nbsp;
+// Cloud types
+export interface CloudAuthStatus {
+  is_authenticated: boolean;
+  email: string | null;
+}
+&nbsp;
+export interface CloudLoginResponse {
+  success: boolean;
+  needs_verification: boolean;
+  message: string;
+}
+&nbsp;
+export interface SlicerSetting {
+  setting_id: string;
+  name: string;
+  type: string;
+  version: string | null;
+  user_id: string | null;
+  updated_time: string | null;
+}
+&nbsp;
+export interface SlicerSettingsResponse {
+  filament: SlicerSetting[];
+  printer: SlicerSetting[];
+  process: SlicerSetting[];
+}
+&nbsp;
+export interface SlicerSettingDetail {
+  message?: string | null;
+  code?: string | null;
+  error?: string | null;
+  public: boolean;
+  version?: string | null;
+  type: string;
+  name: string;
+  update_time?: string | null;
+  nickname?: string | null;
+  base_id?: string | null;
+  setting: Record&lt;string, unknown&gt;;
+  filament_id?: string | null;
+  setting_id?: string | null;
+}
+&nbsp;
+export interface SlicerSettingCreate {
+  type: string;  // 'filament', 'print', or 'printer'
+  name: string;
+  base_id: string;
+  setting: Record&lt;string, unknown&gt;;
+}
+&nbsp;
+export interface SlicerSettingUpdate {
+  name?: string;
+  setting?: Record&lt;string, unknown&gt;;
+}
+&nbsp;
+export interface SlicerSettingDeleteResponse {
+  success: boolean;
+  message: string;
+}
+&nbsp;
+export interface FieldOption {
+  value: string;
+  label: string;
+}
+&nbsp;
+export interface FieldDefinition {
+  key: string;
+  label: string;
+  type: 'text' | 'number' | 'boolean' | 'select';
+  category: string;
+  description?: string;
+  options?: FieldOption[];
+  unit?: string;
+  min?: number;
+  max?: number;
+  step?: number;
+}
+&nbsp;
+export interface FieldDefinitionsResponse {
+  version: string;
+  description: string;
+  fields: FieldDefinition[];
+}
+&nbsp;
+export interface CloudDevice {
+  dev_id: string;
+  name: string;
+  dev_model_name: string | null;
+  dev_product_name: string | null;
+  online: boolean;
+}
+&nbsp;
+// Smart Plug types
+export interface SmartPlug {
+  id: number;
+  name: string;
+  ip_address: string;
+  printer_id: number | null;
+  enabled: boolean;
+  auto_on: boolean;
+  auto_off: boolean;
+  off_delay_mode: 'time' | 'temperature';
+  off_delay_minutes: number;
+  off_temp_threshold: number;
+  username: string | null;
+  password: string | null;
+  // Power alerts
+  power_alert_enabled: boolean;
+  power_alert_high: number | null;
+  power_alert_low: number | null;
+  power_alert_last_triggered: string | null;
+  // Schedule
+  schedule_enabled: boolean;
+  schedule_on_time: string | null;
+  schedule_off_time: string | null;
+  // Status
+  last_state: string | null;
+  last_checked: string | null;
+  auto_off_executed: boolean;  // True when auto-off was triggered after print
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+export interface SmartPlugCreate {
+  name: string;
+  ip_address: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+  // Power alerts
+  power_alert_enabled?: boolean;
+  power_alert_high?: number | null;
+  power_alert_low?: number | null;
+  // Schedule
+  schedule_enabled?: boolean;
+  schedule_on_time?: string | null;
+  schedule_off_time?: string | null;
+}
+&nbsp;
+export interface SmartPlugUpdate {
+  name?: string;
+  ip_address?: string;
+  printer_id?: number | null;
+  enabled?: boolean;
+  auto_on?: boolean;
+  auto_off?: boolean;
+  off_delay_mode?: 'time' | 'temperature';
+  off_delay_minutes?: number;
+  off_temp_threshold?: number;
+  username?: string | null;
+  password?: string | null;
+  // Power alerts
+  power_alert_enabled?: boolean;
+  power_alert_high?: number | null;
+  power_alert_low?: number | null;
+  // Schedule
+  schedule_enabled?: boolean;
+  schedule_on_time?: string | null;
+  schedule_off_time?: string | null;
+}
+&nbsp;
+export interface SmartPlugEnergy {
+  power: number | null;  // Current watts
+  voltage: number | null;  // Volts
+  current: number | null;  // Amps
+  today: number | null;  // kWh used today
+  yesterday: number | null;  // kWh used yesterday
+  total: number | null;  // Total kWh
+  factor: number | null;  // Power factor (0-1)
+  apparent_power: number | null;  // VA
+  reactive_power: number | null;  // VAr
+}
+&nbsp;
+export interface SmartPlugStatus {
+  state: string | null;
+  reachable: boolean;
+  device_name: string | null;
+  energy: SmartPlugEnergy | null;
+}
+&nbsp;
+export interface SmartPlugTestResult {
+  success: boolean;
+  state: string | null;
+  device_name: string | null;
+}
+&nbsp;
+// Print Queue types
+export interface PrintQueueItem {
+  id: number;
+  printer_id: number;
+  archive_id: number;
+  position: number;
+  scheduled_time: string | null;
+  require_previous_success: boolean;
+  auto_off_after: boolean;
+  status: 'pending' | 'printing' | 'completed' | 'failed' | 'skipped' | 'cancelled';
+  started_at: string | null;
+  completed_at: string | null;
+  error_message: string | null;
+  created_at: string;
+  archive_name?: string | null;
+  archive_thumbnail?: string | null;
+  printer_name?: string | null;
+  print_time_seconds?: number | null;  // Estimated print time from archive
+}
+&nbsp;
+export interface PrintQueueItemCreate {
+  printer_id: number;
+  archive_id: number;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+}
+&nbsp;
+export interface PrintQueueItemUpdate {
+  printer_id?: number;
+  position?: number;
+  scheduled_time?: string | null;
+  require_previous_success?: boolean;
+  auto_off_after?: boolean;
+}
+&nbsp;
+// MQTT Logging types
+export interface MQTTLogEntry {
+  timestamp: string;
+  topic: string;
+  direction: 'in' | 'out';
+  payload: Record&lt;string, unknown&gt;;
+}
+&nbsp;
+export interface MQTTLogsResponse {
+  logging_enabled: boolean;
+  logs: MQTTLogEntry[];
+}
+&nbsp;
+// K-Profile types
+export interface KProfile {
+  slot_id: number;
+  extruder_id: number;
+  nozzle_id: string;
+  nozzle_diameter: string;
+  filament_id: string;
+  name: string;
+  k_value: string;
+  n_coef: string;
+  ams_id: number;
+  tray_id: number;
+  setting_id: string | null;
+}
+&nbsp;
+export interface KProfileCreate {
+  slot_id?: number;  // Storage slot, 0 for new profiles
+  extruder_id?: number;
+  nozzle_id: string;
+  nozzle_diameter: string;
+  filament_id: string;
+  name: string;
+  k_value: string;
+  n_coef?: string;
+  ams_id?: number;
+  tray_id?: number;
+  setting_id?: string | null;
+}
+&nbsp;
+export interface KProfileDelete {
+  slot_id: number;  // cali_idx - calibration index to delete
+  extruder_id: number;
+  nozzle_id: string;  // e.g., "HH00-0.4"
+  nozzle_diameter: string;  // e.g., "0.4"
+  filament_id: string;  // Bambu filament identifier
+  setting_id?: string | null;  // Setting ID (for X1C series)
+}
+&nbsp;
+export interface KProfilesResponse {
+  profiles: KProfile[];
+  nozzle_diameter: string;
+}
+&nbsp;
+export interface KProfileNote {
+  setting_id: string;
+  note: string;
+}
+&nbsp;
+export interface KProfileNotesResponse {
+  notes: Record&lt;string, string&gt;;  // setting_id -&gt; note
+}
+&nbsp;
+// Slot Preset Mapping
+export interface SlotPresetMapping {
+  ams_id: number;
+  tray_id: number;
+  preset_id: string;
+  preset_name: string;
+}
+&nbsp;
+// Filament types
+export interface Filament {
+  id: number;
+  name: string;
+  type: string;  // PLA, PETG, ABS, etc.
+  brand: string | null;
+  color: string | null;
+  color_hex: string | null;
+  cost_per_kg: number;
+  spool_weight_g: number;
+  currency: string;
+  density: number | null;
+  print_temp_min: number | null;
+  print_temp_max: number | null;
+  bed_temp_min: number | null;
+  bed_temp_max: number | null;
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+// Notification Provider types
+export type ProviderType = 'callmebot' | 'ntfy' | 'pushover' | 'telegram' | 'email' | 'discord' | 'webhook';
+&nbsp;
+export interface NotificationProvider {
+  id: number;
+  name: string;
+  provider_type: ProviderType;
+  enabled: boolean;
+  config: Record&lt;string, unknown&gt;;
+  // Print lifecycle events
+  on_print_start: boolean;
+  on_print_complete: boolean;
+  on_print_failed: boolean;
+  on_print_stopped: boolean;
+  on_print_progress: boolean;
+  // Printer status events
+  on_printer_offline: boolean;
+  on_printer_error: boolean;
+  on_filament_low: boolean;
+  on_maintenance_due: boolean;
+  // AMS environmental alarms
+  on_ams_humidity_high: boolean;
+  on_ams_temperature_high: boolean;
+  // Quiet hours
+  quiet_hours_enabled: boolean;
+  quiet_hours_start: string | null;
+  quiet_hours_end: string | null;
+  // Daily digest
+  daily_digest_enabled: boolean;
+  daily_digest_time: string | null;
+  // Printer filter
+  printer_id: number | null;
+  // Status tracking
+  last_success: string | null;
+  last_error: string | null;
+  last_error_at: string | null;
+  // Timestamps
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+export interface NotificationProviderCreate {
+  name: string;
+  provider_type: ProviderType;
+  enabled?: boolean;
+  config: Record&lt;string, unknown&gt;;
+  // Print lifecycle events
+  on_print_start?: boolean;
+  on_print_complete?: boolean;
+  on_print_failed?: boolean;
+  on_print_stopped?: boolean;
+  on_print_progress?: boolean;
+  // Printer status events
+  on_printer_offline?: boolean;
+  on_printer_error?: boolean;
+  on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
+  // AMS environmental alarms
+  on_ams_humidity_high?: boolean;
+  on_ams_temperature_high?: boolean;
+  // Quiet hours
+  quiet_hours_enabled?: boolean;
+  quiet_hours_start?: string | null;
+  quiet_hours_end?: string | null;
+  // Daily digest
+  daily_digest_enabled?: boolean;
+  daily_digest_time?: string | null;
+  // Printer filter
+  printer_id?: number | null;
+}
+&nbsp;
+export interface NotificationProviderUpdate {
+  name?: string;
+  provider_type?: ProviderType;
+  enabled?: boolean;
+  config?: Record&lt;string, unknown&gt;;
+  // Print lifecycle events
+  on_print_start?: boolean;
+  on_print_complete?: boolean;
+  on_print_failed?: boolean;
+  on_print_stopped?: boolean;
+  on_print_progress?: boolean;
+  // Printer status events
+  on_printer_offline?: boolean;
+  on_printer_error?: boolean;
+  on_filament_low?: boolean;
+  on_maintenance_due?: boolean;
+  // AMS environmental alarms
+  on_ams_humidity_high?: boolean;
+  on_ams_temperature_high?: boolean;
+  // Quiet hours
+  quiet_hours_enabled?: boolean;
+  quiet_hours_start?: string | null;
+  quiet_hours_end?: string | null;
+  // Daily digest
+  daily_digest_enabled?: boolean;
+  daily_digest_time?: string | null;
+  // Printer filter
+  printer_id?: number | null;
+}
+&nbsp;
+export interface NotificationTestRequest {
+  provider_type: ProviderType;
+  config: Record&lt;string, unknown&gt;;
+}
+&nbsp;
+export interface NotificationTestResponse {
+  success: boolean;
+  message: string;
+}
+&nbsp;
+// Provider-specific config types for reference
+export interface CallMeBotConfig {
+  phone: string;
+  apikey: string;
+}
+&nbsp;
+export interface NtfyConfig {
+  server?: string;
+  topic: string;
+  auth_token?: string | null;
+}
+&nbsp;
+export interface PushoverConfig {
+  user_key: string;
+  app_token: string;
+  priority?: number;
+}
+&nbsp;
+export interface TelegramConfig {
+  bot_token: string;
+  chat_id: string;
+}
+&nbsp;
+export interface EmailConfig {
+  smtp_server: string;
+  smtp_port?: number;
+  username: string;
+  password: string;
+  from_email: string;
+  to_email: string;
+  use_tls?: boolean;
+}
+&nbsp;
+// Notification Template types
+export interface NotificationTemplate {
+  id: number;
+  event_type: string;
+  name: string;
+  title_template: string;
+  body_template: string;
+  is_default: boolean;
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+export interface NotificationTemplateUpdate {
+  title_template?: string;
+  body_template?: string;
+}
+&nbsp;
+export interface EventVariablesResponse {
+  event_type: string;
+  event_name: string;
+  variables: string[];
+}
+&nbsp;
+export interface TemplatePreviewRequest {
+  event_type: string;
+  title_template: string;
+  body_template: string;
+}
+&nbsp;
+export interface TemplatePreviewResponse {
+  title: string;
+  body: string;
+}
+&nbsp;
+// Notification Log types
+export interface NotificationLogEntry {
+  id: number;
+  provider_id: number;
+  provider_name: string | null;
+  provider_type: string | null;
+  event_type: string;
+  title: string;
+  message: string;
+  success: boolean;
+  error_message: string | null;
+  printer_id: number | null;
+  printer_name: string | null;
+  created_at: string;
+}
+&nbsp;
+export interface NotificationLogStats {
+  total: number;
+  success_count: number;
+  failure_count: number;
+  by_event_type: Record&lt;string, number&gt;;
+  by_provider: Record&lt;string, number&gt;;
+}
+&nbsp;
+// Spoolman types
+export interface SpoolmanStatus {
+  enabled: boolean;
+  connected: boolean;
+  url: string | null;
+}
+&nbsp;
+export interface SpoolmanSyncResult {
+  success: boolean;
+  synced_count: number;
+  errors: string[];
+}
+&nbsp;
+// Update types
+export interface VersionInfo {
+  version: string;
+  repo: string;
+}
+&nbsp;
+export interface UpdateCheckResult {
+  update_available: boolean;
+  current_version: string;
+  latest_version: string | null;
+  release_name?: string;
+  release_notes?: string;
+  release_url?: string;
+  published_at?: string;
+  error?: string;
+  message?: string;
+}
+&nbsp;
+export interface UpdateStatus {
+  status: 'idle' | 'checking' | 'downloading' | 'installing' | 'complete' | 'error';
+  progress: number;
+  message: string;
+  error: string | null;
+}
+&nbsp;
+// Maintenance types
+export interface MaintenanceType {
+  id: number;
+  name: string;
+  description: string | null;
+  default_interval_hours: number;
+  interval_type: 'hours' | 'days';  // "hours" = print hours, "days" = calendar days
+  icon: string | null;
+  is_system: boolean;
+  created_at: string;
+}
+&nbsp;
+export interface MaintenanceTypeCreate {
+  name: string;
+  description?: string | null;
+  default_interval_hours?: number;
+  interval_type?: 'hours' | 'days';
+  icon?: string | null;
+}
+&nbsp;
+export interface MaintenanceStatus {
+  id: number;
+  printer_id: number;
+  printer_name: string;
+  maintenance_type_id: number;
+  maintenance_type_name: string;
+  maintenance_type_icon: string | null;
+  enabled: boolean;
+  interval_hours: number;  // For hours type: print hours; for days type: number of days
+  interval_type: 'hours' | 'days';
+  current_hours: number;
+  hours_since_maintenance: number;
+  hours_until_due: number;
+  days_since_maintenance: number | null;  // For days type
+  days_until_due: number | null;  // For days type
+  is_due: boolean;
+  is_warning: boolean;
+  last_performed_at: string | null;
+}
+&nbsp;
+export interface PrinterMaintenanceOverview {
+  printer_id: number;
+  printer_name: string;
+  total_print_hours: number;
+  maintenance_items: MaintenanceStatus[];
+  due_count: number;
+  warning_count: number;
+}
+&nbsp;
+export interface MaintenanceHistory {
+  id: number;
+  printer_maintenance_id: number;
+  performed_at: string;
+  hours_at_maintenance: number;
+  notes: string | null;
+}
+&nbsp;
+export interface MaintenanceSummary {
+  total_due: number;
+  total_warning: number;
+  printers_with_issues: Array&lt;{
+    printer_id: number;
+    printer_name: string;
+    due_count: number;
+    warning_count: number;
+  }&gt;;
+}
+&nbsp;
+// External Links (sidebar)
+export interface ExternalLink {
+  id: number;
+  name: string;
+  url: string;
+  icon: string;
+  custom_icon: string | null;
+  sort_order: number;
+  created_at: string;
+  updated_at: string;
+}
+&nbsp;
+export interface ExternalLinkCreate {
+  name: string;
+  url: string;
+  icon: string;
+}
+&nbsp;
+export interface ExternalLinkUpdate {
+  name?: string;
+  url?: string;
+  icon?: string;
+}
+&nbsp;
+// API functions
+export const api = {
+  // Printers
+  getPrinters: () =&gt; request&lt;Printer[]&gt;('/printers/'),
+  getPrinter: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;Printer&gt;(`/printers/${id}`),</span>
+  createPrinter: <span class="fstat-no" title="function not covered" >(data: PrinterCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Printer&gt;('/printers/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updatePrinter: <span class="fstat-no" title="function not covered" >(id: number, data: Partial&lt;PrinterCreate&gt;) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Printer&gt;(`/printers/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deletePrinter: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;void&gt;(`/printers/${id}`, { method: 'DELETE' }),</span>
+  getPrinterStatus: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;PrinterStatus&gt;(`/printers/${id}/status`),</span>
+  connectPrinter: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ connected: boolean }&gt;(`/printers/${id}/connect`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  disconnectPrinter: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ connected: boolean }&gt;(`/printers/${id}/disconnect`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // MQTT Debug Logging
+  enableMQTTLogging: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ logging_enabled: boolean }&gt;(`/printers/${printerId}/logging/enable`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  disableMQTTLogging: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ logging_enabled: boolean }&gt;(`/printers/${printerId}/logging/disable`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getMQTTLogs: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MQTTLogsResponse&gt;(`/printers/${printerId}/logging`),</span>
+  clearMQTTLogs: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string }&gt;(`/printers/${printerId}/logging`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // Printer File Manager
+  getPrinterFiles: <span class="fstat-no" title="function not covered" >(printerId: number, path = '/') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{</span>
+      path: string;
+      files: Array&lt;{
+        name: string;
+        is_directory: boolean;
+        size: number;
+        path: string;
+      }&gt;;
+<span class="cstat-no" title="statement not covered" >    }&gt;(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),</span>
+  getPrinterFileDownloadUrl: <span class="fstat-no" title="function not covered" >(printerId: number, path: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,</span>
+  deletePrinterFile: <span class="fstat-no" title="function not covered" >(printerId: number, path: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string; path: string }&gt;(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getPrinterStorage: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ used_bytes: number | null; free_bytes: number | null }&gt;(`/printers/${printerId}/storage`),</span>
+&nbsp;
+  // Archives
+  getArchives: <span class="fstat-no" title="function not covered" >(printerId?: number, projectId?: number, limit = 50, offset = 0) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (printerId) params.set('printer_id', String(printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (projectId) params.set('project_id', String(projectId));</span>
+<span class="cstat-no" title="statement not covered" >    params.set('limit', String(limit));</span>
+<span class="cstat-no" title="statement not covered" >    params.set('offset', String(offset));</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;Archive[]&gt;(`/archives/?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  getArchive: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;Archive&gt;(`/archives/${id}`),</span>
+  searchArchives: <span class="fstat-no" title="function not covered" >(query: string, options?: {</span>
+    printerId?: number;
+    projectId?: number;
+    status?: string;
+    limit?: number;
+    offset?: number;
+<span class="cstat-no" title="statement not covered" >  }) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    params.set('q', query);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.printerId) params.set('printer_id', String(options.printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.projectId) params.set('project_id', String(options.projectId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.status) params.set('status', options.status);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.limit) params.set('limit', String(options.limit));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.offset) params.set('offset', String(options.offset));</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;Archive[]&gt;(`/archives/search?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  rebuildSearchIndex: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;{ message: string }&gt;('/archives/search/rebuild-index', { method: 'POST' }),</span>
+  updateArchive: <span class="fstat-no" title="function not covered" >(id: number, data: {</span>
+    printer_id?: number | null;
+    project_id?: number | null;
+    print_name?: string;
+    is_favorite?: boolean;
+    tags?: string;
+    notes?: string;
+    cost?: number;
+    failure_reason?: string | null;
+  }) =&gt;
+<span class="cstat-no" title="statement not covered" >    request&lt;Archive&gt;(`/archives/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  toggleFavorite: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Archive&gt;(`/archives/${id}/favorite`, { method: 'POST' }),</span>
+  deleteArchive: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;void&gt;(`/archives/${id}`, { method: 'DELETE' }),</span>
+  getArchiveStats: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;ArchiveStats&gt;('/archives/stats'),</span>
+  getFailureAnalysis: <span class="fstat-no" title="function not covered" >(options?: { days?: number; printerId?: number; projectId?: number }) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.days) params.set('days', String(options.days));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.printerId) params.set('printer_id', String(options.printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.projectId) params.set('project_id', String(options.projectId));</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;FailureAnalysis&gt;(`/archives/analysis/failures?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  compareArchives: <span class="fstat-no" title="function not covered" >(archiveIds: number[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;ArchiveComparison&gt;(`/archives/compare?archive_ids=${archiveIds.join(',')}`),</span>
+  findSimilarArchives: <span class="fstat-no" title="function not covered" >(archiveId: number, limit = 10) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SimilarArchive[]&gt;(`/archives/${archiveId}/similar?limit=${limit}`),</span>
+  exportArchives: <span class="fstat-no" title="function not covered" >async (options?: {</span>
+    format?: 'csv' | 'xlsx';
+    fields?: string[];
+    printerId?: number;
+    projectId?: number;
+    status?: string;
+    dateFrom?: string;
+    dateTo?: string;
+    search?: string;
+<span class="cstat-no" title="statement not covered" >  }): Promise&lt;{ blob: Blob; filename: string }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.format) params.set('format', options.format);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.fields) params.set('fields', options.fields.join(','));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.printerId) params.set('printer_id', String(options.printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.projectId) params.set('project_id', String(options.projectId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.status) params.set('status', options.status);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.dateFrom) params.set('date_from', options.dateFrom);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.dateTo) params.set('date_to', options.dateTo);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.search) params.set('search', options.search);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/archives/export?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const contentDisposition = response.headers.get('Content-Disposition');</span>
+<span class="cstat-no" title="statement not covered" >    let filename = options?.format === 'xlsx' ? 'archives_export.xlsx' : 'archives_export.csv';</span>
+<span class="cstat-no" title="statement not covered" >    if (contentDisposition) {</span>
+<span class="cstat-no" title="statement not covered" >      const match = contentDisposition.match(/filename="?([^"]+)"?/);</span>
+<span class="cstat-no" title="statement not covered" >      if (match) filename = match[1];</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const blob = await response.blob();</span>
+<span class="cstat-no" title="statement not covered" >    return { blob, filename };</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  exportStats: <span class="fstat-no" title="function not covered" >async (options?: {</span>
+    format?: 'csv' | 'xlsx';
+    days?: number;
+    printerId?: number;
+    projectId?: number;
+<span class="cstat-no" title="statement not covered" >  }): Promise&lt;{ blob: Blob; filename: string }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.format) params.set('format', options.format);</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.days) params.set('days', String(options.days));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.printerId) params.set('printer_id', String(options.printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (options?.projectId) params.set('project_id', String(options.projectId));</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/archives/stats/export?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const contentDisposition = response.headers.get('Content-Disposition');</span>
+<span class="cstat-no" title="statement not covered" >    let filename = options?.format === 'xlsx' ? 'stats_export.xlsx' : 'stats_export.csv';</span>
+<span class="cstat-no" title="statement not covered" >    if (contentDisposition) {</span>
+<span class="cstat-no" title="statement not covered" >      const match = contentDisposition.match(/filename="?([^"]+)"?/);</span>
+<span class="cstat-no" title="statement not covered" >      if (match) filename = match[1];</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const blob = await response.blob();</span>
+<span class="cstat-no" title="statement not covered" >    return { blob, filename };</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  getArchiveDuplicates: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ duplicates: ArchiveDuplicate[]; count: number }&gt;(`/archives/${id}/duplicates`),</span>
+  backfillContentHashes: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ updated: number; errors: Array&lt;{ id: number; error: string }&gt; }&gt;('/archives/backfill-hashes', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getArchiveThumbnail: <span class="fstat-no" title="function not covered" >(id: number) =&gt; `${API_BASE}/archives/${id}/thumbnail`,</span>
+  getArchiveDownload: <span class="fstat-no" title="function not covered" >(id: number) =&gt; `${API_BASE}/archives/${id}/download`,</span>
+  getArchiveGcode: <span class="fstat-no" title="function not covered" >(id: number) =&gt; `${API_BASE}/archives/${id}/gcode`,</span>
+  getArchiveTimelapse: <span class="fstat-no" title="function not covered" >(id: number) =&gt; `${API_BASE}/archives/${id}/timelapse`,</span>
+  scanArchiveTimelapse: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{</span>
+      status: string;
+      message: string;
+      filename?: string;
+      available_files?: Array&lt;{ name: string; path: string; size: number; mtime: string | null }&gt;;
+<span class="cstat-no" title="statement not covered" >    }&gt;(`/archives/${id}/timelapse/scan`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  selectArchiveTimelapse: <span class="fstat-no" title="function not covered" >(id: number, filename: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string; message: string; filename: string }&gt;(</span>
+<span class="cstat-no" title="statement not covered" >      `/archives/${id}/timelapse/select?filename=${encodeURIComponent(filename)}`,</span>
+<span class="cstat-no" title="statement not covered" >      { method: 'POST' }</span>
+<span class="cstat-no" title="statement not covered" >    ),</span>
+  uploadArchiveTimelapse: <span class="fstat-no" title="function not covered" >async (archiveId: number, file: File): Promise&lt;{ status: string; filename: string }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/archives/${archiveId}/timelapse/upload`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  // Photos
+  getArchivePhotoUrl: <span class="fstat-no" title="function not covered" >(archiveId: number, filename: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${archiveId}/photos/${encodeURIComponent(filename)}`,</span>
+  uploadArchivePhoto: <span class="fstat-no" title="function not covered" >async (archiveId: number, file: File): Promise&lt;{ status: string; filename: string; photos: string[] }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/archives/${archiveId}/photos`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  deleteArchivePhoto: <span class="fstat-no" title="function not covered" >(archiveId: number, filename: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string; photos: string[] | null }&gt;(`/archives/${archiveId}/photos/${encodeURIComponent(filename)}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  // Source 3MF (original slicer project file)
+  getSource3mfDownloadUrl: <span class="fstat-no" title="function not covered" >(archiveId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${archiveId}/source`,</span>
+  getSource3mfForSlicer: <span class="fstat-no" title="function not covered" >(archiveId: number, filename: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${archiveId}/source/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,</span>
+  uploadSource3mf: <span class="fstat-no" title="function not covered" >async (archiveId: number, file: File): Promise&lt;{ status: string; filename: string }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/archives/${archiveId}/source`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  deleteSource3mf: <span class="fstat-no" title="function not covered" >(archiveId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string }&gt;(`/archives/${archiveId}/source`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // QR Code
+  getArchiveQRCodeUrl: <span class="fstat-no" title="function not covered" >(archiveId: number, size = 200) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${archiveId}/qrcode?size=${size}`,</span>
+  getArchiveCapabilities: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{</span>
+      has_model: boolean;
+      has_gcode: boolean;
+      build_volume: { x: number; y: number; z: number };
+<span class="cstat-no" title="statement not covered" >    }&gt;(`/archives/${id}/capabilities`),</span>
+  // Project Page
+  getArchiveProjectPage: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{</span>
+      title: string | null;
+      description: string | null;
+      designer: string | null;
+      designer_user_id: string | null;
+      license: string | null;
+      copyright: string | null;
+      creation_date: string | null;
+      modification_date: string | null;
+      origin: string | null;
+      profile_title: string | null;
+      profile_description: string | null;
+      profile_cover: string | null;
+      profile_user_id: string | null;
+      profile_user_name: string | null;
+      design_model_id: string | null;
+      design_profile_id: string | null;
+      design_region: string | null;
+      model_pictures: Array&lt;{ name: string; path: string; url: string }&gt;;
+      profile_pictures: Array&lt;{ name: string; path: string; url: string }&gt;;
+      thumbnails: Array&lt;{ name: string; path: string; url: string }&gt;;
+<span class="cstat-no" title="statement not covered" >    }&gt;(`/archives/${id}/project-page`),</span>
+  updateArchiveProjectPage: <span class="fstat-no" title="function not covered" >(id: number, data: {</span>
+    title?: string;
+    description?: string;
+    designer?: string;
+    license?: string;
+    copyright?: string;
+    profile_title?: string;
+    profile_description?: string;
+  }) =&gt;
+<span class="cstat-no" title="statement not covered" >    request(`/archives/${id}/project-page`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getArchiveProjectImageUrl: <span class="fstat-no" title="function not covered" >(archiveId: number, imagePath: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${archiveId}/project-image/${encodeURIComponent(imagePath)}`,</span>
+  getArchiveForSlicer: <span class="fstat-no" title="function not covered" >(id: number, filename: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/archives/${id}/file/${encodeURIComponent(filename.endsWith('.3mf') ? filename : filename + '.3mf')}`,</span>
+  reprintArchive: <span class="fstat-no" title="function not covered" >(archiveId: number, printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string; printer_id: number; archive_id: number; filename: string }&gt;(</span>
+<span class="cstat-no" title="statement not covered" >      `/archives/${archiveId}/reprint?printer_id=${printerId}`,</span>
+<span class="cstat-no" title="statement not covered" >      { method: 'POST' }</span>
+<span class="cstat-no" title="statement not covered" >    ),</span>
+  uploadArchive: <span class="fstat-no" title="function not covered" >async (file: File, printerId?: number): Promise&lt;Archive&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const url = printerId</span>
+<span class="cstat-no" title="statement not covered" >      ? `${API_BASE}/archives/upload?printer_id=${printerId}`</span>
+<span class="cstat-no" title="statement not covered" >      : `${API_BASE}/archives/upload`;</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(url, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  uploadArchivesBulk: <span class="fstat-no" title="function not covered" >async (files: File[], printerId?: number): Promise&lt;BulkUploadResult&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    files.forEach((file) =&gt; formData.append('files', file));</span>
+<span class="cstat-no" title="statement not covered" >    const url = printerId</span>
+<span class="cstat-no" title="statement not covered" >      ? `${API_BASE}/archives/upload-bulk?printer_id=${printerId}`</span>
+<span class="cstat-no" title="statement not covered" >      : `${API_BASE}/archives/upload-bulk`;</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(url, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+&nbsp;
+  // Settings
+  getSettings: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;AppSettings&gt;('/settings/'),</span>
+  updateSettings: <span class="fstat-no" title="function not covered" >(data: AppSettingsUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;AppSettings&gt;('/settings/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  resetSettings: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;AppSettings&gt;('/settings/reset', { method: 'POST' }),</span>
+  exportBackup: <span class="fstat-no" title="function not covered" >async (categories?: Record&lt;string, boolean&gt;): Promise&lt;{ blob: Blob; filename: string }&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (categories) {</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.settings !== undefined) params.set('include_settings', String(categories.settings));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.notifications !== undefined) params.set('include_notifications', String(categories.notifications));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.templates !== undefined) params.set('include_templates', String(categories.templates));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.smart_plugs !== undefined) params.set('include_smart_plugs', String(categories.smart_plugs));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.printers !== undefined) params.set('include_printers', String(categories.printers));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.filaments !== undefined) params.set('include_filaments', String(categories.filaments));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.maintenance !== undefined) params.set('include_maintenance', String(categories.maintenance));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.archives !== undefined) params.set('include_archives', String(categories.archives));</span>
+<span class="cstat-no" title="statement not covered" >      if (categories.access_codes !== undefined) params.set('include_access_codes', String(categories.access_codes));</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    const url = `${API_BASE}/settings/backup${params.toString() ? '?' + params.toString() : ''}`;</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(url);</span>
+&nbsp;
+    // Get filename from Content-Disposition header
+<span class="cstat-no" title="statement not covered" >    const contentDisposition = response.headers.get('Content-Disposition');</span>
+<span class="cstat-no" title="statement not covered" >    let filename = 'bambuddy-backup.json';</span>
+<span class="cstat-no" title="statement not covered" >    if (contentDisposition) {</span>
+<span class="cstat-no" title="statement not covered" >      const match = contentDisposition.match(/filename=([^;]+)/);</span>
+<span class="cstat-no" title="statement not covered" >      if (match) filename = match[1].trim();</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const blob = await response.blob();</span>
+<span class="cstat-no" title="statement not covered" >    return { blob, filename };</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  importBackup: <span class="fstat-no" title="function not covered" >async (file: File, overwrite = false) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const url = `${API_BASE}/settings/restore${overwrite ? '?overwrite=true' : ''}`;</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(url, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    return response.json() as Promise&lt;{</span>
+      success: boolean;
+      message: string;
+      restored?: Record&lt;string, number&gt;;
+      skipped?: Record&lt;string, number&gt;;
+      skipped_details?: Record&lt;string, string[]&gt;;
+      files_restored?: number;
+      total_skipped?: number;
+    }&gt;;
+<span class="cstat-no" title="statement not covered" >  },</span>
+  checkFfmpeg: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ installed: boolean; path: string | null }&gt;('/settings/check-ffmpeg'),</span>
+&nbsp;
+  // Cloud
+  getCloudStatus: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;CloudAuthStatus&gt;('/cloud/status'),</span>
+  cloudLogin: <span class="fstat-no" title="function not covered" >(email: string, password: string, region = 'global') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;CloudLoginResponse&gt;('/cloud/login', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ email, password, region }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  cloudVerify: <span class="fstat-no" title="function not covered" >(email: string, code: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;CloudLoginResponse&gt;('/cloud/verify', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ email, code }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  cloudSetToken: <span class="fstat-no" title="function not covered" >(access_token: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;CloudAuthStatus&gt;('/cloud/token', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ access_token }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  cloudLogout: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean }&gt;('/cloud/logout', { method: 'POST' }),</span>
+  getCloudSettings: <span class="fstat-no" title="function not covered" >(version = '02.04.00.70') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlicerSettingsResponse&gt;(`/cloud/settings?version=${version}`),</span>
+  getCloudSettingDetail: <span class="fstat-no" title="function not covered" >(settingId: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlicerSettingDetail&gt;(`/cloud/settings/${settingId}`),</span>
+  createCloudSetting: <span class="fstat-no" title="function not covered" >(data: SlicerSettingCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlicerSettingDetail&gt;('/cloud/settings', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateCloudSetting: <span class="fstat-no" title="function not covered" >(settingId: string, data: SlicerSettingUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlicerSettingDetail&gt;(`/cloud/settings/${settingId}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteCloudSetting: <span class="fstat-no" title="function not covered" >(settingId: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlicerSettingDeleteResponse&gt;(`/cloud/settings/${settingId}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getCloudDevices: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;CloudDevice[]&gt;('/cloud/devices'),</span>
+  getCloudFields: <span class="fstat-no" title="function not covered" >(presetType: 'filament' | 'print' | 'process' | 'printer') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;FieldDefinitionsResponse&gt;(`/cloud/fields/${presetType}`),</span>
+  getAllCloudFields: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Record&lt;string, FieldDefinitionsResponse&gt;&gt;('/cloud/fields'),</span>
+&nbsp;
+  // Smart Plugs
+  getSmartPlugs: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;SmartPlug[]&gt;('/smart-plugs/'),</span>
+  getSmartPlug: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;SmartPlug&gt;(`/smart-plugs/${id}`),</span>
+  getSmartPlugByPrinter: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt; request&lt;SmartPlug | null&gt;(`/smart-plugs/by-printer/${printerId}`),</span>
+  createSmartPlug: <span class="fstat-no" title="function not covered" >(data: SmartPlugCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SmartPlug&gt;('/smart-plugs/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateSmartPlug: <span class="fstat-no" title="function not covered" >(id: number, data: SmartPlugUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SmartPlug&gt;(`/smart-plugs/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteSmartPlug: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;void&gt;(`/smart-plugs/${id}`, { method: 'DELETE' }),</span>
+  controlSmartPlug: <span class="fstat-no" title="function not covered" >(id: number, action: 'on' | 'off' | 'toggle') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; action: string }&gt;(`/smart-plugs/${id}/control`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ action }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getSmartPlugStatus: (id: number) =&gt;
+    request&lt;SmartPlugStatus&gt;(`/smart-plugs/${id}/status`),
+  testSmartPlugConnection: <span class="fstat-no" title="function not covered" >(ip_address: string, username?: string | null, password?: string | null) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SmartPlugTestResult&gt;('/smart-plugs/test-connection', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ ip_address, username, password }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // Print Queue
+  getQueue: <span class="fstat-no" title="function not covered" >(printerId?: number, status?: string) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (printerId) params.set('printer_id', String(printerId));</span>
+<span class="cstat-no" title="statement not covered" >    if (status) params.set('status', status);</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;PrintQueueItem[]&gt;(`/queue/?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  getQueueItem: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;PrintQueueItem&gt;(`/queue/${id}`),</span>
+  addToQueue: <span class="fstat-no" title="function not covered" >(data: PrintQueueItemCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;PrintQueueItem&gt;('/queue/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateQueueItem: <span class="fstat-no" title="function not covered" >(id: number, data: PrintQueueItemUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;PrintQueueItem&gt;(`/queue/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  removeFromQueue: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/queue/${id}`, { method: 'DELETE' }),</span>
+  reorderQueue: <span class="fstat-no" title="function not covered" >(items: { id: number; position: number }[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;('/queue/reorder', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ items }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  cancelQueueItem: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/queue/${id}/cancel`, { method: 'POST' }),</span>
+  stopQueueItem: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/queue/${id}/stop`, { method: 'POST' }),</span>
+&nbsp;
+  // K-Profiles
+  getKProfiles: <span class="fstat-no" title="function not covered" >(printerId: number, nozzleDiameter = '0.4') =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;KProfilesResponse&gt;(`/printers/${printerId}/kprofiles/?nozzle_diameter=${nozzleDiameter}`),</span>
+  setKProfile: <span class="fstat-no" title="function not covered" >(printerId: number, profile: KProfileCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;(`/printers/${printerId}/kprofiles/`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(profile),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteKProfile: <span class="fstat-no" title="function not covered" >(printerId: number, profile: KProfileDelete) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;(`/printers/${printerId}/kprofiles/`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(profile),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  setKProfilesBatch: <span class="fstat-no" title="function not covered" >(printerId: number, profiles: KProfileCreate[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;(`/printers/${printerId}/kprofiles/batch`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(profiles),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // K-Profile Notes (stored locally, not on printer)
+  getKProfileNotes: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;KProfileNotesResponse&gt;(`/printers/${printerId}/kprofiles/notes`),</span>
+  setKProfileNote: <span class="fstat-no" title="function not covered" >(printerId: number, settingId: string, note: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;(`/printers/${printerId}/kprofiles/notes`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ setting_id: settingId, note }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteKProfileNote: <span class="fstat-no" title="function not covered" >(printerId: number, settingId: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;(`/printers/${printerId}/kprofiles/notes/${encodeURIComponent(settingId)}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // Slot Preset Mappings
+  getSlotPresets: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Record&lt;number, SlotPresetMapping&gt;&gt;(`/printers/${printerId}/slot-presets`),</span>
+  getSlotPreset: <span class="fstat-no" title="function not covered" >(printerId: number, amsId: number, trayId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlotPresetMapping | null&gt;(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`),</span>
+  saveSlotPreset: <span class="fstat-no" title="function not covered" >(printerId: number, amsId: number, trayId: number, presetId: string, presetName: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SlotPresetMapping&gt;(`/printers/${printerId}/slot-presets/${amsId}/${trayId}?preset_id=${encodeURIComponent(presetId)}&amp;preset_name=${encodeURIComponent(presetName)}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteSlotPreset: <span class="fstat-no" title="function not covered" >(printerId: number, amsId: number, trayId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean }&gt;(`/printers/${printerId}/slot-presets/${amsId}/${trayId}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'DELETE',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // Filaments
+  listFilaments: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;Filament[]&gt;('/filaments/'),</span>
+  getFilament: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;Filament&gt;(`/filaments/${id}`),</span>
+  getFilamentsByType: <span class="fstat-no" title="function not covered" >(type: string) =&gt; request&lt;Filament[]&gt;(`/filaments/by-type/${type}`),</span>
+&nbsp;
+  // Notification Providers
+  getNotificationProviders: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;NotificationProvider[]&gt;('/notifications/'),</span>
+  getNotificationProvider: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;NotificationProvider&gt;(`/notifications/${id}`),</span>
+  createNotificationProvider: <span class="fstat-no" title="function not covered" >(data: NotificationProviderCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationProvider&gt;('/notifications/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateNotificationProvider: <span class="fstat-no" title="function not covered" >(id: number, data: NotificationProviderUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationProvider&gt;(`/notifications/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteNotificationProvider: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/notifications/${id}`, { method: 'DELETE' }),</span>
+  testNotificationProvider: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationTestResponse&gt;(`/notifications/${id}/test`, { method: 'POST' }),</span>
+  testNotificationConfig: <span class="fstat-no" title="function not covered" >(data: NotificationTestRequest) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationTestResponse&gt;('/notifications/test-config', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  testAllNotificationProviders: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{</span>
+      tested: number;
+      success: number;
+      failed: number;
+      results: Array&lt;{
+        provider_id: number;
+        provider_name: string;
+        provider_type: string;
+        success: boolean;
+        message: string;
+      }&gt;;
+<span class="cstat-no" title="statement not covered" >    }&gt;('/notifications/test-all', { method: 'POST' }),</span>
+&nbsp;
+  // Notification Templates
+  getNotificationTemplates: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;NotificationTemplate[]&gt;('/notification-templates'),</span>
+  getNotificationTemplate: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;NotificationTemplate&gt;(`/notification-templates/${id}`),</span>
+  updateNotificationTemplate: <span class="fstat-no" title="function not covered" >(id: number, data: NotificationTemplateUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationTemplate&gt;(`/notification-templates/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  resetNotificationTemplate: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationTemplate&gt;(`/notification-templates/${id}/reset`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getTemplateVariables: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;EventVariablesResponse[]&gt;('/notification-templates/variables'),</span>
+  previewTemplate: <span class="fstat-no" title="function not covered" >(data: TemplatePreviewRequest) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;TemplatePreviewResponse&gt;('/notification-templates/preview', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // Notification Logs
+  getNotificationLogs: <span class="fstat-no" title="function not covered" >(params?: {</span>
+    limit?: number;
+    offset?: number;
+    provider_id?: number;
+    event_type?: string;
+    success?: boolean;
+    days?: number;
+<span class="cstat-no" title="statement not covered" >  }) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const searchParams = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.limit) searchParams.set('limit', String(params.limit));</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.offset) searchParams.set('offset', String(params.offset));</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.provider_id) searchParams.set('provider_id', String(params.provider_id));</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.event_type) searchParams.set('event_type', params.event_type);</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.success !== undefined) searchParams.set('success', String(params.success));</span>
+<span class="cstat-no" title="statement not covered" >    if (params?.days) searchParams.set('days', String(params.days));</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;NotificationLogEntry[]&gt;(`/notifications/logs?${searchParams}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  getNotificationLogStats: <span class="fstat-no" title="function not covered" >(days = 7) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;NotificationLogStats&gt;(`/notifications/logs/stats?days=${days}`),</span>
+  clearNotificationLogs: <span class="fstat-no" title="function not covered" >(olderThanDays = 30) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ deleted: number; message: string }&gt;(</span>
+<span class="cstat-no" title="statement not covered" >      `/notifications/logs?older_than_days=${olderThanDays}`,</span>
+<span class="cstat-no" title="statement not covered" >      { method: 'DELETE' }</span>
+<span class="cstat-no" title="statement not covered" >    ),</span>
+&nbsp;
+  // Spoolman Integration
+  getSpoolmanStatus: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;SpoolmanStatus&gt;('/spoolman/status'),</span>
+  connectSpoolman: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;('/spoolman/connect', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  disconnectSpoolman: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string }&gt;('/spoolman/disconnect', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  syncPrinterAms: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SpoolmanSyncResult&gt;(`/spoolman/sync/${printerId}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  syncAllPrintersAms: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;SpoolmanSyncResult&gt;('/spoolman/sync-all', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getSpoolmanSpools: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ spools: unknown[] }&gt;('/spoolman/spools'),</span>
+  getSpoolmanFilaments: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ filaments: unknown[] }&gt;('/spoolman/filaments'),</span>
+&nbsp;
+  // Updates
+  getVersion: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;VersionInfo&gt;('/updates/version'),</span>
+  checkForUpdates: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;UpdateCheckResult&gt;('/updates/check'),</span>
+  applyUpdate: <span class="fstat-no" title="function not covered" >() =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message: string; status: UpdateStatus }&gt;('/updates/apply', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getUpdateStatus: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;UpdateStatus&gt;('/updates/status'),</span>
+&nbsp;
+  // Maintenance
+  getMaintenanceTypes: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;MaintenanceType[]&gt;('/maintenance/types'),</span>
+  createMaintenanceType: <span class="fstat-no" title="function not covered" >(data: MaintenanceTypeCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MaintenanceType&gt;('/maintenance/types', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateMaintenanceType: <span class="fstat-no" title="function not covered" >(id: number, data: Partial&lt;MaintenanceTypeCreate&gt;) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MaintenanceType&gt;(`/maintenance/types/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteMaintenanceType: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ status: string }&gt;(`/maintenance/types/${id}`, { method: 'DELETE' }),</span>
+  getMaintenanceOverview: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;PrinterMaintenanceOverview[]&gt;('/maintenance/overview'),</span>
+  getPrinterMaintenance: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;PrinterMaintenanceOverview&gt;(`/maintenance/printers/${printerId}`),</span>
+  updateMaintenanceItem: <span class="fstat-no" title="function not covered" >(itemId: number, data: { custom_interval_hours?: number | null; custom_interval_type?: 'hours' | 'days' | null; enabled?: boolean }) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MaintenanceStatus&gt;(`/maintenance/items/${itemId}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  performMaintenance: <span class="fstat-no" title="function not covered" >(itemId: number, notes?: string) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MaintenanceStatus&gt;(`/maintenance/items/${itemId}/perform`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ notes }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  getMaintenanceHistory: <span class="fstat-no" title="function not covered" >(itemId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;MaintenanceHistory[]&gt;(`/maintenance/items/${itemId}/history`),</span>
+  getMaintenanceSummary: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;MaintenanceSummary&gt;('/maintenance/summary'),</span>
+  setPrinterHours: <span class="fstat-no" title="function not covered" >(printerId: number, totalHours: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ printer_id: number; total_hours: number; archive_hours: number; offset_hours: number }&gt;(</span>
+<span class="cstat-no" title="statement not covered" >      `/maintenance/printers/${printerId}/hours?total_hours=${totalHours}`,</span>
+<span class="cstat-no" title="statement not covered" >      { method: 'PATCH' }</span>
+<span class="cstat-no" title="statement not covered" >    ),</span>
+&nbsp;
+  // Camera
+  getCameraStreamUrl: <span class="fstat-no" title="function not covered" >(printerId: number, fps = 10) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/printers/${printerId}/camera/stream?fps=${fps}`,</span>
+  getCameraSnapshotUrl: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    `${API_BASE}/printers/${printerId}/camera/snapshot`,</span>
+  testCameraConnection: <span class="fstat-no" title="function not covered" >(printerId: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ success: boolean; message?: string; error?: string }&gt;(`/printers/${printerId}/camera/test`),</span>
+&nbsp;
+  // External Links
+  getExternalLinks: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;ExternalLink[]&gt;('/external-links/'),</span>
+  getExternalLink: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;ExternalLink&gt;(`/external-links/${id}`),</span>
+  createExternalLink: <span class="fstat-no" title="function not covered" >(data: ExternalLinkCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;ExternalLink&gt;('/external-links/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateExternalLink: <span class="fstat-no" title="function not covered" >(id: number, data: ExternalLinkUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;ExternalLink&gt;(`/external-links/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteExternalLink: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/external-links/${id}`, { method: 'DELETE' }),</span>
+  reorderExternalLinks: <span class="fstat-no" title="function not covered" >(ids: number[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;ExternalLink[]&gt;('/external-links/reorder', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PUT',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ ids }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  uploadExternalLinkIcon: <span class="fstat-no" title="function not covered" >async (id: number, file: File): Promise&lt;ExternalLink&gt; =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const formData = new FormData();</span>
+<span class="cstat-no" title="statement not covered" >    formData.append('file', file);</span>
+<span class="cstat-no" title="statement not covered" >    const response = await fetch(`${API_BASE}/external-links/${id}/icon`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: formData,</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    if (!response.ok) {</span>
+<span class="cstat-no" title="statement not covered" >      const error = await response.json().catch(() =&gt; ({}));</span>
+<span class="cstat-no" title="statement not covered" >      throw new Error(error.detail || `HTTP ${response.status}`);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    return response.json();</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  deleteExternalLinkIcon: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;ExternalLink&gt;(`/external-links/${id}/icon`, { method: 'DELETE' }),</span>
+  getExternalLinkIconUrl: <span class="fstat-no" title="function not covered" >(id: number) =&gt; `${API_BASE}/external-links/${id}/icon`,</span>
+&nbsp;
+  // Projects
+  getProjects: <span class="fstat-no" title="function not covered" >(status?: string) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const params = new URLSearchParams();</span>
+<span class="cstat-no" title="statement not covered" >    if (status) params.set('status', status);</span>
+<span class="cstat-no" title="statement not covered" >    return request&lt;ProjectListItem[]&gt;(`/projects/?${params}`);</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+  getProject: <span class="fstat-no" title="function not covered" >(id: number) =&gt; request&lt;Project&gt;(`/projects/${id}`),</span>
+  createProject: <span class="fstat-no" title="function not covered" >(data: ProjectCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Project&gt;('/projects/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateProject: <span class="fstat-no" title="function not covered" >(id: number, data: ProjectUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Project&gt;(`/projects/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteProject: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/projects/${id}`, { method: 'DELETE' }),</span>
+  getProjectArchives: <span class="fstat-no" title="function not covered" >(id: number, limit = 100, offset = 0) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;Archive[]&gt;(`/projects/${id}/archives?limit=${limit}&amp;offset=${offset}`),</span>
+  addArchivesToProject: <span class="fstat-no" title="function not covered" >(projectId: number, archiveIds: number[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/projects/${projectId}/add-archives`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ archive_ids: archiveIds }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  removeArchivesFromProject: <span class="fstat-no" title="function not covered" >(projectId: number, archiveIds: number[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/projects/${projectId}/remove-archives`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ archive_ids: archiveIds }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  addQueueItemsToProject: <span class="fstat-no" title="function not covered" >(projectId: number, queueItemIds: number[]) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/projects/${projectId}/add-queue`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify({ queue_item_ids: queueItemIds }),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+&nbsp;
+  // API Keys
+  getAPIKeys: <span class="fstat-no" title="function not covered" >() =&gt; request&lt;APIKey[]&gt;('/api-keys/'),</span>
+  createAPIKey: <span class="fstat-no" title="function not covered" >(data: APIKeyCreate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;APIKeyCreateResponse&gt;('/api-keys/', {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'POST',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  updateAPIKey: <span class="fstat-no" title="function not covered" >(id: number, data: APIKeyUpdate) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;APIKey&gt;(`/api-keys/${id}`, {</span>
+<span class="cstat-no" title="statement not covered" >      method: 'PATCH',</span>
+<span class="cstat-no" title="statement not covered" >      body: JSON.stringify(data),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+  deleteAPIKey: <span class="fstat-no" title="function not covered" >(id: number) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;{ message: string }&gt;(`/api-keys/${id}`, { method: 'DELETE' }),</span>
+&nbsp;
+  // AMS History
+  getAMSHistory: <span class="fstat-no" title="function not covered" >(printerId: number, amsId: number, hours = 24) =&gt;</span>
+<span class="cstat-no" title="statement not covered" >    request&lt;AMSHistoryResponse&gt;(`/ams-history/${printerId}/${amsId}?hours=${hours}`),</span>
+};
+&nbsp;
+// AMS History types
+export interface AMSHistoryPoint {
+  recorded_at: string;
+  humidity: number | null;
+  humidity_raw: number | null;
+  temperature: number | null;
+}
+&nbsp;
+export interface AMSHistoryResponse {
+  printer_id: number;
+  ams_id: number;
+  data: AMSHistoryPoint[];
+  min_humidity: number | null;
+  max_humidity: number | null;
+  avg_humidity: number | null;
+  min_temperature: number | null;
+  max_temperature: number | null;
+  avg_temperature: number | null;
+}
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 116 - 0
frontend/coverage/src/api/index.html

@@ -0,0 +1,116 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/api</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> src/api</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">27.54% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>187/679</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">75% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>3/4</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">1.76% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>3/170</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">27.54% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>187/679</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <div class="pad1">
+<table class="coverage-summary">
+<thead>
+<tr>
+   <th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
+   <th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
+   <th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
+   <th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
+   <th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
+   <th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
+   <th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
+   <th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
+</tr>
+</thead>
+<tbody><tr>
+	<td class="file low" data-value="client.ts"><a href="client.ts.html">client.ts</a></td>
+	<td data-value="27.54" class="pic low">
+	<div class="chart"><div class="cover-fill" style="width: 27%"></div><div class="cover-empty" style="width: 73%"></div></div>
+	</td>
+	<td data-value="27.54" class="pct low">27.54%</td>
+	<td data-value="679" class="abs low">187/679</td>
+	<td data-value="75" class="pct medium">75%</td>
+	<td data-value="4" class="abs medium">3/4</td>
+	<td data-value="1.76" class="pct low">1.76%</td>
+	<td data-value="170" class="abs low">3/170</td>
+	<td data-value="27.54" class="pct low">27.54%</td>
+	<td data-value="679" class="abs low">187/679</td>
+	</tr>
+
+</tbody>
+</table>
+</div>
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 1198 - 0
frontend/coverage/src/components/AMSHistoryModal.tsx.html

@@ -0,0 +1,1198 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/AMSHistoryModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> AMSHistoryModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/287</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/287</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a>
+<a name='L297'></a><a href='#L297'>297</a>
+<a name='L298'></a><a href='#L298'>298</a>
+<a name='L299'></a><a href='#L299'>299</a>
+<a name='L300'></a><a href='#L300'>300</a>
+<a name='L301'></a><a href='#L301'>301</a>
+<a name='L302'></a><a href='#L302'>302</a>
+<a name='L303'></a><a href='#L303'>303</a>
+<a name='L304'></a><a href='#L304'>304</a>
+<a name='L305'></a><a href='#L305'>305</a>
+<a name='L306'></a><a href='#L306'>306</a>
+<a name='L307'></a><a href='#L307'>307</a>
+<a name='L308'></a><a href='#L308'>308</a>
+<a name='L309'></a><a href='#L309'>309</a>
+<a name='L310'></a><a href='#L310'>310</a>
+<a name='L311'></a><a href='#L311'>311</a>
+<a name='L312'></a><a href='#L312'>312</a>
+<a name='L313'></a><a href='#L313'>313</a>
+<a name='L314'></a><a href='#L314'>314</a>
+<a name='L315'></a><a href='#L315'>315</a>
+<a name='L316'></a><a href='#L316'>316</a>
+<a name='L317'></a><a href='#L317'>317</a>
+<a name='L318'></a><a href='#L318'>318</a>
+<a name='L319'></a><a href='#L319'>319</a>
+<a name='L320'></a><a href='#L320'>320</a>
+<a name='L321'></a><a href='#L321'>321</a>
+<a name='L322'></a><a href='#L322'>322</a>
+<a name='L323'></a><a href='#L323'>323</a>
+<a name='L324'></a><a href='#L324'>324</a>
+<a name='L325'></a><a href='#L325'>325</a>
+<a name='L326'></a><a href='#L326'>326</a>
+<a name='L327'></a><a href='#L327'>327</a>
+<a name='L328'></a><a href='#L328'>328</a>
+<a name='L329'></a><a href='#L329'>329</a>
+<a name='L330'></a><a href='#L330'>330</a>
+<a name='L331'></a><a href='#L331'>331</a>
+<a name='L332'></a><a href='#L332'>332</a>
+<a name='L333'></a><a href='#L333'>333</a>
+<a name='L334'></a><a href='#L334'>334</a>
+<a name='L335'></a><a href='#L335'>335</a>
+<a name='L336'></a><a href='#L336'>336</a>
+<a name='L337'></a><a href='#L337'>337</a>
+<a name='L338'></a><a href='#L338'>338</a>
+<a name='L339'></a><a href='#L339'>339</a>
+<a name='L340'></a><a href='#L340'>340</a>
+<a name='L341'></a><a href='#L341'>341</a>
+<a name='L342'></a><a href='#L342'>342</a>
+<a name='L343'></a><a href='#L343'>343</a>
+<a name='L344'></a><a href='#L344'>344</a>
+<a name='L345'></a><a href='#L345'>345</a>
+<a name='L346'></a><a href='#L346'>346</a>
+<a name='L347'></a><a href='#L347'>347</a>
+<a name='L348'></a><a href='#L348'>348</a>
+<a name='L349'></a><a href='#L349'>349</a>
+<a name='L350'></a><a href='#L350'>350</a>
+<a name='L351'></a><a href='#L351'>351</a>
+<a name='L352'></a><a href='#L352'>352</a>
+<a name='L353'></a><a href='#L353'>353</a>
+<a name='L354'></a><a href='#L354'>354</a>
+<a name='L355'></a><a href='#L355'>355</a>
+<a name='L356'></a><a href='#L356'>356</a>
+<a name='L357'></a><a href='#L357'>357</a>
+<a name='L358'></a><a href='#L358'>358</a>
+<a name='L359'></a><a href='#L359'>359</a>
+<a name='L360'></a><a href='#L360'>360</a>
+<a name='L361'></a><a href='#L361'>361</a>
+<a name='L362'></a><a href='#L362'>362</a>
+<a name='L363'></a><a href='#L363'>363</a>
+<a name='L364'></a><a href='#L364'>364</a>
+<a name='L365'></a><a href='#L365'>365</a>
+<a name='L366'></a><a href='#L366'>366</a>
+<a name='L367'></a><a href='#L367'>367</a>
+<a name='L368'></a><a href='#L368'>368</a>
+<a name='L369'></a><a href='#L369'>369</a>
+<a name='L370'></a><a href='#L370'>370</a>
+<a name='L371'></a><a href='#L371'>371</a>
+<a name='L372'></a><a href='#L372'>372</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect } from 'react';</span></span></span>
+import { useQuery } from '@tanstack/react-query';
+import { X, Droplets, Thermometer, TrendingUp, TrendingDown, Minus } from 'lucide-react';
+import {
+  LineChart,
+  Line,
+  XAxis,
+  YAxis,
+  CartesianGrid,
+  Tooltip,
+  ResponsiveContainer,
+  Legend,
+  ReferenceLine,
+} from 'recharts';
+import { api, type AMSHistoryResponse } from '../api/client';
+import { useTranslation } from 'react-i18next';
+import { useTheme } from '../contexts/ThemeContext';
+&nbsp;
+interface AMSHistoryModalProps {
+  isOpen: boolean;
+  onClose: () =&gt; void;
+  printerId: number;
+  printerName: string;
+  amsId: number;
+  amsLabel: string;
+  initialMode?: 'humidity' | 'temperature';
+  thresholds?: {
+    humidityGood: number;
+    humidityFair: number;
+    tempGood: number;
+    tempFair: number;
+  };
+}
+&nbsp;
+type TimeRange = '6h' | '24h' | '48h' | '7d';
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const TIME_RANGES: { value: TimeRange; label: string; hours: number }[] = [</span>
+<span class="cstat-no" title="statement not covered" >  { value: '6h', label: '6h', hours: 6 },</span>
+<span class="cstat-no" title="statement not covered" >  { value: '24h', label: '24h', hours: 24 },</span>
+<span class="cstat-no" title="statement not covered" >  { value: '48h', label: '48h', hours: 48 },</span>
+<span class="cstat-no" title="statement not covered" >  { value: '7d', label: '7d', hours: 168 },</span>
+<span class="cstat-no" title="statement not covered" >];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function AMSHistoryModal({</span>
+<span class="cstat-no" title="statement not covered" >  isOpen,</span>
+<span class="cstat-no" title="statement not covered" >  onClose,</span>
+<span class="cstat-no" title="statement not covered" >  printerId,</span>
+<span class="cstat-no" title="statement not covered" >  printerName,</span>
+<span class="cstat-no" title="statement not covered" >  amsId,</span>
+<span class="cstat-no" title="statement not covered" >  amsLabel,</span>
+<span class="cstat-no" title="statement not covered" >  initialMode = 'humidity',</span>
+<span class="cstat-no" title="statement not covered" >  thresholds,</span>
+<span class="cstat-no" title="statement not covered" >}: AMSHistoryModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const { t } = useTranslation();</span>
+<span class="cstat-no" title="statement not covered" >  const { theme } = useTheme();</span>
+<span class="cstat-no" title="statement not covered" >  const [timeRange, setTimeRange] = useState&lt;TimeRange&gt;('24h');</span>
+<span class="cstat-no" title="statement not covered" >  const [mode, setMode] = useState&lt;'humidity' | 'temperature'&gt;(initialMode);</span>
+<span class="cstat-no" title="statement not covered" >  const isDark = theme === 'dark';</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (!isOpen) return;</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [isOpen, onClose]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const hours = TIME_RANGES.find(r =&gt; r.value === timeRange)?.hours || 24;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const { data, isLoading, error } = useQuery&lt;AMSHistoryResponse&gt;({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['ams-history', printerId, amsId, hours],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: () =&gt; api.getAMSHistory(printerId, amsId, hours),</span>
+<span class="cstat-no" title="statement not covered" >    enabled: isOpen,</span>
+<span class="cstat-no" title="statement not covered" >    refetchInterval: 60000, // Refresh every minute</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  if (!isOpen) return null;</span>
+&nbsp;
+  // Format data for chart
+<span class="cstat-no" title="statement not covered" >  const chartData = data?.data.map(point =&gt; ({</span>
+<span class="cstat-no" title="statement not covered" >    time: new Date(point.recorded_at).getTime(),</span>
+<span class="cstat-no" title="statement not covered" >    humidity: point.humidity,</span>
+<span class="cstat-no" title="statement not covered" >    temperature: point.temperature,</span>
+<span class="cstat-no" title="statement not covered" >    timeLabel: new Date(point.recorded_at).toLocaleTimeString([], {</span>
+<span class="cstat-no" title="statement not covered" >      hour: '2-digit',</span>
+<span class="cstat-no" title="statement not covered" >      minute: '2-digit',</span>
+<span class="cstat-no" title="statement not covered" >      ...(hours &gt; 24 ? { day: 'numeric', month: 'short' } : {}),</span>
+<span class="cstat-no" title="statement not covered" >    }),</span>
+<span class="cstat-no" title="statement not covered" >  })) || [];</span>
+&nbsp;
+  // Get thresholds
+<span class="cstat-no" title="statement not covered" >  const humidityGood = thresholds?.humidityGood || 40;</span>
+<span class="cstat-no" title="statement not covered" >  const humidityFair = thresholds?.humidityFair || 60;</span>
+<span class="cstat-no" title="statement not covered" >  const tempGood = thresholds?.tempGood || 30;</span>
+<span class="cstat-no" title="statement not covered" >  const tempFair = thresholds?.tempFair || 35;</span>
+&nbsp;
+  // Current values (last data point)
+<span class="cstat-no" title="statement not covered" >  const lastPoint = chartData[chartData.length - 1];</span>
+<span class="cstat-no" title="statement not covered" >  const currentHumidity = lastPoint?.humidity;</span>
+<span class="cstat-no" title="statement not covered" >  const currentTemp = lastPoint?.temperature;</span>
+&nbsp;
+  // Trend calculation (compare first and last 20% of data)
+<span class="cstat-no" title="statement not covered" >  const getTrend = (values: (number | null)[]) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const filtered = values.filter((v): v is number =&gt; v != null);</span>
+<span class="cstat-no" title="statement not covered" >    if (filtered.length &lt; 4) return 'stable';</span>
+<span class="cstat-no" title="statement not covered" >    const firstQuarter = filtered.slice(0, Math.floor(filtered.length / 4));</span>
+<span class="cstat-no" title="statement not covered" >    const lastQuarter = filtered.slice(-Math.floor(filtered.length / 4));</span>
+<span class="cstat-no" title="statement not covered" >    const firstAvg = firstQuarter.reduce((a, b) =&gt; a + b, 0) / firstQuarter.length;</span>
+<span class="cstat-no" title="statement not covered" >    const lastAvg = lastQuarter.reduce((a, b) =&gt; a + b, 0) / lastQuarter.length;</span>
+<span class="cstat-no" title="statement not covered" >    const diff = lastAvg - firstAvg;</span>
+<span class="cstat-no" title="statement not covered" >    if (Math.abs(diff) &lt; 2) return 'stable';</span>
+<span class="cstat-no" title="statement not covered" >    return diff &gt; 0 ? 'up' : 'down';</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const humidityTrend = getTrend(chartData.map(d =&gt; d.humidity));</span>
+<span class="cstat-no" title="statement not covered" >  const tempTrend = getTrend(chartData.map(d =&gt; d.temperature));</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const TrendIcon = ({ trend }: { trend: string }) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (trend === 'up') return &lt;TrendingUp className="w-4 h-4 text-red-400" /&gt;;</span>
+<span class="cstat-no" title="statement not covered" >    if (trend === 'down') return &lt;TrendingDown className="w-4 h-4 text-green-400" /&gt;;</span>
+<span class="cstat-no" title="statement not covered" >    return &lt;Minus className="w-4 h-4 text-gray-400 dark:text-bambu-gray" /&gt;;</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+  // Get status color for current value
+<span class="cstat-no" title="statement not covered" >  const getHumidityColor = (value: number | undefined | null) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (value == null) return '#9ca3af';</span>
+<span class="cstat-no" title="statement not covered" >    if (value &lt;= humidityGood) return '#22a352';</span>
+<span class="cstat-no" title="statement not covered" >    if (value &lt;= humidityFair) return '#d4a017';</span>
+<span class="cstat-no" title="statement not covered" >    return '#c62828';</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const getTempColor = (value: number | undefined | null) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (value == null) return '#9ca3af';</span>
+<span class="cstat-no" title="statement not covered" >    if (value &lt;= tempGood) return '#22a352';</span>
+<span class="cstat-no" title="statement not covered" >    if (value &lt;= tempFair) return '#d4a017';</span>
+<span class="cstat-no" title="statement not covered" >    return '#c62828';</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+  // Theme-aware styles (using isDark since dark: prefix doesn't work in portals)
+<span class="cstat-no" title="statement not covered" >  const modalBg = isDark ? '#2d2d2d' : '#ffffff';</span>
+<span class="cstat-no" title="statement not covered" >  const cardBg = isDark ? '#1d1d1d' : '#f3f4f6';</span>
+<span class="cstat-no" title="statement not covered" >  const borderColor = isDark ? '#3d3d3d' : '#e5e7eb';</span>
+<span class="cstat-no" title="statement not covered" >  const textPrimary = isDark ? '#ffffff' : '#111827';</span>
+<span class="cstat-no" title="statement not covered" >  const textSecondary = isDark ? '#9ca3af' : '#4b5563';</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;div</span>
+<span class="cstat-no" title="statement not covered" >        className="rounded-xl w-full max-w-4xl max-h-[90vh] overflow-hidden shadow-xl"</span>
+<span class="cstat-no" title="statement not covered" >        style={{ backgroundColor: modalBg }}</span>
+<span class="cstat-no" title="statement not covered" >        onClick={e =&gt; e.stopPropagation()}</span>
+      &gt;
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div</span>
+<span class="cstat-no" title="statement not covered" >          className="flex items-center justify-between px-6 py-4 border-b"</span>
+<span class="cstat-no" title="statement not covered" >          style={{ borderColor }}</span>
+        &gt;
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;h2 className="text-lg font-semibold" style={{ color: textPrimary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {amsLabel} {t('common.history', 'History')}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm" style={{ color: textSecondary }}&gt;{printerName}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >            className="p-2 rounded-lg transition-colors"</span>
+<span class="cstat-no" title="statement not covered" >            style={{ color: textSecondary }}</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Content */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="p-6 space-y-6 overflow-y-auto max-h-[calc(90vh-80px)]"&gt;</span>
+          {/* Time Range &amp; Mode Selector */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; setMode('humidity')}</span>
+<span class="cstat-no" title="statement not covered" >                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                  mode === 'humidity' ? 'bg-blue-600 text-white' : ''</span>
+<span class="cstat-no" title="statement not covered" >                }`}</span>
+<span class="cstat-no" title="statement not covered" >                style={mode !== 'humidity' ? { color: textSecondary } : undefined}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                &lt;Droplets className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.humidity', 'Humidity')}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; setMode('temperature')}</span>
+<span class="cstat-no" title="statement not covered" >                className={`flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                  mode === 'temperature' ? 'bg-orange-600 text-white' : ''</span>
+<span class="cstat-no" title="statement not covered" >                }`}</span>
+<span class="cstat-no" title="statement not covered" >                style={mode !== 'temperature' ? { color: textSecondary } : undefined}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                &lt;Thermometer className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.temperature', 'Temperature')}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-1 rounded-lg p-1" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {TIME_RANGES.map(range =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                  key={range.value}</span>
+<span class="cstat-no" title="statement not covered" >                  onClick={() =&gt; setTimeRange(range.value)}</span>
+<span class="cstat-no" title="statement not covered" >                  className={`px-3 py-1 text-sm rounded-md transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                    timeRange === range.value ? 'bg-bambu-green text-white' : ''</span>
+<span class="cstat-no" title="statement not covered" >                  }`}</span>
+<span class="cstat-no" title="statement not covered" >                  style={timeRange !== range.value ? { color: textSecondary } : undefined}</span>
+                &gt;
+<span class="cstat-no" title="statement not covered" >                  {range.label}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Stats Cards */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="grid grid-cols-4 gap-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {mode === 'humidity' ? (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.current', 'Current')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="text-2xl font-bold" style={{ color: getHumidityColor(currentHumidity) }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      {currentHumidity != null ? `${currentHumidity}%` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;TrendIcon trend={humidityTrend} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.average', 'Average')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold" style={{ color: textPrimary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.avg_humidity != null ? `${data.avg_humidity}%` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.min', 'Min')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold text-green-500"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.min_humidity != null ? `${data.min_humidity}%` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.max', 'Max')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold text-red-500"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.max_humidity != null ? `${data.max_humidity}%` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/&gt;</span>
+            ) : (
+<span class="cstat-no" title="statement not covered" >              &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.current', 'Current')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="text-2xl font-bold" style={{ color: getTempColor(currentTemp) }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      {currentTemp != null ? `${currentTemp}°C` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;TrendIcon trend={tempTrend} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.average', 'Average')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold" style={{ color: textPrimary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.avg_temperature != null ? `${data.avg_temperature}°C` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.min', 'Min')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold text-blue-500"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.min_temperature != null ? `${data.min_temperature}°C` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-xs" style={{ color: textSecondary }}&gt;{t('common.max', 'Max')}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;p className="text-2xl font-bold text-red-500"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {data?.max_temperature != null ? `${data.max_temperature}°C` : '—'}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Chart */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="rounded-lg p-4" style={{ backgroundColor: cardBg }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {isLoading ? (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.loading', 'Loading...')}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ) : error ? (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="h-[300px] flex items-center justify-center text-red-500"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.error', 'Error loading data')}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ) : chartData.length === 0 ? (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="h-[300px] flex items-center justify-center" style={{ color: textSecondary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.noData', 'No data available for this time range')}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            ) : (
+<span class="cstat-no" title="statement not covered" >              &lt;ResponsiveContainer width="100%" height={300}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;LineChart data={chartData}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;CartesianGrid strokeDasharray="3 3" stroke={isDark ? '#3d3d3d' : '#e5e7eb'} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;XAxis</span>
+<span class="cstat-no" title="statement not covered" >                    dataKey="time"</span>
+<span class="cstat-no" title="statement not covered" >                    type="number"</span>
+<span class="cstat-no" title="statement not covered" >                    domain={['dataMin', 'dataMax']}</span>
+<span class="cstat-no" title="statement not covered" >                    tickFormatter={(ts) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                      const date = new Date(ts);</span>
+<span class="cstat-no" title="statement not covered" >                      if (hours &gt; 24) {</span>
+<span class="cstat-no" title="statement not covered" >                        return date.toLocaleDateString([], { day: 'numeric', month: 'short' });</span>
+<span class="cstat-no" title="statement not covered" >                      }</span>
+<span class="cstat-no" title="statement not covered" >                      return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });</span>
+<span class="cstat-no" title="statement not covered" >                    }}</span>
+<span class="cstat-no" title="statement not covered" >                    stroke={isDark ? '#9ca3af' : '#6b7280'}</span>
+<span class="cstat-no" title="statement not covered" >                    tick={{ fontSize: 12 }}</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;YAxis</span>
+<span class="cstat-no" title="statement not covered" >                    stroke={isDark ? '#9ca3af' : '#6b7280'}</span>
+<span class="cstat-no" title="statement not covered" >                    tick={{ fontSize: 12 }}</span>
+<span class="cstat-no" title="statement not covered" >                    domain={mode === 'humidity' ? [0, 100] : ['auto', 'auto']}</span>
+<span class="cstat-no" title="statement not covered" >                    tickFormatter={(value) =&gt; mode === 'humidity' ? `${value}%` : `${value}°C`}</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Tooltip</span>
+<span class="cstat-no" title="statement not covered" >                    contentStyle={{</span>
+<span class="cstat-no" title="statement not covered" >                      backgroundColor: isDark ? '#2d2d2d' : '#ffffff',</span>
+<span class="cstat-no" title="statement not covered" >                      border: `1px solid ${isDark ? '#3d3d3d' : '#e5e7eb'}`,</span>
+<span class="cstat-no" title="statement not covered" >                      borderRadius: '8px',</span>
+<span class="cstat-no" title="statement not covered" >                      color: isDark ? '#fff' : '#000',</span>
+<span class="cstat-no" title="statement not covered" >                    }}</span>
+<span class="cstat-no" title="statement not covered" >                    labelFormatter={(ts) =&gt; new Date(ts).toLocaleString()}</span>
+<span class="cstat-no" title="statement not covered" >                    formatter={(value: number) =&gt; [</span>
+<span class="cstat-no" title="statement not covered" >                      mode === 'humidity' ? `${value}%` : `${value}°C`,</span>
+<span class="cstat-no" title="statement not covered" >                      mode === 'humidity' ? 'Humidity' : 'Temperature'</span>
+<span class="cstat-no" title="statement not covered" >                    ]}</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Legend /&gt;</span>
+&nbsp;
+                  {/* Threshold lines */}
+<span class="cstat-no" title="statement not covered" >                  {mode === 'humidity' ? (</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;ReferenceLine y={humidityGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;ReferenceLine y={humidityFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/&gt;</span>
+                  ) : (
+<span class="cstat-no" title="statement not covered" >                    &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;ReferenceLine y={tempGood} stroke="#22a352" strokeDasharray="5 5" label={{ value: 'Good', fill: '#22a352', fontSize: 10 }} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;ReferenceLine y={tempFair} stroke="#d4a017" strokeDasharray="5 5" label={{ value: 'Fair', fill: '#d4a017', fontSize: 10 }} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/&gt;</span>
+                  )}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >                  &lt;Line</span>
+<span class="cstat-no" title="statement not covered" >                    type="monotone"</span>
+<span class="cstat-no" title="statement not covered" >                    dataKey={mode}</span>
+<span class="cstat-no" title="statement not covered" >                    name={mode === 'humidity' ? 'Humidity' : 'Temperature'}</span>
+<span class="cstat-no" title="statement not covered" >                    stroke={mode === 'humidity' ? '#3b82f6' : '#f97316'}</span>
+<span class="cstat-no" title="statement not covered" >                    strokeWidth={2}</span>
+<span class="cstat-no" title="statement not covered" >                    dot={false}</span>
+<span class="cstat-no" title="statement not covered" >                    activeDot={{ r: 4 }}</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/LineChart&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/ResponsiveContainer&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Info */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="text-xs text-center" style={{ color: textSecondary }}&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {t('amsHistory.recordingInfo', 'Data is recorded every 5 minutes while the printer is connected')}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 982 - 0
frontend/coverage/src/components/AddExternalLinkModal.tsx.html

@@ -0,0 +1,982 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/AddExternalLinkModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> AddExternalLinkModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/225</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/225</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a>
+<a name='L297'></a><a href='#L297'>297</a>
+<a name='L298'></a><a href='#L298'>298</a>
+<a name='L299'></a><a href='#L299'>299</a>
+<a name='L300'></a><a href='#L300'>300</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect, useRef } from 'react';</span></span></span>
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Save, Loader2, Upload, Trash2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ExternalLink, ExternalLinkCreate, ExternalLinkUpdate } from '../api/client';
+import { Button } from './Button';
+import { IconPicker, getIconByName } from './IconPicker';
+import { useTheme } from '../contexts/ThemeContext';
+&nbsp;
+interface AddExternalLinkModalProps {
+  link?: ExternalLink | null;
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function AddExternalLinkModal({ link, onClose }: AddExternalLinkModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const queryClient = useQueryClient();</span>
+<span class="cstat-no" title="statement not covered" >  const { theme } = useTheme();</span>
+<span class="cstat-no" title="statement not covered" >  const isEditing = !!link;</span>
+<span class="cstat-no" title="statement not covered" >  const fileInputRef = useRef&lt;HTMLInputElement&gt;(null);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const [name, setName] = useState(link?.name || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [url, setUrl] = useState(link?.url || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [icon, setIcon] = useState(link?.icon || 'link');</span>
+<span class="cstat-no" title="statement not covered" >  const [useCustomIcon, setUseCustomIcon] = useState(!!link?.custom_icon);</span>
+<span class="cstat-no" title="statement not covered" >  const [customIconPreview, setCustomIconPreview] = useState&lt;string | null&gt;(</span>
+<span class="cstat-no" title="statement not covered" >    link?.custom_icon ? api.getExternalLinkIconUrl(link.id) : null</span>
+<span class="cstat-no" title="statement not covered" >  );</span>
+<span class="cstat-no" title="statement not covered" >  const [pendingIconFile, setPendingIconFile] = useState&lt;File | null&gt;(null);</span>
+<span class="cstat-no" title="statement not covered" >  const [error, setError] = useState&lt;string | null&gt;(null);</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+  // Create mutation
+<span class="cstat-no" title="statement not covered" >  const createMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: async (data: ExternalLinkCreate) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      const created = await api.createExternalLink(data);</span>
+      // If there's a pending icon file, upload it
+<span class="cstat-no" title="statement not covered" >      if (pendingIconFile) {</span>
+<span class="cstat-no" title="statement not covered" >        return await api.uploadExternalLinkIcon(created.id, pendingIconFile);</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >      return created;</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['external-links'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Update mutation
+<span class="cstat-no" title="statement not covered" >  const updateMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: async (data: ExternalLinkUpdate) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      let updated = await api.updateExternalLink(link!.id, data);</span>
+      // Handle icon changes
+<span class="cstat-no" title="statement not covered" >      if (pendingIconFile) {</span>
+        // Upload new icon
+<span class="cstat-no" title="statement not covered" >        updated = await api.uploadExternalLinkIcon(link!.id, pendingIconFile);</span>
+<span class="cstat-no" title="statement not covered" >      } else if (!useCustomIcon &amp;&amp; link?.custom_icon) {</span>
+        // Remove custom icon if switching to preset
+<span class="cstat-no" title="statement not covered" >        updated = await api.deleteExternalLinkIcon(link!.id);</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >      return updated;</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['external-links'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleFileSelect = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const file = e.target.files?.[0];</span>
+<span class="cstat-no" title="statement not covered" >    if (file) {</span>
+      // Validate file type
+<span class="cstat-no" title="statement not covered" >      const validTypes = ['image/png', 'image/jpeg', 'image/gif', 'image/svg+xml', 'image/webp', 'image/x-icon'];</span>
+<span class="cstat-no" title="statement not covered" >      if (!validTypes.includes(file.type)) {</span>
+<span class="cstat-no" title="statement not covered" >        setError('Please select a valid image file (PNG, JPG, GIF, SVG, WebP, or ICO)');</span>
+<span class="cstat-no" title="statement not covered" >        return;</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+&nbsp;
+      // Validate file size (max 1MB)
+<span class="cstat-no" title="statement not covered" >      if (file.size &gt; 1024 * 1024) {</span>
+<span class="cstat-no" title="statement not covered" >        setError('Image file must be less than 1MB');</span>
+<span class="cstat-no" title="statement not covered" >        return;</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >      setPendingIconFile(file);</span>
+<span class="cstat-no" title="statement not covered" >      setUseCustomIcon(true);</span>
+&nbsp;
+      // Create preview
+<span class="cstat-no" title="statement not covered" >      const reader = new FileReader();</span>
+<span class="cstat-no" title="statement not covered" >      reader.onload = (e) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >        setCustomIconPreview(e.target?.result as string);</span>
+<span class="cstat-no" title="statement not covered" >      };</span>
+<span class="cstat-no" title="statement not covered" >      reader.readAsDataURL(file);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleRemoveCustomIcon = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    setPendingIconFile(null);</span>
+<span class="cstat-no" title="statement not covered" >    setCustomIconPreview(null);</span>
+<span class="cstat-no" title="statement not covered" >    setUseCustomIcon(false);</span>
+<span class="cstat-no" title="statement not covered" >    if (fileInputRef.current) {</span>
+<span class="cstat-no" title="statement not covered" >      fileInputRef.current.value = '';</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleSubmit = (e: React.FormEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    e.preventDefault();</span>
+<span class="cstat-no" title="statement not covered" >    setError(null);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (!name.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('Name is required');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (!url.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('URL is required');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+    // Validate URL
+<span class="cstat-no" title="statement not covered" >    if (!url.startsWith('http://') &amp;&amp; !url.startsWith('https://')) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('URL must start with http:// or https://');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const data = {</span>
+<span class="cstat-no" title="statement not covered" >      name: name.trim(),</span>
+<span class="cstat-no" title="statement not covered" >      url: url.trim(),</span>
+<span class="cstat-no" title="statement not covered" >      icon: useCustomIcon ? icon : icon, // Keep preset icon as fallback</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (isEditing) {</span>
+<span class="cstat-no" title="statement not covered" >      updateMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    } else {</span>
+<span class="cstat-no" title="statement not covered" >      createMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const isPending = createMutation.isPending || updateMutation.isPending;</span>
+<span class="cstat-no" title="statement not covered" >  const PresetIcon = getIconByName(icon);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div</span>
+<span class="cstat-no" title="statement not covered" >      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"</span>
+<span class="cstat-no" title="statement not covered" >      onClick={onClose}</span>
+    &gt;
+<span class="cstat-no" title="statement not covered" >      &lt;div</span>
+<span class="cstat-no" title="statement not covered" >        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"</span>
+<span class="cstat-no" title="statement not covered" >        onClick={(e) =&gt; e.stopPropagation()}</span>
+      &gt;
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {useCustomIcon &amp;&amp; customIconPreview ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;img src={customIconPreview} alt="" className={`w-5 h-5 rounded ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} /&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;PresetIcon className="w-5 h-5" /&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;h2 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {isEditing ? 'Edit Link' : 'Add External Link'}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >            className="text-bambu-gray hover:text-white transition-colors"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Form */}
+<span class="cstat-no" title="statement not covered" >        &lt;form onSubmit={handleSubmit} className="p-6 space-y-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {error &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {error}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Name */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Name *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;input</span>
+<span class="cstat-no" title="statement not covered" >              type="text"</span>
+<span class="cstat-no" title="statement not covered" >              value={name}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setName(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >              placeholder="My Link"</span>
+<span class="cstat-no" title="statement not covered" >              maxLength={50}</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >            /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* URL */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;URL *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;input</span>
+<span class="cstat-no" title="statement not covered" >              type="text"</span>
+<span class="cstat-no" title="statement not covered" >              value={url}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setUrl(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >              placeholder="https://example.com"</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >            /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Icon Section */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="space-y-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray"&gt;Icon&lt;/label&gt;</span>
+&nbsp;
+            {/* Custom Icon Upload */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex items-center justify-between mb-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;span className="text-sm text-white"&gt;Custom Icon&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  ref={fileInputRef}</span>
+<span class="cstat-no" title="statement not covered" >                  type="file"</span>
+<span class="cstat-no" title="statement not covered" >                  accept="image/png,image/jpeg,image/gif,image/svg+xml,image/webp,image/x-icon"</span>
+<span class="cstat-no" title="statement not covered" >                  className="hidden"</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={handleFileSelect}</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {useCustomIcon &amp;&amp; customIconPreview ? (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;img src={customIconPreview} alt="Custom icon" className={`w-8 h-8 rounded border border-bambu-dark-tertiary ${theme === 'dark' ? 'invert opacity-[0.65]' : 'opacity-60'}`} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                      type="button"</span>
+<span class="cstat-no" title="statement not covered" >                      onClick={handleRemoveCustomIcon}</span>
+<span class="cstat-no" title="statement not covered" >                      className="p-1 text-red-400 hover:text-red-300 transition-colors"</span>
+<span class="cstat-no" title="statement not covered" >                      title="Remove custom icon"</span>
+                    &gt;
+<span class="cstat-no" title="statement not covered" >                      &lt;Trash2 className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+                ) : (
+<span class="cstat-no" title="statement not covered" >                  &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                    type="button"</span>
+<span class="cstat-no" title="statement not covered" >                    variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >                    size="sm"</span>
+<span class="cstat-no" title="statement not covered" >                    onClick={() =&gt; fileInputRef.current?.click()}</span>
+                  &gt;
+<span class="cstat-no" title="statement not covered" >                    &lt;Upload className="w-4 h-4" /&gt;</span>
+                    Upload
+<span class="cstat-no" title="statement not covered" >                  &lt;/Button&gt;</span>
+                )}
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-xs text-bambu-gray"&gt;</span>
+                PNG, JPG, GIF, SVG, WebP, or ICO. Max 1MB.
+<span class="cstat-no" title="statement not covered" >              &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Preset Icon Picker */}
+<span class="cstat-no" title="statement not covered" >            {!useCustomIcon &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;span className="text-sm text-bambu-gray block mb-2"&gt;Or choose a preset icon&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;IconPicker value={icon} onChange={setIcon} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Actions */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-3 pt-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="button"</span>
+<span class="cstat-no" title="statement not covered" >              variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+<span class="cstat-no" title="statement not covered" >            &gt;</span>
+              Cancel
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="submit"</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isPending}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;Save className="w-4 h-4" /&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >              {isEditing ? 'Save' : 'Add'}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/form&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 1696 - 0
frontend/coverage/src/components/AddNotificationModal.tsx.html

@@ -0,0 +1,1696 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/AddNotificationModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> AddNotificationModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/441</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/441</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a>
+<a name='L297'></a><a href='#L297'>297</a>
+<a name='L298'></a><a href='#L298'>298</a>
+<a name='L299'></a><a href='#L299'>299</a>
+<a name='L300'></a><a href='#L300'>300</a>
+<a name='L301'></a><a href='#L301'>301</a>
+<a name='L302'></a><a href='#L302'>302</a>
+<a name='L303'></a><a href='#L303'>303</a>
+<a name='L304'></a><a href='#L304'>304</a>
+<a name='L305'></a><a href='#L305'>305</a>
+<a name='L306'></a><a href='#L306'>306</a>
+<a name='L307'></a><a href='#L307'>307</a>
+<a name='L308'></a><a href='#L308'>308</a>
+<a name='L309'></a><a href='#L309'>309</a>
+<a name='L310'></a><a href='#L310'>310</a>
+<a name='L311'></a><a href='#L311'>311</a>
+<a name='L312'></a><a href='#L312'>312</a>
+<a name='L313'></a><a href='#L313'>313</a>
+<a name='L314'></a><a href='#L314'>314</a>
+<a name='L315'></a><a href='#L315'>315</a>
+<a name='L316'></a><a href='#L316'>316</a>
+<a name='L317'></a><a href='#L317'>317</a>
+<a name='L318'></a><a href='#L318'>318</a>
+<a name='L319'></a><a href='#L319'>319</a>
+<a name='L320'></a><a href='#L320'>320</a>
+<a name='L321'></a><a href='#L321'>321</a>
+<a name='L322'></a><a href='#L322'>322</a>
+<a name='L323'></a><a href='#L323'>323</a>
+<a name='L324'></a><a href='#L324'>324</a>
+<a name='L325'></a><a href='#L325'>325</a>
+<a name='L326'></a><a href='#L326'>326</a>
+<a name='L327'></a><a href='#L327'>327</a>
+<a name='L328'></a><a href='#L328'>328</a>
+<a name='L329'></a><a href='#L329'>329</a>
+<a name='L330'></a><a href='#L330'>330</a>
+<a name='L331'></a><a href='#L331'>331</a>
+<a name='L332'></a><a href='#L332'>332</a>
+<a name='L333'></a><a href='#L333'>333</a>
+<a name='L334'></a><a href='#L334'>334</a>
+<a name='L335'></a><a href='#L335'>335</a>
+<a name='L336'></a><a href='#L336'>336</a>
+<a name='L337'></a><a href='#L337'>337</a>
+<a name='L338'></a><a href='#L338'>338</a>
+<a name='L339'></a><a href='#L339'>339</a>
+<a name='L340'></a><a href='#L340'>340</a>
+<a name='L341'></a><a href='#L341'>341</a>
+<a name='L342'></a><a href='#L342'>342</a>
+<a name='L343'></a><a href='#L343'>343</a>
+<a name='L344'></a><a href='#L344'>344</a>
+<a name='L345'></a><a href='#L345'>345</a>
+<a name='L346'></a><a href='#L346'>346</a>
+<a name='L347'></a><a href='#L347'>347</a>
+<a name='L348'></a><a href='#L348'>348</a>
+<a name='L349'></a><a href='#L349'>349</a>
+<a name='L350'></a><a href='#L350'>350</a>
+<a name='L351'></a><a href='#L351'>351</a>
+<a name='L352'></a><a href='#L352'>352</a>
+<a name='L353'></a><a href='#L353'>353</a>
+<a name='L354'></a><a href='#L354'>354</a>
+<a name='L355'></a><a href='#L355'>355</a>
+<a name='L356'></a><a href='#L356'>356</a>
+<a name='L357'></a><a href='#L357'>357</a>
+<a name='L358'></a><a href='#L358'>358</a>
+<a name='L359'></a><a href='#L359'>359</a>
+<a name='L360'></a><a href='#L360'>360</a>
+<a name='L361'></a><a href='#L361'>361</a>
+<a name='L362'></a><a href='#L362'>362</a>
+<a name='L363'></a><a href='#L363'>363</a>
+<a name='L364'></a><a href='#L364'>364</a>
+<a name='L365'></a><a href='#L365'>365</a>
+<a name='L366'></a><a href='#L366'>366</a>
+<a name='L367'></a><a href='#L367'>367</a>
+<a name='L368'></a><a href='#L368'>368</a>
+<a name='L369'></a><a href='#L369'>369</a>
+<a name='L370'></a><a href='#L370'>370</a>
+<a name='L371'></a><a href='#L371'>371</a>
+<a name='L372'></a><a href='#L372'>372</a>
+<a name='L373'></a><a href='#L373'>373</a>
+<a name='L374'></a><a href='#L374'>374</a>
+<a name='L375'></a><a href='#L375'>375</a>
+<a name='L376'></a><a href='#L376'>376</a>
+<a name='L377'></a><a href='#L377'>377</a>
+<a name='L378'></a><a href='#L378'>378</a>
+<a name='L379'></a><a href='#L379'>379</a>
+<a name='L380'></a><a href='#L380'>380</a>
+<a name='L381'></a><a href='#L381'>381</a>
+<a name='L382'></a><a href='#L382'>382</a>
+<a name='L383'></a><a href='#L383'>383</a>
+<a name='L384'></a><a href='#L384'>384</a>
+<a name='L385'></a><a href='#L385'>385</a>
+<a name='L386'></a><a href='#L386'>386</a>
+<a name='L387'></a><a href='#L387'>387</a>
+<a name='L388'></a><a href='#L388'>388</a>
+<a name='L389'></a><a href='#L389'>389</a>
+<a name='L390'></a><a href='#L390'>390</a>
+<a name='L391'></a><a href='#L391'>391</a>
+<a name='L392'></a><a href='#L392'>392</a>
+<a name='L393'></a><a href='#L393'>393</a>
+<a name='L394'></a><a href='#L394'>394</a>
+<a name='L395'></a><a href='#L395'>395</a>
+<a name='L396'></a><a href='#L396'>396</a>
+<a name='L397'></a><a href='#L397'>397</a>
+<a name='L398'></a><a href='#L398'>398</a>
+<a name='L399'></a><a href='#L399'>399</a>
+<a name='L400'></a><a href='#L400'>400</a>
+<a name='L401'></a><a href='#L401'>401</a>
+<a name='L402'></a><a href='#L402'>402</a>
+<a name='L403'></a><a href='#L403'>403</a>
+<a name='L404'></a><a href='#L404'>404</a>
+<a name='L405'></a><a href='#L405'>405</a>
+<a name='L406'></a><a href='#L406'>406</a>
+<a name='L407'></a><a href='#L407'>407</a>
+<a name='L408'></a><a href='#L408'>408</a>
+<a name='L409'></a><a href='#L409'>409</a>
+<a name='L410'></a><a href='#L410'>410</a>
+<a name='L411'></a><a href='#L411'>411</a>
+<a name='L412'></a><a href='#L412'>412</a>
+<a name='L413'></a><a href='#L413'>413</a>
+<a name='L414'></a><a href='#L414'>414</a>
+<a name='L415'></a><a href='#L415'>415</a>
+<a name='L416'></a><a href='#L416'>416</a>
+<a name='L417'></a><a href='#L417'>417</a>
+<a name='L418'></a><a href='#L418'>418</a>
+<a name='L419'></a><a href='#L419'>419</a>
+<a name='L420'></a><a href='#L420'>420</a>
+<a name='L421'></a><a href='#L421'>421</a>
+<a name='L422'></a><a href='#L422'>422</a>
+<a name='L423'></a><a href='#L423'>423</a>
+<a name='L424'></a><a href='#L424'>424</a>
+<a name='L425'></a><a href='#L425'>425</a>
+<a name='L426'></a><a href='#L426'>426</a>
+<a name='L427'></a><a href='#L427'>427</a>
+<a name='L428'></a><a href='#L428'>428</a>
+<a name='L429'></a><a href='#L429'>429</a>
+<a name='L430'></a><a href='#L430'>430</a>
+<a name='L431'></a><a href='#L431'>431</a>
+<a name='L432'></a><a href='#L432'>432</a>
+<a name='L433'></a><a href='#L433'>433</a>
+<a name='L434'></a><a href='#L434'>434</a>
+<a name='L435'></a><a href='#L435'>435</a>
+<a name='L436'></a><a href='#L436'>436</a>
+<a name='L437'></a><a href='#L437'>437</a>
+<a name='L438'></a><a href='#L438'>438</a>
+<a name='L439'></a><a href='#L439'>439</a>
+<a name='L440'></a><a href='#L440'>440</a>
+<a name='L441'></a><a href='#L441'>441</a>
+<a name='L442'></a><a href='#L442'>442</a>
+<a name='L443'></a><a href='#L443'>443</a>
+<a name='L444'></a><a href='#L444'>444</a>
+<a name='L445'></a><a href='#L445'>445</a>
+<a name='L446'></a><a href='#L446'>446</a>
+<a name='L447'></a><a href='#L447'>447</a>
+<a name='L448'></a><a href='#L448'>448</a>
+<a name='L449'></a><a href='#L449'>449</a>
+<a name='L450'></a><a href='#L450'>450</a>
+<a name='L451'></a><a href='#L451'>451</a>
+<a name='L452'></a><a href='#L452'>452</a>
+<a name='L453'></a><a href='#L453'>453</a>
+<a name='L454'></a><a href='#L454'>454</a>
+<a name='L455'></a><a href='#L455'>455</a>
+<a name='L456'></a><a href='#L456'>456</a>
+<a name='L457'></a><a href='#L457'>457</a>
+<a name='L458'></a><a href='#L458'>458</a>
+<a name='L459'></a><a href='#L459'>459</a>
+<a name='L460'></a><a href='#L460'>460</a>
+<a name='L461'></a><a href='#L461'>461</a>
+<a name='L462'></a><a href='#L462'>462</a>
+<a name='L463'></a><a href='#L463'>463</a>
+<a name='L464'></a><a href='#L464'>464</a>
+<a name='L465'></a><a href='#L465'>465</a>
+<a name='L466'></a><a href='#L466'>466</a>
+<a name='L467'></a><a href='#L467'>467</a>
+<a name='L468'></a><a href='#L468'>468</a>
+<a name='L469'></a><a href='#L469'>469</a>
+<a name='L470'></a><a href='#L470'>470</a>
+<a name='L471'></a><a href='#L471'>471</a>
+<a name='L472'></a><a href='#L472'>472</a>
+<a name='L473'></a><a href='#L473'>473</a>
+<a name='L474'></a><a href='#L474'>474</a>
+<a name='L475'></a><a href='#L475'>475</a>
+<a name='L476'></a><a href='#L476'>476</a>
+<a name='L477'></a><a href='#L477'>477</a>
+<a name='L478'></a><a href='#L478'>478</a>
+<a name='L479'></a><a href='#L479'>479</a>
+<a name='L480'></a><a href='#L480'>480</a>
+<a name='L481'></a><a href='#L481'>481</a>
+<a name='L482'></a><a href='#L482'>482</a>
+<a name='L483'></a><a href='#L483'>483</a>
+<a name='L484'></a><a href='#L484'>484</a>
+<a name='L485'></a><a href='#L485'>485</a>
+<a name='L486'></a><a href='#L486'>486</a>
+<a name='L487'></a><a href='#L487'>487</a>
+<a name='L488'></a><a href='#L488'>488</a>
+<a name='L489'></a><a href='#L489'>489</a>
+<a name='L490'></a><a href='#L490'>490</a>
+<a name='L491'></a><a href='#L491'>491</a>
+<a name='L492'></a><a href='#L492'>492</a>
+<a name='L493'></a><a href='#L493'>493</a>
+<a name='L494'></a><a href='#L494'>494</a>
+<a name='L495'></a><a href='#L495'>495</a>
+<a name='L496'></a><a href='#L496'>496</a>
+<a name='L497'></a><a href='#L497'>497</a>
+<a name='L498'></a><a href='#L498'>498</a>
+<a name='L499'></a><a href='#L499'>499</a>
+<a name='L500'></a><a href='#L500'>500</a>
+<a name='L501'></a><a href='#L501'>501</a>
+<a name='L502'></a><a href='#L502'>502</a>
+<a name='L503'></a><a href='#L503'>503</a>
+<a name='L504'></a><a href='#L504'>504</a>
+<a name='L505'></a><a href='#L505'>505</a>
+<a name='L506'></a><a href='#L506'>506</a>
+<a name='L507'></a><a href='#L507'>507</a>
+<a name='L508'></a><a href='#L508'>508</a>
+<a name='L509'></a><a href='#L509'>509</a>
+<a name='L510'></a><a href='#L510'>510</a>
+<a name='L511'></a><a href='#L511'>511</a>
+<a name='L512'></a><a href='#L512'>512</a>
+<a name='L513'></a><a href='#L513'>513</a>
+<a name='L514'></a><a href='#L514'>514</a>
+<a name='L515'></a><a href='#L515'>515</a>
+<a name='L516'></a><a href='#L516'>516</a>
+<a name='L517'></a><a href='#L517'>517</a>
+<a name='L518'></a><a href='#L518'>518</a>
+<a name='L519'></a><a href='#L519'>519</a>
+<a name='L520'></a><a href='#L520'>520</a>
+<a name='L521'></a><a href='#L521'>521</a>
+<a name='L522'></a><a href='#L522'>522</a>
+<a name='L523'></a><a href='#L523'>523</a>
+<a name='L524'></a><a href='#L524'>524</a>
+<a name='L525'></a><a href='#L525'>525</a>
+<a name='L526'></a><a href='#L526'>526</a>
+<a name='L527'></a><a href='#L527'>527</a>
+<a name='L528'></a><a href='#L528'>528</a>
+<a name='L529'></a><a href='#L529'>529</a>
+<a name='L530'></a><a href='#L530'>530</a>
+<a name='L531'></a><a href='#L531'>531</a>
+<a name='L532'></a><a href='#L532'>532</a>
+<a name='L533'></a><a href='#L533'>533</a>
+<a name='L534'></a><a href='#L534'>534</a>
+<a name='L535'></a><a href='#L535'>535</a>
+<a name='L536'></a><a href='#L536'>536</a>
+<a name='L537'></a><a href='#L537'>537</a>
+<a name='L538'></a><a href='#L538'>538</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect } from 'react';</span></span></span>
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Loader2, Send, CheckCircle, XCircle } from 'lucide-react';
+import { api } from '../api/client';
+import type { NotificationProvider, NotificationProviderCreate, NotificationProviderUpdate, ProviderType } from '../api/client';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+&nbsp;
+interface AddNotificationModalProps {
+  provider?: NotificationProvider | null;
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const PROVIDER_OPTIONS: { value: ProviderType; label: string; description: string }[] = [</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'discord', label: 'Discord', description: 'Send to Discord channel via webhook' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'telegram', label: 'Telegram', description: 'Notifications via Telegram bot' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'ntfy', label: 'ntfy', description: 'Free, self-hostable push notifications' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'pushover', label: 'Pushover', description: 'Simple, reliable push notifications' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'email', label: 'Email', description: 'SMTP email notifications' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'callmebot', label: 'CallMeBot/WhatsApp', description: 'Free WhatsApp notifications via CallMeBot' },</span>
+<span class="cstat-no" title="statement not covered" >  { value: 'webhook', label: 'Webhook', description: 'Generic HTTP POST to any URL' },</span>
+<span class="cstat-no" title="statement not covered" >];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function AddNotificationModal({ provider, onClose }: AddNotificationModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const queryClient = useQueryClient();</span>
+<span class="cstat-no" title="statement not covered" >  const isEditing = !!provider;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const [name, setName] = useState(provider?.name || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [providerType, setProviderType] = useState&lt;ProviderType&gt;(provider?.provider_type || 'discord');</span>
+<span class="cstat-no" title="statement not covered" >  const [printerId, setPrinterId] = useState&lt;number | null&gt;(provider?.printer_id || null);</span>
+<span class="cstat-no" title="statement not covered" >  const [quietHoursEnabled, setQuietHoursEnabled] = useState(provider?.quiet_hours_enabled || false);</span>
+<span class="cstat-no" title="statement not covered" >  const [quietHoursStart, setQuietHoursStart] = useState(provider?.quiet_hours_start || '22:00');</span>
+<span class="cstat-no" title="statement not covered" >  const [quietHoursEnd, setQuietHoursEnd] = useState(provider?.quiet_hours_end || '07:00');</span>
+&nbsp;
+  // Daily digest
+<span class="cstat-no" title="statement not covered" >  const [dailyDigestEnabled, setDailyDigestEnabled] = useState(provider?.daily_digest_enabled || false);</span>
+<span class="cstat-no" title="statement not covered" >  const [dailyDigestTime, setDailyDigestTime] = useState(provider?.daily_digest_time || '08:00');</span>
+&nbsp;
+  // Event toggles
+<span class="cstat-no" title="statement not covered" >  const [onPrintStart, setOnPrintStart] = useState(provider?.on_print_start ?? false);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrintComplete, setOnPrintComplete] = useState(provider?.on_print_complete ?? true);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrintFailed, setOnPrintFailed] = useState(provider?.on_print_failed ?? true);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrintStopped, setOnPrintStopped] = useState(provider?.on_print_stopped ?? true);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrintProgress, setOnPrintProgress] = useState(provider?.on_print_progress ?? false);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrinterOffline, setOnPrinterOffline] = useState(provider?.on_printer_offline ?? false);</span>
+<span class="cstat-no" title="statement not covered" >  const [onPrinterError, setOnPrinterError] = useState(provider?.on_printer_error ?? false);</span>
+<span class="cstat-no" title="statement not covered" >  const [onFilamentLow, setOnFilamentLow] = useState(provider?.on_filament_low ?? false);</span>
+<span class="cstat-no" title="statement not covered" >  const [onMaintenanceDue, setOnMaintenanceDue] = useState(provider?.on_maintenance_due ?? false);</span>
+&nbsp;
+  // Provider-specific config
+<span class="cstat-no" title="statement not covered" >  const [config, setConfig] = useState&lt;Record&lt;string, string&gt;&gt;(</span>
+<span class="cstat-no" title="statement not covered" >    provider?.config ? Object.fromEntries(Object.entries(provider.config).map(([k, v]) =&gt; [k, String(v)])) : {}</span>
+<span class="cstat-no" title="statement not covered" >  );</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const [testResult, setTestResult] = useState&lt;{ success: boolean; message: string } | null&gt;(null);</span>
+<span class="cstat-no" title="statement not covered" >  const [error, setError] = useState&lt;string | null&gt;(null);</span>
+&nbsp;
+  // Fetch printers for linking
+<span class="cstat-no" title="statement not covered" >  const { data: printers } = useQuery({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['printers'],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: api.getPrinters,</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+  // Test configuration mutation
+<span class="cstat-no" title="statement not covered" >  const testMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: () =&gt; api.testNotificationConfig({ provider_type: providerType, config }),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: (result) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setTestResult(result);</span>
+<span class="cstat-no" title="statement not covered" >      setError(null);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setTestResult({ success: false, message: err.message });</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Create mutation
+<span class="cstat-no" title="statement not covered" >  const createMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: (data: NotificationProviderCreate) =&gt; api.createNotificationProvider(data),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Update mutation
+<span class="cstat-no" title="statement not covered" >  const updateMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: (data: NotificationProviderUpdate) =&gt; api.updateNotificationProvider(provider!.id, data),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['notification-providers'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleSubmit = (e: React.FormEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    e.preventDefault();</span>
+<span class="cstat-no" title="statement not covered" >    setError(null);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (!name.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('Name is required');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+    // Validate provider-specific config
+<span class="cstat-no" title="statement not covered" >    const requiredFields = getRequiredFields(providerType);</span>
+<span class="cstat-no" title="statement not covered" >    for (const field of requiredFields) {</span>
+<span class="cstat-no" title="statement not covered" >      if (!config[field.key]?.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >        setError(`${field.label} is required`);</span>
+<span class="cstat-no" title="statement not covered" >        return;</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const data = {</span>
+<span class="cstat-no" title="statement not covered" >      name: name.trim(),</span>
+<span class="cstat-no" title="statement not covered" >      provider_type: providerType,</span>
+<span class="cstat-no" title="statement not covered" >      config,</span>
+<span class="cstat-no" title="statement not covered" >      printer_id: printerId,</span>
+<span class="cstat-no" title="statement not covered" >      quiet_hours_enabled: quietHoursEnabled,</span>
+<span class="cstat-no" title="statement not covered" >      quiet_hours_start: quietHoursEnabled ? quietHoursStart : null,</span>
+<span class="cstat-no" title="statement not covered" >      quiet_hours_end: quietHoursEnabled ? quietHoursEnd : null,</span>
+      // Daily digest
+<span class="cstat-no" title="statement not covered" >      daily_digest_enabled: dailyDigestEnabled,</span>
+<span class="cstat-no" title="statement not covered" >      daily_digest_time: dailyDigestEnabled ? dailyDigestTime : null,</span>
+      // Event toggles
+<span class="cstat-no" title="statement not covered" >      on_print_start: onPrintStart,</span>
+<span class="cstat-no" title="statement not covered" >      on_print_complete: onPrintComplete,</span>
+<span class="cstat-no" title="statement not covered" >      on_print_failed: onPrintFailed,</span>
+<span class="cstat-no" title="statement not covered" >      on_print_stopped: onPrintStopped,</span>
+<span class="cstat-no" title="statement not covered" >      on_print_progress: onPrintProgress,</span>
+<span class="cstat-no" title="statement not covered" >      on_printer_offline: onPrinterOffline,</span>
+<span class="cstat-no" title="statement not covered" >      on_printer_error: onPrinterError,</span>
+<span class="cstat-no" title="statement not covered" >      on_filament_low: onFilamentLow,</span>
+<span class="cstat-no" title="statement not covered" >      on_maintenance_due: onMaintenanceDue,</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (isEditing) {</span>
+<span class="cstat-no" title="statement not covered" >      updateMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    } else {</span>
+<span class="cstat-no" title="statement not covered" >      createMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const isPending = createMutation.isPending || updateMutation.isPending;</span>
+&nbsp;
+  // Get config fields for each provider type
+<span class="cstat-no" title="statement not covered" >  const getConfigFields = (type: ProviderType) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    switch (type) {</span>
+<span class="cstat-no" title="statement not covered" >      case 'callmebot':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'phone', label: 'Phone Number', placeholder: '+1234567890', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'apikey', label: 'API Key', placeholder: 'Your CallMeBot API key', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'ntfy':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'server', label: 'Server URL', placeholder: 'https://ntfy.sh', type: 'text', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'topic', label: 'Topic', placeholder: 'my-bambuddy', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'auth_token', label: 'Auth Token', placeholder: 'Optional authentication', type: 'password', required: false },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'pushover':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'user_key', label: 'User Key', placeholder: 'Your Pushover user key', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'app_token', label: 'App Token', placeholder: 'Your Pushover app token', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'priority', label: 'Priority', placeholder: '0 (normal)', type: 'number', required: false },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'telegram':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'bot_token', label: 'Bot Token', placeholder: 'Bot token from @BotFather', type: 'password', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'chat_id', label: 'Chat ID', placeholder: 'Your chat or group ID', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'email':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'smtp_server', label: 'SMTP Server', placeholder: 'smtp.gmail.com', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'smtp_port', label: 'SMTP Port', placeholder: '587', type: 'number', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'security', label: 'Security', type: 'select', required: false, options: [</span>
+<span class="cstat-no" title="statement not covered" >            { value: 'starttls', label: 'STARTTLS (Port 587)' },</span>
+<span class="cstat-no" title="statement not covered" >            { value: 'ssl', label: 'SSL/TLS (Port 465)' },</span>
+<span class="cstat-no" title="statement not covered" >            { value: 'none', label: 'None (Port 25)' },</span>
+<span class="cstat-no" title="statement not covered" >          ]},</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'auth_enabled', label: 'Authentication', type: 'select', required: false, options: [</span>
+<span class="cstat-no" title="statement not covered" >            { value: 'true', label: 'Enabled' },</span>
+<span class="cstat-no" title="statement not covered" >            { value: 'false', label: 'Disabled' },</span>
+<span class="cstat-no" title="statement not covered" >          ]},</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'username', label: 'Username', placeholder: 'your@email.com', type: 'text', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'password', label: 'Password', placeholder: 'App password', type: 'password', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'from_email', label: 'From Email', placeholder: 'your@email.com', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'to_email', label: 'To Email', placeholder: 'recipient@email.com', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'discord':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://discord.com/api/webhooks/...', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      case 'webhook':</span>
+<span class="cstat-no" title="statement not covered" >        return [</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'webhook_url', label: 'Webhook URL', placeholder: 'https://example.com/webhook', type: 'text', required: true },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'auth_header', label: 'Authorization', placeholder: 'Bearer token (optional)', type: 'password', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'field_title', label: 'Title Field Name', placeholder: 'title', type: 'text', required: false },</span>
+<span class="cstat-no" title="statement not covered" >          { key: 'field_message', label: 'Message Field Name', placeholder: 'message', type: 'text', required: false },</span>
+<span class="cstat-no" title="statement not covered" >        ];</span>
+<span class="cstat-no" title="statement not covered" >      default:</span>
+<span class="cstat-no" title="statement not covered" >        return [];</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const getRequiredFields = (type: ProviderType) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    return getConfigFields(type).filter(f =&gt; f.required);</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const configFields = getConfigFields(providerType);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div</span>
+<span class="cstat-no" title="statement not covered" >      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4 overflow-y-auto"</span>
+<span class="cstat-no" title="statement not covered" >      onClick={onClose}</span>
+    &gt;
+<span class="cstat-no" title="statement not covered" >      &lt;div</span>
+<span class="cstat-no" title="statement not covered" >        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-lg my-8"</span>
+<span class="cstat-no" title="statement not covered" >        onClick={(e) =&gt; e.stopPropagation()}</span>
+      &gt;
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;h2 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {isEditing ? 'Edit Notification Provider' : 'Add Notification Provider'}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >            className="text-bambu-gray hover:text-white transition-colors"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Form */}
+<span class="cstat-no" title="statement not covered" >        &lt;form onSubmit={handleSubmit} className="p-6 space-y-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {error &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {error}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Name */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Name *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;input</span>
+<span class="cstat-no" title="statement not covered" >              type="text"</span>
+<span class="cstat-no" title="statement not covered" >              value={name}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setName(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >              placeholder="My Notifications"</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >            /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Provider Type */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Provider Type *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;select</span>
+<span class="cstat-no" title="statement not covered" >              value={providerType}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                setProviderType(e.target.value as ProviderType);</span>
+<span class="cstat-no" title="statement not covered" >                setConfig({}); // Reset config when changing type</span>
+<span class="cstat-no" title="statement not covered" >                setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >              }}</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isEditing}</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none disabled:opacity-50"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {PROVIDER_OPTIONS.map((option) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;option key={option.value} value={option.value}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {option.label}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/select&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-xs text-bambu-gray mt-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {PROVIDER_OPTIONS.find(o =&gt; o.value === providerType)?.description}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Provider-specific configuration */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="space-y-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm text-bambu-gray"&gt;Configuration&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {configFields.map((field) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div key={field.key}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;label className="block text-sm text-bambu-gray mb-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {field.label} {field.required &amp;&amp; '*'}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {field.type === 'select' &amp;&amp; field.options ? (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;select</span>
+<span class="cstat-no" title="statement not covered" >                    value={config[field.key] || field.options[0]?.value || ''}</span>
+<span class="cstat-no" title="statement not covered" >                    onChange={(e) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                      setConfig({ ...config, [field.key]: e.target.value });</span>
+<span class="cstat-no" title="statement not covered" >                      setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >                    }}</span>
+<span class="cstat-no" title="statement not covered" >                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+                  &gt;
+<span class="cstat-no" title="statement not covered" >                    {field.options.map((opt) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;option key={opt.value} value={opt.value}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        {opt.label}</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    ))}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/select&gt;</span>
+                ) : (
+<span class="cstat-no" title="statement not covered" >                  &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                    type={field.type}</span>
+<span class="cstat-no" title="statement not covered" >                    value={config[field.key] || ''}</span>
+<span class="cstat-no" title="statement not covered" >                    onChange={(e) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                      setConfig({ ...config, [field.key]: e.target.value });</span>
+<span class="cstat-no" title="statement not covered" >                      setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >                    }}</span>
+<span class="cstat-no" title="statement not covered" >                    placeholder={field.placeholder}</span>
+<span class="cstat-no" title="statement not covered" >                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+                )}
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ))}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Test Button */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="button"</span>
+<span class="cstat-no" title="statement not covered" >              variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >              onClick={() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >                testMutation.mutate();</span>
+<span class="cstat-no" title="statement not covered" >              }}</span>
+<span class="cstat-no" title="statement not covered" >              disabled={testMutation.isPending || !config[getRequiredFields(providerType)[0]?.key]}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {testMutation.isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;Send className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              )}</span>
+              Test Configuration
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Test Result */}
+<span class="cstat-no" title="statement not covered" >          {testResult &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className={`p-3 rounded-lg flex items-center gap-2 ${</span>
+<span class="cstat-no" title="statement not covered" >              testResult.success</span>
+<span class="cstat-no" title="statement not covered" >                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'</span>
+<span class="cstat-no" title="statement not covered" >                : 'bg-red-500/20 border border-red-500/50 text-red-400'</span>
+<span class="cstat-no" title="statement not covered" >            }`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {testResult.success ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;CheckCircle className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span&gt;{testResult.message}&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;XCircle className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span&gt;{testResult.message}&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Link to Printer */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Printer Filter&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;select</span>
+<span class="cstat-no" title="statement not covered" >              value={printerId ?? ''}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setPrinterId(e.target.value ? Number(e.target.value) : null)}</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              &lt;option value=""&gt;All printers&lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {printers?.map((p) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;option key={p.id} value={p.id}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {p.name}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/select&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-xs text-bambu-gray mt-1"&gt;</span>
+              Only send notifications for events from this printer
+<span class="cstat-no" title="statement not covered" >            &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Quiet Hours */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="space-y-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="text-sm text-white"&gt;Quiet Hours (Do Not Disturb)&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Toggle</span>
+<span class="cstat-no" title="statement not covered" >                checked={quietHoursEnabled}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={setQuietHoursEnabled}</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {quietHoursEnabled &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="grid grid-cols-2 gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;label className="block text-xs text-bambu-gray mb-1"&gt;Start&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                    type="time"</span>
+<span class="cstat-no" title="statement not covered" >                    value={quietHoursStart}</span>
+<span class="cstat-no" title="statement not covered" >                    onChange={(e) =&gt; setQuietHoursStart(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;label className="block text-xs text-bambu-gray mb-1"&gt;End&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                    type="time"</span>
+<span class="cstat-no" title="statement not covered" >                    value={quietHoursEnd}</span>
+<span class="cstat-no" title="statement not covered" >                    onChange={(e) =&gt; setQuietHoursEnd(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                    className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Daily Digest */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="space-y-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;label className="text-sm text-white"&gt;Daily Digest&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray"&gt;Batch notifications into a single daily summary&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Toggle</span>
+<span class="cstat-no" title="statement not covered" >                checked={dailyDigestEnabled}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={setDailyDigestEnabled}</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {dailyDigestEnabled &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;label className="block text-xs text-bambu-gray mb-1"&gt;Send digest at&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="time"</span>
+<span class="cstat-no" title="statement not covered" >                  value={dailyDigestTime}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setDailyDigestTime(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray mt-1"&gt;</span>
+                  Events will be collected and sent as a single summary at this time
+<span class="cstat-no" title="statement not covered" >                &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Event Toggles */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="space-y-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm text-bambu-gray"&gt;Notification Events&lt;/p&gt;</span>
+&nbsp;
+            {/* Print Events */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="space-y-2 p-3 bg-bambu-dark rounded-lg"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-xs text-bambu-gray uppercase tracking-wide mb-2"&gt;Print Events&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="grid grid-cols-2 gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Start&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrintStart} onChange={setOnPrintStart} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Complete&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrintComplete} onChange={setOnPrintComplete} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Failed&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrintFailed} onChange={setOnPrintFailed} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Stopped&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrintStopped} onChange={setOnPrintStopped} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between col-span-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;span className="text-sm text-white"&gt;Progress&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;span className="text-xs text-bambu-gray ml-1"&gt;(25%, 50%, 75%)&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrintProgress} onChange={setOnPrintProgress} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Printer Status Events */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="space-y-2 p-3 bg-bambu-dark rounded-lg"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-xs text-bambu-gray uppercase tracking-wide mb-2"&gt;Printer Status&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="grid grid-cols-2 gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Offline&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrinterOffline} onChange={setOnPrinterOffline} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Error&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onPrinterError} onChange={setOnPrinterError} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Low Filament&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onFilamentLow} onChange={setOnFilamentLow} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-sm text-white"&gt;Maintenance&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Toggle checked={onMaintenanceDue} onChange={setOnMaintenanceDue} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Actions */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-3 pt-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="button"</span>
+<span class="cstat-no" title="statement not covered" >              variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+<span class="cstat-no" title="statement not covered" >            &gt;</span>
+              Cancel
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="submit"</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isPending}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;Save className="w-4 h-4" /&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >              {isEditing ? 'Save' : 'Add'}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/form&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 1321 - 0
frontend/coverage/src/components/AddSmartPlugModal.tsx.html

@@ -0,0 +1,1321 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/AddSmartPlugModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> AddSmartPlugModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/332</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/332</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a>
+<a name='L297'></a><a href='#L297'>297</a>
+<a name='L298'></a><a href='#L298'>298</a>
+<a name='L299'></a><a href='#L299'>299</a>
+<a name='L300'></a><a href='#L300'>300</a>
+<a name='L301'></a><a href='#L301'>301</a>
+<a name='L302'></a><a href='#L302'>302</a>
+<a name='L303'></a><a href='#L303'>303</a>
+<a name='L304'></a><a href='#L304'>304</a>
+<a name='L305'></a><a href='#L305'>305</a>
+<a name='L306'></a><a href='#L306'>306</a>
+<a name='L307'></a><a href='#L307'>307</a>
+<a name='L308'></a><a href='#L308'>308</a>
+<a name='L309'></a><a href='#L309'>309</a>
+<a name='L310'></a><a href='#L310'>310</a>
+<a name='L311'></a><a href='#L311'>311</a>
+<a name='L312'></a><a href='#L312'>312</a>
+<a name='L313'></a><a href='#L313'>313</a>
+<a name='L314'></a><a href='#L314'>314</a>
+<a name='L315'></a><a href='#L315'>315</a>
+<a name='L316'></a><a href='#L316'>316</a>
+<a name='L317'></a><a href='#L317'>317</a>
+<a name='L318'></a><a href='#L318'>318</a>
+<a name='L319'></a><a href='#L319'>319</a>
+<a name='L320'></a><a href='#L320'>320</a>
+<a name='L321'></a><a href='#L321'>321</a>
+<a name='L322'></a><a href='#L322'>322</a>
+<a name='L323'></a><a href='#L323'>323</a>
+<a name='L324'></a><a href='#L324'>324</a>
+<a name='L325'></a><a href='#L325'>325</a>
+<a name='L326'></a><a href='#L326'>326</a>
+<a name='L327'></a><a href='#L327'>327</a>
+<a name='L328'></a><a href='#L328'>328</a>
+<a name='L329'></a><a href='#L329'>329</a>
+<a name='L330'></a><a href='#L330'>330</a>
+<a name='L331'></a><a href='#L331'>331</a>
+<a name='L332'></a><a href='#L332'>332</a>
+<a name='L333'></a><a href='#L333'>333</a>
+<a name='L334'></a><a href='#L334'>334</a>
+<a name='L335'></a><a href='#L335'>335</a>
+<a name='L336'></a><a href='#L336'>336</a>
+<a name='L337'></a><a href='#L337'>337</a>
+<a name='L338'></a><a href='#L338'>338</a>
+<a name='L339'></a><a href='#L339'>339</a>
+<a name='L340'></a><a href='#L340'>340</a>
+<a name='L341'></a><a href='#L341'>341</a>
+<a name='L342'></a><a href='#L342'>342</a>
+<a name='L343'></a><a href='#L343'>343</a>
+<a name='L344'></a><a href='#L344'>344</a>
+<a name='L345'></a><a href='#L345'>345</a>
+<a name='L346'></a><a href='#L346'>346</a>
+<a name='L347'></a><a href='#L347'>347</a>
+<a name='L348'></a><a href='#L348'>348</a>
+<a name='L349'></a><a href='#L349'>349</a>
+<a name='L350'></a><a href='#L350'>350</a>
+<a name='L351'></a><a href='#L351'>351</a>
+<a name='L352'></a><a href='#L352'>352</a>
+<a name='L353'></a><a href='#L353'>353</a>
+<a name='L354'></a><a href='#L354'>354</a>
+<a name='L355'></a><a href='#L355'>355</a>
+<a name='L356'></a><a href='#L356'>356</a>
+<a name='L357'></a><a href='#L357'>357</a>
+<a name='L358'></a><a href='#L358'>358</a>
+<a name='L359'></a><a href='#L359'>359</a>
+<a name='L360'></a><a href='#L360'>360</a>
+<a name='L361'></a><a href='#L361'>361</a>
+<a name='L362'></a><a href='#L362'>362</a>
+<a name='L363'></a><a href='#L363'>363</a>
+<a name='L364'></a><a href='#L364'>364</a>
+<a name='L365'></a><a href='#L365'>365</a>
+<a name='L366'></a><a href='#L366'>366</a>
+<a name='L367'></a><a href='#L367'>367</a>
+<a name='L368'></a><a href='#L368'>368</a>
+<a name='L369'></a><a href='#L369'>369</a>
+<a name='L370'></a><a href='#L370'>370</a>
+<a name='L371'></a><a href='#L371'>371</a>
+<a name='L372'></a><a href='#L372'>372</a>
+<a name='L373'></a><a href='#L373'>373</a>
+<a name='L374'></a><a href='#L374'>374</a>
+<a name='L375'></a><a href='#L375'>375</a>
+<a name='L376'></a><a href='#L376'>376</a>
+<a name='L377'></a><a href='#L377'>377</a>
+<a name='L378'></a><a href='#L378'>378</a>
+<a name='L379'></a><a href='#L379'>379</a>
+<a name='L380'></a><a href='#L380'>380</a>
+<a name='L381'></a><a href='#L381'>381</a>
+<a name='L382'></a><a href='#L382'>382</a>
+<a name='L383'></a><a href='#L383'>383</a>
+<a name='L384'></a><a href='#L384'>384</a>
+<a name='L385'></a><a href='#L385'>385</a>
+<a name='L386'></a><a href='#L386'>386</a>
+<a name='L387'></a><a href='#L387'>387</a>
+<a name='L388'></a><a href='#L388'>388</a>
+<a name='L389'></a><a href='#L389'>389</a>
+<a name='L390'></a><a href='#L390'>390</a>
+<a name='L391'></a><a href='#L391'>391</a>
+<a name='L392'></a><a href='#L392'>392</a>
+<a name='L393'></a><a href='#L393'>393</a>
+<a name='L394'></a><a href='#L394'>394</a>
+<a name='L395'></a><a href='#L395'>395</a>
+<a name='L396'></a><a href='#L396'>396</a>
+<a name='L397'></a><a href='#L397'>397</a>
+<a name='L398'></a><a href='#L398'>398</a>
+<a name='L399'></a><a href='#L399'>399</a>
+<a name='L400'></a><a href='#L400'>400</a>
+<a name='L401'></a><a href='#L401'>401</a>
+<a name='L402'></a><a href='#L402'>402</a>
+<a name='L403'></a><a href='#L403'>403</a>
+<a name='L404'></a><a href='#L404'>404</a>
+<a name='L405'></a><a href='#L405'>405</a>
+<a name='L406'></a><a href='#L406'>406</a>
+<a name='L407'></a><a href='#L407'>407</a>
+<a name='L408'></a><a href='#L408'>408</a>
+<a name='L409'></a><a href='#L409'>409</a>
+<a name='L410'></a><a href='#L410'>410</a>
+<a name='L411'></a><a href='#L411'>411</a>
+<a name='L412'></a><a href='#L412'>412</a>
+<a name='L413'></a><a href='#L413'>413</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect } from 'react';</span></span></span>
+import { useMutation, useQueryClient, useQuery } from '@tanstack/react-query';
+import { X, Save, Loader2, Wifi, WifiOff, CheckCircle, Bell, Clock } from 'lucide-react';
+import { api } from '../api/client';
+import type { SmartPlug, SmartPlugCreate, SmartPlugUpdate } from '../api/client';
+import { Button } from './Button';
+&nbsp;
+interface AddSmartPlugModalProps {
+  plug?: SmartPlug | null;
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function AddSmartPlugModal({ plug, onClose }: AddSmartPlugModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const queryClient = useQueryClient();</span>
+<span class="cstat-no" title="statement not covered" >  const isEditing = !!plug;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const [name, setName] = useState(plug?.name || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [ipAddress, setIpAddress] = useState(plug?.ip_address || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [username, setUsername] = useState(plug?.username || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [password, setPassword] = useState(plug?.password || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [printerId, setPrinterId] = useState&lt;number | null&gt;(plug?.printer_id || null);</span>
+<span class="cstat-no" title="statement not covered" >  const [testResult, setTestResult] = useState&lt;{ success: boolean; state?: string | null; device_name?: string | null } | null&gt;(null);</span>
+<span class="cstat-no" title="statement not covered" >  const [error, setError] = useState&lt;string | null&gt;(null);</span>
+&nbsp;
+  // Power alert settings
+<span class="cstat-no" title="statement not covered" >  const [powerAlertEnabled, setPowerAlertEnabled] = useState(plug?.power_alert_enabled || false);</span>
+<span class="cstat-no" title="statement not covered" >  const [powerAlertHigh, setPowerAlertHigh] = useState&lt;string&gt;(plug?.power_alert_high?.toString() || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [powerAlertLow, setPowerAlertLow] = useState&lt;string&gt;(plug?.power_alert_low?.toString() || '');</span>
+&nbsp;
+  // Schedule settings
+<span class="cstat-no" title="statement not covered" >  const [scheduleEnabled, setScheduleEnabled] = useState(plug?.schedule_enabled || false);</span>
+<span class="cstat-no" title="statement not covered" >  const [scheduleOnTime, setScheduleOnTime] = useState&lt;string&gt;(plug?.schedule_on_time || '');</span>
+<span class="cstat-no" title="statement not covered" >  const [scheduleOffTime, setScheduleOffTime] = useState&lt;string&gt;(plug?.schedule_off_time || '');</span>
+&nbsp;
+  // Fetch printers for linking
+<span class="cstat-no" title="statement not covered" >  const { data: printers } = useQuery({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['printers'],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: api.getPrinters,</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Fetch existing plugs to check for conflicts
+<span class="cstat-no" title="statement not covered" >  const { data: existingPlugs } = useQuery({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['smart-plugs'],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: api.getSmartPlugs,</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+  // Test connection mutation
+<span class="cstat-no" title="statement not covered" >  const testMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: () =&gt; api.testSmartPlugConnection(ipAddress, username || null, password || null),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: (result) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setTestResult(result);</span>
+<span class="cstat-no" title="statement not covered" >      setError(null);</span>
+      // Auto-fill name from device if empty
+<span class="cstat-no" title="statement not covered" >      if (!name &amp;&amp; result.device_name) {</span>
+<span class="cstat-no" title="statement not covered" >        setName(result.device_name);</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Create mutation
+<span class="cstat-no" title="statement not covered" >  const createMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: (data: SmartPlugCreate) =&gt; api.createSmartPlug(data),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Update mutation
+<span class="cstat-no" title="statement not covered" >  const updateMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: (data: SmartPlugUpdate) =&gt; api.updateSmartPlug(plug!.id, data),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['smart-plugs'] });</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (err: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      setError(err.message);</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Filter out printers that already have a plug assigned (except current plug's printer)
+<span class="cstat-no" title="statement not covered" >  const availablePrinters = printers?.filter(p =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const hasPlug = existingPlugs?.some(ep =&gt; ep.printer_id === p.id &amp;&amp; ep.id !== plug?.id);</span>
+<span class="cstat-no" title="statement not covered" >    return !hasPlug;</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleSubmit = (e: React.FormEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    e.preventDefault();</span>
+<span class="cstat-no" title="statement not covered" >    setError(null);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (!name.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('Name is required');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >    if (!ipAddress.trim()) {</span>
+<span class="cstat-no" title="statement not covered" >      setError('IP address is required');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const data = {</span>
+<span class="cstat-no" title="statement not covered" >      name: name.trim(),</span>
+<span class="cstat-no" title="statement not covered" >      ip_address: ipAddress.trim(),</span>
+<span class="cstat-no" title="statement not covered" >      username: username.trim() || null,</span>
+<span class="cstat-no" title="statement not covered" >      password: password.trim() || null,</span>
+<span class="cstat-no" title="statement not covered" >      printer_id: printerId,</span>
+      // Power alerts
+<span class="cstat-no" title="statement not covered" >      power_alert_enabled: powerAlertEnabled,</span>
+<span class="cstat-no" title="statement not covered" >      power_alert_high: powerAlertHigh ? parseFloat(powerAlertHigh) : null,</span>
+<span class="cstat-no" title="statement not covered" >      power_alert_low: powerAlertLow ? parseFloat(powerAlertLow) : null,</span>
+      // Schedule
+<span class="cstat-no" title="statement not covered" >      schedule_enabled: scheduleEnabled,</span>
+<span class="cstat-no" title="statement not covered" >      schedule_on_time: scheduleOnTime || null,</span>
+<span class="cstat-no" title="statement not covered" >      schedule_off_time: scheduleOffTime || null,</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (isEditing) {</span>
+<span class="cstat-no" title="statement not covered" >      updateMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    } else {</span>
+<span class="cstat-no" title="statement not covered" >      createMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const isPending = createMutation.isPending || updateMutation.isPending;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div</span>
+<span class="cstat-no" title="statement not covered" >      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"</span>
+<span class="cstat-no" title="statement not covered" >      onClick={onClose}</span>
+    &gt;
+<span class="cstat-no" title="statement not covered" >      &lt;div</span>
+<span class="cstat-no" title="statement not covered" >        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-md"</span>
+<span class="cstat-no" title="statement not covered" >        onClick={(e) =&gt; e.stopPropagation()}</span>
+      &gt;
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;h2 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {isEditing ? 'Edit Smart Plug' : 'Add Smart Plug'}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >            className="text-bambu-gray hover:text-white transition-colors"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Form */}
+<span class="cstat-no" title="statement not covered" >        &lt;form onSubmit={handleSubmit} className="p-6 space-y-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {error &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="p-3 bg-red-500/20 border border-red-500/50 rounded-lg text-sm text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {error}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* IP Address */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;IP Address *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                type="text"</span>
+<span class="cstat-no" title="statement not covered" >                value={ipAddress}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={(e) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                  setIpAddress(e.target.value);</span>
+<span class="cstat-no" title="statement not covered" >                  setTestResult(null);</span>
+<span class="cstat-no" title="statement not covered" >                }}</span>
+<span class="cstat-no" title="statement not covered" >                placeholder="192.168.1.100"</span>
+<span class="cstat-no" title="statement not covered" >                className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                type="button"</span>
+<span class="cstat-no" title="statement not covered" >                variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; testMutation.mutate()}</span>
+<span class="cstat-no" title="statement not covered" >                disabled={!ipAddress.trim() || testMutation.isPending}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                {testMutation.isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+                ) : (
+<span class="cstat-no" title="statement not covered" >                  &lt;Wifi className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                )}</span>
+                Test
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Test Result */}
+<span class="cstat-no" title="statement not covered" >          {testResult &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className={`p-3 rounded-lg flex items-center gap-2 ${</span>
+<span class="cstat-no" title="statement not covered" >              testResult.success</span>
+<span class="cstat-no" title="statement not covered" >                ? 'bg-bambu-green/20 border border-bambu-green/50 text-bambu-green'</span>
+<span class="cstat-no" title="statement not covered" >                : 'bg-red-500/20 border border-red-500/50 text-red-400'</span>
+<span class="cstat-no" title="statement not covered" >            }`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {testResult.success ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;CheckCircle className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="font-medium"&gt;Connected!&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="text-sm opacity-80"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      {testResult.device_name &amp;&amp; `Device: ${testResult.device_name} - `}</span>
+<span class="cstat-no" title="statement not covered" >                      State: {testResult.state}</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;WifiOff className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span&gt;Connection failed&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Name */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Name *&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;input</span>
+<span class="cstat-no" title="statement not covered" >              type="text"</span>
+<span class="cstat-no" title="statement not covered" >              value={name}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setName(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >              placeholder="Living Room Plug"</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >            /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Authentication (optional) */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="grid grid-cols-2 gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Username&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                type="text"</span>
+<span class="cstat-no" title="statement not covered" >                value={username}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={(e) =&gt; setUsername(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                placeholder="admin"</span>
+<span class="cstat-no" title="statement not covered" >                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Password&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                type="password"</span>
+<span class="cstat-no" title="statement not covered" >                value={password}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={(e) =&gt; setPassword(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                placeholder="********"</span>
+<span class="cstat-no" title="statement not covered" >                className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;p className="text-xs text-bambu-gray -mt-2"&gt;</span>
+            Leave empty if your Tasmota device doesn't require authentication
+<span class="cstat-no" title="statement not covered" >          &lt;/p&gt;</span>
+&nbsp;
+          {/* Link to Printer */}
+<span class="cstat-no" title="statement not covered" >          &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Link to Printer&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;select</span>
+<span class="cstat-no" title="statement not covered" >              value={printerId ?? ''}</span>
+<span class="cstat-no" title="statement not covered" >              onChange={(e) =&gt; setPrinterId(e.target.value ? Number(e.target.value) : null)}</span>
+<span class="cstat-no" title="statement not covered" >              className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              &lt;option value=""&gt;No printer (manual control only)&lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {availablePrinters?.map((p) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;option key={p.id} value={p.id}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {p.name}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/select&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-xs text-bambu-gray mt-1"&gt;</span>
+              Linking enables automatic on/off when prints start/complete
+<span class="cstat-no" title="statement not covered" >            &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Power Alerts */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="border-t border-bambu-dark-tertiary pt-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center justify-between mb-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Bell className="w-4 h-4 text-bambu-green" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;span className="text-white font-medium"&gt;Power Alerts&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="relative inline-flex items-center cursor-pointer"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="checkbox"</span>
+<span class="cstat-no" title="statement not covered" >                  checked={powerAlertEnabled}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setPowerAlertEnabled(e.target.checked)}</span>
+<span class="cstat-no" title="statement not covered" >                  className="sr-only peer"</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"&gt;&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {powerAlertEnabled &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="space-y-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="grid grid-cols-2 gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Alert if above (W)&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                      type="number"</span>
+<span class="cstat-no" title="statement not covered" >                      value={powerAlertHigh}</span>
+<span class="cstat-no" title="statement not covered" >                      onChange={(e) =&gt; setPowerAlertHigh(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                      placeholder="e.g. 200"</span>
+<span class="cstat-no" title="statement not covered" >                      min="0"</span>
+<span class="cstat-no" title="statement not covered" >                      max="5000"</span>
+<span class="cstat-no" title="statement not covered" >                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                    /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Alert if below (W)&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                      type="number"</span>
+<span class="cstat-no" title="statement not covered" >                      value={powerAlertLow}</span>
+<span class="cstat-no" title="statement not covered" >                      onChange={(e) =&gt; setPowerAlertLow(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                      placeholder="e.g. 10"</span>
+<span class="cstat-no" title="statement not covered" >                      min="0"</span>
+<span class="cstat-no" title="statement not covered" >                      max="5000"</span>
+<span class="cstat-no" title="statement not covered" >                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                    /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray"&gt;</span>
+                  Get notified when power consumption crosses these thresholds. Leave empty to disable that direction.
+<span class="cstat-no" title="statement not covered" >                &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Schedule */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="border-t border-bambu-dark-tertiary pt-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center justify-between mb-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Clock className="w-4 h-4 text-bambu-green" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;span className="text-white font-medium"&gt;Daily Schedule&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="relative inline-flex items-center cursor-pointer"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="checkbox"</span>
+<span class="cstat-no" title="statement not covered" >                  checked={scheduleEnabled}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setScheduleEnabled(e.target.checked)}</span>
+<span class="cstat-no" title="statement not covered" >                  className="sr-only peer"</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"&gt;&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {scheduleEnabled &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="space-y-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="grid grid-cols-2 gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Turn On at&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                      type="time"</span>
+<span class="cstat-no" title="statement not covered" >                      value={scheduleOnTime}</span>
+<span class="cstat-no" title="statement not covered" >                      onChange={(e) =&gt; setScheduleOnTime(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                    /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Turn Off at&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                      type="time"</span>
+<span class="cstat-no" title="statement not covered" >                      value={scheduleOffTime}</span>
+<span class="cstat-no" title="statement not covered" >                      onChange={(e) =&gt; setScheduleOffTime(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                      className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                    /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray"&gt;</span>
+                  Automatically turn the plug on/off at these times daily. Leave empty to skip that action.
+<span class="cstat-no" title="statement not covered" >                &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Actions */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-3 pt-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="button"</span>
+<span class="cstat-no" title="statement not covered" >              variant="secondary"</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+<span class="cstat-no" title="statement not covered" >            &gt;</span>
+              Cancel
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              type="submit"</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isPending}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;Save className="w-4 h-4" /&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >              {isEditing ? 'Save' : 'Add'}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/form&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 802 - 0
frontend/coverage/src/components/AddToQueueModal.tsx.html

@@ -0,0 +1,802 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/AddToQueueModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> AddToQueueModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/177</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/177</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect } from 'react';</span></span></span>
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Calendar, Clock, X, AlertCircle, Power } from 'lucide-react';
+import { api } from '../api/client';
+import type { PrintQueueItemCreate } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+&nbsp;
+interface AddToQueueModalProps {
+  archiveId: number;
+  archiveName: string;
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function AddToQueueModal({ archiveId, archiveName, onClose }: AddToQueueModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const queryClient = useQueryClient();</span>
+<span class="cstat-no" title="statement not covered" >  const { showToast } = useToast();</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const [printerId, setPrinterId] = useState&lt;number | null&gt;(null);</span>
+<span class="cstat-no" title="statement not covered" >  const [scheduleType, setScheduleType] = useState&lt;'asap' | 'scheduled'&gt;('asap');</span>
+<span class="cstat-no" title="statement not covered" >  const [scheduledTime, setScheduledTime] = useState('');</span>
+<span class="cstat-no" title="statement not covered" >  const [requirePreviousSuccess, setRequirePreviousSuccess] = useState(false);</span>
+<span class="cstat-no" title="statement not covered" >  const [autoOffAfter, setAutoOffAfter] = useState(false);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const { data: printers } = useQuery({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['printers'],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: () =&gt; api.getPrinters(),</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+  // Set default printer if only one available
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (printers?.length === 1 &amp;&amp; !printerId) {</span>
+<span class="cstat-no" title="statement not covered" >      setPrinterId(printers[0].id);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  }, [printers, printerId]);</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const addMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: (data: PrintQueueItemCreate) =&gt; api.addToQueue(data),</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['queue'] });</span>
+<span class="cstat-no" title="statement not covered" >      showToast('Added to print queue');</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (error: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      showToast(error.message || 'Failed to add to queue', 'error');</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleSubmit = (e: React.FormEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    e.preventDefault();</span>
+<span class="cstat-no" title="statement not covered" >    if (!printerId) {</span>
+<span class="cstat-no" title="statement not covered" >      showToast('Please select a printer', 'error');</span>
+<span class="cstat-no" title="statement not covered" >      return;</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    const data: PrintQueueItemCreate = {</span>
+<span class="cstat-no" title="statement not covered" >      printer_id: printerId,</span>
+<span class="cstat-no" title="statement not covered" >      archive_id: archiveId,</span>
+<span class="cstat-no" title="statement not covered" >      require_previous_success: requirePreviousSuccess,</span>
+<span class="cstat-no" title="statement not covered" >      auto_off_after: autoOffAfter,</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    if (scheduleType === 'scheduled' &amp;&amp; scheduledTime) {</span>
+<span class="cstat-no" title="statement not covered" >      data.scheduled_time = new Date(scheduledTime).toISOString();</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >    addMutation.mutate(data);</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+  // Get minimum datetime (now + 1 minute)
+<span class="cstat-no" title="statement not covered" >  const getMinDateTime = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const now = new Date();</span>
+<span class="cstat-no" title="statement not covered" >    now.setMinutes(now.getMinutes() + 1);</span>
+<span class="cstat-no" title="statement not covered" >    return now.toISOString().slice(0, 16);</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div</span>
+<span class="cstat-no" title="statement not covered" >      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"</span>
+<span class="cstat-no" title="statement not covered" >      onClick={onClose}</span>
+    &gt;
+<span class="cstat-no" title="statement not covered" >      &lt;Card className="w-full max-w-md" onClick={(e) =&gt; e.stopPropagation()}&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;CardContent className="p-0"&gt;</span>
+          {/* Header */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Calendar className="w-5 h-5 text-bambu-green" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;h2 className="text-xl font-semibold text-white"&gt;Schedule Print&lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="text-bambu-gray hover:text-white transition-colors"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Form */}
+<span class="cstat-no" title="statement not covered" >          &lt;form onSubmit={handleSubmit} className="p-4 space-y-4"&gt;</span>
+            {/* Archive name */}
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Print Job&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-white font-medium truncate"&gt;{archiveName}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Printer selection */}
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Printer&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {printers?.length === 0 ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-center gap-2 text-red-400 text-sm"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;AlertCircle className="w-4 h-4" /&gt;</span>
+                  No printers configured
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;select</span>
+<span class="cstat-no" title="statement not covered" >                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  value={printerId || ''}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setPrinterId(e.target.value ? Number(e.target.value) : null)}</span>
+<span class="cstat-no" title="statement not covered" >                  required</span>
+                &gt;
+<span class="cstat-no" title="statement not covered" >                  &lt;option value=""&gt;Select printer...&lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {printers?.map((p) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;option key={p.id} value={p.id}&gt;{p.name}&lt;/option&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  ))}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/select&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Schedule type */}
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label className="block text-sm text-bambu-gray mb-2"&gt;When to print&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                  type="button"</span>
+<span class="cstat-no" title="statement not covered" >                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                    scheduleType === 'asap'</span>
+<span class="cstat-no" title="statement not covered" >                      ? 'bg-bambu-green border-bambu-green text-white'</span>
+<span class="cstat-no" title="statement not covered" >                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'</span>
+<span class="cstat-no" title="statement not covered" >                  }`}</span>
+<span class="cstat-no" title="statement not covered" >                  onClick={() =&gt; setScheduleType('asap')}</span>
+                &gt;
+<span class="cstat-no" title="statement not covered" >                  &lt;Clock className="w-4 h-4" /&gt;</span>
+                  ASAP (when idle)
+<span class="cstat-no" title="statement not covered" >                &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                  type="button"</span>
+<span class="cstat-no" title="statement not covered" >                  className={`flex-1 px-3 py-2 rounded-lg border text-sm flex items-center justify-center gap-2 transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                    scheduleType === 'scheduled'</span>
+<span class="cstat-no" title="statement not covered" >                      ? 'bg-bambu-green border-bambu-green text-white'</span>
+<span class="cstat-no" title="statement not covered" >                      : 'bg-bambu-dark border-bambu-dark-tertiary text-bambu-gray hover:text-white'</span>
+<span class="cstat-no" title="statement not covered" >                  }`}</span>
+<span class="cstat-no" title="statement not covered" >                  onClick={() =&gt; setScheduleType('scheduled')}</span>
+                &gt;
+<span class="cstat-no" title="statement not covered" >                  &lt;Calendar className="w-4 h-4" /&gt;</span>
+                  Scheduled
+<span class="cstat-no" title="statement not covered" >                &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Scheduled time input */}
+<span class="cstat-no" title="statement not covered" >            {scheduleType === 'scheduled' &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;label className="block text-sm text-bambu-gray mb-1"&gt;Date &amp; Time&lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="datetime-local"</span>
+<span class="cstat-no" title="statement not covered" >                  className="w-full px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  value={scheduledTime}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setScheduledTime(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                  min={getMinDateTime()}</span>
+<span class="cstat-no" title="statement not covered" >                  required</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+&nbsp;
+            {/* Require previous success */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                type="checkbox"</span>
+<span class="cstat-no" title="statement not covered" >                id="requirePrevious"</span>
+<span class="cstat-no" title="statement not covered" >                checked={requirePreviousSuccess}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={(e) =&gt; setRequirePreviousSuccess(e.target.checked)}</span>
+<span class="cstat-no" title="statement not covered" >                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label htmlFor="requirePrevious" className="text-sm text-bambu-gray"&gt;</span>
+                Only start if previous print succeeded
+<span class="cstat-no" title="statement not covered" >              &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Auto power off */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                type="checkbox"</span>
+<span class="cstat-no" title="statement not covered" >                id="autoOffAfter"</span>
+<span class="cstat-no" title="statement not covered" >                checked={autoOffAfter}</span>
+<span class="cstat-no" title="statement not covered" >                onChange={(e) =&gt; setAutoOffAfter(e.target.checked)}</span>
+<span class="cstat-no" title="statement not covered" >                className="rounded border-bambu-dark-tertiary bg-bambu-dark text-bambu-green focus:ring-bambu-green"</span>
+<span class="cstat-no" title="statement not covered" >              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label htmlFor="autoOffAfter" className="text-sm text-bambu-gray flex items-center gap-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Power className="w-3.5 h-3.5" /&gt;</span>
+                Power off printer when done
+<span class="cstat-no" title="statement not covered" >              &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* Help text */}
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-xs text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {scheduleType === 'asap'</span>
+<span class="cstat-no" title="statement not covered" >                ? 'Print will start as soon as the printer is idle.'</span>
+<span class="cstat-no" title="statement not covered" >                : 'Print will start at the scheduled time if the printer is idle. If busy, it will wait until the printer becomes available.'}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/p&gt;</span>
+&nbsp;
+            {/* Actions */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-3 pt-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button type="button" variant="secondary" onClick={onClose} className="flex-1"&gt;</span>
+                Cancel
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                type="submit"</span>
+<span class="cstat-no" title="statement not covered" >                className="flex-1"</span>
+<span class="cstat-no" title="statement not covered" >                disabled={addMutation.isPending || !printerId || printers?.length === 0}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                {addMutation.isPending ? 'Adding...' : 'Add to Queue'}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/form&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/CardContent&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/Card&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 970 - 0
frontend/coverage/src/components/BackupModal.tsx.html

@@ -0,0 +1,970 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/BackupModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> BackupModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/242</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/242</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a>
+<a name='L274'></a><a href='#L274'>274</a>
+<a name='L275'></a><a href='#L275'>275</a>
+<a name='L276'></a><a href='#L276'>276</a>
+<a name='L277'></a><a href='#L277'>277</a>
+<a name='L278'></a><a href='#L278'>278</a>
+<a name='L279'></a><a href='#L279'>279</a>
+<a name='L280'></a><a href='#L280'>280</a>
+<a name='L281'></a><a href='#L281'>281</a>
+<a name='L282'></a><a href='#L282'>282</a>
+<a name='L283'></a><a href='#L283'>283</a>
+<a name='L284'></a><a href='#L284'>284</a>
+<a name='L285'></a><a href='#L285'>285</a>
+<a name='L286'></a><a href='#L286'>286</a>
+<a name='L287'></a><a href='#L287'>287</a>
+<a name='L288'></a><a href='#L288'>288</a>
+<a name='L289'></a><a href='#L289'>289</a>
+<a name='L290'></a><a href='#L290'>290</a>
+<a name='L291'></a><a href='#L291'>291</a>
+<a name='L292'></a><a href='#L292'>292</a>
+<a name='L293'></a><a href='#L293'>293</a>
+<a name='L294'></a><a href='#L294'>294</a>
+<a name='L295'></a><a href='#L295'>295</a>
+<a name='L296'></a><a href='#L296'>296</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useEffect, useState } from 'react';</span></span></span>
+import { Download, X, Settings, Bell, FileText, Plug, Printer, Palette, Wrench, Archive, Loader2, Key, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { Toggle } from './Toggle';
+&nbsp;
+interface BackupCategory {
+  id: string;
+  labelKey: string;
+  defaultLabel: string;
+  icon: React.ReactNode;
+  default: boolean;
+  description: string;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const BACKUP_CATEGORIES: BackupCategory[] = [</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'settings',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.settings',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'App Settings',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Settings className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: true,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Language, theme, update preferences',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'notifications',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.notifications',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Notification Providers',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Bell className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: true,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'ntfy, Pushover, Discord, etc.',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'templates',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.templates',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Notification Templates',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;FileText className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: true,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Custom message templates',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'smart_plugs',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.smartPlugs',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Smart Plugs',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Plug className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: true,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Tasmota plug configurations',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'printers',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.printers',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Printers',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Printer className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: false,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Printer info (access codes excluded)',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'filaments',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.filaments',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Filament Inventory',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Palette className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: false,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Filament types and costs',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'maintenance',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.maintenance',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Maintenance Types',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Wrench className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: false,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'Custom maintenance schedules',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >  {</span>
+<span class="cstat-no" title="statement not covered" >    id: 'archives',</span>
+<span class="cstat-no" title="statement not covered" >    labelKey: 'backup.categories.archives',</span>
+<span class="cstat-no" title="statement not covered" >    defaultLabel: 'Print Archives',</span>
+<span class="cstat-no" title="statement not covered" >    icon: &lt;Archive className="w-4 h-4" /&gt;,</span>
+<span class="cstat-no" title="statement not covered" >    default: false,</span>
+<span class="cstat-no" title="statement not covered" >    description: 'All print data + files (3MF, thumbnails, photos)',</span>
+<span class="cstat-no" title="statement not covered" >  },</span>
+<span class="cstat-no" title="statement not covered" >];</span>
+&nbsp;
+interface BackupModalProps {
+  onClose: () =&gt; void;
+  onExport: (categories: Record&lt;string, boolean&gt;) =&gt; Promise&lt;void&gt;;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function BackupModal({ onClose, onExport }: BackupModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const { t } = useTranslation();</span>
+<span class="cstat-no" title="statement not covered" >  const [selected, setSelected] = useState&lt;Record&lt;string, boolean&gt;&gt;(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const initial: Record&lt;string, boolean&gt; = {};</span>
+<span class="cstat-no" title="statement not covered" >    BACKUP_CATEGORIES.forEach((cat) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      initial[cat.id] = cat.default;</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    return initial;</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+<span class="cstat-no" title="statement not covered" >  const [includeAccessCodes, setIncludeAccessCodes] = useState(false);</span>
+<span class="cstat-no" title="statement not covered" >  const [isExporting, setIsExporting] = useState(false);</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const toggleCategory = (id: string) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    setSelected((prev) =&gt; ({ ...prev, [id]: !prev[id] }));</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const selectAll = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const all: Record&lt;string, boolean&gt; = {};</span>
+<span class="cstat-no" title="statement not covered" >    BACKUP_CATEGORIES.forEach((cat) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      all[cat.id] = true;</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    setSelected(all);</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const selectNone = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const none: Record&lt;string, boolean&gt; = {};</span>
+<span class="cstat-no" title="statement not covered" >    BACKUP_CATEGORIES.forEach((cat) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      none[cat.id] = false;</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    setSelected(none);</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const selectedCount = Object.values(selected).filter(Boolean).length;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleExport = async () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    setIsExporting(true);</span>
+<span class="cstat-no" title="statement not covered" >    try {</span>
+<span class="cstat-no" title="statement not covered" >      await onExport({ ...selected, access_codes: includeAccessCodes &amp;&amp; selected.printers });</span>
+<span class="cstat-no" title="statement not covered" >    } finally {</span>
+<span class="cstat-no" title="statement not covered" >      setIsExporting(false);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div</span>
+<span class="cstat-no" title="statement not covered" >      className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"</span>
+<span class="cstat-no" title="statement not covered" >      onClick={isExporting ? undefined : onClose}</span>
+    &gt;
+<span class="cstat-no" title="statement not covered" >      &lt;Card className="w-full max-w-lg" onClick={(e: React.MouseEvent) =&gt; e.stopPropagation()}&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;CardContent className="p-0"&gt;</span>
+          {/* Header */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="p-2 rounded-full bg-bambu-green/20 text-bambu-green"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Download className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;h3 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {t('backup.exportTitle', { defaultValue: 'Export Backup' })}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/h3&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-sm text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {t('backup.selectCategories', { defaultValue: 'Select data to include' })}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Quick actions */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-2 px-4 pt-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={selectAll}</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isExporting}</span>
+<span class="cstat-no" title="statement not covered" >              className="text-sm text-bambu-green hover:text-bambu-green/80 disabled:opacity-50 disabled:cursor-not-allowed"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {t('common.selectAll', { defaultValue: 'Select All' })}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;span className="text-bambu-gray"&gt;|&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={selectNone}</span>
+<span class="cstat-no" title="statement not covered" >              disabled={isExporting}</span>
+<span class="cstat-no" title="statement not covered" >              className="text-sm text-bambu-gray hover:text-white disabled:opacity-50 disabled:cursor-not-allowed"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {t('common.selectNone', { defaultValue: 'Select None' })}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Categories */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className={`p-4 space-y-2 max-h-[400px] overflow-y-auto ${isExporting ? 'opacity-50 pointer-events-none' : ''}`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {BACKUP_CATEGORIES.map((category) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;label</span>
+<span class="cstat-no" title="statement not covered" >                key={category.id}</span>
+<span class="cstat-no" title="statement not covered" >                className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                  selected[category.id]</span>
+<span class="cstat-no" title="statement not covered" >                    ? 'bg-bambu-green/10 border border-bambu-green/30'</span>
+<span class="cstat-no" title="statement not covered" >                    : 'bg-bambu-dark hover:bg-bambu-dark-tertiary border border-transparent'</span>
+<span class="cstat-no" title="statement not covered" >                }`}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="checkbox"</span>
+<span class="cstat-no" title="statement not covered" >                  checked={selected[category.id]}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={() =&gt; toggleCategory(category.id)}</span>
+<span class="cstat-no" title="statement not covered" >                  disabled={isExporting}</span>
+<span class="cstat-no" title="statement not covered" >                  className="w-4 h-4 rounded border-bambu-gray bg-bambu-dark text-bambu-green focus:ring-bambu-green focus:ring-offset-0"</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className={`${selected[category.id] ? 'text-bambu-green' : 'text-bambu-gray'}`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {category.icon}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="text-white text-sm font-medium"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {t(category.labelKey, { defaultValue: category.defaultLabel })}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="text-xs text-bambu-gray"&gt;{category.description}&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/label&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ))}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Archive warning */}
+<span class="cstat-no" title="statement not covered" >          {selected.archives &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="mx-4 mb-2 p-3 rounded-lg bg-yellow-500/10 border border-yellow-500/30"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex items-start gap-2 text-sm"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Archive className="w-4 h-4 text-yellow-500 mt-0.5 flex-shrink-0" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="text-yellow-200 dark:text-yellow-200 text-yellow-700"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="font-medium"&gt;ZIP file will be created.&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-yellow-600 dark:text-yellow-200/70"&gt; Includes all 3MF files, thumbnails, timelapses, and photos. This may take a while and result in a large file.&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Access codes option - only shown when printers are selected */}
+<span class="cstat-no" title="statement not covered" >          {selected.printers &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="mx-4 mb-2 p-3 rounded-lg bg-bambu-dark border border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex items-center justify-between"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex items-start gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Key className="w-4 h-4 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="text-sm font-medium text-white"&gt;Include Access Codes&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;p className="text-xs text-bambu-gray"&gt;For transferring to another machine&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Toggle checked={includeAccessCodes} onChange={setIncludeAccessCodes} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {includeAccessCodes &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="mt-2 p-2 rounded bg-orange-500/10 border border-orange-500/30"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="flex items-start gap-2 text-xs"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;AlertTriangle className="w-3 h-3 text-orange-500 dark:text-orange-400 mt-0.5 flex-shrink-0" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;span className="text-orange-700 dark:text-orange-200"&gt;</span>
+                      Access codes will be included in plain text. Keep this backup file secure!
+<span class="cstat-no" title="statement not covered" >                    &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          )}
+&nbsp;
+          {/* Footer */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;span className="text-sm text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {t('backup.selectedCount', {</span>
+<span class="cstat-no" title="statement not covered" >                count: selectedCount,</span>
+<span class="cstat-no" title="statement not covered" >                defaultValue: `${selectedCount} categories selected`,</span>
+<span class="cstat-no" title="statement not covered" >              })}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button variant="secondary" onClick={onClose} disabled={isExporting}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {t('common.cancel', { defaultValue: 'Cancel' })}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                onClick={handleExport}</span>
+<span class="cstat-no" title="statement not covered" >                disabled={selectedCount === 0 || isExporting}</span>
+<span class="cstat-no" title="statement not covered" >                className="bg-bambu-green hover:bg-bambu-green-dark disabled:opacity-50 disabled:cursor-not-allowed min-w-[100px]"</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                {isExporting ? (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;Loader2 className="w-4 h-4 mr-2 animate-spin" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {t('backup.exporting', { defaultValue: 'Exporting...' })}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/&gt;</span>
+                ) : (
+<span class="cstat-no" title="statement not covered" >                  &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;Download className="w-4 h-4 mr-2" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {t('backup.export', { defaultValue: 'Export' })}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/&gt;</span>
+                )}
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/CardContent&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/Card&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 778 - 0
frontend/coverage/src/components/BatchTagModal.tsx.html

@@ -0,0 +1,778 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/BatchTagModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> BatchTagModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/178</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/178</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useEffect } from 'react';</span></span></span>
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { X, Tag, Plus, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import { Card, CardContent } from './Card';
+import { Button } from './Button';
+import { useToast } from '../contexts/ToastContext';
+&nbsp;
+interface BatchTagModalProps {
+  selectedIds: number[];
+  existingTags: string[];
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function BatchTagModal({ selectedIds, existingTags, onClose }: BatchTagModalProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const queryClient = useQueryClient();</span>
+<span class="cstat-no" title="statement not covered" >  const { showToast } = useToast();</span>
+<span class="cstat-no" title="statement not covered" >  const [newTag, setNewTag] = useState('');</span>
+<span class="cstat-no" title="statement not covered" >  const [selectedTags, setSelectedTags] = useState&lt;Set&lt;string&gt;&gt;(new Set());</span>
+<span class="cstat-no" title="statement not covered" >  const [mode, setMode] = useState&lt;'add' | 'remove'&gt;('add');</span>
+&nbsp;
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const batchTagMutation = useMutation({</span>
+<span class="cstat-no" title="statement not covered" >    mutationFn: async () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      const tagsArray = Array.from(selectedTags);</span>
+<span class="cstat-no" title="statement not covered" >      let successCount = 0;</span>
+&nbsp;
+      // Process sequentially to avoid SQLite database locks
+<span class="cstat-no" title="statement not covered" >      for (const id of selectedIds) {</span>
+<span class="cstat-no" title="statement not covered" >        try {</span>
+<span class="cstat-no" title="statement not covered" >          const archive = await api.getArchive(id);</span>
+<span class="cstat-no" title="statement not covered" >          const currentTags = archive.tags ? archive.tags.split(',').map(t =&gt; t.trim()).filter(Boolean) : [];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >          let newTags: string[];</span>
+<span class="cstat-no" title="statement not covered" >          if (mode === 'add') {</span>
+            // Add tags that aren't already present
+<span class="cstat-no" title="statement not covered" >            newTags = [...new Set([...currentTags, ...tagsArray])];</span>
+<span class="cstat-no" title="statement not covered" >          } else {</span>
+            // Remove selected tags
+<span class="cstat-no" title="statement not covered" >            newTags = currentTags.filter(t =&gt; !selectedTags.has(t));</span>
+<span class="cstat-no" title="statement not covered" >          }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >          await api.updateArchive(id, { tags: newTags.join(', ') });</span>
+<span class="cstat-no" title="statement not covered" >          successCount++;</span>
+<span class="cstat-no" title="statement not covered" >        } catch (err) {</span>
+<span class="cstat-no" title="statement not covered" >          console.error(`Failed to update archive ${id}:`, err);</span>
+<span class="cstat-no" title="statement not covered" >          throw new Error(`Failed on archive ${id}: ${err instanceof Error ? err.message : 'Unknown error'}`);</span>
+<span class="cstat-no" title="statement not covered" >        }</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >      return { count: successCount, mode, tags: tagsArray };</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onSuccess: ({ count, mode, tags }) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      queryClient.invalidateQueries({ queryKey: ['archives'] });</span>
+<span class="cstat-no" title="statement not covered" >      showToast(`${mode === 'add' ? 'Added' : 'Removed'} ${tags.length} tag${tags.length !== 1 ? 's' : ''} ${mode === 'add' ? 'to' : 'from'} ${count} archive${count !== 1 ? 's' : ''}`);</span>
+<span class="cstat-no" title="statement not covered" >      onClose();</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >    onError: (error: Error) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      showToast(error.message || 'Failed to update tags', 'error');</span>
+<span class="cstat-no" title="statement not covered" >    },</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const toggleTag = (tag: string) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    setSelectedTags((prev) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      const next = new Set(prev);</span>
+<span class="cstat-no" title="statement not covered" >      if (next.has(tag)) {</span>
+<span class="cstat-no" title="statement not covered" >        next.delete(tag);</span>
+<span class="cstat-no" title="statement not covered" >      } else {</span>
+<span class="cstat-no" title="statement not covered" >        next.add(tag);</span>
+<span class="cstat-no" title="statement not covered" >      }</span>
+<span class="cstat-no" title="statement not covered" >      return next;</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const addNewTag = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (newTag.trim() &amp;&amp; !selectedTags.has(newTag.trim())) {</span>
+<span class="cstat-no" title="statement not covered" >      setSelectedTags((prev) =&gt; new Set([...prev, newTag.trim()]));</span>
+<span class="cstat-no" title="statement not covered" >      setNewTag('');</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const handleKeyDown = (e: React.KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (e.key === 'Enter') {</span>
+<span class="cstat-no" title="statement not covered" >      e.preventDefault();</span>
+<span class="cstat-no" title="statement not covered" >      addNewTag();</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;Card className="w-full max-w-md"&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;CardContent className="p-0"&gt;</span>
+          {/* Header */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Tag className="w-5 h-5 text-bambu-green" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;h2 className="text-xl font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {mode === 'add' ? 'Add Tags' : 'Remove Tags'}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >              className="text-bambu-gray hover:text-white transition-colors"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Content */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="p-4 space-y-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {mode === 'add' ? 'Add' : 'Remove'} tags for {selectedIds.length} selected archive{selectedIds.length !== 1 ? 's' : ''}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/p&gt;</span>
+&nbsp;
+            {/* Mode toggle */}
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                size="sm"</span>
+<span class="cstat-no" title="statement not covered" >                variant={mode === 'add' ? 'primary' : 'secondary'}</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; setMode('add')}</span>
+<span class="cstat-no" title="statement not covered" >              &gt;</span>
+                Add Tags
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >                size="sm"</span>
+<span class="cstat-no" title="statement not covered" >                variant={mode === 'remove' ? 'primary' : 'secondary'}</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; setMode('remove')}</span>
+<span class="cstat-no" title="statement not covered" >              &gt;</span>
+                Remove Tags
+<span class="cstat-no" title="statement not covered" >              &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+&nbsp;
+            {/* New tag input (only for add mode) */}
+<span class="cstat-no" title="statement not covered" >            {mode === 'add' &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="flex gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;input</span>
+<span class="cstat-no" title="statement not covered" >                  type="text"</span>
+<span class="cstat-no" title="statement not covered" >                  placeholder="Enter new tag..."</span>
+<span class="cstat-no" title="statement not covered" >                  className="flex-1 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white text-sm focus:border-bambu-green focus:outline-none"</span>
+<span class="cstat-no" title="statement not covered" >                  value={newTag}</span>
+<span class="cstat-no" title="statement not covered" >                  onChange={(e) =&gt; setNewTag(e.target.value)}</span>
+<span class="cstat-no" title="statement not covered" >                  onKeyDown={handleKeyDown}</span>
+<span class="cstat-no" title="statement not covered" >                /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;Button size="sm" variant="secondary" onClick={addNewTag} disabled={!newTag.trim()}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Plus className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+&nbsp;
+            {/* Existing tags */}
+<span class="cstat-no" title="statement not covered" >            {existingTags.length &gt; 0 &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray mb-2"&gt;Existing tags:&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex flex-wrap gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {existingTags.map((tag) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                      key={tag}</span>
+<span class="cstat-no" title="statement not covered" >                      onClick={() =&gt; toggleTag(tag)}</span>
+<span class="cstat-no" title="statement not covered" >                      className={`px-2 py-1 rounded text-sm transition-colors ${</span>
+<span class="cstat-no" title="statement not covered" >                        selectedTags.has(tag)</span>
+<span class="cstat-no" title="statement not covered" >                          ? 'bg-bambu-green text-white'</span>
+<span class="cstat-no" title="statement not covered" >                          : 'bg-bambu-dark-tertiary text-bambu-gray-light hover:bg-bambu-dark'</span>
+<span class="cstat-no" title="statement not covered" >                      }`}</span>
+                    &gt;
+<span class="cstat-no" title="statement not covered" >                      {tag}</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  ))}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+&nbsp;
+            {/* Selected tags preview */}
+<span class="cstat-no" title="statement not covered" >            {selectedTags.size &gt; 0 &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;p className="text-xs text-bambu-gray mb-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  Tags to {mode === 'add' ? 'add' : 'remove'}:</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div className="flex flex-wrap gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {Array.from(selectedTags).map((tag) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;span</span>
+<span class="cstat-no" title="statement not covered" >                      key={tag}</span>
+<span class="cstat-no" title="statement not covered" >                      className={`px-2 py-1 rounded text-sm flex items-center gap-1 ${</span>
+<span class="cstat-no" title="statement not covered" >                        mode === 'add' ? 'bg-green-500/20 text-green-400' : 'bg-red-500/20 text-red-400'</span>
+<span class="cstat-no" title="statement not covered" >                      }`}</span>
+                    &gt;
+<span class="cstat-no" title="statement not covered" >                      {tag}</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;button onClick={() =&gt; toggleTag(tag)} className="hover:opacity-70"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        &lt;X className="w-3 h-3" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  ))}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+&nbsp;
+          {/* Footer */}
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex gap-3 p-4 border-t border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button variant="secondary" onClick={onClose} className="flex-1"&gt;</span>
+              Cancel
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={() =&gt; batchTagMutation.mutate()}</span>
+<span class="cstat-no" title="statement not covered" >              disabled={selectedTags.size === 0 || batchTagMutation.isPending}</span>
+<span class="cstat-no" title="statement not covered" >              className="flex-1"</span>
+            &gt;
+<span class="cstat-no" title="statement not covered" >              {batchTagMutation.isPending ? (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Loader2 className="w-4 h-4 animate-spin" /&gt;</span>
+                  Processing...
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              ) : (
+<span class="cstat-no" title="statement not covered" >                &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;Tag className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {mode === 'add' ? 'Add Tags' : 'Remove Tags'}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/&gt;</span>
+              )}
+<span class="cstat-no" title="statement not covered" >            &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/CardContent&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/Card&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 211 - 0
frontend/coverage/src/components/Button.tsx.html

@@ -0,0 +1,211 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/Button.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> Button.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>30/30</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>1/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>1/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>30/30</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line high'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">107x</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import type { ButtonHTMLAttributes, ReactNode } from 'react';
+&nbsp;
+interface ButtonProps extends ButtonHTMLAttributes&lt;HTMLButtonElement&gt; {
+  variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
+  size?: 'sm' | 'md' | 'lg';
+  children: ReactNode;
+}
+&nbsp;
+export function Button({
+  variant = 'primary',
+  size = 'md',
+  className = '',
+  children,
+  ...props
+}: ButtonProps) {
+  const baseStyles =
+    'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-bambu-dark disabled:opacity-50 disabled:cursor-not-allowed';
+&nbsp;
+  const variants = {
+    primary: 'bg-bambu-green hover:bg-bambu-green-light text-white focus:ring-bambu-green',
+    secondary:
+      'bg-bambu-dark-tertiary hover:bg-bambu-gray-dark text-white focus:ring-bambu-gray',
+    danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500',
+    ghost:
+      'bg-transparent hover:bg-bambu-dark-tertiary text-bambu-gray-light hover:text-white',
+  };
+&nbsp;
+  const sizes = {
+    sm: 'px-3 py-1.5 text-sm gap-1.5 min-h-[44px] md:min-h-0',
+    md: 'px-4 py-2 text-sm gap-2 min-h-[44px] md:min-h-0',
+    lg: 'px-6 py-3 text-base gap-2 min-h-[48px] md:min-h-0',
+  };
+&nbsp;
+  return (
+    &lt;button
+      className={`${baseStyles} ${variants[variant]} ${sizes[size]} ${className}`}
+      {...props}
+    &gt;
+      {children}
+    &lt;/button&gt;
+  );
+}
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 901 - 0
frontend/coverage/src/components/CalendarView.tsx.html

@@ -0,0 +1,901 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/CalendarView.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> CalendarView.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/222</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/222</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a>
+<a name='L192'></a><a href='#L192'>192</a>
+<a name='L193'></a><a href='#L193'>193</a>
+<a name='L194'></a><a href='#L194'>194</a>
+<a name='L195'></a><a href='#L195'>195</a>
+<a name='L196'></a><a href='#L196'>196</a>
+<a name='L197'></a><a href='#L197'>197</a>
+<a name='L198'></a><a href='#L198'>198</a>
+<a name='L199'></a><a href='#L199'>199</a>
+<a name='L200'></a><a href='#L200'>200</a>
+<a name='L201'></a><a href='#L201'>201</a>
+<a name='L202'></a><a href='#L202'>202</a>
+<a name='L203'></a><a href='#L203'>203</a>
+<a name='L204'></a><a href='#L204'>204</a>
+<a name='L205'></a><a href='#L205'>205</a>
+<a name='L206'></a><a href='#L206'>206</a>
+<a name='L207'></a><a href='#L207'>207</a>
+<a name='L208'></a><a href='#L208'>208</a>
+<a name='L209'></a><a href='#L209'>209</a>
+<a name='L210'></a><a href='#L210'>210</a>
+<a name='L211'></a><a href='#L211'>211</a>
+<a name='L212'></a><a href='#L212'>212</a>
+<a name='L213'></a><a href='#L213'>213</a>
+<a name='L214'></a><a href='#L214'>214</a>
+<a name='L215'></a><a href='#L215'>215</a>
+<a name='L216'></a><a href='#L216'>216</a>
+<a name='L217'></a><a href='#L217'>217</a>
+<a name='L218'></a><a href='#L218'>218</a>
+<a name='L219'></a><a href='#L219'>219</a>
+<a name='L220'></a><a href='#L220'>220</a>
+<a name='L221'></a><a href='#L221'>221</a>
+<a name='L222'></a><a href='#L222'>222</a>
+<a name='L223'></a><a href='#L223'>223</a>
+<a name='L224'></a><a href='#L224'>224</a>
+<a name='L225'></a><a href='#L225'>225</a>
+<a name='L226'></a><a href='#L226'>226</a>
+<a name='L227'></a><a href='#L227'>227</a>
+<a name='L228'></a><a href='#L228'>228</a>
+<a name='L229'></a><a href='#L229'>229</a>
+<a name='L230'></a><a href='#L230'>230</a>
+<a name='L231'></a><a href='#L231'>231</a>
+<a name='L232'></a><a href='#L232'>232</a>
+<a name='L233'></a><a href='#L233'>233</a>
+<a name='L234'></a><a href='#L234'>234</a>
+<a name='L235'></a><a href='#L235'>235</a>
+<a name='L236'></a><a href='#L236'>236</a>
+<a name='L237'></a><a href='#L237'>237</a>
+<a name='L238'></a><a href='#L238'>238</a>
+<a name='L239'></a><a href='#L239'>239</a>
+<a name='L240'></a><a href='#L240'>240</a>
+<a name='L241'></a><a href='#L241'>241</a>
+<a name='L242'></a><a href='#L242'>242</a>
+<a name='L243'></a><a href='#L243'>243</a>
+<a name='L244'></a><a href='#L244'>244</a>
+<a name='L245'></a><a href='#L245'>245</a>
+<a name='L246'></a><a href='#L246'>246</a>
+<a name='L247'></a><a href='#L247'>247</a>
+<a name='L248'></a><a href='#L248'>248</a>
+<a name='L249'></a><a href='#L249'>249</a>
+<a name='L250'></a><a href='#L250'>250</a>
+<a name='L251'></a><a href='#L251'>251</a>
+<a name='L252'></a><a href='#L252'>252</a>
+<a name='L253'></a><a href='#L253'>253</a>
+<a name='L254'></a><a href='#L254'>254</a>
+<a name='L255'></a><a href='#L255'>255</a>
+<a name='L256'></a><a href='#L256'>256</a>
+<a name='L257'></a><a href='#L257'>257</a>
+<a name='L258'></a><a href='#L258'>258</a>
+<a name='L259'></a><a href='#L259'>259</a>
+<a name='L260'></a><a href='#L260'>260</a>
+<a name='L261'></a><a href='#L261'>261</a>
+<a name='L262'></a><a href='#L262'>262</a>
+<a name='L263'></a><a href='#L263'>263</a>
+<a name='L264'></a><a href='#L264'>264</a>
+<a name='L265'></a><a href='#L265'>265</a>
+<a name='L266'></a><a href='#L266'>266</a>
+<a name='L267'></a><a href='#L267'>267</a>
+<a name='L268'></a><a href='#L268'>268</a>
+<a name='L269'></a><a href='#L269'>269</a>
+<a name='L270'></a><a href='#L270'>270</a>
+<a name='L271'></a><a href='#L271'>271</a>
+<a name='L272'></a><a href='#L272'>272</a>
+<a name='L273'></a><a href='#L273'>273</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useState, useMemo } from 'react';</span></span></span>
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import type { Archive } from '../api/client';
+import { api } from '../api/client';
+&nbsp;
+interface CalendarViewProps {
+  archives: Archive[];
+  onArchiveClick?: (archive: Archive) =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >function getDaysInMonth(year: number, month: number): number {</span>
+<span class="cstat-no" title="statement not covered" >  return new Date(year, month + 1, 0).getDate();</span>
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >function getFirstDayOfMonth(year: number, month: number): number {</span>
+<span class="cstat-no" title="statement not covered" >  return new Date(year, month, 1).getDay();</span>
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const MONTH_NAMES = [</span>
+<span class="cstat-no" title="statement not covered" >  'January', 'February', 'March', 'April', 'May', 'June',</span>
+<span class="cstat-no" title="statement not covered" >  'July', 'August', 'September', 'October', 'November', 'December'</span>
+<span class="cstat-no" title="statement not covered" >];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function CalendarView({ archives, onArchiveClick }: CalendarViewProps) {</span>
+<span class="cstat-no" title="statement not covered" >  const today = new Date();</span>
+<span class="cstat-no" title="statement not covered" >  const [currentMonth, setCurrentMonth] = useState(today.getMonth());</span>
+<span class="cstat-no" title="statement not covered" >  const [currentYear, setCurrentYear] = useState(today.getFullYear());</span>
+<span class="cstat-no" title="statement not covered" >  const [selectedDate, setSelectedDate] = useState&lt;string | null&gt;(null);</span>
+&nbsp;
+  // Group archives by date
+<span class="cstat-no" title="statement not covered" >  const archivesByDate = useMemo(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const map = new Map&lt;string, Archive[]&gt;();</span>
+<span class="cstat-no" title="statement not covered" >    archives.forEach(archive =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      const date = new Date(archive.completed_at || archive.created_at);</span>
+<span class="cstat-no" title="statement not covered" >      const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;</span>
+<span class="cstat-no" title="statement not covered" >      const existing = map.get(key) || [];</span>
+<span class="cstat-no" title="statement not covered" >      existing.push(archive);</span>
+<span class="cstat-no" title="statement not covered" >      map.set(key, existing);</span>
+<span class="cstat-no" title="statement not covered" >    });</span>
+<span class="cstat-no" title="statement not covered" >    return map;</span>
+<span class="cstat-no" title="statement not covered" >  }, [archives]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const daysInMonth = getDaysInMonth(currentYear, currentMonth);</span>
+<span class="cstat-no" title="statement not covered" >  const firstDay = getFirstDayOfMonth(currentYear, currentMonth);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const prevMonth = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (currentMonth === 0) {</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentMonth(11);</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentYear(currentYear - 1);</span>
+<span class="cstat-no" title="statement not covered" >    } else {</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentMonth(currentMonth - 1);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const nextMonth = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    if (currentMonth === 11) {</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentMonth(0);</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentYear(currentYear + 1);</span>
+<span class="cstat-no" title="statement not covered" >    } else {</span>
+<span class="cstat-no" title="statement not covered" >      setCurrentMonth(currentMonth + 1);</span>
+<span class="cstat-no" title="statement not covered" >    }</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const goToToday = () =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    setCurrentMonth(today.getMonth());</span>
+<span class="cstat-no" title="statement not covered" >    setCurrentYear(today.getFullYear());</span>
+<span class="cstat-no" title="statement not covered" >  };</span>
+&nbsp;
+  // Build calendar grid
+<span class="cstat-no" title="statement not covered" >  const calendarDays: (number | null)[] = [];</span>
+<span class="cstat-no" title="statement not covered" >  for (let i = 0; i &lt; firstDay; i++) {</span>
+<span class="cstat-no" title="statement not covered" >    calendarDays.push(null);</span>
+<span class="cstat-no" title="statement not covered" >  }</span>
+<span class="cstat-no" title="statement not covered" >  for (let day = 1; day &lt;= daysInMonth; day++) {</span>
+<span class="cstat-no" title="statement not covered" >    calendarDays.push(day);</span>
+<span class="cstat-no" title="statement not covered" >  }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const selectedArchives = selectedDate ? archivesByDate.get(selectedDate) || [] : [];</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div className="flex flex-col lg:flex-row gap-6"&gt;</span>
+      {/* Calendar */}
+<span class="cstat-no" title="statement not covered" >      &lt;div className="flex-1"&gt;</span>
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex items-center justify-between mb-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={prevMonth}</span>
+<span class="cstat-no" title="statement not covered" >            className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;ChevronLeft className="w-5 h-5 text-bambu-gray" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center gap-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;h2 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {MONTH_NAMES[currentMonth]} {currentYear}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/h2&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;button</span>
+<span class="cstat-no" title="statement not covered" >              onClick={goToToday}</span>
+<span class="cstat-no" title="statement not covered" >              className="px-2 py-1 text-xs bg-bambu-dark-tertiary hover:bg-bambu-green/20 text-bambu-gray hover:text-white rounded transition-colors"</span>
+<span class="cstat-no" title="statement not covered" >            &gt;</span>
+              Today
+<span class="cstat-no" title="statement not covered" >            &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={nextMonth}</span>
+<span class="cstat-no" title="statement not covered" >            className="p-2 hover:bg-bambu-dark-tertiary rounded-lg transition-colors"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;ChevronRight className="w-5 h-5 text-bambu-gray" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Day headers */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="grid grid-cols-7 gap-1 mb-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {DAY_NAMES.map(day =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div key={day} className="text-center text-xs text-bambu-gray py-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {day}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          ))}</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Calendar grid */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="grid grid-cols-7 gap-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {calendarDays.map((day, index) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >            if (day === null) {</span>
+<span class="cstat-no" title="statement not covered" >              return &lt;div key={`empty-${index}`} className="aspect-square" /&gt;;</span>
+<span class="cstat-no" title="statement not covered" >            }</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >            const dateKey = `${currentYear}-${String(currentMonth + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;</span>
+<span class="cstat-no" title="statement not covered" >            const dayArchives = archivesByDate.get(dateKey) || [];</span>
+<span class="cstat-no" title="statement not covered" >            const hasArchives = dayArchives.length &gt; 0;</span>
+<span class="cstat-no" title="statement not covered" >            const isToday = day === today.getDate() &amp;&amp; currentMonth === today.getMonth() &amp;&amp; currentYear === today.getFullYear();</span>
+<span class="cstat-no" title="statement not covered" >            const isSelected = dateKey === selectedDate;</span>
+<span class="cstat-no" title="statement not covered" >            const successCount = dayArchives.filter(a =&gt; a.status === 'completed').length;</span>
+<span class="cstat-no" title="statement not covered" >            const failedCount = dayArchives.filter(a =&gt; a.status === 'failed').length;</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >            return (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                key={day}</span>
+<span class="cstat-no" title="statement not covered" >                onClick={() =&gt; setSelectedDate(isSelected ? null : dateKey)}</span>
+<span class="cstat-no" title="statement not covered" >                className={`aspect-square rounded-lg p-1 flex flex-col items-center justify-center transition-colors relative ${</span>
+<span class="cstat-no" title="statement not covered" >                  isSelected</span>
+<span class="cstat-no" title="statement not covered" >                    ? 'bg-bambu-green text-white'</span>
+<span class="cstat-no" title="statement not covered" >                    : isToday</span>
+<span class="cstat-no" title="statement not covered" >                    ? 'bg-bambu-green/20 text-white ring-2 ring-bambu-green'</span>
+<span class="cstat-no" title="statement not covered" >                    : hasArchives</span>
+<span class="cstat-no" title="statement not covered" >                    ? 'bg-bambu-dark-tertiary hover:bg-bambu-dark-tertiary/70 text-white'</span>
+<span class="cstat-no" title="statement not covered" >                    : 'hover:bg-bambu-dark-tertiary/50 text-bambu-gray'</span>
+<span class="cstat-no" title="statement not covered" >                }`}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                &lt;span className={`text-sm font-medium ${isToday &amp;&amp; !isSelected ? 'text-bambu-green' : ''}`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  {day}</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {hasArchives &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="absolute bottom-1 left-1/2 -translate-x-1/2 flex items-center gap-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;div className={`w-2 h-2 rounded-full ${</span>
+<span class="cstat-no" title="statement not covered" >                      failedCount &gt; 0 &amp;&amp; successCount === 0</span>
+<span class="cstat-no" title="statement not covered" >                        ? 'bg-red-400'</span>
+<span class="cstat-no" title="statement not covered" >                        : failedCount &gt; 0</span>
+<span class="cstat-no" title="statement not covered" >                        ? 'bg-yellow-400'</span>
+<span class="cstat-no" title="statement not covered" >                        : 'bg-green-400'</span>
+<span class="cstat-no" title="statement not covered" >                    }`} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;span className="text-xs font-medium"&gt;{dayArchives.length}&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+                )}
+<span class="cstat-no" title="statement not covered" >              &lt;/button&gt;</span>
+            );
+<span class="cstat-no" title="statement not covered" >          })}</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Monthly stats */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="mt-4 pt-4 border-t border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;div className="grid grid-cols-3 gap-4 text-center"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-2xl font-bold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {archives.filter(a =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                  const d = new Date(a.completed_at || a.created_at);</span>
+<span class="cstat-no" title="statement not covered" >                  return d.getMonth() === currentMonth &amp;&amp; d.getFullYear() === currentYear;</span>
+<span class="cstat-no" title="statement not covered" >                }).length}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-xs text-bambu-gray"&gt;Prints this month&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-2xl font-bold text-green-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {archives.filter(a =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                  const d = new Date(a.completed_at || a.created_at);</span>
+<span class="cstat-no" title="statement not covered" >                  return d.getMonth() === currentMonth &amp;&amp; d.getFullYear() === currentYear &amp;&amp; a.status === 'completed';</span>
+<span class="cstat-no" title="statement not covered" >                }).length}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-xs text-bambu-gray"&gt;Successful&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-2xl font-bold text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {archives.filter(a =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >                  const d = new Date(a.completed_at || a.created_at);</span>
+<span class="cstat-no" title="statement not covered" >                  return d.getMonth() === currentMonth &amp;&amp; d.getFullYear() === currentYear &amp;&amp; a.status === 'failed';</span>
+<span class="cstat-no" title="statement not covered" >                }).length}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="text-xs text-bambu-gray"&gt;Failed&lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+&nbsp;
+      {/* Selected day details */}
+<span class="cstat-no" title="statement not covered" >      &lt;div className="lg:w-80 bg-bambu-dark rounded-xl p-4"&gt;</span>
+<span class="cstat-no" title="statement not covered" >        {selectedDate ? (</span>
+<span class="cstat-no" title="statement not covered" >          &lt;&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;h3 className="text-sm font-medium text-bambu-gray mb-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {new Date(selectedDate + 'T12:00:00').toLocaleDateString('en-US', {</span>
+<span class="cstat-no" title="statement not covered" >                weekday: 'long',</span>
+<span class="cstat-no" title="statement not covered" >                month: 'long',</span>
+<span class="cstat-no" title="statement not covered" >                day: 'numeric',</span>
+<span class="cstat-no" title="statement not covered" >                year: 'numeric'</span>
+<span class="cstat-no" title="statement not covered" >              })}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/h3&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {selectedArchives.length &gt; 0 ? (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;div className="space-y-2 max-h-96 overflow-y-auto"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {selectedArchives.map(archive =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;button</span>
+<span class="cstat-no" title="statement not covered" >                    key={archive.id}</span>
+<span class="cstat-no" title="statement not covered" >                    onClick={() =&gt; onArchiveClick?.(archive)}</span>
+<span class="cstat-no" title="statement not covered" >                    className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-left"</span>
+                  &gt;
+<span class="cstat-no" title="statement not covered" >                    {archive.thumbnail_path ? (</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;img</span>
+<span class="cstat-no" title="statement not covered" >                        src={api.getArchiveThumbnail(archive.id)}</span>
+<span class="cstat-no" title="statement not covered" >                        alt=""</span>
+<span class="cstat-no" title="statement not covered" >                        className="w-12 h-12 rounded object-cover"</span>
+<span class="cstat-no" title="statement not covered" >                      /&gt;</span>
+                    ) : (
+<span class="cstat-no" title="statement not covered" >                      &lt;div className="w-12 h-12 rounded bg-bambu-dark-tertiary flex items-center justify-center"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        &lt;span className="text-xs text-bambu-gray"&gt;3MF&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;/div&gt;</span>
+                    )}
+<span class="cstat-no" title="statement not covered" >                    &lt;div className="flex-1 min-w-0"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;p className="text-sm text-white truncate"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        {archive.print_name || archive.filename}</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;div className="flex items-center gap-2 text-xs"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        &lt;span className={archive.status === 'failed' ? 'text-red-400' : 'text-green-400'}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                          {archive.status === 'failed' ? 'Failed' : 'Completed'}</span>
+<span class="cstat-no" title="statement not covered" >                        &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                        {archive.filament_color &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >                          &lt;div className="flex gap-0.5"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                            {archive.filament_color.split(',').map((color, i) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                              &lt;div</span>
+<span class="cstat-no" title="statement not covered" >                                key={i}</span>
+<span class="cstat-no" title="statement not covered" >                                className="w-3 h-3 rounded-full border border-white/20"</span>
+<span class="cstat-no" title="statement not covered" >                                style={{ backgroundColor: color }}</span>
+<span class="cstat-no" title="statement not covered" >                              /&gt;</span>
+<span class="cstat-no" title="statement not covered" >                            ))}</span>
+<span class="cstat-no" title="statement not covered" >                          &lt;/div&gt;</span>
+                        )}
+<span class="cstat-no" title="statement not covered" >                      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >                ))}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/div&gt;</span>
+            ) : (
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-sm text-bambu-gray"&gt;No prints on this day&lt;/p&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/&gt;</span>
+        ) : (
+<span class="cstat-no" title="statement not covered" >          &lt;div className="text-center py-8"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm text-bambu-gray"&gt;Select a day to see prints&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+        )}
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 181 - 0
frontend/coverage/src/components/Card.tsx.html

@@ -0,0 +1,181 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/Card.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> Card.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>19/19</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>3/3</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>3/3</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">100% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>19/19</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line high'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a></td><td class="line-coverage quiet"><span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">46x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">4x</span>
+<span class="cline-any cline-yes">4x</span>
+<span class="cline-any cline-yes">4x</span>
+<span class="cline-any cline-yes">4x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">4x</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-yes">1x</span>
+<span class="cline-any cline-yes">44x</span>
+<span class="cline-any cline-yes">44x</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">import type { ReactNode, MouseEvent } from 'react';
+&nbsp;
+interface CardProps {
+  children: ReactNode;
+  className?: string;
+  onClick?: (e: MouseEvent) =&gt; void;
+  onContextMenu?: (e: MouseEvent) =&gt; void;
+}
+&nbsp;
+export function Card({ children, className = '', onClick, onContextMenu }: CardProps) {
+  return (
+    &lt;div
+      className={`bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary ${className}`}
+      onClick={onClick}
+      onContextMenu={onContextMenu}
+    &gt;
+      {children}
+    &lt;/div&gt;
+  );
+}
+&nbsp;
+export function CardHeader({ children, className = '' }: CardProps) {
+  return (
+    &lt;div className={`px-6 py-4 border-b border-bambu-dark-tertiary ${className}`}&gt;
+      {children}
+    &lt;/div&gt;
+  );
+}
+&nbsp;
+export function CardContent({ children, className = '' }: CardProps) {
+  return &lt;div className={`p-6 ${className}`}&gt;{children}&lt;/div&gt;;
+}
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

+ 655 - 0
frontend/coverage/src/components/CompareArchivesModal.tsx.html

@@ -0,0 +1,655 @@
+
+<!doctype html>
+<html lang="en">
+
+<head>
+    <title>Code coverage report for src/components/CompareArchivesModal.tsx</title>
+    <meta charset="utf-8" />
+    <link rel="stylesheet" href="../../prettify.css" />
+    <link rel="stylesheet" href="../../base.css" />
+    <link rel="shortcut icon" type="image/x-icon" href="../../favicon.png" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <style type='text/css'>
+        .coverage-summary .sorter {
+            background-image: url(../../sort-arrow-sprite.png);
+        }
+    </style>
+</head>
+    
+<body>
+<div class='wrapper'>
+    <div class='pad1'>
+        <h1><a href="../../index.html">All files</a> / <a href="index.html">src/components</a> CompareArchivesModal.tsx</h1>
+        <div class='clearfix'>
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Statements</span>
+                <span class='fraction'>0/149</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Branches</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Functions</span>
+                <span class='fraction'>0/1</span>
+            </div>
+        
+            
+            <div class='fl pad1y space-right2'>
+                <span class="strong">0% </span>
+                <span class="quiet">Lines</span>
+                <span class='fraction'>0/149</span>
+            </div>
+        
+            
+        </div>
+        <p class="quiet">
+            Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
+        </p>
+        <template id="filterTemplate">
+            <div class="quiet">
+                Filter:
+                <input type="search" id="fileSearch">
+            </div>
+        </template>
+    </div>
+    <div class='status-line low'></div>
+    <pre><table class="coverage">
+<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
+<a name='L2'></a><a href='#L2'>2</a>
+<a name='L3'></a><a href='#L3'>3</a>
+<a name='L4'></a><a href='#L4'>4</a>
+<a name='L5'></a><a href='#L5'>5</a>
+<a name='L6'></a><a href='#L6'>6</a>
+<a name='L7'></a><a href='#L7'>7</a>
+<a name='L8'></a><a href='#L8'>8</a>
+<a name='L9'></a><a href='#L9'>9</a>
+<a name='L10'></a><a href='#L10'>10</a>
+<a name='L11'></a><a href='#L11'>11</a>
+<a name='L12'></a><a href='#L12'>12</a>
+<a name='L13'></a><a href='#L13'>13</a>
+<a name='L14'></a><a href='#L14'>14</a>
+<a name='L15'></a><a href='#L15'>15</a>
+<a name='L16'></a><a href='#L16'>16</a>
+<a name='L17'></a><a href='#L17'>17</a>
+<a name='L18'></a><a href='#L18'>18</a>
+<a name='L19'></a><a href='#L19'>19</a>
+<a name='L20'></a><a href='#L20'>20</a>
+<a name='L21'></a><a href='#L21'>21</a>
+<a name='L22'></a><a href='#L22'>22</a>
+<a name='L23'></a><a href='#L23'>23</a>
+<a name='L24'></a><a href='#L24'>24</a>
+<a name='L25'></a><a href='#L25'>25</a>
+<a name='L26'></a><a href='#L26'>26</a>
+<a name='L27'></a><a href='#L27'>27</a>
+<a name='L28'></a><a href='#L28'>28</a>
+<a name='L29'></a><a href='#L29'>29</a>
+<a name='L30'></a><a href='#L30'>30</a>
+<a name='L31'></a><a href='#L31'>31</a>
+<a name='L32'></a><a href='#L32'>32</a>
+<a name='L33'></a><a href='#L33'>33</a>
+<a name='L34'></a><a href='#L34'>34</a>
+<a name='L35'></a><a href='#L35'>35</a>
+<a name='L36'></a><a href='#L36'>36</a>
+<a name='L37'></a><a href='#L37'>37</a>
+<a name='L38'></a><a href='#L38'>38</a>
+<a name='L39'></a><a href='#L39'>39</a>
+<a name='L40'></a><a href='#L40'>40</a>
+<a name='L41'></a><a href='#L41'>41</a>
+<a name='L42'></a><a href='#L42'>42</a>
+<a name='L43'></a><a href='#L43'>43</a>
+<a name='L44'></a><a href='#L44'>44</a>
+<a name='L45'></a><a href='#L45'>45</a>
+<a name='L46'></a><a href='#L46'>46</a>
+<a name='L47'></a><a href='#L47'>47</a>
+<a name='L48'></a><a href='#L48'>48</a>
+<a name='L49'></a><a href='#L49'>49</a>
+<a name='L50'></a><a href='#L50'>50</a>
+<a name='L51'></a><a href='#L51'>51</a>
+<a name='L52'></a><a href='#L52'>52</a>
+<a name='L53'></a><a href='#L53'>53</a>
+<a name='L54'></a><a href='#L54'>54</a>
+<a name='L55'></a><a href='#L55'>55</a>
+<a name='L56'></a><a href='#L56'>56</a>
+<a name='L57'></a><a href='#L57'>57</a>
+<a name='L58'></a><a href='#L58'>58</a>
+<a name='L59'></a><a href='#L59'>59</a>
+<a name='L60'></a><a href='#L60'>60</a>
+<a name='L61'></a><a href='#L61'>61</a>
+<a name='L62'></a><a href='#L62'>62</a>
+<a name='L63'></a><a href='#L63'>63</a>
+<a name='L64'></a><a href='#L64'>64</a>
+<a name='L65'></a><a href='#L65'>65</a>
+<a name='L66'></a><a href='#L66'>66</a>
+<a name='L67'></a><a href='#L67'>67</a>
+<a name='L68'></a><a href='#L68'>68</a>
+<a name='L69'></a><a href='#L69'>69</a>
+<a name='L70'></a><a href='#L70'>70</a>
+<a name='L71'></a><a href='#L71'>71</a>
+<a name='L72'></a><a href='#L72'>72</a>
+<a name='L73'></a><a href='#L73'>73</a>
+<a name='L74'></a><a href='#L74'>74</a>
+<a name='L75'></a><a href='#L75'>75</a>
+<a name='L76'></a><a href='#L76'>76</a>
+<a name='L77'></a><a href='#L77'>77</a>
+<a name='L78'></a><a href='#L78'>78</a>
+<a name='L79'></a><a href='#L79'>79</a>
+<a name='L80'></a><a href='#L80'>80</a>
+<a name='L81'></a><a href='#L81'>81</a>
+<a name='L82'></a><a href='#L82'>82</a>
+<a name='L83'></a><a href='#L83'>83</a>
+<a name='L84'></a><a href='#L84'>84</a>
+<a name='L85'></a><a href='#L85'>85</a>
+<a name='L86'></a><a href='#L86'>86</a>
+<a name='L87'></a><a href='#L87'>87</a>
+<a name='L88'></a><a href='#L88'>88</a>
+<a name='L89'></a><a href='#L89'>89</a>
+<a name='L90'></a><a href='#L90'>90</a>
+<a name='L91'></a><a href='#L91'>91</a>
+<a name='L92'></a><a href='#L92'>92</a>
+<a name='L93'></a><a href='#L93'>93</a>
+<a name='L94'></a><a href='#L94'>94</a>
+<a name='L95'></a><a href='#L95'>95</a>
+<a name='L96'></a><a href='#L96'>96</a>
+<a name='L97'></a><a href='#L97'>97</a>
+<a name='L98'></a><a href='#L98'>98</a>
+<a name='L99'></a><a href='#L99'>99</a>
+<a name='L100'></a><a href='#L100'>100</a>
+<a name='L101'></a><a href='#L101'>101</a>
+<a name='L102'></a><a href='#L102'>102</a>
+<a name='L103'></a><a href='#L103'>103</a>
+<a name='L104'></a><a href='#L104'>104</a>
+<a name='L105'></a><a href='#L105'>105</a>
+<a name='L106'></a><a href='#L106'>106</a>
+<a name='L107'></a><a href='#L107'>107</a>
+<a name='L108'></a><a href='#L108'>108</a>
+<a name='L109'></a><a href='#L109'>109</a>
+<a name='L110'></a><a href='#L110'>110</a>
+<a name='L111'></a><a href='#L111'>111</a>
+<a name='L112'></a><a href='#L112'>112</a>
+<a name='L113'></a><a href='#L113'>113</a>
+<a name='L114'></a><a href='#L114'>114</a>
+<a name='L115'></a><a href='#L115'>115</a>
+<a name='L116'></a><a href='#L116'>116</a>
+<a name='L117'></a><a href='#L117'>117</a>
+<a name='L118'></a><a href='#L118'>118</a>
+<a name='L119'></a><a href='#L119'>119</a>
+<a name='L120'></a><a href='#L120'>120</a>
+<a name='L121'></a><a href='#L121'>121</a>
+<a name='L122'></a><a href='#L122'>122</a>
+<a name='L123'></a><a href='#L123'>123</a>
+<a name='L124'></a><a href='#L124'>124</a>
+<a name='L125'></a><a href='#L125'>125</a>
+<a name='L126'></a><a href='#L126'>126</a>
+<a name='L127'></a><a href='#L127'>127</a>
+<a name='L128'></a><a href='#L128'>128</a>
+<a name='L129'></a><a href='#L129'>129</a>
+<a name='L130'></a><a href='#L130'>130</a>
+<a name='L131'></a><a href='#L131'>131</a>
+<a name='L132'></a><a href='#L132'>132</a>
+<a name='L133'></a><a href='#L133'>133</a>
+<a name='L134'></a><a href='#L134'>134</a>
+<a name='L135'></a><a href='#L135'>135</a>
+<a name='L136'></a><a href='#L136'>136</a>
+<a name='L137'></a><a href='#L137'>137</a>
+<a name='L138'></a><a href='#L138'>138</a>
+<a name='L139'></a><a href='#L139'>139</a>
+<a name='L140'></a><a href='#L140'>140</a>
+<a name='L141'></a><a href='#L141'>141</a>
+<a name='L142'></a><a href='#L142'>142</a>
+<a name='L143'></a><a href='#L143'>143</a>
+<a name='L144'></a><a href='#L144'>144</a>
+<a name='L145'></a><a href='#L145'>145</a>
+<a name='L146'></a><a href='#L146'>146</a>
+<a name='L147'></a><a href='#L147'>147</a>
+<a name='L148'></a><a href='#L148'>148</a>
+<a name='L149'></a><a href='#L149'>149</a>
+<a name='L150'></a><a href='#L150'>150</a>
+<a name='L151'></a><a href='#L151'>151</a>
+<a name='L152'></a><a href='#L152'>152</a>
+<a name='L153'></a><a href='#L153'>153</a>
+<a name='L154'></a><a href='#L154'>154</a>
+<a name='L155'></a><a href='#L155'>155</a>
+<a name='L156'></a><a href='#L156'>156</a>
+<a name='L157'></a><a href='#L157'>157</a>
+<a name='L158'></a><a href='#L158'>158</a>
+<a name='L159'></a><a href='#L159'>159</a>
+<a name='L160'></a><a href='#L160'>160</a>
+<a name='L161'></a><a href='#L161'>161</a>
+<a name='L162'></a><a href='#L162'>162</a>
+<a name='L163'></a><a href='#L163'>163</a>
+<a name='L164'></a><a href='#L164'>164</a>
+<a name='L165'></a><a href='#L165'>165</a>
+<a name='L166'></a><a href='#L166'>166</a>
+<a name='L167'></a><a href='#L167'>167</a>
+<a name='L168'></a><a href='#L168'>168</a>
+<a name='L169'></a><a href='#L169'>169</a>
+<a name='L170'></a><a href='#L170'>170</a>
+<a name='L171'></a><a href='#L171'>171</a>
+<a name='L172'></a><a href='#L172'>172</a>
+<a name='L173'></a><a href='#L173'>173</a>
+<a name='L174'></a><a href='#L174'>174</a>
+<a name='L175'></a><a href='#L175'>175</a>
+<a name='L176'></a><a href='#L176'>176</a>
+<a name='L177'></a><a href='#L177'>177</a>
+<a name='L178'></a><a href='#L178'>178</a>
+<a name='L179'></a><a href='#L179'>179</a>
+<a name='L180'></a><a href='#L180'>180</a>
+<a name='L181'></a><a href='#L181'>181</a>
+<a name='L182'></a><a href='#L182'>182</a>
+<a name='L183'></a><a href='#L183'>183</a>
+<a name='L184'></a><a href='#L184'>184</a>
+<a name='L185'></a><a href='#L185'>185</a>
+<a name='L186'></a><a href='#L186'>186</a>
+<a name='L187'></a><a href='#L187'>187</a>
+<a name='L188'></a><a href='#L188'>188</a>
+<a name='L189'></a><a href='#L189'>189</a>
+<a name='L190'></a><a href='#L190'>190</a>
+<a name='L191'></a><a href='#L191'>191</a></td><td class="line-coverage quiet"><span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span>
+<span class="cline-any cline-no">&nbsp;</span>
+<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js"><span class="cstat-no" title="statement not covered" ><span class="fstat-no" title="function not covered" ><span class="branch-0 cbranch-no" title="branch not covered" >import { useEffect } from 'react';</span></span></span>
+import { useQuery } from '@tanstack/react-query';
+import { X, Check, AlertTriangle, Loader2 } from 'lucide-react';
+import { api } from '../api/client';
+import type { ArchiveComparison } from '../api/client';
+import { Button } from './Button';
+&nbsp;
+interface CompareArchivesModalProps {
+  archiveIds: number[];
+  onClose: () =&gt; void;
+}
+&nbsp;
+<span class="cstat-no" title="statement not covered" >export function CompareArchivesModal({ archiveIds, onClose }: CompareArchivesModalProps) {</span>
+  // Close on Escape key
+<span class="cstat-no" title="statement not covered" >  useEffect(() =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >    const handleKeyDown = (e: KeyboardEvent) =&gt; {</span>
+<span class="cstat-no" title="statement not covered" >      if (e.key === 'Escape') onClose();</span>
+<span class="cstat-no" title="statement not covered" >    };</span>
+<span class="cstat-no" title="statement not covered" >    window.addEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >    return () =&gt; window.removeEventListener('keydown', handleKeyDown);</span>
+<span class="cstat-no" title="statement not covered" >  }, [onClose]);</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  const { data: comparison, isLoading, error } = useQuery({</span>
+<span class="cstat-no" title="statement not covered" >    queryKey: ['archive-comparison', archiveIds],</span>
+<span class="cstat-no" title="statement not covered" >    queryFn: () =&gt; api.compareArchives(archiveIds),</span>
+<span class="cstat-no" title="statement not covered" >  });</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div className="fixed inset-0 bg-black/80 flex items-center justify-center z-50 p-4" onClick={onClose}&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;div className="bg-bambu-dark-secondary rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col border border-bambu-dark-tertiary" onClick={(e) =&gt; e.stopPropagation()}&gt;</span>
+        {/* Header */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex items-center justify-between p-4 border-b border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;h3 className="text-lg font-semibold text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            Compare Archives ({archiveIds.length})</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/h3&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;button</span>
+<span class="cstat-no" title="statement not covered" >            onClick={onClose}</span>
+<span class="cstat-no" title="statement not covered" >            className="text-bambu-gray hover:text-white p-1"</span>
+          &gt;
+<span class="cstat-no" title="statement not covered" >            &lt;X className="w-5 h-5" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Content */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="flex-1 overflow-auto p-4 bg-bambu-dark-secondary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {isLoading ? (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="flex items-center justify-center py-12"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;Loader2 className="w-8 h-8 text-bambu-green animate-spin" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          ) : error ? (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="text-center py-12 text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;AlertTriangle className="w-12 h-12 mx-auto mb-4 opacity-50" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p&gt;Failed to load comparison&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;p className="text-sm text-bambu-gray mt-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {error instanceof Error ? error.message : 'Unknown error'}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          ) : comparison ? (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;ComparisonContent comparison={comparison} /&gt;</span>
+<span class="cstat-no" title="statement not covered" >          ) : null}</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+&nbsp;
+        {/* Footer */}
+<span class="cstat-no" title="statement not covered" >        &lt;div className="p-4 border-t border-bambu-dark-tertiary"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;Button variant="secondary" onClick={onClose} className="w-full"&gt;</span>
+            Close
+<span class="cstat-no" title="statement not covered" >          &lt;/Button&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;
+<span class="cstat-no" title="statement not covered" >function ComparisonContent({ comparison }: { comparison: ArchiveComparison }) {</span>
+<span class="cstat-no" title="statement not covered" >  return (</span>
+<span class="cstat-no" title="statement not covered" >    &lt;div className="space-y-6"&gt;</span>
+      {/* Archive Headers */}
+<span class="cstat-no" title="statement not covered" >      &lt;div className="overflow-x-auto"&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;table className="w-full"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;thead&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;tr&gt;</span>
+<span class="cstat-no" title="statement not covered" >              &lt;th className="text-left text-sm text-bambu-gray font-medium pb-2 pr-4 min-w-[150px]"&gt;</span>
+                Setting
+<span class="cstat-no" title="statement not covered" >              &lt;/th&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {comparison.archives.map((archive) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;th</span>
+<span class="cstat-no" title="statement not covered" >                  key={archive.id}</span>
+<span class="cstat-no" title="statement not covered" >                  className="text-left text-sm font-medium pb-2 px-2 min-w-[120px]"</span>
+                &gt;
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="text-white truncate max-w-[150px]" title={archive.print_name}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {archive.print_name}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className={`text-xs ${</span>
+<span class="cstat-no" title="statement not covered" >                    archive.status === 'completed' ? 'text-bambu-green' :</span>
+<span class="cstat-no" title="statement not covered" >                    archive.status === 'failed' ? 'text-red-400' : 'text-bambu-gray'</span>
+<span class="cstat-no" title="statement not covered" >                  }`}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {archive.status}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/th&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/tr&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/thead&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;tbody className="divide-y divide-bambu-gray/20"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {comparison.comparison.map((field) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;tr</span>
+<span class="cstat-no" title="statement not covered" >                key={field.field}</span>
+<span class="cstat-no" title="statement not covered" >                className={field.has_difference ? 'bg-yellow-500/5' : ''}</span>
+              &gt;
+<span class="cstat-no" title="statement not covered" >                &lt;td className="py-2 pr-4 text-sm"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;div className="flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {field.has_difference &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;AlertTriangle className="w-3 h-3 text-yellow-400 flex-shrink-0" /&gt;</span>
+                    )}
+<span class="cstat-no" title="statement not covered" >                    &lt;span className={field.has_difference ? 'text-yellow-400' : 'text-bambu-gray'}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                      {field.label}</span>
+<span class="cstat-no" title="statement not covered" >                    &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/td&gt;</span>
+<span class="cstat-no" title="statement not covered" >                {field.values.map((value, idx) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;td key={idx} className="py-2 px-2 text-sm text-white"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                    {value ?? &lt;span className="text-bambu-gray/50"&gt;-&lt;/span&gt;}</span>
+<span class="cstat-no" title="statement not covered" >                    {field.unit &amp;&amp; value !== null &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >                      &lt;span className="text-bambu-gray ml-1"&gt;{field.unit}&lt;/span&gt;</span>
+                    )}
+<span class="cstat-no" title="statement not covered" >                  &lt;/td&gt;</span>
+<span class="cstat-no" title="statement not covered" >                ))}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/tr&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ))}</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/tbody&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/table&gt;</span>
+<span class="cstat-no" title="statement not covered" >      &lt;/div&gt;</span>
+&nbsp;
+      {/* Differences Summary */}
+<span class="cstat-no" title="statement not covered" >      {comparison.differences.length &gt; 0 &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >        &lt;div className="p-4 bg-yellow-500/10 border border-yellow-500/30 rounded-lg"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;h4 className="text-sm font-medium text-yellow-400 mb-2 flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;AlertTriangle className="w-4 h-4" /&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {comparison.differences.length} Difference{comparison.differences.length &gt; 1 ? 's' : ''} Found</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/h4&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;ul className="text-sm text-white/80 space-y-1"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            {comparison.differences.slice(0, 5).map((diff) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;li key={diff.field}&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;span className="text-yellow-400"&gt;{diff.label}&lt;/span&gt;: {diff.values.join(' vs ')} {diff.unit || ''}</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/li&gt;</span>
+<span class="cstat-no" title="statement not covered" >            ))}</span>
+<span class="cstat-no" title="statement not covered" >            {comparison.differences.length &gt; 5 &amp;&amp; (</span>
+<span class="cstat-no" title="statement not covered" >              &lt;li className="text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                ...and {comparison.differences.length - 5} more</span>
+<span class="cstat-no" title="statement not covered" >              &lt;/li&gt;</span>
+            )}
+<span class="cstat-no" title="statement not covered" >          &lt;/ul&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+      )}
+&nbsp;
+      {/* Success Correlation */}
+<span class="cstat-no" title="statement not covered" >      {comparison.success_correlation.has_both_outcomes ? (</span>
+<span class="cstat-no" title="statement not covered" >        &lt;div className="p-4 bg-bambu-dark rounded-lg"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;h4 className="text-sm font-medium text-white mb-3 flex items-center gap-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;Check className="w-4 h-4 text-bambu-green" /&gt;</span>
+            Success/Failure Analysis
+<span class="cstat-no" title="statement not covered" >          &lt;/h4&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;div className="flex items-center gap-4 text-sm mb-3"&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;span className="text-bambu-green"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {comparison.success_correlation.successful_count} successful</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >            &lt;span className="text-red-400"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {comparison.success_correlation.failed_count} failed</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >          {comparison.success_correlation.insights &amp;&amp; comparison.success_correlation.insights.length &gt; 0 ? (</span>
+<span class="cstat-no" title="statement not covered" >            &lt;div className="space-y-2"&gt;</span>
+<span class="cstat-no" title="statement not covered" >              {comparison.success_correlation.insights.map((insight) =&gt; (</span>
+<span class="cstat-no" title="statement not covered" >                &lt;div key={insight.field} className="text-sm p-2 bg-bambu-dark-secondary rounded"&gt;</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-white font-medium"&gt;{insight.label}:&lt;/span&gt;{' '}</span>
+<span class="cstat-no" title="statement not covered" >                  &lt;span className="text-white/80"&gt;{insight.insight}&lt;/span&gt;</span>
+<span class="cstat-no" title="statement not covered" >                &lt;/div&gt;</span>
+<span class="cstat-no" title="statement not covered" >              ))}</span>
+<span class="cstat-no" title="statement not covered" >            &lt;/div&gt;</span>
+          ) : (
+<span class="cstat-no" title="statement not covered" >            &lt;p className="text-sm text-bambu-gray"&gt;No clear correlations found between settings and outcomes.&lt;/p&gt;</span>
+          )}
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+      ) : (
+<span class="cstat-no" title="statement not covered" >        &lt;div className="p-4 bg-bambu-dark rounded-lg text-sm text-bambu-gray"&gt;</span>
+<span class="cstat-no" title="statement not covered" >          &lt;p&gt;{comparison.success_correlation.message || 'Need both successful and failed prints for correlation analysis.'}&lt;/p&gt;</span>
+<span class="cstat-no" title="statement not covered" >        &lt;/div&gt;</span>
+      )}
+<span class="cstat-no" title="statement not covered" >    &lt;/div&gt;</span>
+  );
+<span class="cstat-no" title="statement not covered" >}</span>
+&nbsp;</pre></td></tr></table></pre>
+
+                <div class='push'></div><!-- for sticky footer -->
+            </div><!-- /wrapper -->
+            <div class='footer quiet pad2 space-top1 center small'>
+                Code coverage generated by
+                <a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
+                at 2025-12-11T08:38:30.022Z
+            </div>
+        <script src="../../prettify.js"></script>
+        <script>
+            window.onload = function () {
+                prettyPrint();
+            };
+        </script>
+        <script src="../../sorter.js"></script>
+        <script src="../../block-navigation.js"></script>
+    </body>
+</html>
+    

Some files were not shown because too many files changed in this diff