Skip to content

Commit d99a467

Browse files
fix: analystics dashboard fix
1 parent daa926e commit d99a467

7 files changed

Lines changed: 315 additions & 171 deletions

File tree

app/api/routers/breeze_buddy/analytics/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
get_call_based_analytics,
2222
get_call_details_analytics,
2323
get_conversion_analytics,
24+
get_dashboard_counts,
2425
get_distinct_merchant_ids,
2526
get_distinct_outcomes,
2627
get_distinct_resellers,
@@ -44,6 +45,7 @@
4445
AnalyticsType.OUTBOUND_NUMBERS: get_outbound_numbers_analytics,
4546
AnalyticsType.CONVERSION: get_conversion_analytics,
4647
AnalyticsType.PERFORMANCE: get_performance_analytics,
48+
AnalyticsType.DASHBOARD_COUNTS: get_dashboard_counts,
4749
AnalyticsType.DISTINCT_OUTCOMES: get_distinct_outcomes,
4850
AnalyticsType.OUTCOME_COUNTS: get_outcome_counts,
4951
AnalyticsType.DISTINCT_RESELLERS: get_distinct_resellers,

app/api/routers/breeze_buddy/analytics/handlers.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
get_analytics_count_from_db,
1616
get_call_detail_records,
1717
get_call_details_from_db,
18+
get_dashboard_counts_from_db,
1819
get_distinct_merchant_ids_from_db,
1920
get_distinct_outcomes_from_db,
2021
get_distinct_resellers_from_db,
@@ -549,6 +550,24 @@ async def get_performance_analytics(
549550
}
550551

551552

553+
async def get_dashboard_counts(
554+
filters: Dict[str, Any],
555+
options: Dict[str, Any],
556+
current_user: UserInfo,
557+
) -> Dict[str, Any]:
558+
"""
559+
Get lightweight dashboard card counts.
560+
Optimised for the top-row analytics cards on the Loom dashboard.
561+
"""
562+
counts = await get_dashboard_counts_from_db(filters)
563+
564+
return {
565+
"type": "dashboard-counts",
566+
"filters_applied": filters,
567+
"results": [counts],
568+
}
569+
570+
552571
async def get_lead_status_counts(
553572
filters: Dict[str, Any], options: Dict[str, Any], current_user: UserInfo
554573
) -> Dict[str, Any]:

app/database/accessor/breeze_buddy/analytics.py

