1use anyhow::Result;
2use chrono::{DateTime, Utc, NaiveDate, TimeZone};
3use crate::db::{Database, initialize_database};
4use crate::utils::paths::get_data_dir;
5use rusqlite::Row;
6use serde::{Serialize, Deserialize};
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 writeln!(file, "Date,Project,Context,Duration (minutes),Session Count")?;
81
82 for entry in &report.entries {
84 writeln!(
85 file,
86 "{},{},{},{},{}",
87 entry.date,
88 entry.project_name,
89 entry.context,
90 entry.duration / 60,
91 entry.session_count
92 )?;
93 }
94
95 Ok(())
96 }
97
98 pub fn export_json(&self, report: &TimeReport, output_path: &PathBuf) -> Result<()> {
99 let json = serde_json::to_string_pretty(report)?;
100 std::fs::write(output_path, json)?;
101 Ok(())
102 }
103
104 fn fetch_report_data(
105 &self,
106 project_filter: Option<String>,
107 from_date: Option<DateTime<Utc>>,
108 to_date: Option<DateTime<Utc>>,
109 group_by: &str,
110 ) -> Result<Vec<ReportEntry>> {
111 let group_clause = match group_by {
112 "day" => "date(s.start_time)",
113 "week" => "date(s.start_time, 'weekday 0', '-6 days')",
114 "month" => "date(s.start_time, 'start of month')",
115 "project" => "'All Time'",
116 _ => "date(s.start_time)",
117 };
118
119 let mut sql = format!(
120 "SELECT
121 {} as period,
122 p.name as project_name,
123 p.path as project_path,
124 s.context,
125 SUM(CASE
126 WHEN s.end_time IS NOT NULL
127 THEN (julianday(s.end_time) - julianday(s.start_time)) * 86400 - COALESCE(s.paused_duration, 0)
128 ELSE 0
129 END) as total_duration,
130 COUNT(*) as session_count
131 FROM sessions s
132 JOIN projects p ON s.project_id = p.id
133 WHERE s.end_time IS NOT NULL",
134 group_clause
135 );
136
137 let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![];
138
139 if let Some(from) = from_date {
140 sql.push_str(" AND s.start_time >= ?");
141 params.push(Box::new(from));
142 }
143
144 if let Some(to) = to_date {
145 sql.push_str(" AND s.start_time <= ?");
146 params.push(Box::new(to));
147 }
148
149 if let Some(project) = project_filter {
150 sql.push_str(" AND (p.name LIKE ? OR p.path LIKE ?)");
151 let pattern = format!("%{}%", project);
152 params.push(Box::new(pattern.clone()));
153 params.push(Box::new(pattern));
154 }
155
156 sql.push_str(" GROUP BY period, p.id, s.context ORDER BY period DESC, p.name, s.context");
157
158 let mut stmt = self.db.connection.prepare(&sql)?;
159 let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
160
161 let entries = stmt.query_map(param_refs.as_slice(), |row: &Row| {
162 Ok(ReportEntry {
163 date: row.get(0)?,
164 project_name: row.get(1)?,
165 project_path: row.get(2)?,
166 context: row.get(3)?,
167 duration: row.get::<_, f64>(4)? as i64,
168 session_count: row.get(5)?,
169 })
170 })?.collect::<Result<Vec<_>, _>>()?;
171
172 Ok(entries)
173 }
174
175 fn summarize_by_project(&self, entries: &[ReportEntry]) -> HashMap<String, ProjectSummary> {
176 let mut projects = HashMap::new();
177
178 for entry in entries {
179 let summary = projects.entry(entry.project_name.clone()).or_insert_with(|| {
180 ProjectSummary {
181 name: entry.project_name.clone(),
182 path: entry.project_path.clone(),
183 total_duration: 0,
184 session_count: 0,
185 contexts: HashMap::new(),
186 }
187 });
188
189 summary.total_duration += entry.duration;
190 summary.session_count += entry.session_count;
191
192 *summary.contexts.entry(entry.context.clone()).or_insert(0) += entry.duration;
193 }
194
195 projects
196 }
197}
198
199fn parse_date(date_str: Option<String>) -> Result<Option<DateTime<Utc>>> {
200 match date_str {
201 Some(date) => {
202 let naive_date = NaiveDate::parse_from_str(&date, "%Y-%m-%d")
203 .map_err(|_| anyhow::anyhow!("Invalid date format. Use YYYY-MM-DD"))?;
204 let datetime = Utc.from_utc_datetime(&naive_date.and_hms_opt(0, 0, 0)
205 .ok_or_else(|| anyhow::anyhow!("Failed to create datetime from date"))?);
206 Ok(Some(datetime))
207 }
208 None => Ok(None),
209 }
210}
211
212fn format_period(from: Option<DateTime<Utc>>, to: Option<DateTime<Utc>>) -> String {
213 match (from, to) {
214 (Some(from), Some(to)) => {
215 format!("{} to {}", from.format("%Y-%m-%d"), to.format("%Y-%m-%d"))
216 }
217 (Some(from), None) => {
218 format!("From {} to present", from.format("%Y-%m-%d"))
219 }
220 (None, Some(to)) => {
221 format!("Up to {}", to.format("%Y-%m-%d"))
222 }
223 (None, None) => "All time".to_string(),
224 }
225}
226
227pub fn print_report(report: &TimeReport) {
228 println!("Time Report - {}", report.period);
229 println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
230
231 let hours = report.total_duration / 3600;
232 let minutes = (report.total_duration % 3600) / 60;
233 println!("⏱️ Total Time: {}h {}m", hours, minutes);
234 println!();
235
236 if !report.projects.is_empty() {
238 println!("📂 Projects:");
239 let mut sorted_projects: Vec<_> = report.projects.values().collect();
240 sorted_projects.sort_by(|a, b| b.total_duration.cmp(&a.total_duration));
241
242 for project in sorted_projects {
243 let hours = project.total_duration / 3600;
244 let minutes = (project.total_duration % 3600) / 60;
245 println!(" {} - {}h {}m ({} sessions)", project.name, hours, minutes, project.session_count);
246
247 for (context, duration) in &project.contexts {
249 let ctx_hours = duration / 3600;
250 let ctx_minutes = (duration % 3600) / 60;
251 println!(" {} {}h {}m", context, ctx_hours, ctx_minutes);
252 }
253 }
254 println!();
255 }
256
257 if !report.entries.is_empty() {
259 println!("📅 Daily Breakdown:");
260 let mut current_date = String::new();
261
262 for entry in &report.entries {
263 if entry.date != current_date {
264 if !current_date.is_empty() {
265 println!();
266 }
267 println!(" {}", entry.date);
268 current_date = entry.date.clone();
269 }
270
271 let hours = entry.duration / 3600;
272 let minutes = (entry.duration % 3600) / 60;
273 println!(" {} ({}) - {}h {}m", entry.project_name, entry.context, hours, minutes);
274 }
275 }
276
277 if report.entries.is_empty() {
278 println!("No time tracked in this period");
279 }
280}