Skip to main content

sioul_core/
projectview.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! A project on one page: everything dated in it on one line of
5//! time (tasks asked and done, events, mail, notes changed, time noted,
6//! invoices), what is coming first, then what happened, newest first. Mail
7//! belongs to it by its routes or by a link; the rest by its project (REFID) or
8//! a link.
9
10use crate::projects::Project;
11use crate::i18n::Translator;
12use crate::invoice::Invoice;
13use crate::links::{self, Kind, Loaded};
14use crate::mailindex::{MailIndex, Threads};
15use crate::money::Money;
16use crate::porch::{Admission, Gate};
17use crate::tasks::Status;
18use crate::timereport::{Entry, duration};
19use jiff::Timestamp;
20use jiff::tz::TimeZone;
21use serde::Serialize;
22use std::collections::BTreeMap;
23
24/// One thing on the project's line of time.
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct Moment {
27    pub when: i64,
28    /// "Monday 5 October", in words.
29    pub date: String,
30    /// "task", "event", "mail", "note", "time", "invoice".
31    pub kind: String,
32    pub title: String,
33    /// "Done", "Date asked", "From Jane", "2 h 30 noted"…
34    pub detail: String,
35    /// What opens it: its address, for the window's `openThing`.
36    pub uri: String,
37    pub key: String,
38    /// Still to come.
39    pub coming: bool,
40}
41
42#[derive(Debug, Clone, Serialize)]
43pub struct InvoiceRow {
44    pub number: String,
45    pub date: String,
46    pub total: String,
47    pub paid: bool,
48}
49
50/// A project's page.
51#[derive(Debug, Clone, Serialize)]
52pub struct ProjectView {
53    pub id: String,
54    pub title: String,
55    /// Work for a client, its time billable (`Project::is_for_client`).
56    pub for_client: bool,
57    pub status: String,
58    pub client: String,
59    pub rate: f64,
60    pub budget: String,
61    /// "personal": it stays in view in quiet time.
62    pub area: String,
63    /// Open to AI agents (`Project::ai`).
64    pub ai: bool,
65    pub open_tasks: usize,
66    pub done_tasks: usize,
67    /// All the time noted, and what is left to bill.
68    pub time: String,
69    pub unbilled: String,
70    pub unbilled_amount: String,
71    pub moments: Vec<Moment>,
72    pub invoices: Vec<InvoiceRow>,
73}
74
75/// Every project, for the list: open ones first, those for a client first.
76#[derive(Debug, Clone, Serialize)]
77pub struct ProjectRow {
78    pub id: String,
79    pub title: String,
80    /// Work for a client, its time billable.
81    pub for_client: bool,
82    pub status: String,
83    pub client: String,
84    pub open_tasks: usize,
85    pub unbilled: String,
86    /// Yours outside work: listed in quiet time too.
87    pub personal: bool,
88    /// Open to AI agents (`Project::ai`).
89    pub ai: bool,
90}
91
92pub fn rows(loaded: &Loaded, entries: &[Entry], projects: &[Project]) -> Vec<ProjectRow> {
93    let mut out: Vec<ProjectRow> = projects
94        .iter()
95        .map(|c| {
96            let unbilled: u32 = entries.iter().filter(|e| e.project == c.id && e.billable && e.invoice.is_empty()).map(|e| e.minutes).sum();
97            ProjectRow {
98                id: c.id.clone(),
99                title: c.title.clone(),
100                for_client: c.is_for_client(),
101                status: c.status.clone().unwrap_or_default(),
102                client: c.client.clone().unwrap_or_default(),
103                open_tasks: loaded.tasks.iter().filter(|t| t.status.is_open() && t.projects.iter().any(|x| x == &c.id)).count(),
104                unbilled: if unbilled > 0 { duration(unbilled) } else { String::new() },
105                personal: c.area.as_deref() == Some("personal"),
106                ai: c.ai,
107            }
108        })
109        .collect();
110    out.sort_by_key(|r| (r.status == "closed", !r.for_client, r.title.to_lowercase()));
111    out
112}
113
114/// What a message is to projects (`porch::Admission`), read once per
115/// message (by `Gate`), for every project that asks.
116pub type Admissions = BTreeMap<String, Option<Admission>>;
117
118/// The mail a project takes, as its page shows it and agents' consent reads
119/// it (`consent::Consent::of`): by its routes on the sender and the subject,
120/// by a tie (`tied`: the messages' addresses, `by_hand` those you tied
121/// yourself), then the rest of their conversations (`threads`). With `gate`,
122/// each as the Porch takes it: never what it sets aside, nor by its sender's
123/// address or domain when nothing authenticates it; what you tied by hand,
124/// as you tied it; conversations followed through the messages let in only.
125/// Without, every message as it stands. Each message's id, and whether its
126/// sender is proven (always, without a gate).
127pub fn mail_of(mail: &MailIndex, threads: &Threads<'_>, project: &Project, tied: &std::collections::BTreeSet<String>, by_hand: &std::collections::BTreeSet<String>, gate: Option<&Gate>, admissions: &mut Admissions) -> Vec<(String, bool)> {
128    let mut admit = |id: &str, path: &std::path::Path| match gate {
129        Some(gate) => admissions.entry(id.to_string()).or_insert_with(|| gate.admission_of_file(path)).clone(),
130        None => Some(Admission::Admitted),
131    };
132    let mut seeds: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
133    for (id, message) in mail.iter() {
134        let uri = links::mail_uri(id);
135        let is_tied = tied.contains(&uri);
136        let routed = |address: &str| project.routes.iter().any(|r| r.takes_header(address, &message.subject));
137        let taken = by_hand.contains(&uri)
138            || ((is_tied || routed(&message.address))
139                && match admit(id, &message.path) {
140                    Some(Admission::Admitted) => true,
141                    // Nothing proves its sender: a tie, or a route that names no sender.
142                    Some(Admission::Unproven) => is_tied || routed(""),
143                    _ => false,
144                });
145        if taken {
146            seeds.insert(id.clone());
147        }
148    }
149    let shown = threads.through(&seeds, |id, message| by_hand.contains(&links::mail_uri(id)) || admit(id, &message.path).is_some_and(|a| !a.refused()));
150    shown
151        .into_iter()
152        .map(|id| {
153            let proven = mail.get(&id).is_some_and(|message| admit(&id, &message.path) == Some(Admission::Admitted));
154            (id, proven)
155        })
156        .collect()
157}
158
159/// The messages you tied to a project yourself (`links.toml`, any tie but
160/// the "project" ones Sioul makes as mail arrives), by their address.
161fn tied_by_hand(loaded: &Loaded, world: &links::World<'_>, project: &str) -> std::collections::BTreeSet<String> {
162    loaded
163        .local
164        .links
165        .iter()
166        .filter(|edge| !matches!(edge.how.as_str(), "project" | "case"))
167        .filter_map(|edge| {
168            let (from, to) = (world.canonical(&edge.from), world.canonical(&edge.to));
169            if from == project { Some(to) } else if to == project { Some(from) } else { None }
170        })
171        .filter(|uri| links::kind_of(uri) == Kind::Mail)
172        .collect()
173}
174
175/// One project's page, `now` in Unix seconds. Its mail is what `gate`, the
176/// Porch's checks, lets a project take.
177#[allow(clippy::too_many_arguments)]
178pub fn view(loaded: &Loaded, project: &Project, entries: &[Entry], invoices: &[Invoice], default_rate: f64, now: i64, tr: &Translator, gate: &Gate) -> ProjectView {
179    let zone = TimeZone::system();
180    let words = |when: i64| Timestamp::from_second(when).map(|t| tr.day(t.to_zoned(zone.clone()).date())).unwrap_or_default();
181    let mut moments: Vec<Moment> = Vec::new();
182    let mut push = |when: i64, kind: &str, title: String, detail: String, uri: String, key: String| {
183        if when > 0 {
184            moments.push(Moment { when, date: words(when), kind: kind.into(), title, detail, uri, key, coming: when > now });
185        }
186    };
187    let me = links::project_uri(&project.id);
188    let world = loaded.world();
189    let tied: Vec<links::Related> = world.related(&me);
190    let in_project = |uri: &str| tied.iter().any(|r| r.uri == uri);
191    // Tasks: when asked, when done.
192    let (mut open, mut done) = (0, 0);
193    for task in &loaded.tasks {
194        if !task.projects.iter().any(|c| c == &project.id) && !in_project(&links::task_uri(&task.uid)) {
195            continue;
196        }
197        let uri = links::task_uri(&task.uid);
198        if task.status.is_open() {
199            open += 1;
200        } else if task.status == Status::Completed {
201            done += 1;
202        }
203        if let Some(at) = task.completed {
204            push(at, "task", task.title.clone(), tr.text("project-done", None), uri.clone(), task.key.clone());
205        }
206        if task.status.is_open()
207            && let Some(due) = task.due_date().and_then(|d| d.to_zoned(zone.clone()).ok())
208        {
209            push(due.timestamp().as_second(), "task", task.title.clone(), tr.text("project-asked", None), uri, task.key.clone());
210        }
211    }
212    // Events.
213    for event in loaded.events.iter().filter(|e| e.projects.iter().any(|c| c == &project.id) || in_project(&links::event_uri(&e.uid))) {
214        push(event.start, "event", event.summary.clone(), String::new(), links::event_uri(&event.uid), event.key.clone());
215    }
216    // Mail: by its routes, or tied to the project; then the rest of their
217    // conversations. Each as the Porch takes it (`porch::Admission`, read by
218    // `gate`): never what it sets aside, nor by its sender's address or domain
219    // when nothing authenticates it; what you tied by hand, as you tied it.
220    let by_hand = tied_by_hand(loaded, &world, &me);
221    let tied_mail: std::collections::BTreeSet<String> = tied.iter().filter(|r| r.kind == Kind::Mail).map(|r| r.uri.clone()).collect();
222    for (id, proven) in mail_of(&loaded.mail, &loaded.mail.threads(), project, &tied_mail, &by_hand, Some(gate), &mut Admissions::new()) {
223        let Some(mail) = loaded.mail.get(&id) else { continue };
224        let mut args = crate::i18n::args();
225        args.set("sender", mail.from.clone());
226        // A sender nothing proves is said so, as the Porch says it.
227        let detail = tr.text(if proven { "project-from" } else { "project-from-unproven" }, Some(&args));
228        push(mail.date, "mail", if mail.subject.trim().is_empty() { tr.text("mail-no-subject", None) } else { mail.subject.clone() }, detail, links::mail_uri(&id), mail.path.display().to_string());
229    }
230    // Notes: those of the project's record, or tied to it.
231    if let Some(vault) = loaded.vault.as_ref() {
232        for note in vault.notes.iter().filter(|n| project.files.iter().any(|f| f == &n.path) || in_project(&n.uri())) {
233            push(note.modified, "note", note.title.clone(), tr.text("project-note-changed", None), note.uri(), vault.root.join(&note.path).display().to_string());
234        }
235    }
236    // Time: a line a day.
237    let mut by_day: BTreeMap<jiff::civil::Date, (i64, u32)> = BTreeMap::new();
238    for entry in entries.iter().filter(|e| e.project == project.id) {
239        let day = by_day.entry(entry.day).or_insert((entry.start, 0));
240        day.0 = day.0.max(entry.start);
241        day.1 += entry.minutes;
242    }
243    for (_, (when, minutes)) in by_day {
244        let mut args = crate::i18n::args();
245        args.set("time", duration(minutes));
246        push(when, "time", tr.text("project-time-noted", Some(&args)), String::new(), String::new(), String::new());
247    }
248    // Invoices.
249    for invoice in invoices.iter().filter(|i| i.project == project.id) {
250        let at = invoice.date.parse::<jiff::civil::Date>().ok().and_then(|d| d.to_zoned(zone.clone()).ok()).map_or(0, |z| z.timestamp().as_second());
251        let mut args = crate::i18n::args();
252        args.set("number", invoice.number.clone());
253        push(at, "invoice", tr.text("project-invoice", Some(&args)), tr.money(invoice.total()), String::new(), invoice.number.clone());
254    }
255    // What is coming, the nearest first; then what happened, the newest first.
256    moments.sort_by_key(|m| if m.coming { (0, m.when) } else { (1, -m.when) });
257    moments.truncate(300);
258    let all: u32 = entries.iter().filter(|e| e.project == project.id).map(|e| e.minutes).sum();
259    let unbilled: u32 = entries.iter().filter(|e| e.project == project.id && e.billable && e.invoice.is_empty()).map(|e| e.minutes).sum();
260    let rate = project.rate.unwrap_or(default_rate);
261    ProjectView {
262        id: project.id.clone(),
263        title: project.title.clone(),
264        for_client: project.is_for_client(),
265        status: project.status.clone().unwrap_or_default(),
266        client: project.client.clone().unwrap_or_default(),
267        rate,
268        budget: project.budget.clone().unwrap_or_default(),
269        area: project.area.clone().unwrap_or_default(),
270        ai: project.ai,
271        open_tasks: open,
272        done_tasks: done,
273        time: if all > 0 { duration(all) } else { String::new() },
274        unbilled: if unbilled > 0 { duration(unbilled) } else { String::new() },
275        unbilled_amount: if unbilled > 0 && rate > 0.0 { tr.money(crate::timereport::amount(unbilled, rate)) } else { String::new() },
276        moments,
277        invoices: invoices
278            .iter()
279            .filter(|i| i.project == project.id)
280            .rev()
281            .map(|i| InvoiceRow { number: i.number.clone(), date: i.date.clone(), total: tr.money(Money(i.total_cents)), paid: i.paid })
282            .collect(),
283    }
284}
285
286/// The kind of a moment, as `links::Kind` names it, for the window's icons.
287pub fn kind_icon(kind: &str) -> Kind {
288    match kind {
289        "task" => Kind::Task,
290        "event" => Kind::Event,
291        "mail" => Kind::Mail,
292        "note" => Kind::Note,
293        _ => Kind::Other,
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use crate::config::{Priority, Source};
301    use crate::links::{Edge, LocalLinks};
302    use crate::mailindex::MailIndex;
303    use crate::porch::{SenderList, Senders};
304    use crate::projects::{ProjectStore, Route};
305    use crate::tasks::Task;
306    use std::path::PathBuf;
307
308    /// Your provider, whose results the checks trust.
309    const PROVIDER: &str = "mx.provider.example";
310
311    /// A message as stored: its provider's results (none when ""), the ids it cites.
312    fn message(id: &str, from: &str, results: &str, subject: &str, date: i64, cites: &[&str]) -> (String, String) {
313        let results = if results.is_empty() { String::new() } else { format!("Authentication-Results: {PROVIDER}; {results}\r\n") };
314        let refs = if cites.is_empty() { String::new() } else { format!("References: {}\r\n", cites.iter().map(|c| format!("<{c}>")).collect::<Vec<_>>().join(" ")) };
315        let date = Timestamp::from_second(date).unwrap().strftime("%a, %d %b %Y %H:%M:%S +0000");
316        (id.to_string(), format!("{results}Message-ID: <{id}>\r\nFrom: {from}\r\nSubject: {subject}\r\nDate: {date}\r\n{refs}\r\nHello.\r\n"))
317    }
318
319    /// The messages in a Maildir of their own, indexed, and the Porch's checks
320    /// over it: one account, `you@you.example.org`, its provider's results trusted.
321    fn mailbox(name: &str, messages: &[(String, String)], projects: &[Project], senders: Senders, now: i64) -> (PathBuf, MailIndex, Gate) {
322        let root = std::env::temp_dir().join(format!("sioul-projectview-{name}-{}", std::process::id()));
323        let _ = std::fs::remove_dir_all(&root);
324        std::fs::create_dir_all(root.join("cur")).unwrap();
325        for (n, (_, raw)) in messages.iter().enumerate() {
326            std::fs::write(root.join("cur").join(format!("{n}.U1-{n}.sioul{}2,S", crate::maildir::INFO)), raw).unwrap();
327        }
328        let index = MailIndex::build(std::slice::from_ref(&root), &root.join("index.tsv"));
329        let source = Source { account: Some("home".into()), address: Some("you@you.example.org".into()), folder: root.clone(), trusted_ids: vec![PROVIDER.into()], priority: Priority::Average, shielded: false, words: crate::words::Words::builtin(), spam: None };
330        let store = ProjectStore { root: root.clone(), projects: projects.to_vec(), ties: Default::default() };
331        let gate = Gate::new(vec![source], Some(store), SenderList::default(), senders, now);
332        (root, index, gate)
333    }
334
335    #[test]
336    fn a_project_on_one_line_of_time() {
337        let now = 1_791_000_000;
338        let lumen = Project {
339            id: "lumen".into(),
340            title: "Studio Lumen".into(),
341            kind: Some("project".into()),
342            rate: Some(60.0),
343            routes: vec![Route { from_domains: vec!["lumen.example.net".into()], ..Route::default() }],
344            ..Project::default()
345        };
346        let done = Task { uid: "d".into(), title: "Mock-ups".into(), status: Status::Completed, completed: Some(now - 86_400), projects: vec!["lumen".into()], ..Task::default() };
347        let coming = Task { uid: "c".into(), title: "Deliver the site".into(), due: "2030-01-15".into(), projects: vec!["lumen".into()], ..Task::default() };
348        let other = Task { uid: "o".into(), title: "Housing".into(), projects: vec!["housing".into()], ..Task::default() };
349        let messages = [
350            message("q@lumen.example.net", "Jane <jane@lumen.example.net>", "dkim=pass header.d=lumen.example.net; dmarc=pass header.from=lumen.example.net", "Quote", now - 3 * 86_400, &[]),
351            message("x@else.example.org", "x@else.example.org", "", "Other", now - 86_400, &[]),
352            // Your answer to the quote, from your own address: in its conversation, so in the project.
353            message("a@you.example.org", "you@you.example.org", "", "Re: Quote", now - 2 * 86_400 - 3600, &["q@lumen.example.net"]),
354        ];
355        let (root, mail, gate) = mailbox("line", &messages, std::slice::from_ref(&lumen), Senders::default(), now);
356        let loaded = Loaded { tasks: vec![done, coming, other], mail, ..Loaded::default() };
357        let entries = vec![Entry {
358            key: "k".into(),
359            start: now - 2 * 86_400,
360            day: Timestamp::from_second(now - 2 * 86_400).unwrap().to_zoned(TimeZone::system()).date(),
361            minutes: 150,
362            project: "lumen".into(),
363            task: String::new(),
364            title: "Meeting".into(),
365            note: String::new(),
366            billable: true,
367            invoice: String::new(),
368        }];
369        let tr = Translator::new("en");
370        let page = view(&loaded, &lumen, &entries, &[], 40.0, now, &tr, &gate);
371        let line: Vec<(&str, &str, bool)> = page.moments.iter().map(|m| (m.kind.as_str(), m.title.as_str(), m.coming)).collect();
372        assert_eq!(line, vec![("task", "Deliver the site", true), ("task", "Mock-ups", false), ("time", "2 h 30 noted", false), ("mail", "Re: Quote", false), ("mail", "Quote", false)]);
373        assert_eq!(page.moments.last().map(|m| m.detail.as_str()), Some("From Jane"));
374        assert_eq!((page.open_tasks, page.done_tasks, page.unbilled.as_str()), (1, 1, "2 h 30"));
375        assert_eq!(page.unbilled_amount, tr.money(Money(15000)));
376        let listed = rows(&loaded, &entries, &[Project { id: "housing".into(), title: "Housing".into(), ..Project::default() }, lumen]);
377        assert_eq!(listed[0].id, "lumen", "projects first");
378        let _ = std::fs::remove_dir_all(&root);
379    }
380
381    /// A project's page takes mail as the Porch does: what it sets aside
382    /// (forged, a blocked sender, a borrowed name) never shows, by a route, a
383    /// conversation or a tie Sioul made; what nothing authenticates comes by
384    /// no route on its sender; genuine mail, your own and what you tied by
385    /// hand still come.
386    #[test]
387    fn mail_on_a_project_page_passes_the_porchs_checks() {
388        let now = 1_791_000_000;
389        let day = |n: i64| now - n * 86_400;
390        let lumen = Project {
391            id: "lumen".into(),
392            title: "Studio Lumen".into(),
393            routes: vec![Route { from_domains: vec!["lumen.example.net".into()], ..Route::default() }, Route { subject_contains: vec!["Lumen site".into()], ..Route::default() }],
394            ..Project::default()
395        };
396        let genuine = "spf=pass smtp.mailfrom=lumen.example.net; dkim=pass header.d=lumen.example.net; dmarc=pass header.from=lumen.example.net";
397        // The client's domain in From, DMARC failing, no DKIM signature at all.
398        let forged = "spf=fail smtp.mailfrom=attacker.example; dkim=none; dmarc=fail header.from=lumen.example.net";
399        let failed = "spf=fail smtp.mailfrom=lumen.example.net; dkim=fail header.d=lumen.example.net";
400        let messages = [
401            message("quote@lumen.example.net", "Jane <jane@lumen.example.net>", genuine, "Quote", day(9), &[]),
402            message("forged@lumen.example.net", "Jane <jane@lumen.example.net>", forged, "New bank details", day(8), &[]),
403            message("unproven@lumen.example.net", "Jane <jane@lumen.example.net>", failed, "Invoice", day(7), &[]),
404            message("subject@lumen.example.net", "Jane <jane@lumen.example.net>", failed, "About the Lumen site", day(6), &[]),
405            message("borrowed@lumen.example.net", "PayPal <billing@lumen.example.net>", genuine, "Your account", day(5), &[]),
406            message("blocked@lumen.example.net", "Pest <pest@lumen.example.net>", genuine, "Hi", day(5), &[]),
407            // A forged answer, citing the quote and an unrelated conversation: neither shown nor joining them.
408            message("reply@lumen.example.net", "Jane <jane@lumen.example.net>", forged, "Re: Quote", day(4), &["quote@lumen.example.net", "trip@else.example.org"]),
409            message("trip@else.example.org", "Paul <paul@else.example.org>", "dkim=pass header.d=else.example.org", "The trip", day(4), &[]),
410            message("mine@you.example.org", "you@you.example.org", "", "Re: Quote", day(3), &["quote@lumen.example.net"]),
411            message("tied@lumen.example.net", "Jane <jane@lumen.example.net>", forged, "Tied when it came", day(2), &[]),
412            message("hand@lumen.example.net", "Jane <jane@lumen.example.net>", forged, "Tied by hand", day(1), &[]),
413        ];
414        let senders = Senders { blocked: SenderList::parse("pest@lumen.example.net"), ..Senders::default() };
415        let (root, mail, gate) = mailbox("checks", &messages, std::slice::from_ref(&lumen), senders, now);
416        let tie = |id: &str, how: &str| Edge { from: links::mail_uri(id), to: links::project_uri("lumen"), how: how.into() };
417        let local = LocalLinks { links: vec![tie("tied@lumen.example.net", "project"), tie("hand@lumen.example.net", "link")] };
418        let loaded = Loaded { mail, local, ..Loaded::default() };
419        let page = view(&loaded, &lumen, &[], &[], 40.0, now, &Translator::new("en"), &gate);
420        let shown: Vec<(&str, &str)> = page.moments.iter().map(|m| (m.title.as_str(), m.detail.as_str())).collect();
421        assert_eq!(
422            shown,
423            vec![
424                ("Tied by hand", "From Jane, whose address could not be verified"),
425                ("Re: Quote", "From you@you.example.org"),
426                ("About the Lumen site", "From Jane, whose address could not be verified"),
427                ("Quote", "From Jane"),
428            ]
429        );
430        let _ = std::fs::remove_dir_all(&root);
431    }
432}