Lines changed: 95 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
All queries are optimized to filter at database level.
44
"""
55

6+
import asyncio
67
from typing import Any, Dict, List, Optional
78

89
from app.core.logger import logger
@@ -15,9 +16,11 @@
1516
get_analytics_lead_status_counts_query,
1617
get_analytics_lead_status_counts_total_query,
1718
get_analytics_outbound_numbers_query,
19+
get_analytics_outcome_breakdown_query,
1820
get_analytics_summary_query,
1921
get_analytics_trends_query,
2022
get_call_details_records_query,
23+
get_dashboard_counts_query,
2124
get_distinct_merchant_ids_query,
2225
get_distinct_outcomes_query,
2326
get_distinct_resellers_query,
@@ -52,32 +55,19 @@ async def get_summary_analytics_from_db(
5255
)
5356

5457
try:
55-
query_text, values = get_analytics_summary_query(filters, group_by)
56-
result = await run_parameterized_query(query_text, values)
58+
if group_by:
59+
# Grouped mode: single query (paginated groups, already fast enough)
60+
query_text, values = get_analytics_summary_query(filters, group_by)
61+
result = await run_parameterized_query(query_text, values)
5762

58-
logger.debug(
59-
f"[Analytics DB] Summary query returned {len(result) if result else 0} rows"
60-
)
63+
logger.debug(
64+
f"[Analytics DB] Summary query returned {len(result) if result else 0} rows"
65+
)
6166

62-
if not result or len(result) == 0:
63-
logger.warning("[Analytics DB] Summary query returned no results")
64-
if group_by:
67+
if not result or len(result) == 0:
68+
logger.warning("[Analytics DB] Summary query returned no results")
6569
return []
66-
return {
67-
"total_calls": 0,
68-
"outbound_calls": 0,
69-
"inbound_calls": 0,
70-
"completed_calls": 0,
71-
"failed_calls": 0,
72-
"success_rate": 0.0,
73-
"average_duration": None,
74-
"total_templates": 0,
75-
"total_shops": 0,
76-
"outcome_breakdown": {},
77-
}
7870

79-
if group_by:
80-
# Return list of grouped results
8171
grouped_results = []
8272
for row in result:
8373
total_calls = row["total_calls"] or 0
@@ -102,8 +92,8 @@ async def get_summary_analytics_from_db(
10292
if row["average_duration"]
10393
else None
10494
),
105-
"total_templates": row["total_templates"] or 0,
106-
"total_shops": row["total_shops"] or 0,
95+
"total_templates": row.get("total_templates") or 0,
96+
"total_shops": row.get("total_shops") or 0,
10797
"outcome_breakdown": row["outcome_breakdown"] or {},
10898
}
10999
)
@@ -112,36 +102,54 @@ async def get_summary_analytics_from_db(
112102
f"[Analytics DB] Grouped summary returned {len(grouped_results)} groups"
113103
)
114104
return grouped_results
115-
else:
116-
# Return single aggregate result
117-
row = result[0]
118-
total_calls = row["total_calls"] or 0
119-
completed_calls = row["completed_calls"] or 0
120-
failed_calls = row["failed_calls"] or 0
121-
success_rate = (
122-
(completed_calls / total_calls * 100) if total_calls > 0 else 0.0
123-
)
124105

125-
logger.info(
126-
f"[Analytics DB] Summary result: {total_calls} total calls, {completed_calls} completed ({success_rate:.2f}% success)"
127-
)
106+
# Aggregate mode (dashboard cards): run counts and outcome breakdown in
107+
# parallel so the slower jsonb_object_agg doesn't block the counts.
108+
summary_q, summary_v = get_analytics_summary_query(filters)
109+
outcome_q, outcome_v = get_analytics_outcome_breakdown_query(filters)
128110

129-
return {
130-
"total_calls": total_calls,
131-
"outbound_calls": row["outbound_calls"] or 0,
132-
"inbound_calls": row["inbound_calls"] or 0,
133-
"completed_calls": completed_calls,
134-
"failed_calls": failed_calls,
135-
"success_rate": round(success_rate, 2),
136-
"average_duration": (
137-
round(float(row["average_duration"]), 2)
138-
if row["average_duration"]
139-
else None
140-
),
141-
"total_templates": row["total_templates"] or 0,
142-
"total_shops": row["total_shops"] or 0,
143-
"outcome_breakdown": row["outcome_breakdown"] or {},
144-
}
111+
summary_res, outcome_res = await asyncio.gather(
112+
run_parameterized_query(summary_q, summary_v),
113+
run_parameterized_query(outcome_q, outcome_v),
114+
)
115+
116+
logger.debug(
117+
f"[Analytics DB] Summary query returned {len(summary_res) if summary_res else 0} rows, "
118+
f"outbreakdown query returned {len(outcome_res) if outcome_res else 0} rows"
119+
)
120+
121+
row = dict(summary_res[0]) if summary_res and len(summary_res) > 0 else {}
122+
outcome_row = (
123+
dict(outcome_res[0]) if outcome_res and len(outcome_res) > 0 else {}
124+
)
125+
126+
total_calls = row.get("total_calls") or 0
127+
completed_calls = row.get("completed_calls") or 0
128+
failed_calls = row.get("failed_calls") or 0
129+
success_rate = (
130+
(completed_calls / total_calls * 100) if total_calls > 0 else 0.0
131+
)
132+
133+
logger.info(
134+
f"[Analytics DB] Summary result: {total_calls} total calls, {completed_calls} completed ({success_rate:.2f}% success)"
135+
)
136+
137+
return {
138+
"total_calls": total_calls,
139+
"outbound_calls": row.get("outbound_calls") or 0,
140+
"inbound_calls": row.get("inbound_calls") or 0,
141+
"completed_calls": completed_calls,
142+
"failed_calls": failed_calls,
143+
"success_rate": round(success_rate, 2),
144+
"average_duration": (
145+
round(float(row["average_duration"]), 2)
146+
if row.get("average_duration")
147+
else None
148+
),
149+
"total_templates": 0,
150+
"total_shops": 0,
151+
"outcome_breakdown": outcome_row.get("outcome_breakdown") or {},
152+
}
145153

146154
except Exception as e:
147155
logger.error(f"Error getting summary analytics: {e}", exc_info=True)
@@ -444,6 +452,41 @@ async def get_lead_status_counts_from_db(
444452
raise
445453

446454

455+
async def get_dashboard_counts_from_db(
456+
filters: Dict[str, Any],
457+
) -> Dict[str, int]:
458+
"""
459+
Get lightweight dashboard card counts from DB.
460+
"""
461+
logger.info(f"[Analytics DB] Getting dashboard counts with filters: {filters}")
462+
463+
try:
464+
query_text, values = get_dashboard_counts_query(filters)
465+
result = await run_parameterized_query(query_text, values)
466+
467+
if not result or len(result) == 0:
468+
return {
469+
"outbound_calls": 0,
470+
"inbound_calls": 0,
471+
"no_answer_calls": 0,
472+
"busy_calls": 0,
473+
"calls_with_outcomes": 0,
474+
}
475+
476+
row = result[0]
477+
return {
478+
"outbound_calls": row["outbound_calls"] or 0,
479+
"inbound_calls": row["inbound_calls"] or 0,
480+
"no_answer_calls": row["no_answer_calls"] or 0,
481+
"busy_calls": row["busy_calls"] or 0,
482+
"calls_with_outcomes": row["calls_with_outcomes"] or 0,
483+
}
484+
485+
except Exception as e:
486+
logger.error(f"Error getting dashboard counts: {e}", exc_info=True)
487+
raise
488+
489+
447490
async def get_distinct_outcomes_from_db(
448491
filters: Dict[str, Any],
449492
) -> List[str]:
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
-- Migration 031: Add covering index for dashboard count queries
2+
-- Description:
3+
-- Adds a composite index on lead_call_tracker optimized for the dashboard
4+
-- card count queries. Enables index-only scans for filtered COUNT(*)
5+
-- operations over date ranges.
6+
--
7+
-- Columns ordered for:
8+
-- 1. execution_mode (equality: TELEPHONY, HOLD_TRANSFER)
9+
-- 2. call_initiated_time (range: date_from .. date_to)
10+
-- 3. call_direction (equality: OUTBOUND, INBOUND)
11+
-- 4. outcome (equality: NO_ANSWER, BUSY, etc.)
12+
-- 5. status (equality: FINISHED, etc.)
13+
14+
BEGIN;
15+
16+
CREATE INDEX IF NOT EXISTS idx_lct_dashboard_counts
17+
ON lead_call_tracker (execution_mode, call_initiated_time, call_direction, outcome, status);
18+
19+
COMMIT;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
-- Migration 032: Add partial covering index for analytics summary queries
2+
-- Description:
3+
-- Adds a partial covering index on lead_call_tracker that includes every
4+
-- column touched by get_analytics_summary_query() in aggregate mode.
5+
-- Enables a pure Index Only Scan for the call-based analytics endpoint,
6+
-- eliminating heap access entirely for count cards.
7+
--
8+
-- Why partial:
9+
-- The WHERE clause in analytics queries always filters execution_mode to
10+
-- ('TELEPHONY', 'HOLD_TRANSFER'). A partial index is smaller and cheaper
11+
-- to maintain than a full index.
12+
--
13+
-- Columns:
14+
-- Key: execution_mode, call_initiated_time, call_direction, outcome, status
15+
-- Include: template, merchant_id, call_end_time
16+
17+
BEGIN;
18+
19+
CREATE INDEX IF NOT EXISTS idx_lct_analytics_covering
20+
ON lead_call_tracker (execution_mode, call_initiated_time, call_direction, outcome, status)
21+
INCLUDE (template, merchant_id, call_end_time)
22+
WHERE execution_mode IN ('TELEPHONY', 'HOLD_TRANSFER');
23+
24+
COMMIT;

0 commit comments

Comments
 (0)