diff --git a/app/assets/stylesheets/components/profile.css b/app/assets/stylesheets/components/profile.css index 7082a8d..2a7a88b 100644 --- a/app/assets/stylesheets/components/profile.css +++ b/app/assets/stylesheets/components/profile.css @@ -323,6 +323,61 @@ justify-content: center; } +.team-stats { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin-bottom: var(--spacing-4); +} + +.team-stats-group-title { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin-bottom: var(--spacing-2); +} + +.team-stats-group-note { + color: var(--color-text-muted); + font-weight: var(--font-weight-normal); + margin-left: var(--spacing-1); +} + +.team-stats-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: var(--spacing-3); +} + +.team-stat-tile { + padding: var(--spacing-3); + background: var(--color-bg-card); + border: var(--border-width) solid var(--color-border); + border-radius: var(--border-radius-lg); +} + +.team-stat-value { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); +} + +.team-stat-label { + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.team-stat-sub { + color: var(--color-text-muted); + font-size: var(--font-size-xs); +} + +@media (max-width: 720px) { + .team-stats-grid { + grid-template-columns: repeat(2, 1fr); + } +} + .activity-types { display: flex; flex-wrap: wrap; diff --git a/app/assets/stylesheets/components/settings.css b/app/assets/stylesheets/components/settings.css index 7663393..5fa8fce 100644 --- a/app/assets/stylesheets/components/settings.css +++ b/app/assets/stylesheets/components/settings.css @@ -68,7 +68,8 @@ .settings-page input[type="text"], .settings-page input[type="email"], .settings-page input[type="password"], -.settings-page input[type="number"] { +.settings-page input[type="number"], +.settings-page select { width: 100%; padding: var(--spacing-4) var(--spacing-6); border-radius: 999px; diff --git a/app/controllers/concerns/profile_activity.rb b/app/controllers/concerns/profile_activity.rb index bed2301..d72247a 100644 --- a/app/controllers/concerns/profile_activity.rb +++ b/app/controllers/concerns/profile_activity.rb @@ -20,10 +20,12 @@ def person_ids_for_query ids.is_a?(Set) ? ids.to_a : ids end - def load_activity_data(scope: nil, year: nil) + def load_activity_data(scope: nil, year: nil, window_start: nil, window_end: nil) @week_start_day = parse_week_start_day @activity_filters = parse_activity_filters effective_scope = scope || default_recent_scope + @activity_window_start = window_start || default_window_start + @activity_window_end = window_end || Time.current @activity_entries = build_activity_entries(scope: effective_scope, filters: @activity_filters) @activity_summary = build_activity_summary(scope: effective_scope, filters: @activity_filters) @activity_period ||= { type: :recent } if scope.nil? @@ -33,10 +35,13 @@ def load_activity_data(scope: nil, year: nil) @weekday_labels = WeekCalculation.weekday_labels(@week_start_day) end + def default_window_start + 1.month.ago.beginning_of_day + end + def default_recent_scope ids = person_ids_for_query - start_date = 1.month.ago.beginning_of_day - Message.where(sender_person_id: ids, created_at: start_date..) + Message.where(sender_person_id: ids, created_at: default_window_start..) end def build_activity_entries(scope: nil, filters: nil) @@ -99,17 +104,11 @@ def build_activity_summary(scope: nil, filters: nil) filter_symbols = filters&.map(&:to_sym)&.to_set - summary = { - total: 0, - started_thread: 0, - replied_own_thread: 0, - replied_other_thread: 0, - replied_other_topics: 0, - sent_first_patch: 0, - sent_followup_patch: 0 - } + summary = empty_activity_summary replied_other_topic_ids = Set.new + active_thread_ids = Set.new + contributor_ids = Set.new messages.each do |message| topic = message.topic @@ -129,10 +128,18 @@ def build_activity_summary(scope: nil, filters: nil) if activity_types.include?(:replied_other_thread) replied_other_topic_ids << topic.id end + if activity_types.include?(:started_thread) + key = message.is_patch_submission? ? :new_patch_series_count : :discussion_started_count + summary[key] += 1 + end + active_thread_ids << topic.id + contributor_ids << message.sender_person_id end end summary[:replied_other_topics] = replied_other_topic_ids.size + summary[:active_thread_count] = active_thread_ids.size + summary[:unique_contributor_count] = contributor_ids.size summary end @@ -143,6 +150,10 @@ def empty_activity_summary replied_own_thread: 0, replied_other_thread: 0, replied_other_topics: 0, + active_thread_count: 0, + unique_contributor_count: 0, + discussion_started_count: 0, + new_patch_series_count: 0, sent_first_patch: 0, sent_followup_patch: 0 } diff --git a/app/controllers/settings/teams_controller.rb b/app/controllers/settings/teams_controller.rb index 25ba77c..3f2b9c4 100644 --- a/app/controllers/settings/teams_controller.rb +++ b/app/controllers/settings/teams_controller.rb @@ -57,7 +57,7 @@ def team_params end def team_update_params - params.require(:team).permit(:visibility) + params.require(:team).permit(:visibility, :week_start_day) end def require_team_admin! diff --git a/app/controllers/teams_profile_controller.rb b/app/controllers/teams_profile_controller.rb index 8780c21..6a07b4e 100644 --- a/app/controllers/teams_profile_controller.rb +++ b/app/controllers/teams_profile_controller.rb @@ -7,17 +7,23 @@ class TeamsProfileController < ApplicationController def show @members = @team.team_members.includes(user: { person: :default_alias }).order(:role, :created_at) load_activity_data + load_team_stats end def contributions load_activity_data + load_team_stats render :activity end def daily_activity date = parse_activity_date @activity_period = { type: :day, date: date } - load_activity_data(scope: messages_scope_for_date(date), year: date.year) + load_activity_data( + scope: messages_scope_for_date(date), year: date.year, + window_start: date.beginning_of_day, window_end: date.end_of_day + ) + load_team_stats render :activity end @@ -27,18 +33,26 @@ def monthly_activity start_date = Date.new(year, month, 1) end_date = start_date.end_of_month @activity_period = { type: :month, year: year, month: month } - load_activity_data(scope: messages_scope_for_range(start_date, end_date), year: year) + load_activity_data( + scope: messages_scope_for_range(start_date, end_date), year: year, + window_start: start_date.beginning_of_day, window_end: end_date.end_of_day + ) + load_team_stats render :activity end def weekly_activity year = params[:year].to_i week = params[:week].to_i - wday_start = WeekCalculation.parse_week_start(params[:week_start]) + wday_start = parse_week_start_day start_date = WeekCalculation.week_start_date(year, week, wday_start) end_date = start_date + 6 @activity_period = { type: :week, year: year, week: week, start_date: start_date, end_date: end_date } - load_activity_data(scope: messages_scope_for_range(start_date, end_date), year: year) + load_activity_data( + scope: messages_scope_for_range(start_date, end_date), year: year, + window_start: start_date.beginning_of_day, window_end: end_date.end_of_day + ) + load_team_stats render :activity end @@ -48,6 +62,19 @@ def activity_person_ids @member_person_ids end + def parse_week_start_day + return WeekCalculation.parse_week_start(params[:week_start]) if params[:week_start].present? + @team.week_start_day + end + + def load_team_stats + @team_stats = TeamActivityStats.new( + team_person_ids: @member_person_ids, + window_start: @activity_window_start, + window_end: @activity_window_end + ).call + end + def load_team @team = Team.find_by!(name: params[:name]) @member_person_ids = @team.users.joins(:person).pluck("people.id").to_set diff --git a/app/helpers/team_stats_helper.rb b/app/helpers/team_stats_helper.rb new file mode 100644 index 0000000..f762b79 --- /dev/null +++ b/app/helpers/team_stats_helper.rb @@ -0,0 +1,6 @@ +module TeamStatsHelper + def format_duration_hours(hours) + return "–" if hours.nil? + hours < 24 ? "#{hours.round(1)}h" : "#{(hours / 24.0).round(1)}d" + end +end diff --git a/app/models/team.rb b/app/models/team.rb index 21c851c..204e41f 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -13,6 +13,7 @@ class Team < ApplicationRecord validates :name, presence: true validates :name, format: { with: /\A[a-zA-Z0-9_\-\.]+\z/ } + validates :week_start_day, inclusion: { in: 0..6 } validate :name_available_in_reservations after_create :reserve_name diff --git a/app/services/team_activity_stats.rb b/app/services/team_activity_stats.rb new file mode 100644 index 0000000..d2a5c99 --- /dev/null +++ b/app/services/team_activity_stats.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class TeamActivityStats + BACKLOG_CAP = 90.days + + Result = Struct.new( + :new_thread_count, :joined_thread_count, :continuing_thread_count, + :median_first_response_hours, :first_response_sample_size, + :waiting_for_response_count, :patches_waiting_for_update_count, + :external_reply_pct_within_2_bdays, :external_reply_pct_within_5_bdays, + :external_reply_response_events_count, + :unique_external_contributor_count, :threads_with_further_engagement_count, + keyword_init: true + ) + + def initialize(team_person_ids:, window_start:, window_end:) + ids = team_person_ids.is_a?(Set) ? team_person_ids.to_a : Array(team_person_ids) + @team_person_ids = ids + @window_start = window_start + @window_end = window_end + end + + def call + return empty_result if @team_person_ids.blank? + + topic_ids = window_topic_ids + window_stats = topic_ids.any? ? window_dependent_stats(topic_ids) : empty_window_stats + + Result.new( + **window_stats, + waiting_for_response_count: waiting_for_response_count, + patches_waiting_for_update_count: patches_waiting_for_update_count + ) + end + + private + + def window_topic_ids + Message.where(sender_person_id: @team_person_ids, created_at: @window_start..@window_end) + .distinct.pluck(:topic_id) + end + + def window_dependent_stats(topic_ids) + empty_window_stats + .merge(thread_breakdown(topic_ids)) + .merge(first_response_stats(topic_ids)) + .merge(external_reply_business_day_stats(topic_ids)) + .merge( + unique_external_contributor_count: unique_external_contributor_count(topic_ids), + threads_with_further_engagement_count: threads_with_further_engagement_count(topic_ids) + ) + end + + def team_first_message_at_by_topic(topic_ids) + TopicParticipant.where(topic_id: topic_ids, person_id: @team_person_ids) + .group(:topic_id).minimum(:first_message_at) + end + + def thread_breakdown(topic_ids) + team_first_at = team_first_message_at_by_topic(topic_ids) + creator_by_topic = Topic.where(id: topic_ids).pluck(:id, :creator_person_id).to_h + team_id_set = @team_person_ids.to_set + new_count = 0 + joined_count = 0 + continuing_count = 0 + + topic_ids.each do |topic_id| + first_at = team_first_at[topic_id] + next unless first_at # defensive: a team message in-window guarantees a TopicParticipant row + + if @window_start <= first_at && first_at <= @window_end + team_id_set.include?(creator_by_topic[topic_id]) ? new_count += 1 : joined_count += 1 + else + continuing_count += 1 + end + end + + { new_thread_count: new_count, joined_thread_count: joined_count, continuing_thread_count: continuing_count } + end + + def first_response_stats(topic_ids) + team_first_at = team_first_message_at_by_topic(topic_ids) + external_topic_ids = Topic.where(id: topic_ids).where.not(creator_person_id: @team_person_ids).pluck(:id) + topic_created_at = Topic.where(id: external_topic_ids).pluck(:id, :created_at).to_h + + deltas = external_topic_ids.filter_map do |topic_id| + first_at = team_first_at[topic_id] + next unless first_at && @window_start <= first_at && first_at <= @window_end + + first_at - topic_created_at[topic_id] + end + + return { median_first_response_hours: nil, first_response_sample_size: 0 } if deltas.empty? + + { median_first_response_hours: (median(deltas) / 1.hour).round(1), first_response_sample_size: deltas.size } + end + + def median(values) + sorted = values.sort + mid = sorted.length / 2 + sorted.length.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0 + end + + def waiting_for_response_count + Topic.joins(:topic_participants) + .where(topic_participants: { person_id: @team_person_ids }) + .where.not(last_sender_person_id: @team_person_ids) + .where(last_message_at: BACKLOG_CAP.ago..) + .distinct.count(:id) + end + + def patches_waiting_for_update_count + team_first_patch_msg_id_by_topic = Message.where(sender_person_id: @team_person_ids, is_patch_submission: true) + .group(:topic_id).minimum(:id) + return 0 if team_first_patch_msg_id_by_topic.empty? + + topic_wide_first_patch_id = Message.where(topic_id: team_first_patch_msg_id_by_topic.keys, is_patch_submission: true) + .group(:topic_id).minimum(:id) + matching_topic_ids = team_first_patch_msg_id_by_topic.select { |tid, mid| topic_wide_first_patch_id[tid] == mid }.keys + + Topic.where(id: matching_topic_ids) + .where.not(last_sender_person_id: @team_person_ids) + .where(last_message_at: BACKLOG_CAP.ago..) + .count + end + + def external_reply_business_day_stats(topic_ids) + rows = Message.where(topic_id: topic_ids).order(:created_at).pluck(:topic_id, :sender_person_id, :created_at) + deltas_bdays = [] + + rows.group_by(&:first).each_value do |topic_rows| + pending_external_at = nil + topic_rows.each do |(_topic_id, sender_id, created_at)| + if @team_person_ids.include?(sender_id) + if pending_external_at + deltas_bdays << BusinessDays.between(pending_external_at, created_at) + pending_external_at = nil + end + else + pending_external_at ||= created_at + end + end + end + + if deltas_bdays.empty? + return { + external_reply_pct_within_2_bdays: nil, external_reply_pct_within_5_bdays: nil, + external_reply_response_events_count: 0 + } + end + + { + external_reply_pct_within_2_bdays: (deltas_bdays.count { |d| d <= 2 } * 100.0 / deltas_bdays.size).round(1), + external_reply_pct_within_5_bdays: (deltas_bdays.count { |d| d <= 5 } * 100.0 / deltas_bdays.size).round(1), + external_reply_response_events_count: deltas_bdays.size + } + end + + def unique_external_contributor_count(topic_ids) + TopicParticipant.where(topic_id: topic_ids).where.not(person_id: @team_person_ids).distinct.count(:person_id) + end + + def threads_with_further_engagement_count(topic_ids) + team_last_at = Message.where(topic_id: topic_ids, sender_person_id: @team_person_ids, created_at: @window_start..@window_end) + .group(:topic_id).maximum(:created_at) + return 0 if team_last_at.empty? + + external_last_at = Message.where(topic_id: team_last_at.keys).where.not(sender_person_id: @team_person_ids) + .group(:topic_id).maximum(:created_at) + + team_last_at.count { |topic_id, team_at| external_last_at[topic_id] && external_last_at[topic_id] > team_at } + end + + def empty_window_stats + { + new_thread_count: 0, joined_thread_count: 0, continuing_thread_count: 0, + median_first_response_hours: nil, first_response_sample_size: 0, + external_reply_pct_within_2_bdays: nil, external_reply_pct_within_5_bdays: nil, + external_reply_response_events_count: 0, + unique_external_contributor_count: 0, threads_with_further_engagement_count: 0 + } + end + + def empty_result + Result.new(**empty_window_stats, waiting_for_response_count: 0, patches_waiting_for_update_count: 0) + end +end diff --git a/app/views/settings/teams/show.html.slim b/app/views/settings/teams/show.html.slim index 5269f6b..f8992e3 100644 --- a/app/views/settings/teams/show.html.slim +++ b/app/views/settings/teams/show.html.slim @@ -30,6 +30,11 @@ span.radio-text strong Open span.radio-description Anyone can see this team and mention @#{@team.name} + .form-group + label + strong Week starts on + = f.select :week_start_day, Date::DAYNAMES.each_with_index.map { |name, i| [ name, i ] } + p.settings-hint Used for the team's weekly activity view and contribution heatmap. Times are shown in UTC. = f.submit "Save", class: "button-primary" - elsif @is_member p.settings-hint diff --git a/app/views/teams_profile/_team_stats.html.slim b/app/views/teams_profile/_team_stats.html.slim new file mode 100644 index 0000000..d42f20d --- /dev/null +++ b/app/views/teams_profile/_team_stats.html.slim @@ -0,0 +1,74 @@ +- if @team_stats + .team-stats + .team-stats-group + h3.team-stats-group-title Activity + .team-stats-grid + .team-stat-tile + .team-stat-value = @activity_summary[:active_thread_count] || 0 + .team-stat-label Active threads + .team-stat-tile + .team-stat-value = @activity_summary[:unique_contributor_count] || 0 + .team-stat-label Team contributors active + .team-stat-tile + .team-stat-value = @activity_summary[:discussion_started_count] || 0 + .team-stat-label Discussions started + .team-stat-tile + .team-stat-value = @activity_summary[:new_patch_series_count] || 0 + .team-stat-label New patch series + .team-stat-tile + .team-stat-value = @activity_summary[:sent_first_patch] || 0 + .team-stat-label Patches sent + .team-stat-tile + .team-stat-value = @activity_summary[:sent_followup_patch] || 0 + .team-stat-label Patch revisions + + .team-stats-group + h3.team-stats-group-title Thread engagement + .team-stats-grid + .team-stat-tile + .team-stat-value = @team_stats.new_thread_count + .team-stat-label New threads + .team-stat-tile + .team-stat-value = @team_stats.joined_thread_count + .team-stat-label Threads joined + .team-stat-tile + .team-stat-value = @team_stats.continuing_thread_count + .team-stat-label Threads continued + + .team-stats-group + h3.team-stats-group-title + | Responsiveness + span.team-stats-group-note + | (UTC) + .team-stats-grid + .team-stat-tile + .team-stat-value = @team_stats.median_first_response_hours ? format_duration_hours(@team_stats.median_first_response_hours) : "–" + .team-stat-label Median time to first response + - if @team_stats.first_response_sample_size.positive? + .team-stat-sub + = "n=#{@team_stats.first_response_sample_size}" + .team-stat-tile + .team-stat-value = @team_stats.waiting_for_response_count + .team-stat-label Threads waiting for a response + .team-stat-sub last 90 days + .team-stat-tile + .team-stat-value = @team_stats.patches_waiting_for_update_count + .team-stat-label Patches waiting for an author update + .team-stat-sub last 90 days + .team-stat-tile + .team-stat-value = @team_stats.external_reply_pct_within_2_bdays ? "#{@team_stats.external_reply_pct_within_2_bdays}%" : "–" + .team-stat-label Answered within 2 business days + .team-stat-tile + .team-stat-value = @team_stats.external_reply_pct_within_5_bdays ? "#{@team_stats.external_reply_pct_within_5_bdays}%" : "–" + .team-stat-label Answered within 5 business days + + .team-stats-group + h3.team-stats-group-title Community reach + .team-stats-grid + .team-stat-tile + .team-stat-value = @team_stats.unique_external_contributor_count + .team-stat-label = "Contributors outside #{@team.name} engaged" + .team-stat-tile + .team-stat-value = @team_stats.threads_with_further_engagement_count + .team-stat-label Threads with further community engagement + .team-stat-sub as of now diff --git a/app/views/teams_profile/activity.html.slim b/app/views/teams_profile/activity.html.slim index dc318f7..05e17b4 100644 --- a/app/views/teams_profile/activity.html.slim +++ b/app/views/teams_profile/activity.html.slim @@ -1,4 +1,5 @@ = turbo_frame_tag "team-activity" do + = render "team_stats" = render "activity_filters" .profile-section = render "contributions" diff --git a/app/views/teams_profile/show.html.slim b/app/views/teams_profile/show.html.slim index 7313d05..ca9642f 100644 --- a/app/views/teams_profile/show.html.slim +++ b/app/views/teams_profile/show.html.slim @@ -37,6 +37,7 @@ = pluralize(@members.count, 'member') = turbo_frame_tag "team-activity" do + = render "team_stats" = render "activity_filters" .profile-section = render "contributions" diff --git a/db/migrate/20260724120000_add_week_start_day_to_teams.rb b/db/migrate/20260724120000_add_week_start_day_to_teams.rb new file mode 100644 index 0000000..a34ce74 --- /dev/null +++ b/db/migrate/20260724120000_add_week_start_day_to_teams.rb @@ -0,0 +1,5 @@ +class AddWeekStartDayToTeams < ActiveRecord::Migration[8.0] + def change + add_column :teams, :week_start_day, :integer, default: 1, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index f47a432..cdb3c3a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do +ActiveRecord::Schema[8.0].define(version: 2026_07_24_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_stat_statements" @@ -648,6 +648,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.enum "visibility", default: "private", null: false, enum_type: "team_visibility" + t.integer "week_start_day", default: 1, null: false end create_table "thread_awarenesses", force: :cascade do |t| diff --git a/lib/business_days.rb b/lib/business_days.rb new file mode 100644 index 0000000..6b6babf --- /dev/null +++ b/lib/business_days.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Whole-business-day delta between two times. No holiday calendar; a v1 +# simplification that will read as slightly generous around public holidays. +module BusinessDays + WEEKEND_WDAYS = [ 0, 6 ].freeze + + def self.between(start_time, end_time) + return 0 if end_time <= start_time + + full_days = (end_time.to_date - start_time.to_date).to_i + (1..full_days).count { |offset| !WEEKEND_WDAYS.include?((start_time.to_date + offset).wday) } + end +end diff --git a/spec/lib/business_days_spec.rb b/spec/lib/business_days_spec.rb new file mode 100644 index 0000000..12d5c17 --- /dev/null +++ b/spec/lib/business_days_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BusinessDays do + describe ".between" do + it "returns 0 for a same-day response" do + monday = Time.zone.parse("2026-07-20 09:00") + expect(described_class.between(monday, monday + 3.hours)).to eq(0) + end + + it "returns 1 for a response the next weekday" do + monday = Time.zone.parse("2026-07-20 09:00") + tuesday = Time.zone.parse("2026-07-21 09:00") + expect(described_class.between(monday, tuesday)).to eq(1) + end + + it "counts a Friday-to-Monday response as 1 business day, skipping the weekend" do + friday = Time.zone.parse("2026-07-24 09:00") + monday = Time.zone.parse("2026-07-27 09:00") + expect(described_class.between(friday, monday)).to eq(1) + end + + it "skips multiple weekends when spanning several weeks" do + start_monday = Time.zone.parse("2026-07-20 09:00") + two_weeks_later = Time.zone.parse("2026-08-03 09:00") + expect(described_class.between(start_monday, two_weeks_later)).to eq(10) + end + + it "returns 0 when end_time is before start_time" do + monday = Time.zone.parse("2026-07-20 09:00") + expect(described_class.between(monday, monday - 1.day)).to eq(0) + end + end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index 7168bd1..bac7b9f 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -95,6 +95,26 @@ end end + describe "week_start_day" do + it "defaults to Monday" do + expect(team.week_start_day).to eq(1) + end + + it "accepts any day 0..6" do + team.week_start_day = 0 + expect(team).to be_valid + + team.week_start_day = 6 + expect(team).to be_valid + end + + it "rejects values outside 0..6" do + team.week_start_day = 7 + expect(team).not_to be_valid + expect(team.errors[:week_start_day]).to be_present + end + end + describe "#mentionable_by?" do it "allows only members to mention private teams" do expect(team.mentionable_by?(user)).to be(true) diff --git a/spec/requests/teams_profile_spec.rb b/spec/requests/teams_profile_spec.rb index 7364b33..c1bdef0 100644 --- a/spec/requests/teams_profile_spec.rb +++ b/spec/requests/teams_profile_spec.rb @@ -106,5 +106,32 @@ def attach_verified_alias(user, email:, primary: true) expect(response.body).to include(person_path("sender@example.com")) expect(response.body).to include("Sender activity thread") end + + it "renders the new team stats tiles" do + team.update!(visibility: :visible) + + member_alias = attach_verified_alias(member, email: "member@example.com") + topic = create(:topic, creator_alias: member_alias, title: "New discussion thread", created_at: 2.days.ago) + create(:message, topic: topic, sender: member_alias, sender_person_id: member.person.id, created_at: 2.days.ago) + + get team_profile_path("test-team") + + expect(response).to have_http_status(:success) + expect(response.body).to include("Active threads") + expect(response.body).to include("Discussions started") + expect(response.body).to include("New patch series") + expect(response.body).to include("Thread engagement") + expect(response.body).to include("Responsiveness") + expect(response.body).to include("Community reach") + end + + it "renders zero-value tiles without error for a team with no activity" do + team.update!(visibility: :visible) + + get team_profile_path("test-team") + + expect(response).to have_http_status(:success) + expect(response.body).to include("Active threads") + end end end diff --git a/spec/services/team_activity_stats_spec.rb b/spec/services/team_activity_stats_spec.rb new file mode 100644 index 0000000..178e079 --- /dev/null +++ b/spec/services/team_activity_stats_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe TeamActivityStats do + let(:team_alias) { create(:alias) } + let(:other_team_alias) { create(:alias) } + let(:external_alias) { create(:alias) } + let(:team_person_ids) { [ team_alias.person_id, other_team_alias.person_id ] } + + let(:window_start) { 7.days.ago.beginning_of_day } + let(:window_end) { Time.current.end_of_day } + + subject(:result) do + described_class.new(team_person_ids: team_person_ids, window_start: window_start, window_end: window_end).call + end + + it "returns all-zero/nil stats when the team has no members" do + empty_result = described_class.new(team_person_ids: [], window_start: window_start, window_end: window_end).call + + expect(empty_result.new_thread_count).to eq(0) + expect(empty_result.median_first_response_hours).to be_nil + expect(empty_result.waiting_for_response_count).to eq(0) + end + + it "returns all-zero/nil window stats when the team sent no messages in the window" do + expect(result.new_thread_count).to eq(0) + expect(result.joined_thread_count).to eq(0) + expect(result.continuing_thread_count).to eq(0) + expect(result.median_first_response_hours).to be_nil + end + + describe "thread breakdown" do + it "classifies a thread the team started in-window as new" do + topic = create(:topic, creator_alias: team_alias, created_at: 3.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 3.days.ago) + + expect(result.new_thread_count).to eq(1) + expect(result.joined_thread_count).to eq(0) + expect(result.continuing_thread_count).to eq(0) + end + + it "classifies an externally-started thread the team first posted in this week as joined" do + topic = create(:topic, creator_alias: external_alias, created_at: 20.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 20.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 3.days.ago) + + expect(result.joined_thread_count).to eq(1) + expect(result.new_thread_count).to eq(0) + expect(result.continuing_thread_count).to eq(0) + end + + it "classifies a thread the team already participated in before the window as continuing" do + topic = create(:topic, creator_alias: external_alias, created_at: 20.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 20.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 15.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 2.days.ago) + + expect(result.continuing_thread_count).to eq(1) + expect(result.joined_thread_count).to eq(0) + expect(result.new_thread_count).to eq(0) + end + end + + describe "median time to first response" do + it "computes the delta between an external thread's start and the team's first reply, in hours" do + topic = create(:topic, creator_alias: external_alias, created_at: 4.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 4.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 4.days.ago + 30.hours) + + expect(result.median_first_response_hours).to eq(30.0) + expect(result.first_response_sample_size).to eq(1) + end + end + + describe "backlog metrics" do + it "counts threads waiting for a team response within the last 90 days" do + topic = create(:topic, creator_alias: team_alias, created_at: 10.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 10.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 9.days.ago) + + expect(result.waiting_for_response_count).to eq(1) + end + + it "excludes threads whose last message is older than 90 days" do + topic = create(:topic, creator_alias: team_alias, created_at: 200.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 200.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 150.days.ago) + + expect(result.waiting_for_response_count).to eq(0) + end + + it "counts patches the team introduced that are waiting for an author update" do + topic = create(:topic, creator_alias: team_alias, created_at: 10.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, + created_at: 10.days.ago, is_patch_submission: true) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 9.days.ago) + + expect(result.patches_waiting_for_update_count).to eq(1) + end + + it "does not count patches where an external contributor sent the first revision" do + topic = create(:topic, creator_alias: external_alias, created_at: 10.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, + created_at: 10.days.ago, is_patch_submission: true) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 9.days.ago) + + expect(result.patches_waiting_for_update_count).to eq(0) + end + end + + describe "community reach" do + it "counts unique external contributors across in-window topics" do + topic = create(:topic, creator_alias: team_alias, created_at: 3.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 3.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 2.days.ago) + + expect(result.unique_external_contributor_count).to eq(1) + end + + it "counts threads with further community engagement after the team's last in-window message" do + topic = create(:topic, creator_alias: team_alias, created_at: 3.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 3.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 2.days.ago) + + expect(result.threads_with_further_engagement_count).to eq(1) + end + + it "does not count threads where nobody replied after the team" do + topic = create(:topic, creator_alias: team_alias, created_at: 3.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 3.days.ago) + + expect(result.threads_with_further_engagement_count).to eq(0) + end + end + + describe "business-day response percentages" do + it "reports 100% within 5 business days for a same-week reply" do + topic = create(:topic, creator_alias: team_alias, created_at: 3.days.ago) + create(:message, topic: topic, sender: external_alias, sender_person_id: external_alias.person_id, created_at: 3.days.ago) + create(:message, topic: topic, sender: team_alias, sender_person_id: team_alias.person_id, created_at: 2.days.ago) + + expect(result.external_reply_response_events_count).to eq(1) + expect(result.external_reply_pct_within_5_bdays).to eq(100.0) + end + end +end