Files
remote-rig/docs/plans/2026-05-21-cub-196-cameracard.md
T

15 KiB

CUB-196: CameraCard Component Implementation Plan

For Hermes: Use subagent-driven-development skill to implement this plan task-by-task.

Goal: Build the CameraCard React component that displays one camera's live status with color-coded battery, pulsing recording indicator, and online/offline state.

Architecture: Single component at src/components/CameraCard.tsx consuming CameraStatus from the existing Zustand store. Uses Tailwind for styling (existing dark dashboard palette), lucide-react for icons. No new dependencies needed — everything is already in package.json.

Tech Stack: React 19, TypeScript 5.7, Tailwind CSS 3.4, Zustand 5, lucide-react 0.469


Prerequisites

Before any task, ensure the repo is on dev and clean:

cd /mnt/ai-storage/projects/remote-rig
git checkout dev
git pull origin dev
git checkout -b agent/<your-agent-name>/CUB-196-cameracard

Task 1: Create the CameraCard component

Objective: Build src/components/CameraCard.tsx with all required display elements.

Files:

  • Create: src/components/CameraCard.tsx

Step 1: Create the component file

Create src/components/CameraCard.tsx:

import { Battery, Radio, Signal, Video, Wifi, WifiOff } from 'lucide-react'
import type { CameraStatus } from '../types'

interface CameraCardProps {
  camera: CameraStatus
}

/** Format ISO timestamp as relative time (e.g. "2m ago", "1h ago") */
function formatRelativeTime(iso: string): string {
  const diff = Date.now() - new Date(iso).getTime()
  const sec = Math.floor(diff / 1000)
  if (sec < 60) return 'just now'
  const min = Math.floor(sec / 60)
  if (min < 60) return `${min}m ago`
  const hours = Math.floor(min / 60)
  if (hours < 24) return `${hours}h ago`
  const days = Math.floor(hours / 24)
  return `${days}d ago`
}

/** Return Tailwind color classes for battery level */
function batteryColor(pct: number | null): { bar: string; text: string } {
  if (pct === null) return { bar: 'bg-rig-dark-500', text: 'text-rig-dark-300' }
  if (pct >= 50) return { bar: 'bg-rig-success', text: 'text-rig-success' }
  if (pct >= 15) return { bar: 'bg-rig-warning', text: 'text-rig-warning' }
  return { bar: 'bg-rig-danger', text: 'text-rig-danger' }
}

