Align learner progress rollups with practice-scoped lesson counts.

Count only children that have published practices at module and above for LMS and exam prep; keep lesson at 100% after one practice and module at 100% after direct module practice.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-06-02 02:56:12 -07:00
parent a83745fd93
commit 256183ae64
4 changed files with 120 additions and 59 deletions

View File

@ -6,6 +6,21 @@ import (
dbgen "Yimaru-Backend/gen/db" dbgen "Yimaru-Backend/gen/db"
) )
// ExamPrepCountPublishedPracticesInLesson returns published practices attached to an exam-prep lesson.
func (s *Store) ExamPrepCountPublishedPracticesInLesson(ctx context.Context, lessonID int64) (int32, error) {
return s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)
}
// ExamPrepCountPublishedPracticesInModule returns published practices under an exam-prep module (via lessons).
func (s *Store) ExamPrepCountPublishedPracticesInModule(ctx context.Context, moduleID int64) (int32, error) {
return s.queries.CountPublishedExamPrepPracticesInModule(ctx, moduleID)
}
// ExamPrepCountPublishedPracticesInUnit returns published practices under an exam-prep unit.
func (s *Store) ExamPrepCountPublishedPracticesInUnit(ctx context.Context, unitID int64) (int32, error) {
return s.queries.CountPublishedExamPrepPracticesInUnit(ctx, unitID)
}
// ExamPrepUserPracticeProgressInLesson returns published practice completion counts scoped to an exam-prep lesson. // ExamPrepUserPracticeProgressInLesson returns published practice completion counts scoped to an exam-prep lesson.
func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) { func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID) total, err = s.queries.CountPublishedExamPrepPracticesInLesson(ctx, lessonID)

View File

@ -43,6 +43,16 @@ func (s *Store) LmsCountPublishedPracticesInLesson(ctx context.Context, lessonID
return s.queries.CountPublishedPracticesInLesson(ctx, toPgInt8(&lessonID)) return s.queries.CountPublishedPracticesInLesson(ctx, toPgInt8(&lessonID))
} }
// LmsCountPublishedPracticesInModule returns published practices in a module (direct + lesson-attached).
func (s *Store) LmsCountPublishedPracticesInModule(ctx context.Context, moduleID int64) (int32, error) {
return s.queries.CountPublishedPracticesInModule(ctx, toPgInt8(&moduleID))
}
// LmsCountPublishedPracticesInCourse returns published practices in a course (direct + module + lesson scope).
func (s *Store) LmsCountPublishedPracticesInCourse(ctx context.Context, courseID int64) (int32, error) {
return s.queries.CountPublishedPracticesInCourse(ctx, toPgInt8(&courseID))
}
// LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson. // LmsUserPracticeProgressInLesson returns published practice completion counts scoped to a lesson.
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) { func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonIDPG := toPgInt8(&lessonID) lessonIDPG := toPgInt8(&lessonID)

View File

@ -332,11 +332,8 @@ func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64)
doneLessons++ doneLessons++
} }
} }
if total == 0 { fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
return 0, false, 0, 0, nil return fraction, done, completed, total, nil
}
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
} }
func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) (fraction float64, done bool, completed, total int32, err error) { func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64) (fraction float64, done bool, completed, total int32, err error) {
@ -352,25 +349,27 @@ func (s *Service) lmsCourseProgress(ctx context.Context, userID, courseID int64)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64 var doneModules int32
var fullDone int32
for _, moduleID := range moduleIDs { for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID) practiceCount, err := s.store.LmsCountPublishedPracticesInModule(ctx, moduleID)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
sum += moduleFraction if practiceCount == 0 {
if moduleFraction >= 1 { continue
fullDone++ }
total++
_, moduleDone, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if moduleDone {
doneModules++
} }
} }
total = int32(len(moduleIDs)) fraction, done, completed, total = practiceScopeFraction(doneModules, total)
fraction = sum / float64(total) return fraction, done, completed, total, nil
return fraction, fraction >= 1, fullDone, total, nil
} }
func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int64) (fraction float64, done bool, completed, total int32, err error) { func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int64) (fraction float64, done bool, completed, total int32, err error) {
@ -378,25 +377,27 @@ func (s *Service) lmsProgramProgress(ctx context.Context, userID, programID int6
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
if len(courseIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64 var doneCourses int32
var fullDone int32
for _, courseID := range courseIDs { for _, courseID := range courseIDs {
courseFraction, _, _, _, err := s.lmsCourseProgress(ctx, userID, courseID) practiceCount, err := s.store.LmsCountPublishedPracticesInCourse(ctx, courseID)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
sum += courseFraction if practiceCount == 0 {
if courseFraction >= 1 { continue
fullDone++ }
total++
_, courseDone, _, _, err := s.lmsCourseProgress(ctx, userID, courseID)
if err != nil {
return 0, false, 0, 0, err
}
if courseDone {
doneCourses++
} }
} }
total = int32(len(courseIDs)) fraction, done, completed, total = practiceScopeFraction(doneCourses, total)
fraction = sum / float64(total) return fraction, done, completed, total, nil
return fraction, fraction >= 1, fullDone, total, nil
} }
func (s *Service) hasCompletedDirectModulePractice(ctx context.Context, userID, moduleID int64) (bool, error) { func (s *Service) hasCompletedDirectModulePractice(ctx context.Context, userID, moduleID int64) (bool, error) {
@ -431,11 +432,17 @@ func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID i
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
if len(lessonIDs) == 0 {
return 0, false, 0, 0, nil
}
var doneLessons int32 var doneLessons int32
for _, lessonID := range lessonIDs { for _, lessonID := range lessonIDs {
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInLesson(ctx, lessonID)
if err != nil {
return 0, false, 0, 0, err
}
if practiceCount == 0 {
continue
}
total++
lessonFraction, _, _, _, err := s.examPrepLessonProgress(ctx, userID, lessonID) lessonFraction, _, _, _, err := s.examPrepLessonProgress(ctx, userID, lessonID)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
@ -444,9 +451,8 @@ func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID i
doneLessons++ doneLessons++
} }
} }
total = int32(len(lessonIDs)) fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
fraction = float64(doneLessons) / float64(total) return fraction, done, completed, total, nil
return fraction, fraction >= 1, doneLessons, total, nil
} }
func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64) (fraction float64, done bool, completed, total int32, err error) { func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64) (fraction float64, done bool, completed, total int32, err error) {
@ -454,24 +460,27 @@ func (s *Service) examPrepUnitProgress(ctx context.Context, userID, unitID int64
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil var doneModules int32
}
var sum float64
var fullDone int32
for _, moduleID := range moduleIDs { for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID) practiceCount, err := s.store.ExamPrepCountPublishedPracticesInModule(ctx, moduleID)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
sum += moduleFraction if practiceCount == 0 {
if moduleFraction >= 1 { continue
fullDone++ }
total++
_, moduleDone, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
if moduleDone {
doneModules++
} }
} }
total = int32(len(moduleIDs)) fraction, done, completed, total = practiceScopeFraction(doneModules, total)
fraction = sum / float64(total) return fraction, done, completed, total, nil
return fraction, fraction >= 1, fullDone, total, nil
} }
func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, catalogCourseID int64) (fraction float64, done bool, completed, total int32, err error) { func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, catalogCourseID int64) (fraction float64, done bool, completed, total int32, err error) {
@ -479,24 +488,36 @@ func (s *Service) examPrepCatalogCourseProgress(ctx context.Context, userID, cat
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
if len(unitIDs) == 0 {
return 0, false, 0, 0, nil var doneUnits int32
}
var sum float64
var fullDone int32
for _, unitID := range unitIDs { for _, unitID := range unitIDs {
unitFraction, _, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID) practiceCount, err := s.store.ExamPrepCountPublishedPracticesInUnit(ctx, unitID)
if err != nil { if err != nil {
return 0, false, 0, 0, err return 0, false, 0, 0, err
} }
sum += unitFraction if practiceCount == 0 {
if unitFraction >= 1 { continue
fullDone++ }
total++
_, unitDone, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID)
if err != nil {
return 0, false, 0, 0, err
}
if unitDone {
doneUnits++
} }
} }
total = int32(len(unitIDs)) fraction, done, completed, total = practiceScopeFraction(doneUnits, total)
fraction = sum / float64(total) return fraction, done, completed, total, nil
return fraction, fraction >= 1, fullDone, total, nil }
// practiceScopeFraction returns done/total for entities that only count children with published practices.
func practiceScopeFraction(done, total int32) (fraction float64, complete bool, completed, totalOut int32) {
if total == 0 {
return 0, false, 0, 0
}
fraction = float64(done) / float64(total)
return fraction, fraction >= 1, done, total
} }
func lmsProgressComplete(completed, total int32) bool { func lmsProgressComplete(completed, total int32) bool {

View File

@ -75,6 +75,21 @@ func TestLMSProgressCounts(t *testing.T) {
} }
} }
func TestPracticeScopeFraction(t *testing.T) {
fraction, done, completed, total := practiceScopeFraction(1, 5)
if fraction != 0.2 || done || completed != 1 || total != 5 {
t.Fatalf("practiceScopeFraction(1,5)=(%v,%v,%d,%d), want (0.2,false,1,5)", fraction, done, completed, total)
}
fraction, done, completed, total = practiceScopeFraction(5, 5)
if fraction != 1 || !done || completed != 5 || total != 5 {
t.Fatalf("practiceScopeFraction(5,5)=(%v,%v,%d,%d), want (1,true,5,5)", fraction, done, completed, total)
}
fraction, done, completed, total = practiceScopeFraction(0, 0)
if fraction != 0 || done || completed != 0 || total != 0 {
t.Fatalf("practiceScopeFraction(0,0)=(%v,%v,%d,%d), want (0,false,0,0)", fraction, done, completed, total)
}
}
func TestLMSProgressComplete(t *testing.T) { func TestLMSProgressComplete(t *testing.T) {
tests := []struct { tests := []struct {
name string name string