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"
)
// 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.
func (s *Store) ExamPrepUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
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))
}
// 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.
func (s *Store) LmsUserPracticeProgressInLesson(ctx context.Context, userID, lessonID int64) (completed, total int32, err error) {
lessonIDPG := toPgInt8(&lessonID)

View File

@ -332,11 +332,8 @@ func (s *Service) lmsModuleProgress(ctx context.Context, userID, moduleID int64)
doneLessons++
}
}
if total == 0 {
return 0, false, 0, 0, nil
}
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
return fraction, done, completed, total, nil
}
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 {
return 0, false, 0, 0, err
}
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
var doneModules int32
for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.lmsModuleProgress(ctx, userID, moduleID)
practiceCount, err := s.store.LmsCountPublishedPracticesInModule(ctx, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
sum += moduleFraction
if moduleFraction >= 1 {
fullDone++
if practiceCount == 0 {
continue
}
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 = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
return fraction, done, completed, total, nil
}
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 {
return 0, false, 0, 0, err
}
if len(courseIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
var doneCourses int32
for _, courseID := range courseIDs {
courseFraction, _, _, _, err := s.lmsCourseProgress(ctx, userID, courseID)
practiceCount, err := s.store.LmsCountPublishedPracticesInCourse(ctx, courseID)
if err != nil {
return 0, false, 0, 0, err
}
sum += courseFraction
if courseFraction >= 1 {
fullDone++
if practiceCount == 0 {
continue
}
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 = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
fraction, done, completed, total = practiceScopeFraction(doneCourses, total)
return fraction, done, completed, total, nil
}
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 {
return 0, false, 0, 0, err
}
if len(lessonIDs) == 0 {
return 0, false, 0, 0, nil
}
var doneLessons int32
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)
if err != nil {
return 0, false, 0, 0, err
@ -444,9 +451,8 @@ func (s *Service) examPrepModuleProgress(ctx context.Context, userID, moduleID i
doneLessons++
}
}
total = int32(len(lessonIDs))
fraction = float64(doneLessons) / float64(total)
return fraction, fraction >= 1, doneLessons, total, nil
fraction, done, completed, total = practiceScopeFraction(doneLessons, total)
return fraction, done, completed, total, nil
}
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 {
return 0, false, 0, 0, err
}
if len(moduleIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
var doneModules int32
for _, moduleID := range moduleIDs {
moduleFraction, _, _, _, err := s.examPrepModuleProgress(ctx, userID, moduleID)
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInModule(ctx, moduleID)
if err != nil {
return 0, false, 0, 0, err
}
sum += moduleFraction
if moduleFraction >= 1 {
fullDone++
if practiceCount == 0 {
continue
}
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 = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
fraction, done, completed, total = practiceScopeFraction(doneModules, total)
return fraction, done, completed, total, nil
}
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 {
return 0, false, 0, 0, err
}
if len(unitIDs) == 0 {
return 0, false, 0, 0, nil
}
var sum float64
var fullDone int32
var doneUnits int32
for _, unitID := range unitIDs {
unitFraction, _, _, _, err := s.examPrepUnitProgress(ctx, userID, unitID)
practiceCount, err := s.store.ExamPrepCountPublishedPracticesInUnit(ctx, unitID)
if err != nil {
return 0, false, 0, 0, err
}
sum += unitFraction
if unitFraction >= 1 {
fullDone++
if practiceCount == 0 {
continue
}
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 = sum / float64(total)
return fraction, fraction >= 1, fullDone, total, nil
fraction, done, completed, total = practiceScopeFraction(doneUnits, total)
return fraction, done, completed, 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 {

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) {
tests := []struct {
name string