export function CameraCard({ camera }: CameraCardProps) {
  const battery = batteryColor(camera.battery_pct)
  const online = camera.online

  return (
    <div
      className={`
        rounded-xl border bg-rig-dark-800/60 p-5 transition-all
        ${online
          ? 'border-rig-dark-700 hover:border-rig-dark-600'
          : 'border-rig-danger/30 opacity-70'
        }
      `}
    >
      {/* Header: camera name + online indicator */}
      <div className="mb-4 flex items-center justify-between">
        <div className="flex items-center gap-2.5 min-w-0">
          <Video className="h-5 w-5 shrink-0 text-rig-accent" />
          <h3 className="truncate text-base font-semibold text-rig-dark-100">
            {camera.friendly_name}
          </h3>
        </div>
        <span
          className={`
            flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
            ${online
              ? 'bg-rig-success/10 text-rig-success'
              : 'bg-rig-danger/10 text-rig-danger'
            }
          `}
        >
          {online ? (
            <Wifi className="h-3 w-3" />
          ) : (
            <WifiOff className="h-3 w-3" />
          )}
          {online ? 'Online' : 'Offline'}
        </span>
      </div>

      {/* Body: resolution + fps, recording indicator */}
      <div className="mb-4 space-y-2.5">
        <div className="flex items-center gap-2 text-sm text-rig-dark-300">
          <Signal className="h-4 w-4 text-rig-dark-400" />
          <span>{camera.resolution}</span>
          <span className="text-rig-dark-500">·</span>
          <span>{camera.fps} FPS</span>
        </div>

        <div className="flex items-center gap-2 text-sm">
          <span
            className={`
              inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium
              ${camera.recording
                ? 'bg-rig-danger/15 text-rig-danger'
                : 'bg-rig-dark-600/50 text-rig-dark-400'
              }
            `}
          >
            <span
              className={`
                inline-block h-2 w-2 rounded-full
                ${camera.recording ? 'animate-pulse bg-rig-danger' : 'bg-rig-dark-500'}
              `}
            />
            {camera.recording ? 'REC' : 'IDLE'}
          </span>
          <span className="text-xs text-rig-dark-500">{camera.mode}</span>
        </div>
      </div>

      {/* Battery bar */}
      <div className="mb-3">
        <div className="mb-1.5 flex items-center justify-between">
          <div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
            <Battery className="h-3.5 w-3.5" />
            <span>Battery</span>
          </div>
          <span className={`text-xs font-medium ${battery.text}`}>
            {camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'}
          </span>
        </div>
        <div className="h-1.5 w-full overflow-hidden rounded-full bg-rig-dark-700">
          <div
            className={`h-full rounded-full transition-all duration-500 ${battery.bar}`}
            style={{
              width: `${camera.battery_pct !== null ? Math.max(camera.battery_pct, 4) : 0}%`,
            }}
          />
        </div>
      </div>

      {/* Footer: last seen + video remaining */}
      <div className="flex items-center justify-between border-t border-rig-dark-700 pt-3">
        <div className="flex items-center gap-1.5 text-xs text-rig-dark-400">
          <Radio className="h-3 w-3" />
          <span>
            {camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`}
          </span>
        </div>
        {camera.video_remaining_sec !== null && (
          <span className="text-xs font-mono text-rig-dark-300">
            {Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left
          </span>
        )}
      </div>
    </div>
  )
}

Step 2: Create barrel export

Create src/components/index.ts:

export { CameraCard } from './CameraCard'

Step 3: Type-check

Run: npx tsc --noEmit Expected: No errors.

Step 4: Commit

git add src/components/
git commit -m "CUB-196: add CameraCard component with battery bar, recording indicator, online status"

Task 2: Wire CameraCard into App.tsx

Objective: Replace the placeholder with a live camera grid using the SSE-backed store.

Files:

  • Modify: src/App.tsx

Step 1: Update App.tsx

Replace src/App.tsx content:

import { Camera, Radio } from 'lucide-react'
import { useSSE } from './hooks/useSSE'
import { useCameraStore } from './store/useCameraStore'
import { CameraCard } from './components/CameraCard'

function App() {
  useSSE()

  const cameras = useCameraStore((s) => s.getCameras())
  const onlineCount = useCameraStore((s) => s.getOnlineCount())
  const recordingCount = useCameraStore((s) => s.getRecordingCount())

  return (
    <div className="min-h-screen bg-rig-dark-900">
      {/* Header */}
      <header className="border-b border-rig-dark-700 bg-rig-dark-800/50 backdrop-blur-sm">
        <div className="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-3">
              <Camera className="h-7 w-7 text-rig-accent" />
              <h1 className="text-xl font-bold tracking-tight text-rig-dark-50">
                RemoteRig
              </h1>
              <span className="ml-2 rounded-full bg-rig-accent/10 px-2.5 py-0.5 text-xs font-medium text-rig-accent">
                Dashboard
              </span>
            </div>

            {/* Stats */}
            {cameras.length > 0 && (
              <div className="flex items-center gap-4 text-sm">
                <span className="flex items-center gap-1.5 text-rig-dark-300">
                  <span className="inline-block h-2 w-2 rounded-full bg-rig-success" />
                  {onlineCount} online
                </span>
                <span className="flex items-center gap-1.5 text-rig-dark-300">
                  <span className="inline-block h-2 w-2 animate-pulse rounded-full bg-rig-danger" />
                  {recordingCount} recording
                </span>
              </div>
            )}
          </div>
        </div>
      </header>

      {/* Main Content */}
      <main className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
        {cameras.length === 0 ? (
          <div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-rig-dark-600 bg-rig-dark-800/30 py-24 text-center">
            <Radio className="mb-4 h-12 w-12 animate-pulse text-rig-dark-500" />
            <h2 className="text-lg font-semibold text-rig-dark-200">
              Waiting for cameras...
            </h2>
            <p className="mt-2 max-w-sm text-sm text-rig-dark-400">
              Connect a camera or start the hub backend to see live status here.
            </p>
          </div>
        ) : (
          <div className="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
            {cameras.map((cam) => (
              <CameraCard key={cam.camera_id} camera={cam} />
            ))}
          </div>
        )}
      </main>

      {/* Footer */}
      <footer className="border-t border-rig-dark-700 bg-rig-dark-800/30">
        <div className="mx-auto max-w-7xl px-4 py-3 sm:px-6 lg:px-8">
          <p className="text-center text-xs text-rig-dark-500">
            RemoteRig v0.1.0 &mdash; Multi-Camera Remote Monitoring System
          </p>
        </div>
      </footer>
    </div>
  )
}

export default App

Step 2: Type-check and build

npx tsc --noEmit
npm run build

Expected: TypeScript compiles clean, Vite build succeeds.

Step 3: Commit

git add src/App.tsx
git commit -m "CUB-196: wire CameraCard into App grid with SSE live updates"

Task 3: Add unit tests for CameraCard

Objective: Install vitest + testing-library and write tests for battery colors, recording indicator, and online/offline state.

Files:

  • Create: src/components/CameraCard.test.tsx
  • Modify: package.json (add test script)

Step 1: Install test dependencies

npm install --save-dev vitest @testing-library/react @testing-library/jest-dom jsdom

Step 2: Add test config

Add to vite.config.ts the test block (reference: https://vitest.dev/config/):

/// <reference types="vitest/config" />
// ... existing imports and config, add:
test: {
  globals: true,
  environment: 'jsdom',
  setupFiles: [],
}

Step 3: Create setup file

Create src/test-setup.ts:

import '@testing-library/jest-dom'

Update vite.config.ts setupFiles to ['./src/test-setup.ts'].

Step 4: Add test script

Update package.json scripts:

"test": "vitest run",
"test:watch": "vitest"

Step 5: Write CameraCard tests

Create src/components/CameraCard.test.tsx:

import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { CameraCard } from './CameraCard'
import type { CameraStatus } from '../types'

function makeCamera(overrides: Partial<CameraStatus> = {}): CameraStatus {
  return {
    camera_id: 'cam-1',
    friendly_name: 'Front Camera',
    battery_pct: 85,
    video_remaining_sec: 3600,
    recording: false,
    mode: 'video',
    resolution: '1080p',
    fps: 30,
    online: true,
    last_seen: new Date().toISOString(),
    ...overrides,
  }
}

describe('CameraCard', () => {
  it('renders camera name', () => {
    render(<CameraCard camera={makeCamera()} />)
    expect(screen.getByText('Front Camera')).toBeInTheDocument()
  })

  it('shows resolution and FPS', () => {
    render(<CameraCard camera={makeCamera({ resolution: '4K', fps: 60 })} />)
    expect(screen.getByText('4K')).toBeInTheDocument()
    expect(screen.getByText('60 FPS')).toBeInTheDocument()
  })

  it('shows battery percentage', () => {
    render(<CameraCard camera={makeCamera({ battery_pct: 85 })} />)
    expect(screen.getByText('85%')).toBeInTheDocument()
  })

  it('shows N/A when battery is null', () => {
    render(<CameraCard camera={makeCamera({ battery_pct: null })} />)
    expect(screen.getByText('N/A')).toBeInTheDocument()
  })

  it('uses green for high battery (>=50%)', () => {
    const { container } = render(
      <CameraCard camera={makeCamera({ battery_pct: 85 })} />,
    )
    // The battery bar inner div should have bg-rig-success
    const bar = container.querySelector('.h-1\\.5 > div')
    expect(bar?.className).toContain('bg-rig-success')
  })

  it('uses yellow for medium battery (15-49%)', () => {
    const { container } = render(
      <CameraCard camera={makeCamera({ battery_pct: 30 })} />,
    )
    const bar = container.querySelector('.h-1\\.5 > div')
    expect(bar?.className).toContain('bg-rig-warning')
  })

  it('uses red for low battery (<15%)', () => {
    const { container } = render(
      <CameraCard camera={makeCamera({ battery_pct: 8 })} />,
    )
    const bar = container.querySelector('.h-1\\.5 > div')
    expect(bar?.className).toContain('bg-rig-danger')
  })

  it('shows REC badge with pulsing dot when recording', () => {
    render(<CameraCard camera={makeCamera({ recording: true })} />)
    expect(screen.getByText('REC')).toBeInTheDocument()
  })

  it('shows IDLE badge when not recording', () => {
    render(<CameraCard camera={makeCamera({ recording: false })} />)
    expect(screen.getByText('IDLE')).toBeInTheDocument()
  })

  it('shows Online badge when camera is online', () => {
    render(<CameraCard camera={makeCamera({ online: true })} />)
    expect(screen.getByText('Online')).toBeInTheDocument()
  })

  it('shows Offline badge when camera is offline', () => {
    render(<CameraCard camera={makeCamera({ online: false })} />)
    expect(screen.getByText('Offline')).toBeInTheDocument()
  })

  it('shows video remaining time when available', () => {
    render(
      <CameraCard camera={makeCamera({ video_remaining_sec: 125 })} />,
    )
    expect(screen.getByText('2m 5s left')).toBeInTheDocument()
  })

  it('does not show video remaining when null', () => {
    const { container } = render(
      <CameraCard camera={makeCamera({ video_remaining_sec: null })} />,
    )
    expect(container.textContent).not.toContain('left')
  })

  it('shows Live in footer when online', () => {
    render(<CameraCard camera={makeCamera({ online: true })} />)
    expect(screen.getByText('Live')).toBeInTheDocument()
  })

  it('shows relative time in footer when offline', () => {
    render(
      <CameraCard
        camera={makeCamera({
          online: false,
          last_seen: new Date(Date.now() - 5 * 60 * 1000).toISOString(),
        })}
      />,
    )
    expect(screen.getByText(/5m ago/)).toBeInTheDocument()
  })
})

Step 6: Run tests

npm test

Expected: All 15 tests pass.

Step 7: Commit

git add src/components/CameraCard.test.tsx src/test-setup.ts vite.config.ts package.json
git commit -m "CUB-196: add CameraCard unit tests (vitest + testing-library)"

Verification

After all tasks complete:

# Type-check
npx tsc --noEmit

# Build
npm run build

# Tests
npm test

All must pass before pushing.