# 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: ```bash cd /mnt/ai-storage/projects/remote-rig git checkout dev git pull origin dev git checkout -b agent//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`: ```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 (
{/* Header: camera name + online indicator */}
{online ? ( ) : ( )} {online ? 'Online' : 'Offline'}
{/* Body: resolution + fps, recording indicator */}
{camera.resolution} · {camera.fps} FPS
{camera.recording ? 'REC' : 'IDLE'} {camera.mode}
{/* Battery bar */}
Battery
{camera.battery_pct !== null ? `${camera.battery_pct}%` : 'N/A'}
{/* Footer: last seen + video remaining */}
{camera.online ? 'Live' : `Last seen ${formatRelativeTime(camera.last_seen)}`}
{camera.video_remaining_sec !== null && ( {Math.floor(camera.video_remaining_sec / 60)}m {camera.video_remaining_sec % 60}s left )}
) } ``` **Step 2: Create barrel export** Create `src/components/index.ts`: ```ts export { CameraCard } from './CameraCard' ``` **Step 3: Type-check** Run: `npx tsc --noEmit` Expected: No errors. **Step 4: Commit** ```bash 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: ```tsx 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 (
{/* Header */}

RemoteRig

Dashboard
{/* Stats */} {cameras.length > 0 && (
{onlineCount} online {recordingCount} recording
)}
{/* Main Content */}
{cameras.length === 0 ? (

Waiting for cameras...

Connect a camera or start the hub backend to see live status here.

) : (
{cameras.map((cam) => ( ))}
)}
{/* Footer */}

RemoteRig v0.1.0 — Multi-Camera Remote Monitoring System

) } export default App ``` **Step 2: Type-check and build** ```bash npx tsc --noEmit npm run build ``` Expected: TypeScript compiles clean, Vite build succeeds. **Step 3: Commit** ```bash 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** ```bash 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/): ```ts /// // ... existing imports and config, add: test: { globals: true, environment: 'jsdom', setupFiles: [], } ``` **Step 3: Create setup file** Create `src/test-setup.ts`: ```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: ```json "test": "vitest run", "test:watch": "vitest" ``` **Step 5: Write CameraCard tests** Create `src/components/CameraCard.test.tsx`: ```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 { 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() expect(screen.getByText('Front Camera')).toBeInTheDocument() }) it('shows resolution and FPS', () => { render() expect(screen.getByText('4K')).toBeInTheDocument() expect(screen.getByText('60 FPS')).toBeInTheDocument() }) it('shows battery percentage', () => { render() expect(screen.getByText('85%')).toBeInTheDocument() }) it('shows N/A when battery is null', () => { render() expect(screen.getByText('N/A')).toBeInTheDocument() }) it('uses green for high battery (>=50%)', () => { const { container } = render( , ) // 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( , ) 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( , ) 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() expect(screen.getByText('REC')).toBeInTheDocument() }) it('shows IDLE badge when not recording', () => { render() expect(screen.getByText('IDLE')).toBeInTheDocument() }) it('shows Online badge when camera is online', () => { render() expect(screen.getByText('Online')).toBeInTheDocument() }) it('shows Offline badge when camera is offline', () => { render() expect(screen.getByText('Offline')).toBeInTheDocument() }) it('shows video remaining time when available', () => { render( , ) expect(screen.getByText('2m 5s left')).toBeInTheDocument() }) it('does not show video remaining when null', () => { const { container } = render( , ) expect(container.textContent).not.toContain('left') }) it('shows Live in footer when online', () => { render() expect(screen.getByText('Live')).toBeInTheDocument() }) it('shows relative time in footer when offline', () => { render( , ) expect(screen.getByText(/5m ago/)).toBeInTheDocument() }) }) ``` **Step 6: Run tests** ```bash npm test ``` Expected: All 15 tests pass. **Step 7: Commit** ```bash 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: ```bash # Type-check npx tsc --noEmit # Build npm run build # Tests npm test ``` All must pass before pushing.