From e2db5ecef88fa3f51deb52e66ab5d803dd6417ca Mon Sep 17 00:00:00 2001 From: rmsitz Date: Sun, 23 Aug 2026 21:22:00 -0400 Subject: [PATCH] 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 --- offlineu_core.py | 40 ++++++++++++++--- templates/course_dashboard.html | 78 +++++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/offlineu_core.py b/offlineu_core.py index 9bf3fda..edfdbb0 100644 --- a/offlineu_core.py +++ b/offlineu_core.py @@ -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: diff --git a/templates/course_dashboard.html b/templates/course_dashboard.html index a5d4459..3b82a10 100644 --- a/templates/course_dashboard.html +++ b/templates/course_dashboard.html @@ -145,6 +145,38 @@ word-break: break-word; } + .toast-container { + position: fixed; + left: 0; + right: 0; + bottom: calc(60px + env(safe-area-inset-bottom, 0px)); + z-index: 9500; + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 0 16px; + pointer-events: none; + } + .toast { + max-width: 480px; + width: 100%; + background: var(--bg-tertiary); + color: var(--text-primary); + border-left: 4px solid var(--error); + border-radius: var(--radius); + padding: 12px 16px; + font-size: 0.9em; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s, transform 0.2s; + } + .toast.toast-visible { + opacity: 1; + transform: translateY(0); + } + .bottom-tab-bar { position: fixed; left: 0; @@ -766,6 +798,16 @@ font-size: 0.75em; color: var(--text-muted); } + .grid-card-progress-track { + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.08); + overflow: hidden; + } + .grid-card-progress-fill { + height: 100%; + background: var(--accent); + } .transcript-search-toggle { display: flex; @@ -1260,7 +1302,23 @@ +
+