Add per-course progress badges to library cards, replace alert() with toasts

Compute a cheap, uncached completion percentage per course (completed
lessons / media files) and surface it as a badge + thin progress bar on
library cards in both list and grid view, and in search results - untouched
courses stay unbadged so the list doesn't get noisy with "0%".

Replace the native alert() dialogs used for bulk-select validation and
course-load errors with a small slide-in toast component that matches the
rest of the UI instead of a jarring browser popup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 21:22:00 -04:00
co-authored by Claude Sonnet 5
parent 3039d1206d
commit e2db5ecef8
2 changed files with 109 additions and 9 deletions
+35 -5
View File
@@ -806,6 +806,7 @@ def list_library_directory(dir_path: str, skip_hidden: bool = True) -> Dict[str,
if _looks_like_course(entry):
item = _course_summary(entry)
item['hidden'] = is_hidden
item['completion_percentage'] = _course_completion_percentage(entry, item['media_files'])
items.append(item)
else:
if skip_hidden:
@@ -904,6 +905,32 @@ def _course_summary(course_dir: Path) -> Dict[str, Any]:
return dict(cache_get_or_compute(f'course_summary:{course_dir}', compute))
def _course_completion_percentage(course_dir: Path, media_files: int) -> Optional[float]:
"""
Cheap, uncached completion percentage for a single course card - reads
only that course's own small progress JSON directly (same pattern as
_scan_library_activity), never the cached _course_summary result, so a
lesson finished a second ago shows up immediately instead of waiting out
the library-scan cache TTL. None if nothing's completed yet, so
untouched courses don't show a noisy "0%" badge.
"""
if media_files <= 0:
return None
try:
with open(course_dir / '.offlineu_progress.json', 'r') as f:
progress = json.load(f)
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
completed = sum(
1 for key, entry in progress.items()
if key != 'last_accessed_path' and isinstance(entry, dict) and entry.get('completed')
)
if completed == 0:
return None
return round(min(100.0, 100 * completed / media_files), 1)
def search_library_courses(query: str) -> List[Dict[str, Any]]:
"""
Search the library for courses whose name contains `query`
@@ -912,11 +939,14 @@ def search_library_courses(query: str) -> List[Dict[str, Any]]:
get_all_course_dirs.
"""
query_lower = query.lower()
return [
_course_summary(course)
for course in get_all_course_dirs()
if query_lower in course.name.lower()
]
results = []
for course in get_all_course_dirs():
if query_lower not in course.name.lower():
continue
item = _course_summary(course)
item['completion_percentage'] = _course_completion_percentage(course, item['media_files'])
results.append(item)
return results
def _extract_subtitle_snippet(text: str, query_lower: str, context_chars: int = 80) -> str: