F9: Small polish batch
- getYouTubeId also recognizes /embed/<id> and /shorts/<id> URLs (test renders all three variants as thumbnails on the exercise detail view) - useListNavigation JSDoc: itemCount is a Ref<number>, not a number - collectionRepository.getAll: N+1 per-collection count queries folded into one LEFT JOIN ... GROUP BY Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
166cf87e95
commit
0bc20f81d8
|
|
@ -3,9 +3,9 @@ import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
/**
|
/**
|
||||||
* Provides keyboard navigation for a list of items using arrow keys.
|
* Provides keyboard navigation for a list of items using arrow keys.
|
||||||
*
|
*
|
||||||
* @param {number} itemCount - The number of items in the list
|
* @param {import('vue').Ref<number>} itemCount - Ref holding the number of items in the list
|
||||||
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
|
* @param {Function} onSelect - Callback when Enter is pressed on a focused item (receives index)
|
||||||
* @returns {{ focusedIndex: Ref<number>, onKeydown: Function, focusItem: Function }}
|
* @returns {{ focusedIndex: import('vue').Ref<number>, onKeydown: Function, focusItem: Function }}
|
||||||
*/
|
*/
|
||||||
export function useListNavigation(itemCount, onSelect) {
|
export function useListNavigation(itemCount, onSelect) {
|
||||||
const focusedIndex = ref(-1)
|
const focusedIndex = ref(-1)
|
||||||
|
|
|
||||||
|
|
@ -20,15 +20,13 @@ export const collectionRepository = {
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAll() {
|
async getAll() {
|
||||||
const collections = await db.selectAll('SELECT * FROM collection ORDER BY label')
|
return db.selectAll(
|
||||||
for (const c of collections) {
|
`SELECT c.*, COUNT(cs.session_id) AS sessionCount
|
||||||
const row = await db.selectOne(
|
FROM collection c
|
||||||
'SELECT COUNT(*) AS sessionCount FROM collection_session WHERE collection_id = ?',
|
LEFT JOIN collection_session cs ON cs.collection_id = c.id
|
||||||
[c.id],
|
GROUP BY c.id
|
||||||
|
ORDER BY c.label`,
|
||||||
)
|
)
|
||||||
c.sessionCount = row ? row.sessionCount : 0
|
|
||||||
}
|
|
||||||
return collections
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async getAllWithSessions() {
|
async getAllWithSessions() {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ export function getYouTubeId(url) {
|
||||||
return u.pathname.slice(1)
|
return u.pathname.slice(1)
|
||||||
}
|
}
|
||||||
if (u.hostname.includes('youtube.com')) {
|
if (u.hostname.includes('youtube.com')) {
|
||||||
|
const pathMatch = u.pathname.match(/^\/(?:embed|shorts)\/([^/?]+)/)
|
||||||
|
if (pathMatch) return pathMatch[1]
|
||||||
return u.searchParams.get('v')
|
return u.searchParams.get('v')
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Step 3 — Exercise Management Tests
|
||||||
- test_delete_exercise: Delete an exercise with confirmation
|
- test_delete_exercise: Delete an exercise with confirmation
|
||||||
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
- test_delete_exercise_cancel: Cancelling delete does not remove exercise
|
||||||
- test_delete_exercise_used_in_session: Delete confirm mentions usage; no session_item orphans
|
- test_delete_exercise_used_in_session: Delete confirm mentions usage; no session_item orphans
|
||||||
|
- test_exercise_video_embed_and_shorts_urls: embed/shorts YouTube URLs render as thumbnails
|
||||||
- test_search_exercises: Search filters exercises by title
|
- test_search_exercises: Search filters exercises by title
|
||||||
- test_search_no_results: Search with no matches shows empty message
|
- test_search_no_results: Search with no matches shows empty message
|
||||||
- test_search_clear_button: Clear button empties the search field and restores the full list
|
- test_search_clear_button: Clear button empties the search field and restores the full list
|
||||||
|
|
@ -172,6 +173,46 @@ def test_create_exercise_full(page: Page, app_url: str):
|
||||||
_clean_exercises(page)
|
_clean_exercises(page)
|
||||||
|
|
||||||
|
|
||||||
|
def test_exercise_video_embed_and_shorts_urls(page: Page, app_url: str):
|
||||||
|
"""YouTube /embed/<id> and /shorts/<id> URLs are recognized like watch
|
||||||
|
URLs and render as thumbnails on the detail view."""
|
||||||
|
_go_to_exercises(page, app_url)
|
||||||
|
_clean_exercises(page)
|
||||||
|
|
||||||
|
exercise_id = page.evaluate(
|
||||||
|
"""async () => {
|
||||||
|
const uuid = await window.__repos.settings.get('user_uuid');
|
||||||
|
return window.__repos.exercises.create({
|
||||||
|
title: 'Video URL Variants',
|
||||||
|
description: '',
|
||||||
|
asymmetric: false,
|
||||||
|
alternate: false,
|
||||||
|
image_urls: [],
|
||||||
|
video_urls: [
|
||||||
|
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com/embed/dQw4w9WgXcQ',
|
||||||
|
'https://www.youtube.com/shorts/dQw4w9WgXcQ',
|
||||||
|
],
|
||||||
|
default_reps: 10,
|
||||||
|
default_weight: 0,
|
||||||
|
created_by: uuid
|
||||||
|
});
|
||||||
|
}"""
|
||||||
|
)
|
||||||
|
|
||||||
|
page.goto(f"{app_url}/#/exercises/{exercise_id}")
|
||||||
|
page.wait_for_selector('[data-testid="exercise-detail-video-0"]', timeout=5000)
|
||||||
|
|
||||||
|
for i in range(3):
|
||||||
|
video = page.locator(f'[data-testid="exercise-detail-video-{i}"]')
|
||||||
|
expect(video).to_have_class("youtube-thumb")
|
||||||
|
expect(video.locator("img")).to_have_attribute(
|
||||||
|
"src", "https://img.youtube.com/vi/dQw4w9WgXcQ/mqdefault.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
_clean_exercises(page)
|
||||||
|
|
||||||
|
|
||||||
def test_exercise_appears_in_list(page: Page, app_url: str):
|
def test_exercise_appears_in_list(page: Page, app_url: str):
|
||||||
"""Created exercise appears in the list view with correct info."""
|
"""Created exercise appears in the list view with correct info."""
|
||||||
_go_to_exercises(page, app_url)
|
_go_to_exercises(page, app_url)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue