use std::collections::HashMap;
use chrono::{Datelike, Duration, NaiveDate, Utc};
use rusqlite::{Connection, params};
use crate::db::models::*;
use crate::error::LificError;
pub const DEFAULT_WEEKS: i64 = 12;
pub const MAX_WEEKS: i64 = 52;
pub fn clamp_weeks(weeks: Option<i64>) -> i64 {
weeks.unwrap_or(DEFAULT_WEEKS).clamp(1, MAX_WEEKS)
}
fn week_starts(count: i64) -> Vec<NaiveDate> {
let today = Utc::now().date_naive();
let this_monday = today - Duration::days(today.weekday().num_days_from_monday() as i64);
(0..count)
.rev()
.map(|k| this_monday - Duration::days(7 * k))
.collect()
}
fn bucket_weekly(
conn: &Connection,
sql: &str,
project_id: i64,
since: &str,
starts: &[NaiveDate],
) -> Result<Vec<WeekPoint>, LificError> {
let mut stmt = conn.prepare(sql)?;
let rows = stmt.query_map(params![project_id, since], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut counts: HashMap<String, i64> = HashMap::new();
for row in rows {
let (week, n) = row?;
counts.insert(week, n);
}
Ok(starts
.iter()
.map(|d| {
let key = d.format("%Y-%m-%d").to_string();
let count = counts.get(&key).copied().unwrap_or(0);
WeekPoint { week_start: key, count }
})
.collect())
}
fn created_per_week(
conn: &Connection,
project_id: i64,
since: &str,
starts: &[NaiveDate],
) -> Result<Vec<WeekPoint>, LificError> {
bucket_weekly(
conn,
"SELECT date(created_at, 'weekday 0', '-6 days') AS wk, COUNT(*)
FROM issues
WHERE project_id = ?1 AND created_at >= ?2
GROUP BY wk",
project_id,
since,
starts,
)
}
fn closed_per_week(
conn: &Connection,
project_id: i64,
since: &str,
starts: &[NaiveDate],
) -> Result<Vec<WeekPoint>, LificError> {
bucket_weekly(
conn,
"SELECT date(a.ts, 'weekday 0', '-6 days') AS wk, COUNT(*)
FROM audit_log a
WHERE a.project_id = ?1
AND a.entity_type = 'issue'
AND a.field = 'status'
AND a.ts >= ?2
AND a.new_value IN ('done', 'cancelled')
AND a.id = (
SELECT MAX(a2.id) FROM audit_log a2
WHERE a2.entity_type = 'issue'
AND a2.entity_id = a.entity_id
AND a2.field = 'status'
)
GROUP BY wk",
project_id,
since,
starts,
)
}
fn priority_counts(conn: &Connection, project_id: i64) -> Result<PriorityCounts, LificError> {
let mut counts = PriorityCounts::default();
let mut stmt = conn.prepare_cached(
"SELECT priority, COUNT(*) FROM issues WHERE project_id = ?1 GROUP BY priority",
)?;
let rows = stmt.query_map(params![project_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for row in rows {
let (priority, n) = row?;
match priority.as_str() {
"urgent" => counts.urgent = n,
"high" => counts.high = n,
"medium" => counts.medium = n,
"low" => counts.low = n,
"none" => counts.none = n,
_ => {}
}
counts.total += n;
}
Ok(counts)
}
fn module_counts(conn: &Connection, project_id: i64) -> Result<Vec<ModuleCount>, LificError> {
let mut stmt = conn.prepare_cached(
"SELECT i.module_id, COALESCE(m.name, 'No module'), COUNT(*)
FROM issues i LEFT JOIN modules m ON m.id = i.module_id
WHERE i.project_id = ?1
GROUP BY i.module_id
ORDER BY COUNT(*) DESC, name ASC",
)?;
let rows = stmt.query_map(params![project_id], |row| {
Ok(ModuleCount {
module_id: row.get(0)?,
name: row.get(1)?,
count: row.get(2)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
}
const TOP_ACTORS_LIMIT: i64 = 10;
fn top_actors(
conn: &Connection,
project_id: i64,
since: &str,
) -> Result<Vec<ActorStat>, LificError> {
let mut stmt = conn.prepare_cached(
"SELECT a.actor_user_id, u.username, u.display_name, COALESCE(u.is_bot, 0),
COUNT(*) AS actions, MAX(a.ts) AS last_ts,
(SELECT t.transport FROM audit_log t
WHERE t.project_id = a.project_id
AND t.actor_user_id IS a.actor_user_id
AND t.ts >= ?2
GROUP BY t.transport ORDER BY COUNT(*) DESC LIMIT 1) AS top_transport
FROM audit_log a
LEFT JOIN users u ON u.id = a.actor_user_id
WHERE a.project_id = ?1 AND a.ts >= ?2
GROUP BY a.actor_user_id
ORDER BY actions DESC
LIMIT ?3",
)?;
let rows = stmt.query_map(params![project_id, since, TOP_ACTORS_LIMIT], |row| {
Ok(ActorStat {
actor_user_id: row.get(0)?,
username: row.get(1)?,
display_name: row.get(2)?,
is_bot: row.get::<_, i64>(3)? != 0,
actions: row.get(4)?,
last_ts: row.get(5)?,
top_transport: row.get(6)?,
})
})?;
rows.collect::<Result<Vec<_>, _>>().map_err(LificError::Database)
}
pub fn get_insights(
conn: &Connection,
project_id: i64,
weeks: i64,
) -> Result<InsightsPayload, LificError> {
let starts = week_starts(weeks);
let since = starts[0].format("%Y-%m-%d").to_string();
Ok(InsightsPayload {
weeks,
created_per_week: created_per_week(conn, project_id, &since, &starts)?,
closed_per_week: closed_per_week(conn, project_id, &since, &starts)?,
status_counts: super::count_issues_by_status(conn, project_id)?,
priority_counts: priority_counts(conn, project_id)?,
module_counts: module_counts(conn, project_id)?,
top_actors: top_actors(conn, project_id, &since)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::queries;
fn seeded() -> (crate::db::DbPool, i64) {
let pool = crate::db::open_memory().expect("test db");
let project = {
let conn = pool.write().unwrap();
queries::create_project(
&conn,
&CreateProject {
name: "Insights Test".into(),
identifier: "INS".into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap()
};
(pool, project.id)
}
fn quick_issue(conn: &Connection, pid: i64, title: &str, priority: &str) -> Issue {
queries::create_issue(
conn,
&CreateIssue {
project_id: pid,
title: title.into(),
description: String::new(),
status: "backlog".into(),
priority: priority.into(),
module_id: None,
start_date: None,
target_date: None,
labels: vec![],
source: None,
},
)
.unwrap()
}
fn no_update() -> UpdateIssue {
UpdateIssue {
title: None,
description: None,
status: None,
priority: None,
module_id: None,
sort_order: None,
start_date: None,
target_date: None,
labels: None,
}
}
#[test]
fn clamp_weeks_defaults_and_bounds() {
assert_eq!(clamp_weeks(None), DEFAULT_WEEKS);
assert_eq!(clamp_weeks(Some(0)), 1);
assert_eq!(clamp_weeks(Some(1)), 1);
assert_eq!(clamp_weeks(Some(4)), 4);
assert_eq!(clamp_weeks(Some(999)), MAX_WEEKS);
assert_eq!(clamp_weeks(Some(-5)), 1);
}
#[test]
fn week_starts_are_dense_ascending_mondays() {
let starts = week_starts(6);
assert_eq!(starts.len(), 6);
for w in &starts {
assert_eq!(w.weekday(), chrono::Weekday::Mon, "{w} must be a Monday");
}
for pair in starts.windows(2) {
assert_eq!(
(pair[1] - pair[0]).num_days(),
7,
"buckets must be exactly one week apart"
);
}
let today = Utc::now().date_naive();
let last = *starts.last().unwrap();
assert!(today >= last && (today - last).num_days() < 7);
}
#[test]
fn created_per_week_buckets_by_iso_week_and_fills_gaps() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
quick_issue(&conn, pid, "A", "none");
quick_issue(&conn, pid, "B", "none");
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
assert_eq!(payload.weeks, 4);
assert_eq!(payload.created_per_week.len(), 4, "dense — one point per week");
for pt in &payload.created_per_week {
let d = NaiveDate::parse_from_str(&pt.week_start, "%Y-%m-%d").unwrap();
assert_eq!(d.weekday(), chrono::Weekday::Mon);
}
let total: i64 = payload.created_per_week.iter().map(|p| p.count).sum();
assert_eq!(total, 2);
assert_eq!(payload.created_per_week.last().unwrap().count, 2);
assert!(payload.created_per_week[..3].iter().all(|p| p.count == 0));
}
#[test]
fn closed_per_week_counts_a_simple_close_once() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
let issue = quick_issue(&conn, pid, "Close me", "none");
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("done".into()), ..no_update() },
)
.unwrap();
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
let total: i64 = payload.closed_per_week.iter().map(|p| p.count).sum();
assert_eq!(total, 1, "one close event must be counted exactly once");
}
#[test]
fn closed_per_week_excludes_reopened_issues() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
let issue = quick_issue(&conn, pid, "Reopened", "none");
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("done".into()), ..no_update() },
)
.unwrap();
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("todo".into()), ..no_update() },
)
.unwrap();
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
let total: i64 = payload.closed_per_week.iter().map(|p| p.count).sum();
assert_eq!(total, 0, "currently-open issue must not appear in closed_per_week");
}
#[test]
fn closed_per_week_counts_reclosed_issue_once_not_twice() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
let issue = quick_issue(&conn, pid, "Closed twice", "none");
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("done".into()), ..no_update() },
)
.unwrap();
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("todo".into()), ..no_update() },
)
.unwrap();
queries::update_issue(
&conn,
issue.id,
&UpdateIssue { status: Some("cancelled".into()), ..no_update() },
)
.unwrap();
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
let total: i64 = payload.closed_per_week.iter().map(|p| p.count).sum();
assert_eq!(
total, 1,
"the superseded first close must not be double-counted alongside the second"
);
}
#[test]
fn status_and_priority_counts_match_seeded_issues() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
quick_issue(&conn, pid, "A", "urgent");
quick_issue(&conn, pid, "B", "high");
let c = quick_issue(&conn, pid, "C", "none");
queries::update_issue(
&conn,
c.id,
&UpdateIssue { status: Some("done".into()), ..no_update() },
)
.unwrap();
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
assert_eq!(payload.status_counts.total, 3);
assert_eq!(payload.status_counts.backlog, 2);
assert_eq!(payload.status_counts.done, 1);
assert_eq!(payload.priority_counts.total, 3);
assert_eq!(payload.priority_counts.urgent, 1);
assert_eq!(payload.priority_counts.high, 1);
assert_eq!(payload.priority_counts.none, 1);
}
#[test]
fn module_counts_include_a_no_module_bucket() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
let module = queries::create_module(
&conn,
&CreateModule {
project_id: pid,
name: "Backend".into(),
description: String::new(),
status: "active".into(),
emoji: None,
},
)
.unwrap();
let a = quick_issue(&conn, pid, "A", "none");
queries::update_issue(
&conn,
a.id,
&UpdateIssue { module_id: Some(Some(module.id)), ..no_update() },
)
.unwrap();
quick_issue(&conn, pid, "B", "none");
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
assert_eq!(payload.module_counts.len(), 2);
let backend = payload
.module_counts
.iter()
.find(|m| m.module_id == Some(module.id))
.unwrap();
assert_eq!(backend.name, "Backend");
assert_eq!(backend.count, 1);
let unassigned = payload.module_counts.iter().find(|m| m.module_id.is_none()).unwrap();
assert_eq!(unassigned.name, "No module");
assert_eq!(unassigned.count, 1);
}
#[test]
fn top_actors_ranks_by_action_count_within_window() {
let (pool, pid) = seeded();
let conn = pool.write().unwrap();
let alice = {
conn.execute(
"INSERT INTO users (username, email, password_hash, display_name, is_admin, is_bot)
VALUES ('alice', 'alice@test.local', 'x', 'Alice', 0, 0)",
[],
)
.unwrap();
conn.last_insert_rowid()
};
crate::actor::stamp(
&conn,
&crate::actor::ActorCtx { user_id: Some(alice), transport: crate::actor::Transport::Web },
);
quick_issue(&conn, pid, "A1", "none");
quick_issue(&conn, pid, "A2", "none");
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
assert!(!payload.top_actors.is_empty());
assert_eq!(payload.top_actors[0].username.as_deref(), Some("alice"));
assert_eq!(payload.top_actors[0].actions, 2);
}
#[test]
fn insights_are_scoped_to_the_requested_project() {
let (pool, pid) = seeded();
let other_pid = {
let conn = pool.write().unwrap();
queries::create_project(
&conn,
&CreateProject {
name: "Other".into(),
identifier: "OTH".into(),
description: String::new(),
emoji: None,
lead_user_id: None,
},
)
.unwrap()
.id
};
let conn = pool.write().unwrap();
quick_issue(&conn, pid, "Mine", "none");
quick_issue(&conn, other_pid, "Not mine", "none");
quick_issue(&conn, other_pid, "Also not mine", "none");
drop(conn);
let conn = pool.read().unwrap();
let payload = get_insights(&conn, pid, 4).unwrap();
assert_eq!(payload.status_counts.total, 1);
}
}