Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions app/api/projects_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
94 changes: 94 additions & 0 deletions app/services/engagement_heatmap_service.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions config/environments/test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
187 changes: 187 additions & 0 deletions test/api/projects_api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading