diff --git a/app/builders/v2/reports/label_summary_builder.rb b/app/builders/v2/reports/label_summary_builder.rb
new file mode 100644
index 000000000..abc68b26b
--- /dev/null
+++ b/app/builders/v2/reports/label_summary_builder.rb
@@ -0,0 +1,101 @@
+class V2::Reports::LabelSummaryBuilder < V2::Reports::BaseSummaryBuilder
+ attr_reader :account, :params
+
+ # rubocop:disable Lint/MissingSuper
+ # the parent class has no initialize
+ def initialize(account:, params:)
+ @account = account
+ @params = params
+
+ timezone_offset = (params[:timezone_offset] || 0).to_f
+ @timezone = ActiveSupport::TimeZone[timezone_offset]&.name
+ end
+ # rubocop:enable Lint/MissingSuper
+
+ def build
+ labels = account.labels.to_a
+ return [] if labels.empty?
+
+ report_data = collect_report_data
+ labels.map { |label| build_label_report(label, report_data) }
+ end
+
+ private
+
+ def collect_report_data
+ conversation_filter = build_conversation_filter
+ use_business_hours = use_business_hours?
+
+ {
+ conversation_counts: fetch_conversation_counts(conversation_filter),
+ resolved_counts: fetch_resolved_counts(conversation_filter),
+ resolution_metrics: fetch_metrics(conversation_filter, 'conversation_resolved', use_business_hours),
+ first_response_metrics: fetch_metrics(conversation_filter, 'first_response', use_business_hours),
+ reply_metrics: fetch_metrics(conversation_filter, 'reply', use_business_hours)
+ }
+ end
+
+ def build_label_report(label, report_data)
+ {
+ id: label.id,
+ name: label.title,
+ conversations_count: report_data[:conversation_counts][label.title] || 0,
+ avg_resolution_time: report_data[:resolution_metrics][label.title] || 0,
+ avg_first_response_time: report_data[:first_response_metrics][label.title] || 0,
+ avg_reply_time: report_data[:reply_metrics][label.title] || 0,
+ resolved_conversations_count: report_data[:resolved_counts][label.title] || 0
+ }
+ end
+
+ def use_business_hours?
+ ActiveModel::Type::Boolean.new.cast(params[:business_hours])
+ end
+
+ def build_conversation_filter
+ conversation_filter = { account_id: account.id }
+ conversation_filter[:created_at] = range if range.present?
+
+ conversation_filter
+ end
+
+ def fetch_conversation_counts(conversation_filter)
+ fetch_counts(conversation_filter)
+ end
+
+ def fetch_resolved_counts(conversation_filter)
+ fetch_counts(conversation_filter.merge(status: :resolved))
+ end
+
+ def fetch_counts(conversation_filter)
+ ActsAsTaggableOn::Tagging
+ .joins('INNER JOIN conversations ON taggings.taggable_id = conversations.id')
+ .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .where(
+ taggable_type: 'Conversation',
+ context: 'labels',
+ conversations: conversation_filter
+ )
+ .select('tags.name, COUNT(taggings.*) AS count')
+ .group('tags.name')
+ .each_with_object({}) { |record, hash| hash[record.name] = record.count }
+ end
+
+ def fetch_metrics(conversation_filter, event_name, use_business_hours)
+ ReportingEvent
+ .joins('INNER JOIN conversations ON reporting_events.conversation_id = conversations.id')
+ .joins('INNER JOIN taggings ON taggings.taggable_id = conversations.id')
+ .joins('INNER JOIN tags ON taggings.tag_id = tags.id')
+ .where(
+ conversations: conversation_filter,
+ name: event_name,
+ taggings: { taggable_type: 'Conversation', context: 'labels' }
+ )
+ .group('tags.name')
+ .order('tags.name')
+ .select(
+ 'tags.name',
+ use_business_hours ? 'AVG(reporting_events.value_in_business_hours) as avg_value' : 'AVG(reporting_events.value) as avg_value'
+ )
+ .each_with_object({}) { |record, hash| hash[record.name] = record.avg_value.to_f }
+ end
+end
diff --git a/app/controllers/api/v2/accounts/summary_reports_controller.rb b/app/controllers/api/v2/accounts/summary_reports_controller.rb
index 989952cfd..f31a53c7e 100644
--- a/app/controllers/api/v2/accounts/summary_reports_controller.rb
+++ b/app/controllers/api/v2/accounts/summary_reports_controller.rb
@@ -1,6 +1,6 @@
class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
- before_action :prepare_builder_params, only: [:agent, :team, :inbox]
+ before_action :prepare_builder_params, only: [:agent, :team, :inbox, :label]
def agent
render_report_with(V2::Reports::AgentSummaryBuilder)
@@ -14,6 +14,10 @@ class Api::V2::Accounts::SummaryReportsController < Api::V1::Accounts::BaseContr
render_report_with(V2::Reports::InboxSummaryBuilder)
end
+ def label
+ render_report_with(V2::Reports::LabelSummaryBuilder)
+ end
+
private
def check_authorization
diff --git a/app/helpers/api/v2/accounts/reports_helper.rb b/app/helpers/api/v2/accounts/reports_helper.rb
index 22c51b6ef..23694d08d 100644
--- a/app/helpers/api/v2/accounts/reports_helper.rb
+++ b/app/helpers/api/v2/accounts/reports_helper.rb
@@ -36,9 +36,13 @@ module Api::V2::Accounts::ReportsHelper
end
def generate_labels_report
- Current.account.labels.map do |label|
- label_report = report_builder({ type: :label, id: label.id }).short_summary
- [label.title] + generate_readable_report_metrics(label_report)
+ reports = V2::Reports::LabelSummaryBuilder.new(
+ account: Current.account,
+ params: build_params({})
+ ).build
+
+ reports.map do |report|
+ [report[:name]] + generate_readable_report_metrics(report)
end
end
diff --git a/app/javascript/dashboard/api/summaryReports.js b/app/javascript/dashboard/api/summaryReports.js
index f772ef86f..fad26bf6f 100644
--- a/app/javascript/dashboard/api/summaryReports.js
+++ b/app/javascript/dashboard/api/summaryReports.js
@@ -35,6 +35,16 @@ class SummaryReportsAPI extends ApiClient {
},
});
}
+
+ getLabelReports({ since, until, businessHours } = {}) {
+ return axios.get(`${this.url}/label`, {
+ params: {
+ since,
+ until,
+ business_hours: businessHours,
+ },
+ });
+ }
}
export default new SummaryReportsAPI();
diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
index 5d7aac3c3..171f4a4d8 100644
--- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
+++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue
@@ -87,7 +87,7 @@ const newReportRoutes = () => [
{
name: 'Reports Label',
label: t('SIDEBAR.REPORTS_LABEL'),
- to: accountScopedRoute('label_reports'),
+ to: accountScopedRoute('label_reports_index'),
},
{
name: 'Reports Inbox',
diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json
index 294ca2e7b..7c42fdfba 100644
--- a/app/javascript/dashboard/i18n/locale/en/report.json
+++ b/app/javascript/dashboard/i18n/locale/en/report.json
@@ -193,6 +193,7 @@
},
"LABEL_REPORTS": {
"HEADER": "Labels Overview",
+ "DESCRIPTION": "Track label performance with key metrics including conversations, response times, resolution times, and resolved cases. Click a label name for detailed insights.",
"LOADING_CHART": "Loading chart data...",
"NO_ENOUGH_DATA": "We've not received enough data points to generate report, Please try again later.",
"DOWNLOAD_LABEL_REPORTS": "Download label reports",
@@ -559,6 +560,7 @@
"INBOX": "Inbox",
"AGENT": "Agent",
"TEAM": "Team",
+ "LABEL": "Label",
"AVG_RESOLUTION_TIME": "Avg. Resolution Time",
"AVG_FIRST_RESPONSE_TIME": "Avg. First Response Time",
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue
new file mode 100644
index 000000000..956b30974
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsIndex.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue
new file mode 100644
index 000000000..678028410
--- /dev/null
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/LabelReportsShow.vue
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
index 4c0c4fe64..7e0b9be8f 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/components/SummaryReports.vue
@@ -108,7 +108,8 @@ const tableData = computed(() =>
} = rowMetrics;
return {
id: row.id,
- name: row.name,
+ // we fallback on title, label for instance does not have a name property
+ name: row.name ?? row.title,
type: props.type,
conversationsCount: renderCount(conversationsCount),
avgFirstResponseTime: renderAvgTime(avgFirstResponseTime),
@@ -177,7 +178,7 @@ defineExpose({ downloadReports });
diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
index 43855240c..af01f6d74 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/settings/reports/reports.routes.js
@@ -7,10 +7,12 @@ import Index from './Index.vue';
import AgentReportsIndex from './AgentReportsIndex.vue';
import InboxReportsIndex from './InboxReportsIndex.vue';
import TeamReportsIndex from './TeamReportsIndex.vue';
+import LabelReportsIndex from './LabelReportsIndex.vue';
import AgentReportsShow from './AgentReportsShow.vue';
import InboxReportsShow from './InboxReportsShow.vue';
import TeamReportsShow from './TeamReportsShow.vue';
+import LabelReportsShow from './LabelReportsShow.vue';
import AgentReports from './AgentReports.vue';
import InboxReports from './InboxReports.vue';
@@ -104,6 +106,22 @@ const revisedReportRoutes = [
},
component: TeamReportsShow,
},
+ {
+ path: 'labels_overview',
+ name: 'label_reports_index',
+ meta: {
+ permissions: ['administrator', 'report_manage'],
+ },
+ component: LabelReportsIndex,
+ },
+ {
+ path: 'labels/:id',
+ name: 'label_reports_show',
+ meta: {
+ permissions: ['administrator', 'report_manage'],
+ },
+ component: LabelReportsShow,
+ },
];
export default {
diff --git a/app/javascript/dashboard/store/modules/labels.js b/app/javascript/dashboard/store/modules/labels.js
index 34f63f2a1..662c2973e 100644
--- a/app/javascript/dashboard/store/modules/labels.js
+++ b/app/javascript/dashboard/store/modules/labels.js
@@ -26,6 +26,9 @@ export const getters = {
.filter(record => record.show_on_sidebar)
.sort((a, b) => a.title.localeCompare(b.title));
},
+ getLabelById: _state => id => {
+ return _state.records.find(record => record.id === Number(id));
+ },
};
export const actions = {
diff --git a/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js b/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
index 043987136..c1a199856 100644
--- a/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
+++ b/app/javascript/dashboard/store/modules/specs/summaryReports.spec.js
@@ -7,6 +7,7 @@ vi.mock('dashboard/api/summaryReports', () => ({
getInboxReports: vi.fn(),
getAgentReports: vi.fn(),
getTeamReports: vi.fn(),
+ getLabelReports: vi.fn(),
},
}));
@@ -25,10 +26,12 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
+ labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
+ isFetchingLabelSummaryReports: false,
},
});
});
@@ -39,6 +42,7 @@ describe('Summary Reports Store', () => {
inboxSummaryReports: [{ id: 1 }],
agentSummaryReports: [{ id: 2 }],
teamSummaryReports: [{ id: 3 }],
+ labelSummaryReports: [{ id: 4 }],
uiFlags: { isFetchingInboxSummaryReports: true },
};
@@ -54,6 +58,10 @@ describe('Summary Reports Store', () => {
expect(store.getters.getTeamSummaryReports(state)).toEqual([{ id: 3 }]);
});
+ it('should return label summary reports', () => {
+ expect(store.getters.getLabelSummaryReports(state)).toEqual([{ id: 4 }]);
+ });
+
it('should return UI flags', () => {
expect(store.getters.getUIFlags(state)).toEqual({
isFetchingInboxSummaryReports: true,
@@ -86,6 +94,14 @@ describe('Summary Reports Store', () => {
expect(state.teamSummaryReports).toEqual(data);
});
+ it('should set label summary report', () => {
+ const state = { ...initialState };
+ const data = [{ id: 4 }];
+
+ store.mutations.setLabelSummaryReport(state, data);
+ expect(state.labelSummaryReports).toEqual(data);
+ });
+
it('should merge UI flags with existing flags', () => {
const state = {
uiFlags: { flag1: true, flag2: false },
@@ -185,5 +201,29 @@ describe('Summary Reports Store', () => {
});
});
});
+
+ describe('fetchLabelSummaryReports', () => {
+ it('should fetch label reports successfully', async () => {
+ const params = { labelId: 789 };
+ const mockResponse = {
+ data: [{ label_id: 789, label_name: 'Test Label' }],
+ };
+
+ SummaryReportsAPI.getLabelReports.mockResolvedValue(mockResponse);
+
+ await store.actions.fetchLabelSummaryReports({ commit }, params);
+
+ expect(commit).toHaveBeenCalledWith('setUIFlags', {
+ isFetchingLabelSummaryReports: true,
+ });
+ expect(SummaryReportsAPI.getLabelReports).toHaveBeenCalledWith(params);
+ expect(commit).toHaveBeenCalledWith('setLabelSummaryReport', [
+ { labelId: 789, labelName: 'Test Label' },
+ ]);
+ expect(commit).toHaveBeenCalledWith('setUIFlags', {
+ isFetchingLabelSummaryReports: false,
+ });
+ });
+ });
});
});
diff --git a/app/javascript/dashboard/store/modules/summaryReports.js b/app/javascript/dashboard/store/modules/summaryReports.js
index 15df7589b..d95c0df30 100644
--- a/app/javascript/dashboard/store/modules/summaryReports.js
+++ b/app/javascript/dashboard/store/modules/summaryReports.js
@@ -17,6 +17,11 @@ const typeMap = {
apiMethod: 'getTeamReports',
mutationKey: 'setTeamSummaryReport',
},
+ label: {
+ flagKey: 'isFetchingLabelSummaryReports',
+ apiMethod: 'getLabelReports',
+ mutationKey: 'setLabelSummaryReport',
+ },
};
async function fetchSummaryReports(type, params, { commit }) {
@@ -38,10 +43,12 @@ export const initialState = {
inboxSummaryReports: [],
agentSummaryReports: [],
teamSummaryReports: [],
+ labelSummaryReports: [],
uiFlags: {
isFetchingInboxSummaryReports: false,
isFetchingAgentSummaryReports: false,
isFetchingTeamSummaryReports: false,
+ isFetchingLabelSummaryReports: false,
},
};
@@ -55,6 +62,9 @@ export const getters = {
getTeamSummaryReports(state) {
return state.teamSummaryReports;
},
+ getLabelSummaryReports(state) {
+ return state.labelSummaryReports;
+ },
getUIFlags(state) {
return state.uiFlags;
},
@@ -72,6 +82,10 @@ export const actions = {
fetchTeamSummaryReports({ commit }, params) {
return fetchSummaryReports('team', params, { commit });
},
+
+ fetchLabelSummaryReports({ commit }, params) {
+ return fetchSummaryReports('label', params, { commit });
+ },
};
export const mutations = {
@@ -84,6 +98,9 @@ export const mutations = {
setTeamSummaryReport(state, data) {
state.teamSummaryReports = data;
},
+ setLabelSummaryReport(state, data) {
+ state.labelSummaryReports = data;
+ },
setUIFlags(state, uiFlag) {
state.uiFlags = { ...state.uiFlags, ...uiFlag };
},
diff --git a/config/routes.rb b/config/routes.rb
index 12ce70d77..1ed080457 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -344,6 +344,7 @@ Rails.application.routes.draw do
get :agent
get :team
get :inbox
+ get :label
end
end
resources :reports, only: [:index] do
diff --git a/lib/seeders/reports/conversation_creator.rb b/lib/seeders/reports/conversation_creator.rb
new file mode 100644
index 000000000..b6259de7d
--- /dev/null
+++ b/lib/seeders/reports/conversation_creator.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+class Seeders::Reports::ConversationCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ def initialize(account:, resources:)
+ @account = account
+ @contacts = resources[:contacts]
+ @inboxes = resources[:inboxes]
+ @teams = resources[:teams]
+ @labels = resources[:labels]
+ @agents = resources[:agents]
+ @priorities = [nil, 'urgent', 'high', 'medium', 'low']
+ end
+
+ def create_conversation(created_at:)
+ conversation = nil
+
+ ActiveRecord::Base.transaction do
+ travel_to(created_at) do
+ conversation = build_conversation
+ conversation.save!
+
+ add_labels_to_conversation(conversation)
+ create_messages_for_conversation(conversation)
+ resolve_conversation_if_needed(conversation)
+ end
+
+ travel_back
+ end
+
+ conversation
+ end
+
+ private
+
+ def build_conversation
+ contact = @contacts.sample
+ inbox = @inboxes.sample
+
+ contact_inbox = find_or_create_contact_inbox(contact, inbox)
+ assignee = select_assignee(inbox)
+ team = select_team
+ priority = @priorities.sample
+
+ contact_inbox.conversations.new(
+ account: @account,
+ inbox: inbox,
+ contact: contact,
+ assignee: assignee,
+ team: team,
+ priority: priority
+ )
+ end
+
+ def find_or_create_contact_inbox(contact, inbox)
+ inbox.contact_inboxes.find_or_create_by!(
+ contact: contact,
+ source_id: SecureRandom.hex
+ )
+ end
+
+ def select_assignee(inbox)
+ rand(10) < 8 ? inbox.members.sample : nil
+ end
+
+ def select_team
+ rand(10) < 7 ? @teams.sample : nil
+ end
+
+ def add_labels_to_conversation(conversation)
+ labels_to_add = @labels.sample(rand(5..20))
+ conversation.update_labels(labels_to_add.map(&:title))
+ end
+
+ def create_messages_for_conversation(conversation)
+ message_creator = Seeders::Reports::MessageCreator.new(
+ account: @account,
+ agents: @agents,
+ conversation: conversation
+ )
+ message_creator.create_messages
+ end
+
+ def resolve_conversation_if_needed(conversation)
+ return unless rand < 0.7
+
+ resolution_delay = rand((30.minutes)..(24.hours))
+ travel(resolution_delay)
+ conversation.update!(status: :resolved)
+
+ trigger_conversation_resolved_event(conversation)
+ end
+
+ def trigger_conversation_resolved_event(conversation)
+ event_data = { conversation: conversation }
+
+ ReportingEventListener.instance.conversation_resolved(
+ Events::Base.new('conversation_resolved', Time.current, event_data)
+ )
+ end
+end
diff --git a/lib/seeders/reports/message_creator.rb b/lib/seeders/reports/message_creator.rb
new file mode 100644
index 000000000..fc10716d9
--- /dev/null
+++ b/lib/seeders/reports/message_creator.rb
@@ -0,0 +1,141 @@
+# frozen_string_literal: true
+
+require 'faker'
+require 'active_support/testing/time_helpers'
+
+class Seeders::Reports::MessageCreator
+ include ActiveSupport::Testing::TimeHelpers
+
+ MESSAGES_PER_CONVERSATION = 5
+
+ def initialize(account:, agents:, conversation:)
+ @account = account
+ @agents = agents
+ @conversation = conversation
+ end
+
+ def create_messages
+ message_count = rand(MESSAGES_PER_CONVERSATION..MESSAGES_PER_CONVERSATION + 5)
+ first_agent_reply = true
+
+ message_count.times do |i|
+ message = create_single_message(i)
+ first_agent_reply = handle_reply_tracking(message, i, first_agent_reply)
+ end
+ end
+
+ def create_single_message(index)
+ is_incoming = index.even?
+ add_realistic_delay(index, is_incoming) if index.positive?
+ create_message(is_incoming)
+ end
+
+ def handle_reply_tracking(message, index, first_agent_reply)
+ return first_agent_reply if index.even? # Skip incoming messages
+
+ handle_agent_reply_events(message, first_agent_reply)
+ false # No longer first reply after any agent message
+ end
+
+ private
+
+ def add_realistic_delay(_message_index, is_incoming)
+ delay = calculate_message_delay(is_incoming)
+ travel(delay)
+ end
+
+ def calculate_message_delay(is_incoming)
+ if is_incoming
+ # Customer response time: 1 minute to 4 hours
+ rand((1.minute)..(4.hours))
+ elsif business_hours_active?(Time.current)
+ # Agent response time varies by business hours
+ rand((30.seconds)..(30.minutes))
+ else
+ rand((1.hour)..(8.hours))
+ end
+ end
+
+ def create_message(is_incoming)
+ if is_incoming
+ create_incoming_message
+ else
+ create_outgoing_message
+ end
+ end
+
+ def create_incoming_message
+ @conversation.messages.create!(
+ account: @account,
+ inbox: @conversation.inbox,
+ message_type: :incoming,
+ content: generate_message_content,
+ sender: @conversation.contact
+ )
+ end
+
+ def create_outgoing_message
+ sender = @conversation.assignee || @agents.sample
+
+ @conversation.messages.create!(
+ account: @account,
+ inbox: @conversation.inbox,
+ message_type: :outgoing,
+ content: generate_message_content,
+ sender: sender
+ )
+ end
+
+ def generate_message_content
+ Faker::Lorem.paragraph(sentence_count: rand(1..5))
+ end
+
+ def handle_agent_reply_events(message, is_first_reply)
+ if is_first_reply
+ trigger_first_reply_event(message)
+ else
+ trigger_reply_event(message)
+ end
+ end
+
+ def business_hours_active?(time)
+ weekday = time.wday
+ hour = time.hour
+ weekday.between?(1, 5) && hour.between?(9, 17)
+ end
+
+ def trigger_first_reply_event(message)
+ event_data = {
+ message: message,
+ conversation: message.conversation
+ }
+
+ ReportingEventListener.instance.first_reply_created(
+ Events::Base.new('first_reply_created', Time.current, event_data)
+ )
+ end
+
+ def trigger_reply_event(message)
+ waiting_since = calculate_waiting_since(message)
+
+ event_data = {
+ message: message,
+ conversation: message.conversation,
+ waiting_since: waiting_since
+ }
+
+ ReportingEventListener.instance.reply_created(
+ Events::Base.new('reply_created', Time.current, event_data)
+ )
+ end
+
+ def calculate_waiting_since(message)
+ last_customer_message = message.conversation.messages
+ .where(message_type: :incoming)
+ .where('created_at < ?', message.created_at)
+ .order(:created_at)
+ .last
+
+ last_customer_message&.created_at || message.conversation.created_at
+ end
+end
diff --git a/lib/seeders/reports/report_data_seeder.rb b/lib/seeders/reports/report_data_seeder.rb
new file mode 100644
index 000000000..909818b72
--- /dev/null
+++ b/lib/seeders/reports/report_data_seeder.rb
@@ -0,0 +1,234 @@
+# frozen_string_literal: true
+
+# Reports Data Seeder
+#
+# Generates realistic test data for performance testing of reports and analytics.
+# Creates conversations, messages, contacts, agents, teams, and labels with proper
+# reporting events (first response times, resolution times, etc.) using time travel
+# to generate historical data with realistic timestamps.
+#
+# Usage:
+# ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data
+#
+# This will create:
+# - 1000 conversations with realistic message exchanges
+# - 100 contacts with realistic profiles
+# - 20 agents assigned to teams and inboxes
+# - 5 teams with realistic distribution
+# - 30 labels with random assignments
+# - 3 inboxes with agent assignments
+# - Realistic reporting events with historical timestamps
+#
+# Note: This seeder clears existing data for the account before seeding.
+
+require 'faker'
+require_relative 'conversation_creator'
+require_relative 'message_creator'
+
+# rubocop:disable Rails/Output
+class Seeders::Reports::ReportDataSeeder
+ include ActiveSupport::Testing::TimeHelpers
+
+ TOTAL_CONVERSATIONS = 1000
+ TOTAL_CONTACTS = 100
+ TOTAL_AGENTS = 20
+ TOTAL_TEAMS = 5
+ TOTAL_LABELS = 30
+ TOTAL_INBOXES = 3
+ MESSAGES_PER_CONVERSATION = 5
+ START_DATE = 3.months.ago # rubocop:disable Rails/RelativeDateConstant
+ END_DATE = Time.current
+
+ def initialize(account:)
+ raise 'Account Seeding is not allowed.' unless ENV.fetch('ENABLE_ACCOUNT_SEEDING', !Rails.env.production?)
+
+ @account = account
+ @teams = []
+ @agents = []
+ @labels = []
+ @inboxes = []
+ @contacts = []
+ end
+
+ def perform!
+ puts "Starting reports data seeding for account: #{@account.name}"
+
+ # Clear existing data
+ clear_existing_data
+
+ create_teams
+ create_agents
+ create_labels
+ create_inboxes
+ create_contacts
+
+ create_conversations
+
+ puts "Completed reports data seeding for account: #{@account.name}"
+ end
+
+ private
+
+ def clear_existing_data
+ puts "Clearing existing data for account: #{@account.id}"
+ @account.teams.destroy_all
+ @account.conversations.destroy_all
+ @account.labels.destroy_all
+ @account.inboxes.destroy_all
+ @account.contacts.destroy_all
+ @account.agents.destroy_all
+ @account.reporting_events.destroy_all
+ end
+
+ def create_teams
+ TOTAL_TEAMS.times do |i|
+ team = @account.teams.create!(
+ name: "#{Faker::Company.industry} Team #{i + 1}"
+ )
+ @teams << team
+ print "\rCreating teams: #{i + 1}/#{TOTAL_TEAMS}"
+ end
+
+ print "\n"
+ end
+
+ def create_agents
+ TOTAL_AGENTS.times do |i|
+ user = create_single_agent(i)
+ assign_agent_to_teams(user)
+ @agents << user
+ print "\rCreating agents: #{i + 1}/#{TOTAL_AGENTS}"
+ end
+
+ print "\n"
+ end
+
+ def create_single_agent(index)
+ random_suffix = SecureRandom.hex(4)
+ user = User.create!(
+ name: Faker::Name.name,
+ email: "agent_#{index + 1}_#{random_suffix}@#{@account.domain || 'example.com'}",
+ password: 'Password1!.',
+ confirmed_at: Time.current
+ )
+ user.skip_confirmation!
+ user.save!
+
+ AccountUser.create!(
+ account_id: @account.id,
+ user_id: user.id,
+ role: :agent
+ )
+
+ user
+ end
+
+ def assign_agent_to_teams(user)
+ teams_to_assign = @teams.sample(rand(1..3))
+ teams_to_assign.each do |team|
+ TeamMember.create!(
+ team_id: team.id,
+ user_id: user.id
+ )
+ end
+ end
+
+ def create_labels
+ TOTAL_LABELS.times do |i|
+ label = @account.labels.create!(
+ title: "Label-#{i + 1}-#{Faker::Lorem.word}",
+ description: Faker::Company.catch_phrase,
+ color: Faker::Color.hex_color
+ )
+ @labels << label
+ print "\rCreating labels: #{i + 1}/#{TOTAL_LABELS}"
+ end
+
+ print "\n"
+ end
+
+ def create_inboxes
+ TOTAL_INBOXES.times do |_i|
+ inbox = create_single_inbox
+ assign_agents_to_inbox(inbox)
+ @inboxes << inbox
+ print "\rCreating inboxes: #{@inboxes.size}/#{TOTAL_INBOXES}"
+ end
+
+ print "\n"
+ end
+
+ def create_single_inbox
+ channel = Channel::WebWidget.create!(
+ website_url: "https://#{Faker::Internet.domain_name}",
+ account_id: @account.id
+ )
+
+ @account.inboxes.create!(
+ name: "#{Faker::Company.name} Website",
+ channel: channel
+ )
+ end
+
+ def assign_agents_to_inbox(inbox)
+ agents_to_assign = if @inboxes.empty?
+ # First inbox gets all agents to ensure coverage
+ @agents
+ else
+ # Subsequent inboxes get random selection with some overlap
+ min_agents = [@agents.size / TOTAL_INBOXES, 10].max
+ max_agents = [(@agents.size * 0.8).to_i, 50].min
+ @agents.sample(rand(min_agents..max_agents))
+ end
+
+ agents_to_assign.each do |agent|
+ InboxMember.create!(inbox: inbox, user: agent)
+ end
+ end
+
+ def create_contacts
+ TOTAL_CONTACTS.times do |i|
+ contact = @account.contacts.create!(
+ name: Faker::Name.name,
+ email: Faker::Internet.email,
+ phone_number: Faker::PhoneNumber.cell_phone_in_e164,
+ identifier: SecureRandom.uuid,
+ additional_attributes: {
+ company: Faker::Company.name,
+ city: Faker::Address.city,
+ country: Faker::Address.country,
+ customer_since: Faker::Date.between(from: 2.years.ago, to: Time.zone.today)
+ }
+ )
+ @contacts << contact
+
+ print "\rCreating contacts: #{i + 1}/#{TOTAL_CONTACTS}"
+ end
+
+ print "\n"
+ end
+
+ def create_conversations
+ conversation_creator = Seeders::Reports::ConversationCreator.new(
+ account: @account,
+ resources: {
+ contacts: @contacts,
+ inboxes: @inboxes,
+ teams: @teams,
+ labels: @labels,
+ agents: @agents
+ }
+ )
+
+ TOTAL_CONVERSATIONS.times do |i|
+ created_at = Faker::Time.between(from: START_DATE, to: END_DATE)
+ conversation_creator.create_conversation(created_at: created_at)
+
+ completion_percentage = ((i + 1).to_f / TOTAL_CONVERSATIONS * 100).round
+ print "\rCreating conversations: #{i + 1}/#{TOTAL_CONVERSATIONS} (#{completion_percentage}%)"
+ end
+
+ print "\n"
+ end
+end
+# rubocop:enable Rails/Output
diff --git a/lib/tasks/seed_reports_data.rake b/lib/tasks/seed_reports_data.rake
new file mode 100644
index 000000000..5c80f1e51
--- /dev/null
+++ b/lib/tasks/seed_reports_data.rake
@@ -0,0 +1,24 @@
+namespace :db do
+ namespace :seed do
+ desc 'Seed test data for reports with conversations, contacts, agents, teams, and realistic reporting events'
+ task reports_data: :environment do
+ if ENV['ACCOUNT_ID'].blank?
+ puts 'Please provide an ACCOUNT_ID environment variable'
+ puts 'Usage: ACCOUNT_ID=1 ENABLE_ACCOUNT_SEEDING=true bundle exec rake db:seed:reports_data'
+ exit 1
+ end
+
+ ENV['ENABLE_ACCOUNT_SEEDING'] = 'true' if ENV['ENABLE_ACCOUNT_SEEDING'].blank?
+
+ account_id = ENV.fetch('ACCOUNT_ID', nil)
+ account = Account.find(account_id)
+
+ puts "Starting reports data seeding for account: #{account.name} (ID: #{account.id})"
+
+ seeder = Seeders::Reports::ReportDataSeeder.new(account: account)
+ seeder.perform!
+
+ puts "Finished seeding reports data for account: #{account.name}"
+ end
+ end
+end
diff --git a/spec/builders/v2/reports/label_summary_builder_spec.rb b/spec/builders/v2/reports/label_summary_builder_spec.rb
new file mode 100644
index 000000000..7a5a589dd
--- /dev/null
+++ b/spec/builders/v2/reports/label_summary_builder_spec.rb
@@ -0,0 +1,317 @@
+require 'rails_helper'
+
+RSpec.describe V2::Reports::LabelSummaryBuilder do
+ include ActiveJob::TestHelper
+
+ let_it_be(:account) { create(:account) }
+ let_it_be(:label_1) { create(:label, title: 'label_1', account: account) }
+ let_it_be(:label_2) { create(:label, title: 'label_2', account: account) }
+ let_it_be(:label_3) { create(:label, title: 'label_3', account: account) }
+
+ let(:params) do
+ {
+ business_hours: business_hours,
+ since: (Time.zone.today - 3.days).to_time.to_i.to_s,
+ until: Time.zone.today.end_of_day.to_time.to_i.to_s,
+ timezone_offset: 0
+ }
+ end
+ let(:builder) { described_class.new(account: account, params: params) }
+
+ describe '#initialize' do
+ let(:business_hours) { false }
+
+ it 'sets account and params' do
+ expect(builder.account).to eq(account)
+ expect(builder.params).to eq(params)
+ end
+
+ it 'sets timezone from timezone_offset' do
+ builder_with_offset = described_class.new(account: account, params: { timezone_offset: -8 })
+ expect(builder_with_offset.instance_variable_get(:@timezone)).to eq('Pacific Time (US & Canada)')
+ end
+
+ it 'defaults timezone when timezone_offset is not provided' do
+ builder_without_offset = described_class.new(account: account, params: {})
+ expect(builder_without_offset.instance_variable_get(:@timezone)).not_to be_nil
+ end
+ end
+
+ describe '#build' do
+ context 'when there are no labels' do
+ let(:business_hours) { false }
+ let(:empty_account) { create(:account) }
+ let(:empty_builder) { described_class.new(account: empty_account, params: params) }
+
+ it 'returns empty array' do
+ expect(empty_builder.build).to eq([])
+ end
+ end
+
+ context 'when there are labels but no conversations' do
+ let(:business_hours) { false }
+
+ it 'returns zero values for all labels' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ bug_report = report.find { |r| r[:name] == 'label_1' }
+ feature_request = report.find { |r| r[:name] == 'label_2' }
+ customer_support = report.find { |r| r[:name] == 'label_3' }
+
+ [
+ [bug_report, label_1, 'label_1'],
+ [feature_request, label_2, 'label_2'],
+ [customer_support, label_3, 'label_3']
+ ].each do |report_data, label, label_name|
+ expect(report_data).to include(
+ id: label.id,
+ name: label_name,
+ conversations_count: 0,
+ avg_resolution_time: 0,
+ avg_first_response_time: 0,
+ avg_reply_time: 0,
+ resolved_conversations_count: 0
+ )
+ end
+ end
+ end
+
+ context 'when there are labeled conversations with metrics' do
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ # Create conversations with label_1
+ 3.times do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ create_list(:message, 2, message_type: 'outgoing',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 1.hour)
+ create_list(:message, 1, message_type: 'incoming',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 2.hours)
+ conversation.update_labels('label_1')
+ conversation.label_list
+ conversation.save!
+ end
+
+ # Create conversations with label_2
+ 2.times do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ create_list(:message, 1, message_type: 'outgoing',
+ account: account, inbox: inbox,
+ conversation: conversation,
+ created_at: Time.zone.today + 1.hour)
+ conversation.update_labels('label_2')
+ conversation.label_list
+ conversation.save!
+ end
+
+ # Resolve some conversations
+ conversations_to_resolve = account.conversations.first(2)
+ conversations_to_resolve.each(&:toggle_status)
+
+ # Create some reporting events
+ account.conversations.reload.each_with_index do |conv, idx|
+ # First response times
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'first_response',
+ value: (30 + (idx * 10)) * 60,
+ value_in_business_hours: (20 + (idx * 5)) * 60,
+ created_at: Time.zone.today)
+
+ # Reply times
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'reply',
+ value: (15 + (idx * 5)) * 60,
+ value_in_business_hours: (10 + (idx * 3)) * 60,
+ created_at: Time.zone.today)
+
+ # Resolution times for resolved conversations
+ next unless conv.resolved?
+
+ create(:reporting_event,
+ account: account,
+ conversation: conv,
+ name: 'conversation_resolved',
+ value: (60 + (idx * 30)) * 60,
+ value_in_business_hours: (45 + (idx * 20)) * 60,
+ created_at: Time.zone.today)
+ end
+ end
+ end
+ end
+
+ context 'when business hours is disabled' do
+ let(:business_hours) { false }
+
+ it 'returns correct label stats using regular values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ label_2_report = report.find { |r| r[:name] == 'label_2' }
+ label_3_report = report.find { |r| r[:name] == 'label_3' }
+
+ expect(label_1_report).to include(
+ conversations_count: 3,
+ avg_first_response_time: be > 0,
+ avg_reply_time: be > 0
+ )
+
+ expect(label_2_report).to include(
+ conversations_count: 2,
+ avg_first_response_time: be > 0,
+ avg_reply_time: be > 0
+ )
+
+ expect(label_3_report).to include(
+ conversations_count: 0,
+ avg_first_response_time: 0,
+ avg_reply_time: 0
+ )
+ end
+ end
+
+ context 'when business hours is enabled' do
+ let(:business_hours) { true }
+
+ it 'returns correct label stats using business hours values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ label_2_report = report.find { |r| r[:name] == 'label_2' }
+
+ expect(label_1_report[:conversations_count]).to eq(3)
+ expect(label_1_report[:avg_first_response_time]).to be > 0
+ expect(label_1_report[:avg_reply_time]).to be > 0
+
+ expect(label_2_report[:conversations_count]).to eq(2)
+ expect(label_2_report[:avg_first_response_time]).to be > 0
+ expect(label_2_report[:avg_reply_time]).to be > 0
+ end
+ end
+ end
+
+ context 'when filtering by date range' do
+ let(:business_hours) { false }
+
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ # Conversation within range
+ conversation_in_range = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: 2.days.ago)
+ conversation_in_range.update_labels('label_1')
+ conversation_in_range.label_list
+ conversation_in_range.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation_in_range,
+ name: 'first_response',
+ value: 1800,
+ created_at: 2.days.ago)
+
+ # Conversation outside range (too old)
+ conversation_out_of_range = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: 1.week.ago)
+ conversation_out_of_range.update_labels('label_1')
+ conversation_out_of_range.label_list
+ conversation_out_of_range.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation_out_of_range,
+ name: 'first_response',
+ value: 3600,
+ created_at: 1.week.ago)
+ end
+ end
+ end
+
+ it 'only includes conversations within the date range' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ expect(label_1_report).not_to be_nil
+ expect(label_1_report[:conversations_count]).to eq(1)
+ expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
+ end
+ end
+
+ context 'with business hours parameter' do
+ let(:business_hours) { 'true' }
+
+ before do
+ travel_to(Time.zone.today) do
+ user = create(:user, account: account)
+ inbox = create(:inbox, account: account)
+ create(:inbox_member, user: user, inbox: inbox)
+
+ gravatar_url = 'https://www.gravatar.com'
+ stub_request(:get, /#{gravatar_url}.*/).to_return(status: 404)
+
+ perform_enqueued_jobs do
+ conversation = create(:conversation, account: account,
+ inbox: inbox, assignee: user,
+ created_at: Time.zone.today)
+ conversation.update_labels('label_1')
+ conversation.label_list
+ conversation.save!
+
+ create(:reporting_event,
+ account: account,
+ conversation: conversation,
+ name: 'first_response',
+ value: 3600,
+ value_in_business_hours: 1800,
+ created_at: Time.zone.today)
+ end
+ end
+ end
+
+ it 'properly casts string "true" to boolean and uses business hours values' do
+ report = builder.build
+
+ expect(report.length).to eq(3)
+
+ label_1_report = report.find { |r| r[:name] == 'label_1' }
+ expect(label_1_report).not_to be_nil
+ expect(label_1_report[:avg_first_response_time]).to eq(1800.0)
+ end
+ end
+ end
+end