Skip to content

Commit 8b8ff91

Browse files
wolfieschclaude
andcommitted
feat(imessage): implement Phase 5 daemon command handlers
Implement all 8 command handlers in DaemonService to connect hot resources (SQLite connection + contacts cache) to query logic: - recent: 2.4ms - list recent messages with contact enrichment - unread: 2.8ms - list unread messages - analytics: 20.5ms - message stats, busiest times, top contacts - followup: 6.1ms - unanswered questions and stale conversations - handles: 3.0ms - list all message handles - unknown: filter handles not in contacts - discover: find frequent unknown senders as contact candidates - bundle: combine multiple queries in single request Key changes: - Add src/db/helpers.rs with shared query functions - Refactor analytics.rs to use db::helpers - Implement all handlers in daemon/service.rs Performance: 5/8 commands meet sub-5ms target. Analytics slower due to 6 sequential queries (future optimization opportunity). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent f7d4c8b commit 8b8ff91

File tree

5 files changed

+844
-147
lines changed

5 files changed

+844
-147
lines changed

Texting/gateway/wolfies-imessage/src/commands/analytics.rs

Lines changed: 11 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,20 @@
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;
1112
use rayon::prelude::*;
12-
use rusqlite::{self, Connection};
1313
use serde::Serialize;
1414
use std::sync::Arc;
1515

1616
use 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)]
2620
struct 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

Comments
 (0)