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)
|
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
|
@staticmethod
|
||||||
def _notes_from_entry(entry: Dict[str, Any]) -> List[Dict[str, Any]]:
|
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:
|
if current_index < len(all_lessons) - 1:
|
||||||
next_lesson = all_lessons[current_index + 1][0]
|
next_lesson = all_lessons[current_index + 1][0]
|
||||||
|
|
||||||
# Update last accessed
|
# Update last accessed without touching saved progress/completed state
|
||||||
ProgressTracker.update_lesson_progress(current_course, lesson_path)
|
ProgressTracker.touch_lesson_accessed(current_course, lesson_path)
|
||||||
|
|
||||||
# Record for the cross-course "Recently Viewed" list on the dashboard
|
# Record for the cross-course "Recently Viewed" list on the dashboard
|
||||||
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
|
record_recent_view(current_course.name, current_course.path, lesson_path, lesson.title)
|
||||||
|
|
||||||
# Read notes directly from the progress file rather than the Lesson
|
# Read notes and progress directly from the progress file rather than
|
||||||
# object - apply_progress_to_tree (which populates Lesson fields) isn't
|
# the Lesson object - apply_progress_to_tree (which populates Lesson
|
||||||
# called on this code path, only on the dashboard's tree render.
|
# 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, {})
|
lesson_progress = ProgressTracker.load_progress(current_course).get(lesson_path, {})
|
||||||
|
|
||||||
seek_seconds = request.args.get('t', type=int)
|
seek_seconds = request.args.get('t', type=int)
|
||||||
@@ -2358,6 +2376,7 @@ def view_lesson(lesson_path: str):
|
|||||||
lesson=lesson,
|
lesson=lesson,
|
||||||
lesson_path=lesson_path,
|
lesson_path=lesson_path,
|
||||||
lesson_notes=ProgressTracker._notes_from_entry(lesson_progress),
|
lesson_notes=ProgressTracker._notes_from_entry(lesson_progress),
|
||||||
|
lesson_progress_seconds=lesson_progress.get('progress_seconds', 0),
|
||||||
initial_seek_seconds=seek_seconds,
|
initial_seek_seconds=seek_seconds,
|
||||||
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
|
outline_topic_id=lesson_progress.get('outline_topic_id', ''),
|
||||||
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
|
outline_topic_name=lesson_progress.get('outline_topic_name', ''),
|
||||||
|
|||||||
+19
-11
@@ -912,12 +912,27 @@
|
|||||||
|
|
||||||
renderNotes();
|
renderNotes();
|
||||||
|
|
||||||
if (activeMedia && INITIAL_SEEK_SECONDS !== null && INITIAL_SEEK_SECONDS !== undefined) {
|
// Seek once metadata is available (activeMedia.duration is NaN
|
||||||
const doInitialSeek = function() { activeMedia.currentTime = INITIAL_SEEK_SECONDS; };
|
// 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) {
|
if (activeMedia.readyState >= 1) {
|
||||||
doInitialSeek();
|
applyInitialSeek();
|
||||||
} else {
|
} else {
|
||||||
activeMedia.addEventListener('loadedmetadata', doInitialSeek, { once: true });
|
activeMedia.addEventListener('loadedmetadata', applyInitialSeek, { once: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1047,13 +1062,6 @@
|
|||||||
saveProgress(activeMedia.currentTime, true);
|
saveProgress(activeMedia.currentTime, true);
|
||||||
markAsCompleted();
|
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) {
|
function saveProgress(progressSeconds, completed = false) {
|
||||||
|
|||||||
@@ -117,6 +117,17 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px;
|
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 {
|
.note-card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
@@ -213,10 +224,14 @@
|
|||||||
|
|
||||||
{% if not notes %}
|
{% if not notes %}
|
||||||
<div class="empty-hint">No notes yet - jot something down on a lesson page and it'll show up here.</div>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% for note in notes %}
|
{% 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">
|
<div class="note-card-header">
|
||||||
<span class="note-card-icon">📝</span>
|
<span class="note-card-icon">📝</span>
|
||||||
<div class="note-card-titles">
|
<div class="note-card-titles">
|
||||||
@@ -251,6 +266,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</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>
|
<script src="/static/theme.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user