11//! Analytics commands: analytics, followup.
22//!
33//! CHANGELOG:
4+ //! - 01/10/2026 - Refactored to use shared db::helpers (Phase 5) (Claude)
45//! - 01/10/2026 - Added parallel query execution (Phase 4B) with rayon (Claude)
56//! - 01/10/2026 - Added contact caching (Phase 4A) - accepts Arc<ContactsManager> (Claude)
67//! - 01/10/2026 - Initial stub implementation (Claude)
78//! - 01/10/2026 - Implemented analytics command (Claude)
89//! - 01/10/2026 - Implemented follow-up detection command (Claude)
910
10- use anyhow:: { Context , Result } ;
11+ use anyhow:: Result ;
1112use rayon:: prelude:: * ;
12- use rusqlite:: { self , Connection } ;
1313use serde:: Serialize ;
1414use std:: sync:: Arc ;
1515
1616use crate :: contacts:: manager:: ContactsManager ;
17- use crate :: db:: { connection:: open_db, queries} ;
18-
19- #[ derive( Debug , Serialize ) ]
20- struct TopContact {
21- phone : String ,
22- message_count : i64 ,
23- }
17+ use crate :: db:: { connection:: open_db, helpers, queries} ;
2418
2519#[ derive( Debug , Serialize ) ]
2620struct Analytics {
@@ -30,7 +24,7 @@ struct Analytics {
3024 avg_daily_messages : f64 ,
3125 busiest_hour : Option < i64 > ,
3226 busiest_day : Option < String > ,
33- top_contacts : Vec < TopContact > ,
27+ top_contacts : Vec < helpers :: TopContact > ,
3428 attachment_count : i64 ,
3529 reaction_count : i64 ,
3630 analysis_period_days : u32 ,
@@ -61,96 +55,6 @@ struct FollowUpReport {
6155 total_items : usize ,
6256}
6357
64- // ============================================================================
65- // Helper functions for parallel query execution (Phase 4B)
66- // ============================================================================
67-
68- /// Query message counts (total, sent, received).
69- fn query_message_counts ( conn : & Connection , cutoff_cocoa : i64 , phone : Option < & str > ) -> Result < ( i64 , i64 , i64 ) > {
70- if let Some ( p) = phone {
71- let mut stmt = conn. prepare ( queries:: ANALYTICS_MESSAGE_COUNTS_PHONE ) ?;
72- let params: & [ & dyn rusqlite:: ToSql ] = & [ & cutoff_cocoa, & p] ;
73- let row = stmt. query_row ( params, |row : & rusqlite:: Row | {
74- Ok ( (
75- row. get :: < _ , i64 > ( 0 ) . unwrap_or ( 0 ) ,
76- row. get :: < _ , i64 > ( 1 ) . unwrap_or ( 0 ) ,
77- row. get :: < _ , i64 > ( 2 ) . unwrap_or ( 0 ) ,
78- ) )
79- } ) . unwrap_or ( ( 0 , 0 , 0 ) ) ;
80- Ok ( row)
81- } else {
82- let mut stmt = conn. prepare ( queries:: ANALYTICS_MESSAGE_COUNTS ) ?;
83- let row = stmt. query_row ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | {
84- Ok ( (
85- row. get :: < _ , i64 > ( 0 ) . unwrap_or ( 0 ) ,
86- row. get :: < _ , i64 > ( 1 ) . unwrap_or ( 0 ) ,
87- row. get :: < _ , i64 > ( 2 ) . unwrap_or ( 0 ) ,
88- ) )
89- } ) . unwrap_or ( ( 0 , 0 , 0 ) ) ;
90- Ok ( row)
91- }
92- }
93-
94- /// Query busiest hour of day.
95- fn query_busiest_hour ( conn : & Connection , cutoff_cocoa : i64 , phone : Option < & str > ) -> Result < Option < i64 > > {
96- if let Some ( p) = phone {
97- let mut stmt = conn. prepare ( queries:: ANALYTICS_BUSIEST_HOUR_PHONE ) ?;
98- let params: & [ & dyn rusqlite:: ToSql ] = & [ & cutoff_cocoa, & p] ;
99- Ok ( stmt. query_row ( params, |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . ok ( ) )
100- } else {
101- let mut stmt = conn. prepare ( queries:: ANALYTICS_BUSIEST_HOUR ) ?;
102- Ok ( stmt. query_row ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . ok ( ) )
103- }
104- }
105-
106- /// Query busiest day of week.
107- fn query_busiest_day ( conn : & Connection , cutoff_cocoa : i64 , phone : Option < & str > ) -> Result < Option < i64 > > {
108- if let Some ( p) = phone {
109- let mut stmt = conn. prepare ( queries:: ANALYTICS_BUSIEST_DAY_PHONE ) ?;
110- let params: & [ & dyn rusqlite:: ToSql ] = & [ & cutoff_cocoa, & p] ;
111- Ok ( stmt. query_row ( params, |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . ok ( ) )
112- } else {
113- let mut stmt = conn. prepare ( queries:: ANALYTICS_BUSIEST_DAY ) ?;
114- Ok ( stmt. query_row ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . ok ( ) )
115- }
116- }
117-
118- /// Query top contacts (only for global analytics).
119- fn query_top_contacts ( conn : & Connection , cutoff_cocoa : i64 ) -> Result < Vec < TopContact > > {
120- let mut stmt = conn. prepare ( queries:: ANALYTICS_TOP_CONTACTS ) ?;
121- let rows = stmt. query_map ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | {
122- Ok ( TopContact {
123- phone : row. get ( 0 ) ?,
124- message_count : row. get ( 1 ) ?,
125- } )
126- } ) ?;
127- Ok ( rows. filter_map ( |r : rusqlite:: Result < TopContact > | r. ok ( ) ) . collect ( ) )
128- }
129-
130- /// Query attachment count.
131- fn query_attachments ( conn : & Connection , cutoff_cocoa : i64 , phone : Option < & str > ) -> Result < i64 > {
132- if let Some ( p) = phone {
133- let mut stmt = conn. prepare ( queries:: ANALYTICS_ATTACHMENTS_PHONE ) ?;
134- let params: & [ & dyn rusqlite:: ToSql ] = & [ & cutoff_cocoa, & p] ;
135- Ok ( stmt. query_row ( params, |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . unwrap_or ( 0 ) )
136- } else {
137- let mut stmt = conn. prepare ( queries:: ANALYTICS_ATTACHMENTS ) ?;
138- Ok ( stmt. query_row ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . unwrap_or ( 0 ) )
139- }
140- }
141-
142- /// Query reaction count.
143- fn query_reactions ( conn : & Connection , cutoff_cocoa : i64 , phone : Option < & str > ) -> Result < i64 > {
144- if let Some ( p) = phone {
145- let mut stmt = conn. prepare ( queries:: ANALYTICS_REACTIONS_PHONE ) ?;
146- let params: & [ & dyn rusqlite:: ToSql ] = & [ & cutoff_cocoa, & p] ;
147- Ok ( stmt. query_row ( params, |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . unwrap_or ( 0 ) )
148- } else {
149- let mut stmt = conn. prepare ( queries:: ANALYTICS_REACTIONS ) ?;
150- Ok ( stmt. query_row ( & [ & cutoff_cocoa] , |row : & rusqlite:: Row | row. get :: < _ , i64 > ( 0 ) ) . unwrap_or ( 0 ) )
151- }
152- }
153-
15458// ============================================================================
15559// Main analytics command with parallel execution
15660// ============================================================================
@@ -176,27 +80,27 @@ pub fn analytics(contact: Option<&str>, days: u32, json: bool, contacts: &Arc<Co
17680 || {
17781 // Query 1: Message counts
17882 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
179- query_message_counts ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
83+ helpers :: query_message_counts ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
18084 } ,
18185 || rayon:: join (
18286 || rayon:: join (
18387 || {
18488 // Query 2: Busiest hour
18589 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
186- query_busiest_hour ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
90+ helpers :: query_busiest_hour ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
18791 } ,
18892 || {
18993 // Query 3: Busiest day
19094 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
191- query_busiest_day ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
95+ helpers :: query_busiest_day ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
19296 }
19397 ) ,
19498 || rayon:: join (
19599 || {
196100 // Query 4: Top contacts (only if no phone filter)
197101 if phone_ref. is_none ( ) {
198102 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
199- query_top_contacts ( & conn, cutoff_cocoa) . expect ( "Query failed" )
103+ helpers :: query_top_contacts ( & conn, cutoff_cocoa) . expect ( "Query failed" )
200104 } else {
201105 Vec :: new ( )
202106 }
@@ -205,26 +109,21 @@ pub fn analytics(contact: Option<&str>, days: u32, json: bool, contacts: &Arc<Co
205109 || {
206110 // Query 5: Attachments
207111 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
208- query_attachments ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
112+ helpers :: query_attachments ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
209113 } ,
210114 || {
211115 // Query 6: Reactions
212116 let conn = open_db ( ) . expect ( "Failed to open DB" ) ;
213- query_reactions ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
117+ helpers :: query_reactions ( & conn, cutoff_cocoa, phone_ref) . expect ( "Query failed" )
214118 }
215119 )
216120 )
217121 )
218122 ) ;
219123
220124 // Convert busiest day number to name
221- let days_of_week = [ "Sunday" , "Monday" , "Tuesday" , "Wednesday" , "Thursday" , "Friday" , "Saturday" ] ;
222125 let busiest_day_name = busiest_day. and_then ( |d| {
223- if d >= 0 && d < 7 {
224- Some ( days_of_week[ d as usize ] . to_string ( ) )
225- } else {
226- None
227- }
126+ helpers:: day_number_to_name ( d) . map ( |s| s. to_string ( ) )
228127 } ) ;
229128
230129 // Build analytics struct
0 commit comments