Stop resetting lesson progress on view, and add Notes Hub search
view_lesson() called update_lesson_progress() with no arguments on every page load, which defaults completed=False and progress_seconds=0 and silently overwrote whatever was already saved - just opening an already-watched lesson reset its progress/completed state unless the client happened to report real values within the next 15 seconds. Replaced with touch_lesson_accessed(), which only bumps last_accessed. Also fixes the "Resume from Xs" feature this fed into: it read a value that was never populated on this route, and even when given a real one, checked activeMedia.duration synchronously before metadata had loaded, so it silently never applied. Now waits for loadedmetadata (or resolves immediately if already available) and defers to an explicit note-timestamp jump (?t=) when both are present. Notes Hub gets a live search box filtering by note text, lesson title, or course name (client-side, no reload), with a "no notes match" state - useful now that a single category folder can hold dozens of courses' worth of timestamped notes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+24
-5
@@ -1446,6 +1446,23 @@ class ProgressTracker:
|
||||
|
||||
ProgressTracker.save_progress(course, progress)
|
||||
|
||||
@staticmethod
|
||||
def touch_lesson_accessed(course: Course, lesson_path: str):
|
||||
"""
|
||||
Record that a lesson was opened, without touching its saved
|
||||
completed/progress_seconds/duration_seconds - update_lesson_progress
|
||||
is for the client reporting real playback progress, and calling it
|
||||
(with its completed=False, progress_seconds=0 defaults) just for
|
||||
viewing a page would silently reset an already-watched lesson back
|
||||
to 0% every time it's opened, before the client gets a chance to
|
||||
report anything real.
|
||||
"""
|
||||
progress = ProgressTracker.load_progress(course)
|
||||
entry = progress.setdefault(lesson_path, {})
|
||||
entry['last_accessed'] = datetime.now().isoformat()
|
||||
progress['last_accessed_path'] = lesson_path
|
||||
ProgressTracker.save_progress(course, progress)
|
||||
|
||||
@staticmethod
|
||||
def _notes_from_entry(entry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -2340,15 +2357,16 @@ def view_lesson(lesson_path: str):
|
||||
if current_index < len(all_lessons) - 1:
|
||||
next_lesson = all_lessons[current_index + 1][0]
|
||||
|
||||
# Update last accessed
|
||||
ProgressTracker.update_lesson_progress(current_course, lesson_path)
|
||||
# Update last accessed without touching saved progress/completed state
|
||||
ProgressTracker.touch_lesson_accessed(current_course, lesson_path)
|
||||
|
||||
# Record for the cross-course "Recently Viewed" list on the dashboard
|
||||
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
|
||||
|
||||
# Read notes directly from the progress file rather than the Lesson
|
||||
# object - apply_progress_to_tree (which populates Lesson fields) isn't
|
||||
# called on this code path, only on the dashboard's tree render.
|
||||
# Read notes and progress directly from the progress file rather than
|
||||
# the Lesson object - apply_progress_to_tree (which populates Lesson
|
||||
# fields) isn't called on this code path, only on the dashboard's tree
|
||||
# render.
|
||||
lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
|
||||
|
||||
seek_seconds = request.args.get('t', type=int)
|
||||
@@ -2358,6 +2376,7 @@ def view_lesson(lesson_path: str):
|
||||
lesson=lesson,
|
||||
lesson_path=lesson_path,
|
||||
lesson_notes=ProgressTracker._notes_from_entry(lesson_progress),
|
||||
lesson_progress_seconds=lesson_progress.get('progress_seconds', 0),
|
||||
initial_seek_seconds=seek_seconds,
|
||||
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
|
||||
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
|
||||
|
||||
+19
-11
@@ -912,12 +912,27 @@
|
||||
|
||||
renderNotes();
|
||||
|
||||
if (activeMedia && INITIAL_SEEK_SECONDS !== null && INITIAL_SEEK_SECONDS !== undefined) {
|
||||
const doInitialSeek = function() { activeMedia.currentTime = INITIAL_SEEK_SECONDS; };
|
||||
// Seek once metadata is available (activeMedia.duration is NaN
|
||||
// before that, so doing this synchronously on script load - as
|
||||
// this used to - silently never applied). An explicit jump to
|
||||
// a note's timestamp (?t=, see INITIAL_SEEK_SECONDS) takes
|
||||
// priority over resuming general playback progress.
|
||||
const savedProgressSeconds = {{ lesson_progress_seconds|default(0) }};
|
||||
|
||||
function applyInitialSeek() {
|
||||
if (INITIAL_SEEK_SECONDS !== null && INITIAL_SEEK_SECONDS !== undefined) {
|
||||
activeMedia.currentTime = INITIAL_SEEK_SECONDS;
|
||||
} else if (savedProgressSeconds > 0 && savedProgressSeconds < activeMedia.duration - 30) {
|
||||
activeMedia.currentTime = savedProgressSeconds;
|
||||
showNotification(`Resumed from ${Math.floor(savedProgressSeconds / 60)}:${String(Math.floor(savedProgressSeconds % 60)).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeMedia) {
|
||||
if (activeMedia.readyState >= 1) {
|
||||
doInitialSeek();
|
||||
applyInitialSeek();
|
||||
} else {
|
||||
activeMedia.addEventListener('loadedmetadata', doInitialSeek, { once: true });
|
||||
activeMedia.addEventListener('loadedmetadata', applyInitialSeek, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1047,13 +1062,6 @@
|
||||
saveProgress(activeMedia.currentTime, true);
|
||||
markAsCompleted();
|
||||
});
|
||||
|
||||
// Resume from saved position
|
||||
const savedProgress = {{ lesson.progress_seconds|default(0) }};
|
||||
if (savedProgress > 0 && savedProgress < activeMedia.duration - 30) {
|
||||
activeMedia.currentTime = savedProgress;
|
||||
showNotification(`Resumed from ${Math.floor(savedProgress / 60)}:${String(Math.floor(savedProgress % 60)).padStart(2, '0')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress(progressSeconds, completed = false) {
|
||||
|
||||
@@ -117,6 +117,17 @@
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
.notes-search-input {
|
||||
width: 100%;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
font-size: 0.95em;
|
||||
font-family: var(--font-family);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.note-card {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius);
|
||||
@@ -213,10 +224,14 @@
|
||||
|
||||
{% if not notes %}
|
||||
<div class="empty-hint">No notes yet - jot something down on a lesson page and it'll show up here.</div>
|
||||
{% else %}
|
||||
<input type="text" id="notes-search-input" class="notes-search-input"
|
||||
placeholder="Search notes, lessons, or courses…" oninput="filterNotes(this.value)">
|
||||
<div id="notes-no-results-hint" class="empty-hint" style="display: none;">No notes match your search.</div>
|
||||
{% endif %}
|
||||
|
||||
{% for note in notes %}
|
||||
<div class="note-card">
|
||||
<div class="note-card" data-search="{{ (note.lesson_title ~ ' ' ~ note.course_name ~ ' ' ~ note.text) | lower }}">
|
||||
<div class="note-card-header">
|
||||
<span class="note-card-icon">📝</span>
|
||||
<div class="note-card-titles">
|
||||
@@ -251,6 +266,22 @@
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function filterNotes(query) {
|
||||
const q = query.trim().toLowerCase();
|
||||
const cards = document.querySelectorAll('.note-card');
|
||||
let visibleCount = 0;
|
||||
cards.forEach(function(card) {
|
||||
const match = !q || card.dataset.search.includes(q);
|
||||
card.style.display = match ? '' : 'none';
|
||||
if (match) visibleCount++;
|
||||
});
|
||||
const noResultsHint = document.getElementById('notes-no-results-hint');
|
||||
if (noResultsHint) {
|
||||
noResultsHint.style.display = (visibleCount === 0 && cards.length > 0) ? 'block' : 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/static/theme.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user