Continued from EPISODE_TIMELINE_DESIGN_PART2.md
File: tests/test_episode_timeline.py
import pytest
from openadapt_viewer.components.episode_timeline import EpisodeTimelineGenerator
def test_calculate_episode_position():
"""Test position calculation for episode labels"""
generator = EpisodeTimelineGenerator()
episode = {
'start_time': 0.0,
'end_time': 3.5,
'duration': 3.5
}
total_duration = 10.0
position = generator.calculate_position(episode, total_duration)
assert position['left'] == '0.0%'
assert position['width'] == '35.0%'
def test_get_episode_at_time():
"""Test finding which episode contains a timestamp"""
episodes = [
{'episode_id': 'ep1', 'start_time': 0.0, 'end_time': 3.5},
{'episode_id': 'ep2', 'start_time': 3.5, 'end_time': 6.7},
]
generator = EpisodeTimelineGenerator(episodes=episodes)
assert generator.get_episode_at_time(2.0)['episode_id'] == 'ep1'
assert generator.get_episode_at_time(5.0)['episode_id'] == 'ep2'
assert generator.get_episode_at_time(10.0) is None
def test_format_duration():
"""Test duration formatting"""
generator = EpisodeTimelineGenerator()
assert generator.format_duration(3.5) == '3.5s'
assert generator.format_duration(65.0) == '1m 5s'
assert generator.format_duration(125.3) == '2m 5s'
def test_truncate_episode_name():
"""Test label truncation"""
generator = EpisodeTimelineGenerator(config={'label_truncate': 20})
short_name = "Short Name"
assert generator.truncate_text(short_name, 20) == "Short Name"
long_name = "This is a very long episode name"
truncated = generator.truncate_text(long_name, 20)
assert len(truncated) == 20
assert truncated.endswith('...')
def test_episode_color_rotation():
"""Test that episode colors cycle through palette"""
generator = EpisodeTimelineGenerator()
assert 'episode-1-bg' in generator.get_episode_color(0)
assert 'episode-2-bg' in generator.get_episode_color(1)
assert 'episode-1-bg' in generator.get_episode_color(5) # Cycles back
def test_graceful_degradation_no_episodes():
"""Test that component handles empty episode list"""
generator = EpisodeTimelineGenerator(episodes=[])
html = generator.render()
assert 'No episodes available' in html or html == ''
def test_episode_boundary_confidence():
"""Test rendering confidence indicators"""
episodes = [
{
'episode_id': 'ep1',
'name': 'Test Episode',
'start_time': 0.0,
'end_time': 3.5,
'boundary_confidence': 0.95
}
]
generator = EpisodeTimelineGenerator(episodes=episodes)
html = generator.render()
assert '95%' in html or 'confidence' in html.lower()File: tests/test_episode_timeline_integration.py
import pytest
from playwright.sync_api import Page, expect
@pytest.fixture
def episode_viewer_page(page: Page) -> Page:
"""Load capture viewer with test episode data"""
page.goto('file:///Users/abrichr/oa/src/openadapt-viewer/capture_viewer.html?episodes=test_episodes.json')
page.wait_for_selector('.oa-episode-timeline')
return page
def test_episode_labels_rendered(episode_viewer_page: Page):
"""Test that episode labels appear on timeline"""
labels = episode_viewer_page.locator('.oa-episode-label')
expect(labels).to_have_count(2)
# Check label text
expect(labels.nth(0)).to_contain_text('Navigate to System Settings')
expect(labels.nth(1)).to_contain_text('Disable Night Shift')
def test_click_episode_label_seeks(episode_viewer_page: Page):
"""Test that clicking episode label jumps to that episode"""
# Get initial position
initial_position = episode_viewer_page.locator('.oa-current-marker')
initial_left = initial_position.get_attribute('style')
# Click second episode label
episode_viewer_page.locator('.oa-episode-label').nth(1).click()
# Wait for seek animation
episode_viewer_page.wait_for_timeout(500)
# Check position changed
new_left = initial_position.get_attribute('style')
assert new_left != initial_left
# Check current episode indicator updated
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 2 of 2')
def test_episode_tooltip_on_hover(episode_viewer_page: Page):
"""Test that hovering episode label shows tooltip"""
label = episode_viewer_page.locator('.oa-episode-label').nth(0)
# Hover over label
label.hover()
# Wait for tooltip
episode_viewer_page.wait_for_selector('.oa-episode-tooltip', state='visible')
tooltip = episode_viewer_page.locator('.oa-episode-tooltip')
# Check tooltip content
expect(tooltip).to_contain_text('Navigate to System Settings')
expect(tooltip).to_contain_text('3.5s')
expect(tooltip).to_contain_text('3 steps')
def test_prev_next_episode_buttons(episode_viewer_page: Page):
"""Test episode navigation buttons"""
prev_btn = episode_viewer_page.get_by_label('Go to previous episode')
next_btn = episode_viewer_page.get_by_label('Go to next episode')
# Initially at episode 1, prev should be disabled
expect(prev_btn).to_be_disabled()
expect(next_btn).to_be_enabled()
# Click next
next_btn.click()
episode_viewer_page.wait_for_timeout(300)
# Now at episode 2
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 2')
expect(prev_btn).to_be_enabled()
expect(next_btn).to_be_disabled()
# Click prev
prev_btn.click()
episode_viewer_page.wait_for_timeout(300)
# Back to episode 1
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 1')
def test_keyboard_navigation(episode_viewer_page: Page):
"""Test keyboard shortcuts for episode navigation"""
# Press right arrow to go to next episode
episode_viewer_page.keyboard.press('ArrowRight')
episode_viewer_page.wait_for_timeout(300)
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 2')
# Press left arrow to go back
episode_viewer_page.keyboard.press('ArrowLeft')
episode_viewer_page.wait_for_timeout(300)
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 1')
# Press 2 to jump to episode 2
episode_viewer_page.keyboard.press('2')
episode_viewer_page.wait_for_timeout(300)
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 2')
# Press Home to go to first episode
episode_viewer_page.keyboard.press('Home')
episode_viewer_page.wait_for_timeout(300)
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 1')
def test_episode_boundary_markers(episode_viewer_page: Page):
"""Test that episode boundaries are visible"""
boundaries = episode_viewer_page.locator('.oa-episode-boundary')
# Should have N-1 boundaries for N episodes (2 episodes = 1 boundary)
expect(boundaries).to_have_count(1)
# Check boundary is positioned correctly (at 52.2% for 3.5s / 6.7s)
boundary_style = boundaries.nth(0).get_attribute('style')
assert 'left: 52' in boundary_style # Approximately 52%
def test_current_episode_highlight(episode_viewer_page: Page):
"""Test that current episode label is highlighted"""
# Start at episode 1
episode_1_label = episode_viewer_page.locator('.oa-episode-label').nth(0)
expect(episode_1_label).to_have_class(/oa-episode-current/)
# Navigate to episode 2
episode_viewer_page.locator('.oa-episode-label').nth(1).click()
episode_viewer_page.wait_for_timeout(300)
# Episode 2 should now be highlighted
episode_2_label = episode_viewer_page.locator('.oa-episode-label').nth(1)
expect(episode_2_label).to_have_class(/oa-episode-current/)
# Episode 1 should NOT be highlighted
expect(episode_1_label).not_to_have_class(/oa-episode-current/)
def test_timeline_click_seeks(episode_viewer_page: Page):
"""Test that clicking timeline seeks to that position"""
track = episode_viewer_page.locator('.oa-timeline-track')
# Get track dimensions
track_box = track.bounding_box()
# Click at 75% position
click_x = track_box['x'] + (track_box['width'] * 0.75)
click_y = track_box['y'] + (track_box['height'] / 2)
episode_viewer_page.mouse.click(click_x, click_y)
episode_viewer_page.wait_for_timeout(300)
# Should now be in episode 2 (starts at 52%)
expect(episode_viewer_page.locator('.oa-episode-current-indicator')).to_contain_text('Episode 2')
def test_episode_color_coding(episode_viewer_page: Page):
"""Test that episodes have different colors"""
label_1 = episode_viewer_page.locator('.oa-episode-label').nth(0)
label_2 = episode_viewer_page.locator('.oa-episode-label').nth(1)
style_1 = label_1.get_attribute('style')
style_2 = label_2.get_attribute('style')
# Check that backgrounds are different
assert 'episode-1-bg' in style_1 or 'gradient' in style_1
assert 'episode-2-bg' in style_2 or 'gradient' in style_2
assert style_1 != style_2File: tests/test_episode_timeline_visual.py
import pytest
from playwright.sync_api import Page
@pytest.fixture
def screenshots_dir():
return 'tests/visual_regression/screenshots'
def test_episode_timeline_default_state(episode_viewer_page: Page, screenshots_dir):
"""Capture screenshot of timeline in default state"""
timeline = episode_viewer_page.locator('.oa-episode-timeline')
timeline.screenshot(path=f'{screenshots_dir}/timeline_default.png')
def test_episode_timeline_hover_state(episode_viewer_page: Page, screenshots_dir):
"""Capture screenshot of timeline with hover state"""
label = episode_viewer_page.locator('.oa-episode-label').nth(0)
label.hover()
episode_viewer_page.wait_for_timeout(300) # Wait for hover animation
timeline = episode_viewer_page.locator('.oa-episode-timeline')
timeline.screenshot(path=f'{screenshots_dir}/timeline_hover.png')
def test_episode_timeline_current_state(episode_viewer_page: Page, screenshots_dir):
"""Capture screenshot with episode 2 active"""
episode_viewer_page.locator('.oa-episode-label').nth(1).click()
episode_viewer_page.wait_for_timeout(500)
timeline = episode_viewer_page.locator('.oa-episode-timeline')
timeline.screenshot(path=f'{screenshots_dir}/timeline_episode_2.png')
def test_episode_timeline_mobile(episode_viewer_page: Page, screenshots_dir):
"""Capture screenshot of mobile layout"""
episode_viewer_page.set_viewport_size({'width': 375, 'height': 667})
episode_viewer_page.wait_for_timeout(300)
timeline = episode_viewer_page.locator('.oa-episode-timeline')
timeline.screenshot(path=f'{screenshots_dir}/timeline_mobile.png')
def test_episode_timeline_many_episodes(page: Page, screenshots_dir):
"""Test timeline with many episodes (10+)"""
# Load fixture with 10 episodes
page.goto('file:///Users/abrichr/oa/src/openadapt-viewer/test_many_episodes.html')
page.wait_for_selector('.oa-episode-timeline')
timeline = page.locator('.oa-episode-timeline')
timeline.screenshot(path=f'{screenshots_dir}/timeline_many_episodes.png')import pytest
import time
from playwright.sync_api import Page
def test_timeline_render_performance(episode_viewer_page: Page):
"""Test that timeline renders quickly with typical episode count"""
start = time.time()
episode_viewer_page.goto('file:///Users/abrichr/oa/src/openadapt-viewer/capture_viewer.html?episodes=test_episodes.json')
episode_viewer_page.wait_for_selector('.oa-episode-timeline')
end = time.time()
render_time = end - start
# Should render in < 2 seconds
assert render_time < 2.0
def test_seek_animation_performance(episode_viewer_page: Page):
"""Test that seek animations are smooth (no jank)"""
# Enable performance profiling
episode_viewer_page.evaluate('performance.mark("seek-start")')
# Click to seek
episode_viewer_page.locator('.oa-episode-label').nth(1).click()
episode_viewer_page.wait_for_timeout(300) # Wait for animation
episode_viewer_page.evaluate('performance.mark("seek-end")')
# Measure performance
duration = episode_viewer_page.evaluate('''
performance.measure("seek-duration", "seek-start", "seek-end");
const measure = performance.getEntriesByName("seek-duration")[0];
return measure.duration;
''')
# Animation should complete in < 500ms
assert duration < 500
def test_many_episodes_performance(page: Page):
"""Test performance with 20 episodes"""
start = time.time()
page.goto('file:///Users/abrichr/oa/src/openadapt-viewer/test_many_episodes.html')
page.wait_for_selector('.oa-episode-timeline')
# Interact with timeline
page.locator('.oa-episode-label').nth(10).click()
page.wait_for_timeout(300)
end = time.time()
total_time = end - start
# Should still be responsive with many episodes
assert total_time < 3.0- Unit Tests: 90% code coverage
- Integration Tests: All user flows covered
- Visual Tests: Key states documented
- Performance Tests: Render time < 2s, seek time < 500ms
# All tests
uv run pytest tests/test_episode_timeline*.py -v
# Unit tests only (fast)
uv run pytest tests/test_episode_timeline.py -v
# Integration tests (requires Playwright)
uv run pytest tests/test_episode_timeline_integration.py -v
# Visual regression tests
uv run pytest tests/test_episode_timeline_visual.py -v
# Performance tests
uv run pytest tests/test_episode_timeline_integration.py -k performance -v
# With coverage
uv run pytest tests/ --cov=openadapt_viewer.components.episode_timeline --cov-report=htmlRequirements:
- ✅ All interactive elements focusable via Tab
- ✅ Focus indicators visible (2px outline)
- ✅ Keyboard shortcuts documented
- ✅ No keyboard traps
Implementation:
<!-- Episode label is keyboard-accessible -->
<div class="oa-episode-label"
role="button"
tabindex="0"
aria-label="Jump to episode 2: Disable Night Shift"
@keydown="handleKeydown">
Disable Night Shift
</div>.oa-episode-label:focus {
outline: 2px solid var(--oa-accent);
outline-offset: 2px;
}ARIA Labels:
<!-- Timeline track -->
<div class="oa-timeline-track"
role="slider"
aria-label="Playback timeline"
aria-valuenow="4.2"
aria-valuemin="0"
aria-valuemax="6.7"
aria-valuetext="Episode 2: Disable Night Shift, 4.2 seconds">
</div>
<!-- Episode boundary -->
<div class="oa-episode-boundary"
role="separator"
aria-label="Boundary between Navigate to Settings and Disable Night Shift">
</div>
<!-- Episode controls -->
<div class="oa-episode-controls" role="group" aria-label="Episode navigation">
<button aria-label="Go to previous episode">...</button>
<button aria-label="Go to next episode">...</button>
</div>Live Regions (announce changes):
<div role="status" aria-live="polite" aria-atomic="true" class="sr-only">
<span id="episode-announcer"></span>
</div>
<script>
// Update announcer when episode changes
function announceEpisodeChange(episode) {
const announcer = document.getElementById('episode-announcer');
announcer.textContent = `Entering episode ${episode.name}. ${episode.steps.length} steps.`;
}
</script>Requirements:
- Text on background: 4.5:1 minimum
- Large text (18pt+): 3:1 minimum
Implementation:
/* Episode label text on gradient background */
.oa-episode-label {
color: white; /* Ensure sufficient contrast on all gradient backgrounds */
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5); /* Enhance readability */
}
/* Current episode indicator */
.oa-episode-current-indicator strong {
color: var(--oa-accent); /* #00d4aa on dark background = 7.2:1 ✓ */
}On Episode Change:
function seekToEpisode(episode) {
// Seek to episode
this.onSeek(episode.start_time);
// Announce change
announceEpisodeChange(episode);
// Optionally move focus to play button
// document.querySelector('[aria-label="Play"]').focus();
}For visual elements:
<!-- Episode boundary marker -->
<div class="oa-episode-boundary"
role="separator"
aria-label="Episode 1 ends at 3.5 seconds">
</div>
<!-- Current position marker -->
<div class="oa-current-marker"
role="slider"
aria-label="Current playback position: 4.2 seconds, in episode Disable Night Shift">
</div>/* Desktop (default) */
@media (min-width: 1024px) {
.oa-episode-labels {
/* Labels side-by-side */
}
}
/* Tablet */
@media (max-width: 1023px) and (min-width: 768px) {
.oa-episode-label {
font-size: 11px;
}
.oa-episode-label-duration {
font-size: 10px;
}
}
/* Mobile */
@media (max-width: 767px) {
.oa-episode-timeline {
padding: var(--oa-space-sm);
}
.oa-episode-labels {
/* Stack labels if too many */
flex-direction: column;
height: auto;
}
.oa-episode-label {
position: relative !important;
width: 100% !important;
left: 0 !important;
margin-bottom: 4px;
}
.oa-episode-current-indicator {
font-size: 12px;
flex-wrap: wrap;
}
.oa-episode-controls {
flex-direction: column;
}
.oa-episode-nav-btn span {
/* Hide text, show only icons */
display: none;
}
}
/* Small Mobile */
@media (max-width: 480px) {
.oa-episode-label-duration {
display: none;
}
.oa-episode-tooltip {
max-width: 250px;
font-size: 11px;
}
}class EpisodeTimeline {
attachTouchListeners() {
const track = this.container.querySelector('.oa-timeline-track');
let touchStartX = 0;
let touchStartTime = 0;
track.addEventListener('touchstart', (e) => {
touchStartX = e.touches[0].clientX;
touchStartTime = Date.now();
});
track.addEventListener('touchend', (e) => {
const touchEndX = e.changedTouches[0].clientX;
const touchEndTime = Date.now();
const deltaX = touchEndX - touchStartX;
const deltaTime = touchEndTime - touchStartTime;
// Swipe detection
if (Math.abs(deltaX) > 50 && deltaTime < 300) {
if (deltaX > 0) {
// Swipe right = previous episode
this.prevEpisode();
} else {
// Swipe left = next episode
this.nextEpisode();
}
}
});
// Long-press for details
this.container.querySelectorAll('.oa-episode-label').forEach(label => {
let longPressTimer;
label.addEventListener('touchstart', (e) => {
longPressTimer = setTimeout(() => {
// Show tooltip on long-press
this.showTooltip(this.getEpisodeById(label.dataset.episodeId), e);
}, 500);
});
label.addEventListener('touchend', () => {
clearTimeout(longPressTimer);
});
});
}
}User Story: As a user, I want to bookmark specific moments within episodes so I can quickly return to important actions.
UI Design:
Timeline with bookmarks:
┌─────────────────────────────────────────────────────────┐
│ [Episode 1] ⭐ [Episode 2] ⭐⭐ │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ↑ ↑↑ │
│ bookmark 1 bookmarks 2,3 │
└─────────────────────────────────────────────────────────┘
Bookmark Panel:
┌─────────────────────────────────────────────────────────┐
│ My Bookmarks (3) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ⭐ Click System Settings icon │ │
│ │ Episode 1 • 0:01.2 • Added Jan 17 │ │
│ │ [Jump] [Edit] [Delete] │ │
│ ├─────────────────────────────────────────────────────┤ │
│ │ ⭐ Scroll down in settings │ │
│ │ Episode 2 • 0:03.8 • Added Jan 17 │ │
│ │ [Jump] [Edit] [Delete] │ │
│ └─────────────────────────────────────────────────────┘ │
│ [+ Add Bookmark at Current Position] │
└─────────────────────────────────────────────────────────┘
Implementation:
class EpisodeBookmarks {
constructor(storageKey = 'oa-episode-bookmarks') {
this.storageKey = storageKey;
this.bookmarks = this.loadBookmarks();
}
addBookmark(episodeId, timestamp, note = '') {
const bookmark = {
id: this.generateId(),
episodeId,
timestamp,
note,
createdAt: new Date().toISOString()
};
this.bookmarks.push(bookmark);
this.saveBookmarks();
return bookmark;
}
removeBookmark(bookmarkId) {
this.bookmarks = this.bookmarks.filter(b => b.id !== bookmarkId);
this.saveBookmarks();
}
getBookmarksForEpisode(episodeId) {
return this.bookmarks.filter(b => b.episodeId === episodeId);
}
loadBookmarks() {
try {
const stored = localStorage.getItem(this.storageKey);
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error('Failed to load bookmarks:', error);
return [];
}
}
saveBookmarks() {
try {
localStorage.setItem(this.storageKey, JSON.stringify(this.bookmarks));
} catch (error) {
console.error('Failed to save bookmarks:', error);
}
}
generateId() {
return `bookmark_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
}User Story: As a user, I want to compare two recordings side-by-side to see how episode structure differs.
UI Design:
┌──────────────────────────────────────────────────────────────┐
│ Recording Comparison │
├───────────────────────────────┬───────────────────────────────┤
│ Recording A │ Recording B │
│ turn-off-nightshift │ turn-off-nightshift-v2 │
│ │ │
│ [Ep1] [Ep2] │ [Ep1] [Ep2] [Ep3] │
│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ │ │
│ • 2 episodes │ • 3 episodes │
│ • 6.7s total │ • 8.2s total │
│ • 100% coverage │ • 95% coverage │
│ │ │
│ Differences: │
│ - Recording B has additional "Close window" episode │
│ - Recording B 22% longer duration │
│ - Episode names 85% similar │
└──────────────────────────────────────────────────────────────┘
User Story: As a developer, I want to track which episodes users view most to understand usage patterns.
Metrics to Track:
- Episode view count (how many times each episode was viewed)
- Episode completion rate (% of users who viewed entire episode)
- Episode skip rate (% of users who skipped over episode)
- Average time spent per episode
- Most common navigation paths (Ep1 → Ep2 vs Ep1 → Ep3)
Dashboard:
┌──────────────────────────────────────────────────────────────┐
│ Episode Analytics - turn-off-nightshift │
├──────────────────────────────────────────────────────────────┤
│ │
│ Episode Performance: │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ 1. Navigate to Settings Views: 145 ██████████ │ │
│ │ Completion: 92% Skip Rate: 5% │ │
│ ├────────────────────────────────────────────────────────┤ │
│ │ 2. Disable Night Shift Views: 134 █████████ │ │
│ │ Completion: 87% Skip Rate: 8% │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ Navigation Paths: │
│ - Ep1 → Ep2: 85% (most common) │
│ - Ep1 only: 10% │
│ - Ep2 only: 5% │
│ │
│ Engagement: │
│ - Avg session: 12.3s │
│ - Replay rate: 23% │
│ - Bookmark rate: 15% │
└──────────────────────────────────────────────────────────────┘
User Story: As a user, I want to adjust episode boundaries if the auto-segmentation is slightly off.
UI Design:
┌──────────────────────────────────────────────────────────────┐
│ Refine Episodes │
├──────────────────────────────────────────────────────────────┤
│ │
│ [Episode 1] [◄ Adjust ►] [Episode 2] │
│ ━━━━━━━━━━━━|━━━━━━━━━━━━━━━━━━━━━━━━━ │
│ ↕ │
│ Drag to adjust │
│ │
│ Episode 1: 0.0s → 3.5s (3.5s) │
│ Episode 2: 3.5s → 6.7s (3.2s) │
│ │
│ Suggested adjustment: Move boundary to 3.2s │
│ Reason: Better alignment with action completion │
│ │
│ [Apply Suggestion] [Reset] [Save Changes] │
└──────────────────────────────────────────────────────────────┘
Implementation:
class EpisodeRefinement {
constructor(episodes, onUpdate) {
this.episodes = episodes;
this.onUpdate = onUpdate;
this.enableDragging();
}
enableDragging() {
const boundaries = document.querySelectorAll('.oa-episode-boundary');
boundaries.forEach((boundary, index) => {
boundary.style.cursor = 'ew-resize';
let isDragging = false;
let startX = 0;
let startTime = this.episodes[index].end_time;
boundary.addEventListener('mousedown', (e) => {
isDragging = true;
startX = e.clientX;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const track = boundary.closest('.oa-timeline-track');
const trackWidth = track.offsetWidth;
const totalDuration = this.getTotalDuration();
// Calculate new boundary time
const deltaTime = (deltaX / trackWidth) * totalDuration;
const newTime = Math.max(0, Math.min(totalDuration, startTime + deltaTime));
// Update boundary position
const newPercent = (newTime / totalDuration) * 100;
boundary.style.left = `${newPercent}%`;
// Update episode times
this.episodes[index].end_time = newTime;
this.episodes[index + 1].start_time = newTime;
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
this.onUpdate(this.episodes);
}
});
});
}
suggestAdjustments() {
// Analyze episodes and suggest boundary adjustments
const suggestions = [];
for (let i = 0; i < this.episodes.length - 1; i++) {
const ep1 = this.episodes[i];
const ep2 = this.episodes[i + 1];
// Check if boundary falls mid-action
// (Implementation depends on step/action data)
const suggestion = this.analyzeBoundary(ep1, ep2);
if (suggestion) {
suggestions.push(suggestion);
}
}
return suggestions;
}
}New Files to Create:
-
src/openadapt_viewer/components/episode_timeline.js -
src/openadapt_viewer/components/episode_timeline.py -
src/openadapt_viewer/styles/episode_timeline.css -
test_episode_timeline.html(interactive demo) -
tests/test_episode_timeline.py(unit tests) -
tests/test_episode_timeline_integration.py(integration tests) -
tests/test_episode_timeline_visual.py(visual regression)
Files to Modify:
-
capture_viewer.html(add episode timeline) -
segmentation_viewer.html(add mini timeline previews) -
synthetic_demo_viewer.html(optional: add episode view) -
benchmark_viewer.html(optional: add episode timeline for tasks)
EpisodeTimeline Class:
class EpisodeTimeline {
constructor(options: EpisodeTimelineOptions);
// Core methods
render(): void;
update(updates: Partial<EpisodeTimelineOptions>): void;
destroy(): void;
// Episode navigation
seekToEpisode(episodeId: string): void;
prevEpisode(): void;
nextEpisode(): void;
getCurrentEpisode(): Episode | null;
getEpisodeAtTime(time: number): Episode | null;
// Utility methods
getEpisodeColor(index: number): string;
formatDuration(seconds: number): string;
calculatePosition(episode: Episode, totalDuration: number): Position;
// Event handlers
on(event: string, callback: Function): void;
off(event: string, callback: Function): void;
}
interface EpisodeTimelineOptions {
container: HTMLElement;
episodes: Episode[];
currentTime: number;
totalDuration: number;
onSeek: (time: number) => void;
onEpisodeChange?: (episode: Episode) => void;
config?: EpisodeTimelineConfig;
}
interface Episode {
episode_id: string;
name: string;
description: string;
start_time: number;
end_time: number;
duration: number;
steps: string[];
boundary_confidence?: number;
coherence_score?: number;
screenshots?: {
thumbnail?: string;
key_frames?: KeyFrame[];
};
}
interface EpisodeTimelineConfig {
showLabels?: boolean;
showBoundaries?: boolean;
enableClickNavigation?: boolean;
enableAutoAdvance?: boolean;
colorScheme?: 'auto' | 'blue' | 'purple' | 'custom';
labelTruncate?: number;
}Episode Timeline Classes:
| Class | Purpose |
|---|---|
.oa-episode-timeline |
Container for entire component |
.oa-episode-current-indicator |
Shows current episode info |
.oa-episode-labels |
Container for episode labels |
.oa-episode-label |
Individual episode label |
.oa-episode-current |
Current episode modifier |
.oa-episode-past |
Past episode modifier |
.oa-episode-future |
Future episode modifier |
.oa-timeline-track |
Timeline track container |
.oa-episode-segment |
Episode segment on timeline |
.oa-episode-boundary |
Episode boundary marker |
.oa-current-marker |
Current playback position |
.oa-episode-controls |
Episode navigation controls |
.oa-episode-nav-btn |
Prev/Next buttons |
.oa-episode-tooltip |
Hover tooltip |
Minimum Requirements:
- Chrome/Edge: 90+
- Firefox: 88+
- Safari: 14+
- Mobile Safari: iOS 14+
- Chrome Android: 90+
Fallbacks:
- CSS Grid → Flexbox (IE11)
- CSS Variables → Hardcoded colors (IE11)
- IntersectionObserver → Polyfill
- ResizeObserver → Polyfill
Target Metrics:
- Initial render: < 500ms (10 episodes)
- Seek animation: < 300ms
- Episode transition: < 200ms
- Memory footprint: < 10MB
- Frame rate: 60fps during animations
Optimization Techniques:
- Virtual scrolling for 20+ episodes
- Lazy render episode labels (only visible ones)
- Debounce timeline hover events
- Use CSS transforms (not left/top) for animations
- RequestAnimationframe for smooth updates
For Existing Viewers:
-
Add Episode Data: Ensure episodes are available (JSON file or catalog)
-
Include CSS: Link episode timeline styles
<link rel="stylesheet" href="styles/episode_timeline.css">
-
Include JS: Add component script
<script src="components/episode_timeline.js"></script>
-
Initialize Timeline: Add to viewer
const timeline = new EpisodeTimeline({ container: document.getElementById('timeline-container'), episodes: loadedEpisodes, currentTime: 0, totalDuration: recordingDuration, onSeek: (time) => { seekToTime(time); } });
-
Update on Playback: Sync current time
// In playback loop timeline.update({ currentTime: player.currentTime });
This comprehensive design document covers:
✅ Visual Design: Colors, typography, spacing, states ✅ Component Architecture: Modular, reusable, extensible ✅ Data Flow: Three options with fallbacks ✅ Implementation Phases: 4 phases from MVP to advanced ✅ Integration Guide: Step-by-step for capture/segmentation viewers ✅ User Interactions: 6 detailed user flows with expected behavior ✅ Technical Implementation: Complete JS component + CSS ✅ Testing Strategy: Unit, integration, visual, performance tests ✅ Accessibility: WCAG 2.1 AA compliant with ARIA support ✅ Responsiveness: Desktop, tablet, mobile layouts ✅ Advanced Features: Bookmarks, comparison, analytics, refinement
Next Steps:
- Review design with team
- Create prototype/mockup
- Implement Phase 1 (MVP)
- Write tests
- Integrate into capture viewer
- Gather user feedback
- Iterate and improve
Estimated Timeline:
- Phase 1 (MVP): 1 week
- Phase 2 (Enhanced UX): 1 week
- Phase 3 (Polish): 1 week
- Phase 4 (Advanced): 2-3 weeks
Total: 5-6 weeks for full implementation
Document Version: 1.0 Last Updated: 2026-01-17 Authors: OpenAdapt Viewer Team Status: Ready for Review