1use chrono::{DateTime, Duration, Utc, Datelike, NaiveDate};
9use serde::Serialize;
10use std::collections::HashMap;
11
12use crate::models::{Requirement, RequirementStatus};
13
14#[derive(Debug, Clone, Serialize)]
16pub struct AnalyticsReport {
17 pub generated_at: DateTime<Utc>,
19 pub total_requirements: usize,
21 pub status_distribution: HashMap<String, usize>,
23 pub type_distribution: HashMap<String, usize>,
25 pub velocity_trend: Vec<TimeBucket>,
27 pub creation_trend: Vec<TimeBucket>,
29 pub churn: ChurnMetrics,
31 pub cycle_time: CycleTimeStats,
33 pub ai_metrics: AiMetrics,
35 pub quality_trend: Vec<QualityPoint>,
37 pub top_contributors: Vec<(String, usize)>,
39 pub traceability: TraceabilityMetrics,
41}
42
43#[derive(Debug, Clone, Serialize)]
45pub struct TimeBucket {
46 pub period: String, pub count: usize,
48}
49
50#[derive(Debug, Clone, Serialize)]
52pub struct ChurnMetrics {
53 pub total_changes: usize,
55 pub stable_count: usize,
57 pub high_churn_count: usize,
59 pub avg_changes_per_req: f64,
61 pub most_churned: Vec<(String, usize)>,
63}
64
65#[derive(Debug, Clone, Serialize)]
67pub struct CycleTimeStats {
68 pub avg_hours: Option<f64>,
70 pub median_hours: Option<f64>,
72 pub p90_hours: Option<f64>,
74 pub min_hours: Option<f64>,
76 pub max_hours: Option<f64>,
78 pub sample_count: usize,
80}
81
82#[derive(Debug, Clone, Serialize)]
84pub struct AiMetrics {
85 pub ai_traced_count: usize,
87 pub ai_evaluated_count: usize,
89 pub avg_quality_score: Option<f64>,
91 pub score_distribution: HashMap<String, usize>,
93 pub stale_evaluations: usize,
95}
96
97#[derive(Debug, Clone, Serialize)]
99pub struct QualityPoint {
100 pub period: String,
101 pub avg_score: f64,
102 pub count: usize,
103}
104
105#[derive(Debug, Clone, Serialize)]
107pub struct TraceabilityMetrics {
108 pub total: usize,
110 pub with_trace_links: usize,
112 pub coverage_pct: f64,
114 pub with_commit_refs: usize,
116}
117
118pub fn compute_analytics(requirements: &[Requirement]) -> AnalyticsReport {
120 AnalyticsReport {
121 generated_at: Utc::now(),
122 total_requirements: requirements.len(),
123 status_distribution: compute_status_distribution(requirements),
124 type_distribution: compute_type_distribution(requirements),
125 velocity_trend: compute_velocity_trend(requirements),
126 creation_trend: compute_creation_trend(requirements),
127 churn: compute_churn(requirements),
128 cycle_time: compute_cycle_time(requirements),
129 ai_metrics: compute_ai_metrics(requirements),
130 quality_trend: compute_quality_trend(requirements),
131 top_contributors: compute_top_contributors(requirements),
132 traceability: compute_traceability(requirements),
133 }
134}
135
136fn compute_status_distribution(reqs: &[Requirement]) -> HashMap<String, usize> {
137 let mut dist = HashMap::new();
138 for req in reqs {
139 *dist.entry(req.effective_status()).or_insert(0) += 1;
140 }
141 dist
142}
143
144fn compute_type_distribution(reqs: &[Requirement]) -> HashMap<String, usize> {
145 let mut dist = HashMap::new();
146 for req in reqs {
147 *dist.entry(format!("{:?}", req.req_type)).or_insert(0) += 1;
148 }
149 dist
150}
151
152fn compute_velocity_trend(reqs: &[Requirement]) -> Vec<TimeBucket> {
153 let mut weekly: HashMap<String, usize> = HashMap::new();
155
156 for req in reqs {
157 if matches!(req.status, RequirementStatus::Completed) {
158 let week = format!("{}-W{:02}", req.modified_at.year(), req.modified_at.iso_week().week());
159 *weekly.entry(week).or_insert(0) += 1;
160 }
161 }
162
163 let mut trend: Vec<TimeBucket> = weekly
164 .into_iter()
165 .map(|(period, count)| TimeBucket { period, count })
166 .collect();
167 trend.sort_by(|a, b| a.period.cmp(&b.period));
168
169 if trend.len() > 12 {
171 trend = trend.split_off(trend.len() - 12);
172 }
173 trend
174}
175
176fn compute_creation_trend(reqs: &[Requirement]) -> Vec<TimeBucket> {
177 let mut weekly: HashMap<String, usize> = HashMap::new();
178
179 for req in reqs {
180 let week = format!("{}-W{:02}", req.created_at.year(), req.created_at.iso_week().week());
181 *weekly.entry(week).or_insert(0) += 1;
182 }
183
184 let mut trend: Vec<TimeBucket> = weekly
185 .into_iter()
186 .map(|(period, count)| TimeBucket { period, count })
187 .collect();
188 trend.sort_by(|a, b| a.period.cmp(&b.period));
189
190 if trend.len() > 12 {
191 trend = trend.split_off(trend.len() - 12);
192 }
193 trend
194}
195
196fn compute_churn(reqs: &[Requirement]) -> ChurnMetrics {
197 let mut total_changes = 0;
198 let mut stable_count = 0;
199 let mut high_churn_count = 0;
200 let mut per_req_changes: Vec<(String, usize)> = Vec::new();
201
202 for req in reqs {
203 let changes = req.history.len();
204 total_changes += changes;
205
206 if changes == 0 {
207 stable_count += 1;
208 }
209 if changes > 5 {
210 high_churn_count += 1;
211 }
212
213 per_req_changes.push((
214 req.spec_id.clone().unwrap_or_else(|| req.id.to_string()),
215 changes,
216 ));
217 }
218
219 per_req_changes.sort_by(|a, b| b.1.cmp(&a.1));
220 let most_churned = per_req_changes.into_iter().take(10).collect();
221
222 let avg = if reqs.is_empty() {
223 0.0
224 } else {
225 total_changes as f64 / reqs.len() as f64
226 };
227
228 ChurnMetrics {
229 total_changes,
230 stable_count,
231 high_churn_count,
232 avg_changes_per_req: avg,
233 most_churned,
234 }
235}
236
237fn compute_cycle_time(reqs: &[Requirement]) -> CycleTimeStats {
238 let mut cycle_times: Vec<f64> = Vec::new();
239
240 for req in reqs {
241 if matches!(req.status, RequirementStatus::Completed) {
242 let hours = (req.modified_at - req.created_at).num_hours() as f64;
243 if hours >= 0.0 {
244 cycle_times.push(hours);
245 }
246 }
247 }
248
249 if cycle_times.is_empty() {
250 return CycleTimeStats {
251 avg_hours: None,
252 median_hours: None,
253 p90_hours: None,
254 min_hours: None,
255 max_hours: None,
256 sample_count: 0,
257 };
258 }
259
260 cycle_times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
261
262 let sum: f64 = cycle_times.iter().sum();
263 let avg = sum / cycle_times.len() as f64;
264 let median = cycle_times[cycle_times.len() / 2];
265 let p90_idx = (cycle_times.len() as f64 * 0.9) as usize;
266 let p90 = cycle_times[p90_idx.min(cycle_times.len() - 1)];
267
268 CycleTimeStats {
269 avg_hours: Some(avg),
270 median_hours: Some(median),
271 p90_hours: Some(p90),
272 min_hours: cycle_times.first().copied(),
273 max_hours: cycle_times.last().copied(),
274 sample_count: cycle_times.len(),
275 }
276}
277
278fn compute_ai_metrics(reqs: &[Requirement]) -> AiMetrics {
279 let mut ai_traced = 0;
280 let mut ai_evaluated = 0;
281 let mut scores: Vec<f64> = Vec::new();
282 let mut score_dist: HashMap<String, usize> = HashMap::new();
283 let mut stale = 0;
284
285 for req in reqs {
286 if req.trace_links.iter().any(|t| {
288 t.notes.as_deref().unwrap_or("").contains("ai:")
289 }) {
290 ai_traced += 1;
291 }
292
293 if let Some(ref eval) = req.ai_evaluation {
295 ai_evaluated += 1;
296 let score = eval.evaluation.quality_score as f64;
297 scores.push(score);
298
299 let bucket = format!("{}-{}", (score as u32 / 10) * 10, ((score as u32 / 10) + 1) * 10);
301 *score_dist.entry(bucket).or_insert(0) += 1;
302
303 if eval.content_hash.is_empty() {
305 stale += 1;
306 }
307 }
308 }
309
310 let avg_score = if scores.is_empty() {
311 None
312 } else {
313 Some(scores.iter().sum::<f64>() / scores.len() as f64)
314 };
315
316 AiMetrics {
317 ai_traced_count: ai_traced,
318 ai_evaluated_count: ai_evaluated,
319 avg_quality_score: avg_score,
320 score_distribution: score_dist,
321 stale_evaluations: stale,
322 }
323}
324
325fn compute_quality_trend(reqs: &[Requirement]) -> Vec<QualityPoint> {
326 let mut monthly: HashMap<String, (f64, usize)> = HashMap::new();
328
329 for req in reqs {
330 if let Some(ref eval) = req.ai_evaluation {
331 let month = format!("{}-{:02}", eval.evaluated_at.year(), eval.evaluated_at.month());
332 let entry = monthly.entry(month).or_insert((0.0, 0));
333 entry.0 += eval.evaluation.quality_score as f64;
334 entry.1 += 1;
335 }
336 }
337
338 let mut trend: Vec<QualityPoint> = monthly
339 .into_iter()
340 .map(|(period, (sum, count))| QualityPoint {
341 period,
342 avg_score: sum / count as f64,
343 count,
344 })
345 .collect();
346 trend.sort_by(|a, b| a.period.cmp(&b.period));
347 trend
348}
349
350fn compute_top_contributors(reqs: &[Requirement]) -> Vec<(String, usize)> {
351 let mut contributions: HashMap<String, usize> = HashMap::new();
352
353 for req in reqs {
354 if !req.owner.is_empty() {
356 *contributions.entry(req.owner.clone()).or_insert(0) += 1;
357 }
358 for entry in &req.history {
360 if !entry.author.is_empty() {
361 *contributions.entry(entry.author.clone()).or_insert(0) += 1;
362 }
363 }
364 }
365
366 let mut sorted: Vec<_> = contributions.into_iter().collect();
367 sorted.sort_by(|a, b| b.1.cmp(&a.1));
368 sorted.truncate(10);
369 sorted
370}
371
372fn compute_traceability(reqs: &[Requirement]) -> TraceabilityMetrics {
373 let total = reqs.len();
374 let with_trace = reqs.iter().filter(|r| !r.trace_links.is_empty()).count();
375 let with_commits = reqs
376 .iter()
377 .filter(|r| {
378 r.comments.iter().any(|c| {
379 c.content.contains("Committed in") || c.content.contains("commit")
380 })
381 })
382 .count();
383
384 TraceabilityMetrics {
385 total,
386 with_trace_links: with_trace,
387 coverage_pct: if total > 0 {
388 (with_trace as f64 / total as f64) * 100.0
389 } else {
390 0.0
391 },
392 with_commit_refs: with_commits,
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::models::RequirementType;
400
401 fn make_req(title: &str, status: RequirementStatus, days_ago_created: i64) -> Requirement {
402 let mut req = Requirement::new(title.into(), "desc".into());
403 req.status = status;
404 req.created_at = Utc::now() - Duration::days(days_ago_created);
405 req.modified_at = Utc::now();
406 req.spec_id = Some(format!("FR-{:03}", days_ago_created));
407 req
408 }
409
410 #[test]
411 fn test_status_distribution() {
412 let reqs = vec![
413 make_req("A", RequirementStatus::Draft, 10),
414 make_req("B", RequirementStatus::Draft, 9),
415 make_req("C", RequirementStatus::Completed, 8),
416 make_req("D", RequirementStatus::Approved, 7),
417 ];
418 let dist = compute_status_distribution(&reqs);
419 assert_eq!(dist.get("Draft"), Some(&2));
420 assert_eq!(dist.get("Completed"), Some(&1));
421 assert_eq!(dist.get("Approved"), Some(&1));
422 }
423
424 #[test]
425 fn test_cycle_time() {
426 let reqs = vec![
427 make_req("Fast", RequirementStatus::Completed, 1),
428 make_req("Slow", RequirementStatus::Completed, 30),
429 make_req("Draft", RequirementStatus::Draft, 5),
430 ];
431 let ct = compute_cycle_time(&reqs);
432 assert_eq!(ct.sample_count, 2);
433 assert!(ct.avg_hours.is_some());
434 assert!(ct.min_hours.unwrap() < ct.max_hours.unwrap());
435 }
436
437 #[test]
438 fn test_churn() {
439 let mut req = make_req("Churny", RequirementStatus::Draft, 5);
440 for i in 0..10 {
441 req.history.push(crate::models::HistoryEntry {
442 id: uuid::Uuid::now_v7(),
443 timestamp: Utc::now(),
444 author: "joe".into(),
445 changes: vec![crate::models::FieldChange {
446 field_name: "title".into(),
447 old_value: format!("v{}", i),
448 new_value: format!("v{}", i + 1),
449 }],
450 });
451 }
452
453 let reqs = vec![req, make_req("Stable", RequirementStatus::Draft, 3)];
454 let churn = compute_churn(&reqs);
455 assert_eq!(churn.total_changes, 10);
456 assert_eq!(churn.stable_count, 1);
457 assert_eq!(churn.high_churn_count, 1);
458 }
459
460 #[test]
461 fn test_full_report() {
462 let reqs = vec![
463 make_req("A", RequirementStatus::Completed, 10),
464 make_req("B", RequirementStatus::Draft, 5),
465 make_req("C", RequirementStatus::Approved, 3),
466 ];
467 let report = compute_analytics(&reqs);
468 assert_eq!(report.total_requirements, 3);
469 assert!(report.cycle_time.sample_count >= 1);
470 }
471
472 #[test]
473 fn test_traceability() {
474 let mut req = make_req("Traced", RequirementStatus::Completed, 5);
475 req.trace_links.push(crate::models::TraceLink {
476 id: uuid::Uuid::now_v7(),
477 artifact_type: crate::models::ArtifactType::SourceCode,
478 file_path: "src/main.rs".into(),
479 symbol: Some("validate".into()),
480 line_start: Some(10),
481 line_end: Some(20),
482 notes: Some("ai:claude".into()),
483 created_at: Utc::now(),
484 created_by: Some("test".into()),
485 commit_hash: None,
486 });
487
488 let reqs = vec![req, make_req("Untraced", RequirementStatus::Draft, 3)];
489 let trace = compute_traceability(&reqs);
490 assert_eq!(trace.total, 2);
491 assert_eq!(trace.with_trace_links, 1);
492 assert!((trace.coverage_pct - 50.0).abs() < 0.1);
493 }
494}