tempo_cli/cli/
reports.rs

1use crate::db::{initialize_database, Database};
2use crate::utils::paths::get_data_dir;
3use anyhow::Result;
4use chrono::{DateTime, NaiveDate, TimeZone, Utc};
5use rusqlite::Row;
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct TimeReport {
12    pub period: String,
13    pub total_duration: i64,
14    pub entries: Vec<ReportEntry>,
15    pub projects: HashMap<String, ProjectSummary>,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ReportEntry {
20    pub date: String,
21    pub project_name: String,
22    pub project_path: String,
23    pub context: String,
24    pub duration: i64,
25    pub session_count: i32,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct ProjectSummary {
30    pub name: String,
31    pub path: String,
32    pub total_duration: i64,
33    pub session_count: i32,
34    pub contexts: HashMap<String, i64>,
35}
36
37pub struct ReportGenerator {
38    db: Database,
39}
40
41impl ReportGenerator {
42    pub fn new() -> Result<Self> {
43        let db_path = get_data_dir()?.join("data.db");
44        let db = initialize_database(&db_path)?;
45        Ok(Self { db })
46    }
47
48    pub fn generate_report(
49        &self,
50        project_filter: Option<String>,
51        from_date: Option<String>,
52        to_date: Option<String>,
53        group_by: Option<String>,
54    ) -> Result<TimeReport> {
55        let from_date = parse_date(from_date)?;
56        let to_date = parse_date(to_date)?;
57        let group_by = group_by.unwrap_or_else(|| "day".to_string());
58
59        let entries = self.fetch_report_data(project_filter, from_date, to_date, &group_by)?;
60        let projects = self.summarize_by_project(&entries);
61        let total_duration = entries.iter().map(|e| e.duration).sum();
62
63        let period = format_period(from_date, to_date);
64
65        Ok(TimeReport {
66            period,
67            total_duration,
68            entries,
69            projects,
70        })
71    }
72
73    pub fn export_csv(&self, report: &TimeReport, output_path: &PathBuf) -> Result<()> {
74        use std::fs::File;
75        use std::io::Write;
76
77        let mut file = File::create(output_path)?;
78
79        // Write headers
80        writeln!(
81            file,
82            "Date,Project,Context,Duration (minutes),Session Count"
83        )?;
84
85        // Write data
86        for entry in &report.entries {
87            writeln!(
88                file,
89                "{},{},{},{},{}",
90                entry.date,
91                entry.project_name,
92                entry.context,
93                entry.duration / 60,
94                entry.session_count
95            )?;
96        }
97
98        Ok(())
99    }
100
101    pub fn export_json(&self, report: &TimeReport, output_path: &PathBuf) -> Result<()> {
102        let json = serde_json::to_string_pretty(report)?;
103        std::fs::write(output_path, json)?;
104        Ok(())
105    }
106
107    fn fetch_report_data(
108        &self,
109        project_filter: Option<String>,
110        from_date: Option<DateTime<Utc>>,
111        to_date: Option<DateTime<Utc>>,
112        group_by: &str,
113    ) -> Result<Vec<ReportEntry>> {
114        let group_clause = match group_by {
115            "day" => "date(s.start_time)",
116            "week" => "date(s.start_time, 'weekday 0', '-6 days')",
117            "month" => "date(s.start_time, 'start of month')",
118            "project" => "'All Time'",
119            _ => "date(s.start_time)",
120        };
121
122        let mut sql = format!(
123            "SELECT 
124                {} as period,
125                p.name as project_name,
126                p.path as project_path,
127                s.context,
128                SUM(CASE 
129                    WHEN s.end_time IS NOT NULL 
130                    THEN (julianday(s.end_time) - julianday(s.start_time)) * 86400 - COALESCE(s.paused_duration, 0)
131                    ELSE 0
132                END) as total_duration,
133                COUNT(*) as session_count
134            FROM sessions s
135            JOIN projects p ON s.project_id = p.id
136            WHERE s.end_time IS NOT NULL",
137            group_clause
138        );
139
140        let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
141
142        if let Some(from) = from_date {
143            sql.push_str(" AND s.start_time >= ?");
144            params.push(Box::new(from));
145        }
146
147        if let Some(to) = to_date {
148            sql.push_str(" AND s.start_time <= ?");
149            params.push(Box::new(to));
150        }
151
152        if let Some(project) = project_filter {
153            sql.push_str(" AND (p.name LIKE ? OR p.path LIKE ?)");
154            let pattern = format!("%{}%", project);
155            params.push(Box::new(pattern.clone()));
156            params.push(Box::new(pattern));
157        }
158
159        sql.push_str(" GROUP BY period, p.id, s.context ORDER BY period DESC, p.name, s.context");
160
161        let mut stmt = self.db.connection.prepare(&sql)?;
162        let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
163
164        let entries = stmt
165            .query_map(param_refs.as_slice(), |row: &Row| {
166                Ok(ReportEntry {
167                    date: row.get(0)?,
168                    project_name: row.get(1)?,
169                    project_path: row.get(2)?,
170                    context: row.get(3)?,
171                    duration: row.get::<_, f64>(4)? as i64,
172                    session_count: row.get(5)?,
173                })
174            })?
175            .collect::<Result<Vec<_>, _>>()?;
176
177        Ok(entries)
178    }
179
180    fn summarize_by_project(&self, entries: &[ReportEntry]) -> HashMap<String, ProjectSummary> {
181        let mut projects = HashMap::new();
182
183        for entry in entries {
184            let summary = projects
185                .entry(entry.project_name.clone())
186                .or_insert_with(|| ProjectSummary {
187                    name: entry.project_name.clone(),
188                    path: entry.project_path.clone(),
189                    total_duration: 0,
190                    session_count: 0,
191                    contexts: HashMap::new(),
192                });
193
194            summary.total_duration += entry.duration;
195            summary.session_count += entry.session_count;
196
197            *summary.contexts.entry(entry.context.clone()).or_insert(0) += entry.duration;
198        }
199
200        projects
201    }
202}
203
204fn parse_date(date_str: Option<String>) -> Result<Option<DateTime<Utc>>> {
205    match date_str {
206        Some(date) => {
207            let naive_date = NaiveDate::parse_from_str(&date, "%Y-%m-%d")
208                .map_err(|_| anyhow::anyhow!("Invalid date format. Use YYYY-MM-DD"))?;
209            let datetime = Utc.from_utc_datetime(
210                &naive_date
211                    .and_hms_opt(0, 0, 0)
212                    .ok_or_else(|| anyhow::anyhow!("Failed to create datetime from date"))?,
213            );
214            Ok(Some(datetime))
215        }
216        None => Ok(None),
217    }
218}
219
220fn format_period(from: Option<DateTime<Utc>>, to: Option<DateTime<Utc>>) -> String {
221    match (from, to) {
222        (Some(from), Some(to)) => {
223            format!("{} to {}", from.format("%Y-%m-%d"), to.format("%Y-%m-%d"))
224        }
225        (Some(from), None) => {
226            format!("From {} to present", from.format("%Y-%m-%d"))
227        }
228        (None, Some(to)) => {
229            format!("Up to {}", to.format("%Y-%m-%d"))
230        }
231        (None, None) => "All time".to_string(),
232    }
233}
234