1use 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#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct Moment {
27 pub when: i64,
28 pub date: String,
30 pub kind: String,
32 pub title: String,
33 pub detail: String,
35 pub uri: String,
37 pub key: String,
38 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#[derive(Debug, Clone, Serialize)]
52pub struct ProjectView {
53 pub id: String,
54 pub title: String,
55 pub for_client: bool,
57 pub status: String,
58 pub client: String,
59 pub rate: f64,
60 pub budget: String,
61 pub area: String,
63 pub ai: bool,
65 pub open_tasks: usize,
66 pub done_tasks: usize,
67 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#[derive(Debug, Clone, Serialize)]
77pub struct ProjectRow {
78 pub id: String,
79 pub title: String,
80 pub for_client: bool,
82 pub status: String,
83 pub client: String,
84 pub open_tasks: usize,
85 pub unbilled: String,
86 pub personal: bool,
88 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
114pub type Admissions = BTreeMap<String, Option<Admission>>;
117
118pub 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 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
159fn 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#[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 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 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 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 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 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(¬e.path).display().to_string());
234 }
235 }
236 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 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 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
286pub 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 const PROVIDER: &str = "mx.provider.example";
310
311 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 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 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 #[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 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 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}