UI Specification: Data Pipeline Integration
> **For Frontend AI**: This document specifies UI components to implement. **DO NOT modify any files in `/lib/`** - the backend data layer is complete. Your job is strictly UI components and visual presentation.
Full Public Reader
UI Specification: Data Pipeline Integration
> For Frontend AI: This document specifies UI components to implement. DO NOT modify any files in `/lib/` - the backend data layer is complete. Your job is strictly UI components and visual presentation.
---
Context: What's Already Built (Backend)
The data pipeline is fully implemented and operational:
### 1. Play Event Tracking (`/lib/audio/PhrasePlayer.ts`)
Every user interaction is automatically tracked to Supabase:
- `play_start` - When a phrase begins playing
- `play_complete` - When a phrase finishes naturally
- `skip` - When user stops before 90
- `pause` - When user stops after 90
- `crossfade_start` - When transition to next phrase begins
- `crossfade_complete` - When transition finishes
Each event includes:
- `session_id` - Groups events by browser session
- `position_seconds` / `position_pct` - Where in the phrase
- `playback_duration_seconds` - How long they listened
- `tempo_sync_method` - `'exact'` | `'half_time'` | `'double_time'` | `'none'`
- `device_type` - `'mobile'` | `'tablet'` | `'desktop'`
- `is_auto` - Whether auto-advance triggered the event
### 2. MediaPipe Capture Storage (`/lib/mediapipe/CaptureStorageService.ts`)
Motion capture sessions are stored with:
- `mediapipe_sessions` - Session metadata, performer name, tracking quality
- `mediapipe_captures` - Frame-by-frame landmark data
- `mediapipe_feature_sequences` - Aggregated motion features (body_energy, arm_spread, etc.)
3. Available Supabase Tables
play_events - User playback analytics
phrase_usage_analytics - Aggregated phrase stats
mediapipe_sessions - Capture session metadata
mediapipe_captures - Raw landmark frames
mediapipe_feature_sequences - Motion feature time series---
UI Components to Implement
Component 1: Phrase Analytics Badge
Location: Phrase card in list view (both `/phrases/page.tsx` list and any phrase cards)
Purpose: Show at-a-glance popularity and capture data for each phrase
Visual Spec:
┌─────────────────────────────────────────────────────────┐
│ [Phrase Card] │
│ ┌─────────────────────────────────────────────────┐ │
│ │ song_name.mp3 │ │
│ │ 120 BPM • 8.5s │ │
│ │ │ │
│ │ ┌────────┐ ┌────────────┐ ┌───────────────┐ │ │
│ │ │ 47 ▶️ │ │ 3 captures │ │ 🎯 Smooth │ │ │
│ │ └────────┘ └────────────┘ └───────────────┘ │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Data Fetching (you implement this query in the component):
// Fetch play counts
const { data: playCounts } = await supabase
.from('play_events')
.select('phrase_id')
.eq('event_type', 'play_start')
.eq('phrase_id', phrase.id)
// Fetch capture counts
const { data: captureCounts } = await supabase
.from('mediapipe_sessions')
.select('id')
.eq('phrase_id', phrase.id)
.eq('status', 'completed')Badge Variants:
| Condition | Badge | Color |
|-----------|-------|-------|
| `playCount > 100` | "🔥 Popular" | `text-orange-400 bg-orange-400/10` |
| `playCount > 50` | "⭐ Trending" | `text-yellow-400 bg-yellow-400/10` |
| `captureCount > 0` | "📹 {n} captures" | `text-cyan-400 bg-cyan-400/10` |
| `captureCount === 0` | "No captures yet" | `text-gray-500` (subtle) |
Tempo Sync Indicator (show when this phrase is queued after current):
| Sync Method | Label | Color |
|-------------|-------|-------|
| `exact` | "🎯 Smooth" | `text-green-400` |
| `half_time` | "½ Half-time" | `text-blue-400` |
| `double_time` | "2× Double" | `text-purple-400` |
| `none` | "⚡ Jump" | `text-gray-400` |
---
Component 2: Smart Queue Panel
Location: Sidebar or collapsible panel in player view
Purpose: Show upcoming phrases ranked by tempo compatibility, not just random order
Visual Spec:
┌──────────────────────────────────────┐
│ UP NEXT ▼ │
├──────────────────────────────────────┤
│ ┌────────────────────────────────┐ │
│ │ 🎯 song_a.mp3 │ │
│ │ 120 BPM • Smooth transition │ │
│ │ 47 plays • 2 captures │ │
│ └────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ ½ song_b.mp3 │ │
│ │ 60 BPM • Half-time │ │
│ │ 23 plays │ │
│ └────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────┐ │
│ │ ⚡ song_c.mp3 │ │
│ │ 95 BPM • Tempo jump │ │
│ │ 12 plays │ │
│ └────────────────────────────────┘ │
│ │
│ ────────────────────────────────── │
│ [Show all phrases →] │
└──────────────────────────────────────┘Sorting Algorithm (implement in component):
interface QueuedPhrase {
phrase: MotionPhrase
syncMethod: 'exact' | 'half_time' | 'double_time' | 'none'
syncScore: number
playCount: number
}
function buildSmartQueue(
currentBpm: number,
allPhrases: MotionPhrase[],
playCountMap: Record<string, number>
): QueuedPhrase[] {
return allPhrases
.filter(p => p.id !== currentPhrase?.id)
.map(p => {
const sync = getTempoSyncInfo(currentBpm, p.tempo_bpm)
return {
phrase: p,
syncMethod: sync.method,
syncScore: sync.score,
playCount: playCountMap[p.id] || 0,
}
})
.sort((a, b) => {
// Primary: tempo compatibility
if (b.syncScore !== a.syncScore) return b.syncScore - a.syncScore
// Secondary: popularity
return b.playCount - a.playCount
})
.slice(0, 8)
}
function getTempoSyncInfo(fromBpm: number, toBpm: number): { method: string; score: number } {
const tolerance = 5
if (Math.abs(fromBpm - toBpm) <= tolerance)
return { method: 'exact', score: 100 }
if (Math.abs(fromBpm * 2 - toBpm) <= tolerance)
return { method: 'double_time', score: 80 }
if (Math.abs(fromBpm / 2 - toBpm) <= tolerance)
return { method: 'half_time', score: 80 }
// Score based on how close the tempos are
const ratio = toBpm / fromBpm
const deviation = Math.abs(1 - ratio)
return { method: 'none', score: Math.max(0, 50 - deviation * 100) }
}Interactive Behavior:
- Click any queued phrase → calls `phrasePlayer.crossfadeTo(phrase)`
- Drag to reorder (optional, nice-to-have)
- Hover shows full phrase info tooltip
---
Component 3: Session Stats Overlay
Location: Floating overlay in bottom-right during playback, or in header
Purpose: Show real-time session statistics as user plays phrases
Visual Spec:
┌────────────────────────────┐
│ SESSION STATS │
│ ──────────────────────── │
│ 🎵 12 phrases played │
│ ⏱️ 4m 32s listening │
│ 🔀 8 smooth transitions │
│ ⚡ 4 tempo jumps │
│ ⏭️ 2 skips │
└────────────────────────────┘State Management (local state, derived from play events):
interface SessionStats {
phrasesPlayed: number
totalListeningSeconds: number
smoothTransitions: number // exact + half_time + double_time
tempoJumps: number // none sync method
skips: number // play_events where event_type = 'skip'
}
// Subscribe to phrasePlayer events to update stats
useEffect(() => {
const player = getPhrasePlayer()
const handlePlaying = (phrase: MotionPhrase) => {
setStats(s => ({ ...s, phrasesPlayed: s.phrasesPlayed + 1 }))
}
const handleFinished = (phrase: MotionPhrase) => {
const duration = phrase.t_end - phrase.t_start
setStats(s => ({
...s,
totalListeningSeconds: s.totalListeningSeconds + duration
}))
}
player.on('phrasePlaying', handlePlaying)
player.on('phraseFinished', handleFinished)
return () => {
player.off('phrasePlaying', handlePlaying)
player.off('phraseFinished', handleFinished)
}
}, [])Visibility:
- Hidden by default on mobile (save space)
- Toggle button in header: 📊
- Auto-collapse after 5 seconds of inactivity
- Expand on hover/tap
---
Component 4: Capture Prompt Card
Location: Appears after a phrase finishes playing (if no capture exists for it)
Purpose: Encourage users to record motion captures for phrases they enjoy
Visual Spec:
┌─────────────────────────────────────────────────────────┐
│ ┌─────────────────────────────────────────────────┐ │
│ │ 📹 Help train the AI! │ │
│ │ │ │
│ │ This phrase has no motion captures yet. │ │
│ │ Record yourself dancing to help improve │ │
│ │ motion generation. │ │
│ │ │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ Record Now 📹 │ │ Maybe Later │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ │ │ │
│ │ □ Don't show again for this session │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Trigger Logic:
// Show after phrase finishes if:
// 1. Phrase has 0 captures
// 2. User hasn't dismissed for this session
// 3. User has played at least 3 phrases (warm them up first)
const [dismissedPhrases, setDismissedPhrases] = useState<Set<string>>(new Set())
const [showCapturePrompt, setShowCapturePrompt] = useState(false)
const [targetPhrase, setTargetPhrase] = useState<MotionPhrase | null>(null)
useEffect(() => {
const player = getPhrasePlayer()
const handleFinished = async (phrase: MotionPhrase) => {
if (dismissedPhrases.has(phrase.id)) return
if (sessionStats.phrasesPlayed < 3) return
// Check if phrase has captures
const { count } = await supabase
.from('mediapipe_sessions')
.select('id', { count: 'exact' })
.eq('phrase_id', phrase.id)
.eq('status', 'completed')
if (count === 0) {
setTargetPhrase(phrase)
setShowCapturePrompt(true)
}
}
player.on('phraseFinished', handleFinished)
return () => player.off('phraseFinished', handleFinished)
}, [dismissedPhrases, sessionStats.phrasesPlayed])Actions:
- "Record Now" → Opens capture panel with phrase pre-selected, starts playback
- "Maybe Later" → Dismisses, adds to session dismissal set
- Checkbox → Adds to localStorage to never show again
---
Component 5: Tempo Match Indicator (Now Playing)
Location: In the now-playing bar/card, next to BPM display
Purpose: When a phrase is queued/about to play, show how it relates to current tempo
Visual Spec:
┌─────────────────────────────────────────────────────────┐
│ NOW PLAYING │
│ ───────────────────────────────────────────────────── │
│ 🎵 awesome_track.mp3 │
│ │
│ ┌───────────┐ ┌───────────────┐ │
│ │ 120 BPM │ ──── 🎯 ────────▶ │ NEXT: 118 │ │
│ └───────────┘ Smooth └───────────────┘ │
│ │
│ ▶️ ━━━━━━━━━━━━●━━━━━━━━━━━━━━━━━━━━━━━ 2:34 / 3:45 │
└─────────────────────────────────────────────────────────┘Visual Indicators:
| Sync | Animation | Color |
|------|-----------|-------|
| `exact` | Smooth pulse, connected line | Green |
| `half_time` | Slow wave animation | Blue |
| `double_time` | Fast wave animation | Purple |
| `none` | Jagged/broken line | Gray/Orange |
Implementation:
function TempoMatchIndicator({
currentBpm,
nextBpm
}: {
currentBpm: number
nextBpm: number | null
}) {
if (!nextBpm) return null
const sync = getTempoSyncInfo(currentBpm, nextBpm)
const styles = {
exact: 'bg-green-500/20 text-green-400 border-green-500/30',
half_time: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
double_time: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
none: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
}
const icons = {
exact: '🎯',
half_time: '½',
double_time: '2×',
none: '⚡',
}
const labels = {
exact: 'Smooth',
half_time: 'Half-time',
double_time: 'Double',
none: 'Jump',
}
return (
<div className={`flex items-center gap-2 px-3 py-1 rounded-full border ${styles[sync.method]}`}>
<span>{icons[sync.method]}</span>
<span className="text-sm font-medium">{labels[sync.method]}</span>
<span className="text-xs opacity-70">→ {nextBpm} BPM</span>
</div>
)
}---
Component 6: Capture Leaderboard (Future Feature)
Location: New tab/section in phrases page or dedicated `/captures` page
Purpose: Show top contributors and their capture sessions
Visual Spec:
┌─────────────────────────────────────────────────────────┐
│ TOP CONTRIBUTORS │
│ ───────────────────────────────────────────────────── │
│ │
│ 🥇 Alex M. 42 captures 98% quality │
│ 🥈 Jordan K. 38 captures 95% quality │
│ 🥉 Sam T. 29 captures 97% quality │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ YOUR STATS │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Captures: 12 │ │
│ │ Avg Quality: 94% │ │
│ │ Phrases Covered: 8 │ │
│ │ Total Frames: 14,832 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ RECENT CAPTURES │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 🎬 song_a.mp3 • 2 hours ago • 847 frames │ │
│ │ 🎬 song_b.mp3 • Yesterday • 1,203 frames │ │
│ └─────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘Data Query:
// Leaderboard
const { data: leaderboard } = await supabase
.from('mediapipe_sessions')
.select('performer_name, tracking_stability_score')
.eq('status', 'completed')
.not('performer_name', 'is', null)
.order('created_at', { ascending: false })
// Group and aggregate
const performers = Object.entries(
leaderboard.reduce((acc, session) => {
const name = session.performer_name
if (!acc[name]) acc[name] = { count: 0, totalQuality: 0 }
acc[name].count++
acc[name].totalQuality += session.tracking_stability_score || 0
return acc
}, {} as Record<string, { count: number; totalQuality: number }>)
)
.map(([name, stats]) => ({
name,
captures: stats.count,
avgQuality: Math.round((stats.totalQuality / stats.count) * 100),
}))
.sort((a, b) => b.captures - a.captures)
.slice(0, 10)---
File Structure for New Components
Create these files:
src/components/
├── analytics/
│ ├── PhraseAnalyticsBadge.tsx # Component 1
│ ├── SessionStatsOverlay.tsx # Component 3
│ └── index.ts
├── queue/
│ ├── SmartQueuePanel.tsx # Component 2
│ ├── TempoMatchIndicator.tsx # Component 5
│ └── index.ts
├── capture/
│ ├── CapturePromptCard.tsx # Component 4
│ ├── CaptureLeaderboard.tsx # Component 6
│ ├── MotionCapturePanel.tsx # Already exists
│ └── index.ts---
Integration Points in Existing Pages
`/phrases/page.tsx` - List View
// Add to each phrase card
<PhraseAnalyticsBadge
phraseId={phrase.id}
playCount={playCountMap[phrase.id]}
captureCount={captureCountMap[phrase.id]}
/>`/phrases/page.tsx` - Player View
// Add smart queue sidebar
<SmartQueuePanel
currentPhrase={currentPhrase}
allPhrases={phrases}
onSelectPhrase={(p) => phrasePlayer.crossfadeTo(p)}
/>
// Add tempo indicator to now-playing
<TempoMatchIndicator
currentBpm={currentPhrase?.tempo_bpm}
nextBpm={upcomingPhrase?.tempo_bpm}
/>
// Add session stats overlay
<SessionStatsOverlay />
// Add capture prompt (conditional)
{showCapturePrompt && (
<CapturePromptCard
phrase={targetPhrase}
onRecord={() => {
setShowCapturePanel(true)
setShowCapturePrompt(false)
}}
onDismiss={() => {
setDismissedPhrases(s => new Set([...s, targetPhrase.id]))
setShowCapturePrompt(false)
}}
/>
)}---
Styling Guidelines
- Use existing Tailwind classes from the codebase
- Dark theme: `bg-gray-800`, `bg-gray-900`, `text-gray-400`
- Accent colors: `cyan-400/500` (primary), `green-400` (success), `red-400` (recording)
- Use `framer-motion` for animations (already imported)
- Glass morphism: `bg-gray-800/50 backdrop-blur-sm border border-white/10`
---
DO NOT MODIFY
These files contain the data layer and MUST NOT be changed:
- `/lib/audio/PhrasePlayer.ts` - Play event tracking is complete
- `/lib/mediapipe/CaptureStorageService.ts` - Capture storage is complete
- `/lib/mediapipe/MediaPipeService.ts` - MediaPipe detection is complete
- `/lib/supabase/*` - All Supabase clients and types
- Any files in `/lib/` directory
---
Questions?
If you need:
- Additional data from Supabase → Ask, I'll add the query pattern
- New event types tracked → Ask, I'll add to PhrasePlayer
- Clarification on any component → Ask before implementing
The backend is complete. Your job is making it visible and useful through great UI.
Promotion Decision
Keep in the searchable backlog until it intersects a live paper or system.
Source Anchor
Comp-Core/apps/web/cc-dashboard/UI_SPECIFICATION.md
Detected Structure
Method · Code Anchors · Architecture