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 — 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.