Grand Diomande Research · Full HTML 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.

Embodied Trajectory Systems research note backlog reference score 22 .md

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):

typescript
// 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):

typescript
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):

typescript
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:

typescript
// 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:

tsx
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:

typescript
// 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

tsx
// Add to each phrase card
<PhraseAnalyticsBadge
  phraseId={phrase.id}
  playCount={playCountMap[phrase.id]}
  captureCount={captureCountMap[phrase.id]}
/>

`/phrases/page.tsx` - Player View

tsx
// 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