From 61d35aebcd28b86f7ec2fdb2fe2c3a4513b453d9 Mon Sep 17 00:00:00 2001 From: David Kamau Nganga Date: Wed, 15 Apr 2026 14:55:56 +1000 Subject: [PATCH] Add engagement heatmap API for unit-scoped task activity --- app/api/projects_api.rb | 14 ++ app/services/engagement_heatmap_service.rb | 94 +++++++++++ config/environments/test.rb | 6 + test/api/projects_api_test.rb | 187 +++++++++++++++++++++ 4 files changed, 301 insertions(+) create mode 100644 app/services/engagement_heatmap_service.rb diff --git a/app/api/projects_api.rb b/app/api/projects_api.rb index fd0d8b4868..2524b51a70 100644 --- a/app/api/projects_api.rb +++ b/app/api/projects_api.rb @@ -214,4 +214,18 @@ class ProjectsApi < Grape::API present portfolio_tasks.map(&:id) end + desc 'Engagement heatmap for this project (unit-specific task activity, last 84 days)' + params do + requires :id, type: Integer, desc: 'The project id' + end + get '/projects/:id/engagement_heatmap' do + project = Project.eager_load(:unit, :user).find(params[:id]) + + unless authorise? current_user, project, :get + error!({ error: "Couldn't find Project with id=#{params[:id]}" }, 403) + end + + present EngagementHeatmapService.build(project: project), with: Grape::Presenters::Presenter + end + end diff --git a/app/services/engagement_heatmap_service.rb b/app/services/engagement_heatmap_service.rb new file mode 100644 index 0000000000..5ddb95f7e4 --- /dev/null +++ b/app/services/engagement_heatmap_service.rb @@ -0,0 +1,94 @@ +class EngagementHeatmapService + WINDOW_DAYS = 84 + + def self.build(project:) + new(project).build + end + + def initialize(project) + @project = project + end + + def build + end_date = Time.zone.today + start_date = end_date - (WINDOW_DAYS - 1).days + + scoped_engagements = TaskEngagement + .joins(:task) + .where(tasks: { project_id: project.id }) + .where(engagement_time: start_date.beginning_of_day..end_date.end_of_day) + + daily_counts_raw = scoped_engagements + .group("DATE(task_engagements.engagement_time)") + .count + + daily_counts = normalize_daily_count_keys(daily_counts_raw) + + days = (start_date..end_date).map do |date| + date_str = date.strftime('%Y-%m-%d') + { + date: date_str, + activity_count: daily_counts[date_str] || 0 + } + end + + { + project_id: project.id, + unit_id: project.unit_id, + range: { + start_date: start_date.strftime('%Y-%m-%d'), + end_date: end_date.strftime('%Y-%m-%d'), + days: WINDOW_DAYS + }, + days: days, + summary: { + tasks_completed: tasks_completed_count(scoped_engagements), + active_days: days.count { |entry| entry[:activity_count] > 0 }, + current_streak: current_streak(days, start_date, end_date) + } + } + end + + private + + attr_reader :project + + # Grouped DATE(...) keys vary by adapter (Date, String, Time). Normalize to + # 'YYYY-MM-DD' strings so lookups match the day loop regardless of DB return type. + def normalize_daily_count_keys(raw) + raw.each_with_object(Hash.new(0)) do |(key, count), memo| + memo[canonical_date_string(key)] += count + end + end + + def canonical_date_string(key) + case key + when Date + key.strftime('%Y-%m-%d') + when Time, ActiveSupport::TimeWithZone + key.in_time_zone.to_date.strftime('%Y-%m-%d') + else + Date.parse(key.to_s).strftime('%Y-%m-%d') + end + end + + def tasks_completed_count(scoped_engagements) + scoped_engagements + .where(engagement: TaskStatus.complete.name) + .distinct + .count(:task_id) + end + + def current_streak(days, start_date, end_date) + counts_by_date = days.to_h { |entry| [Date.parse(entry[:date]), entry[:activity_count]] } + streak_day = counts_by_date[end_date].to_i > 0 ? end_date : end_date - 1.day + streak = 0 + + while streak_day >= start_date && counts_by_date[streak_day].to_i > 0 + streak += 1 + streak_day -= 1.day + end + + streak + end +end diff --git a/config/environments/test.rb b/config/environments/test.rb index 24fcb3d4cc..e6c20d3f42 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -37,6 +37,12 @@ # Logging level (:debug, :info, :warn, :error, :fatal) config.log_level = :warn + # Rack::Test uses Host: example.org by default. HostAuthorization must not block API tests. + # Allow the default host and also bypass checks so Spring/bootsnap or branch drift cannot + # leave the suite stuck on 403 + HTML when integration tests parse JSON. + config.hosts << "example.org" + config.host_authorization = { exclude: ->(_request) { true } } + config.active_record.encryption.key_derivation_salt = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'U9jurHMfZbMpzlbDTMe5OSAhUJYHla9Z' config.active_record.encryption.deterministic_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || 'zYtzYUlLFaWdvdUO5eIINRT6ZKDddcgx' config.active_record.encryption.primary_key = ENV['DF_ENCRYPTION_KEY_DERIVATION_SALT'] || '92zoF7RJaQ01JEExOgHbP9bRWldNQUz5' diff --git a/test/api/projects_api_test.rb b/test/api/projects_api_test.rb index 4e49eced6a..82d07307e5 100644 --- a/test/api/projects_api_test.rb +++ b/test/api/projects_api_test.rb @@ -7,6 +7,7 @@ class ProjectsApiTest < ActiveSupport::TestCase include TestHelpers::AuthHelper include TestHelpers::JsonHelper include TestHelpers::TestFileHelper + include ActiveSupport::Testing::TimeHelpers def app Rails.application @@ -192,4 +193,190 @@ def test_download_portfolio ensure FileUtils.rm_f(project.portfolio_path) end + + def test_engagement_heatmap_success_and_contract + travel_to Time.zone.parse('2026-04-15 12:00') do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + + TaskEngagement.create!( + task: task, + engagement_time: Time.zone.parse('2026-04-15 09:00'), + engagement: TaskStatus.ready_for_feedback.name + ) + TaskEngagement.create!( + task: task, + engagement_time: Time.zone.parse('2026-04-14 11:00'), + engagement: TaskStatus.complete.name + ) + + add_auth_header_for(user: project.student) + get "/api/projects/#{project.id}/engagement_heatmap" + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + + assert_equal %w[days project_id range summary unit_id], body.keys.sort + assert_equal project.id, body['project_id'] + assert_equal unit.id, body['unit_id'] + + end_d = Time.zone.today + start_d = end_d - (EngagementHeatmapService::WINDOW_DAYS - 1).days + assert_equal start_d.strftime('%Y-%m-%d'), body['range']['start_date'] + assert_equal end_d.strftime('%Y-%m-%d'), body['range']['end_date'] + assert_equal EngagementHeatmapService::WINDOW_DAYS, body['range']['days'] + + assert_equal EngagementHeatmapService::WINDOW_DAYS, body['days'].length + body['days'].each do |day| + assert_equal %w[activity_count date], day.keys.sort + end + + assert_equal 1, body['days'].find { |d| d['date'] == '2026-04-15' }['activity_count'] + assert_equal 1, body['days'].find { |d| d['date'] == '2026-04-14' }['activity_count'] + + assert_equal 1, body['summary']['tasks_completed'] + assert_equal 2, body['summary']['active_days'] + assert_equal 2, body['summary']['current_streak'] + end + end + + def test_engagement_heatmap_unauthorized + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + project = unit.active_projects.first + other = FactoryBot.create(:user, :student) + + add_auth_header_for(user: other) + get "/api/projects/#{project.id}/engagement_heatmap" + + assert_equal 403, last_response.status + end + + def test_engagement_heatmap_scoped_to_project_no_cross_unit_leakage + travel_to Time.zone.parse('2026-05-01 10:00') do + unit_a = FactoryBot.create(:unit, student_count: 1, task_count: 1) + unit_b = FactoryBot.create(:unit, student_count: 1, task_count: 1) + project_a = unit_a.active_projects.first + project_b = unit_b.active_projects.first + task_a = project_a.task_for_task_definition(unit_a.task_definitions.first) + + 5.times do |i| + TaskEngagement.create!( + task: task_a, + engagement_time: Time.zone.parse("2026-05-01 #{9 + i}:00"), + engagement: TaskStatus.working_on_it.name + ) + end + + add_auth_header_for(user: project_b.student) + get "/api/projects/#{project_b.id}/engagement_heatmap" + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + + assert_equal 0, body['summary']['active_days'] + assert_equal 0, body['summary']['tasks_completed'] + assert_equal 0, body['summary']['current_streak'] + assert body['days'].all? { |d| d['activity_count'].zero? } + end + end + + def test_engagement_heatmap_no_activity_all_zeros + travel_to Time.zone.parse('2026-06-10 08:00') do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + project = unit.active_projects.first + + add_auth_header_for(user: project.student) + get "/api/projects/#{project.id}/engagement_heatmap" + + assert_equal 200, last_response.status, last_response.body + body = last_response_body + + assert body['days'].all? { |d| d['activity_count'].zero? } + assert_equal 0, body['summary']['active_days'] + assert_equal 0, body['summary']['tasks_completed'] + assert_equal 0, body['summary']['current_streak'] + end + end + + def test_engagement_heatmap_sparse_activity_and_tasks_completed_distinct + travel_to Time.zone.parse('2026-07-20 15:00') do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 2) + project = unit.active_projects.first + td1 = unit.task_definitions.first + td2 = unit.task_definitions.second + task1 = project.task_for_task_definition(td1) + task2 = project.task_for_task_definition(td2) + + TaskEngagement.create!( + task: task1, + engagement_time: Time.zone.parse('2026-07-20 10:00'), + engagement: TaskStatus.need_help.name + ) + TaskEngagement.create!( + task: task1, + engagement_time: Time.zone.parse('2026-07-20 14:00'), + engagement: TaskStatus.working_on_it.name + ) + TaskEngagement.create!( + task: task2, + engagement_time: Time.zone.parse('2026-07-18 09:00'), + engagement: TaskStatus.complete.name + ) + TaskEngagement.create!( + task: task1, + engagement_time: Time.zone.parse('2026-07-10 12:00'), + engagement: TaskStatus.complete.name + ) + + add_auth_header_for(user: project.student) + get "/api/projects/#{project.id}/engagement_heatmap" + + body = last_response_body + + assert_equal 2, body['days'].find { |d| d['date'] == '2026-07-20' }['activity_count'] + assert_equal 1, body['days'].find { |d| d['date'] == '2026-07-18' }['activity_count'] + assert_equal 1, body['days'].find { |d| d['date'] == '2026-07-10' }['activity_count'] + + assert_equal 2, body['summary']['tasks_completed'] + assert_equal 3, body['summary']['active_days'] + end + end + + def test_engagement_heatmap_streak_ends_yesterday_when_today_empty + travel_to Time.zone.parse('2026-08-05 12:00') do + unit = FactoryBot.create(:unit, student_count: 1, task_count: 1) + project = unit.active_projects.first + task = project.task_for_task_definition(unit.task_definitions.first) + + TaskEngagement.create!( + task: task, + engagement_time: Time.zone.parse('2026-08-04 10:00'), + engagement: TaskStatus.ready_for_feedback.name + ) + TaskEngagement.create!( + task: task, + engagement_time: Time.zone.parse('2026-08-03 10:00'), + engagement: TaskStatus.ready_for_feedback.name + ) + + add_auth_header_for(user: project.student) + get "/api/projects/#{project.id}/engagement_heatmap" + + body = last_response_body + + assert_equal 0, body['days'].find { |d| d['date'] == '2026-08-05' }['activity_count'] + assert_equal 2, body['summary']['current_streak'] + end + end + + def test_engagement_heatmap_unknown_project_returns_404 + user = FactoryBot.create(:user, :student, enrol_in: 1) + missing_id = Project.maximum(:id).to_i + 999_999 + + add_auth_header_for(user: user) + get "/api/projects/#{missing_id}/engagement_heatmap" + + assert_equal 404, last_response.status + end end