1use crate::agenda::{self, Occurrence};
31use crate::lines;
32use crate::tasks::{self, Link, Status, Task};
33use crate::vdir::{self, Collection, Kind};
34use jiff::civil::{Date, DateTime};
35use jiff::tz::TimeZone;
36use jiff::{Timestamp, Zoned};
37use serde::Serialize;
38use std::collections::{BTreeMap, BTreeSet};
39
40pub const TASK: &str = "X-SIOUL-TASK";
42pub const TURN: &str = "X-SIOUL-TURN";
45pub const BLOCK_OF: &str = "tag:aurelienpierre.com,2026:sioul/rel/block-of";
48pub const TIME_BLOCK: &str = "tag:aurelienpierre.com,2026:sioul/rel/time-block";
50pub const CALENDAR: &str = "planned-tasks";
54pub const ALARM_BEFORE: &str = "-PT5M";
56pub const AHEAD_DAYS: i64 = 3 * 366;
58pub const SHORTEST: u32 = 5;
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
63pub struct Pin {
64 pub key: String,
66 pub uid: String,
67 pub start: i64,
69 pub end: i64,
70 pub date: Date,
72 pub before: u32,
74 pub after: u32,
75 pub read_only: bool,
77}
78
79impl Pin {
80 pub fn minutes(&self) -> u32 {
82 u32::try_from((self.end - self.start).max(0) / 60).unwrap_or(u32::MAX)
83 }
84
85 pub fn span(&self) -> (i64, i64) {
87 (self.start - i64::from(self.before) * 60, self.end + i64::from(self.after) * 60)
88 }
89}
90
91#[derive(Debug, Clone, Default, PartialEq, Eq)]
94pub struct Blocks {
95 pub pins: BTreeMap<String, Pin>,
97 pub records: BTreeMap<String, (i64, i64)>,
100}
101
102pub fn turn_of(task: &Task) -> String {
105 if task.repeat.is_empty() {
106 return String::new();
107 }
108 task.due.get(..10).or_else(|| task.start.get(..10)).unwrap_or("").to_string()
109}
110
111fn of_a_turn_done(block: &Occurrence, task: &Task) -> bool {
114 let turn = turn_of(task);
115 !block.turn.is_empty() && !turn.is_empty() && turn.as_str() > block.turn.as_str()
116}
117
118fn known(tasks: &[Task]) -> BTreeSet<&str> {
120 tasks.iter().map(|t| t.uid.as_str()).collect()
121}
122
123pub fn without_blocks(mut events: Vec<Occurrence>, tasks: &[Task]) -> Vec<Occurrence> {
126 let known = known(tasks);
127 events.retain(|e| e.task.is_empty() || !known.contains(e.task.as_str()));
128 events
129}
130
131impl Blocks {
132 pub fn of(blocks: &[Occurrence], tasks: &[Task], now: i64, zone: &TimeZone) -> Blocks {
135 let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
136 let day = |seconds: i64| Timestamp::from_second(seconds).ok().map(|t| t.to_zoned(zone.clone()).date());
137 let mut seen = BTreeSet::new();
138 let mut sorted: Vec<&Occurrence> = blocks.iter().filter(|b| !b.task.is_empty() && !b.cancelled && !b.all_day && b.end > b.start).filter(|b| seen.insert((b.key.clone(), b.start))).collect();
139 sorted.sort_by(|a, b| (a.start, a.end, &a.key).cmp(&(b.start, b.end, &b.key)));
140 let mut out = Blocks::default();
141 for block in sorted {
142 let Some(task) = by_uid.get(block.task.as_str()) else { continue };
143 if task.status.is_open() {
144 if block.end > now && !of_a_turn_done(block, task) && !out.pins.contains_key(&task.uid) {
145 let Some(date) = day(block.start) else { continue };
146 out.pins.insert(task.uid.clone(), Pin { key: block.key.clone(), uid: block.uid.clone(), start: block.start, end: block.end, date, before: task.margins.before, after: task.margins.after, read_only: block.read_only });
147 }
148 } else if task.status == Status::Completed
149 && let Some(done) = task.completed
150 && block.start <= done
151 && day(block.start) == day(done)
152 {
153 out.records.insert(task.uid.clone(), (block.start, block.end));
155 }
156 }
157 out
158 }
159}
160
161pub fn calendars(chosen: &str) -> Vec<Collection> {
165 vdir::collections(Kind::Calendars).into_iter().filter(|c| c.id == CALENDAR || (!chosen.is_empty() && format!("{}/{}", c.account, c.id) == chosen)).collect()
166}
167
168pub fn read(calendars: &[Collection], from: i64, to: i64, zone: &TimeZone) -> Vec<Occurrence> {
171 calendars.iter().flat_map(|c| c.items().into_iter().flat_map(|path| agenda::file_occurrences(&path, c, from, to, zone)).collect::<Vec<_>>()).filter(|o| !o.task.is_empty()).collect()
172}
173
174fn one_line(value: &str) -> String {
176 value.chars().filter(|c| !c.is_control()).collect()
177}
178
179fn utc(seconds: i64) -> Result<String, String> {
180 Timestamp::from_second(seconds).map(|t| t.strftime("%Y%m%dT%H%M%SZ").to_string()).map_err(|e| e.to_string())
181}
182
183pub fn new_block(task: &Task, uid: &str, start: i64, end: i64, alarm: bool, now: &Zoned) -> Result<String, String> {
186 if end <= start {
187 return Err(format!("{start}–{end}?"));
188 }
189 let stamp = utc(now.timestamp().as_second())?;
190 let mut out = vec!["BEGIN:VCALENDAR".to_string(), "VERSION:2.0".to_string(), "PRODID:-//Sioul//Sioul//EN".to_string(), "CALSCALE:GREGORIAN".to_string(), "BEGIN:VEVENT".to_string()];
191 out.extend([format!("UID:{}", one_line(uid)), format!("DTSTAMP:{stamp}"), format!("CREATED:{stamp}"), format!("LAST-MODIFIED:{stamp}")]);
192 out.extend([format!("DTSTART:{}", utc(start)?), format!("DTEND:{}", utc(end)?)]);
193 let title = task.title.trim();
194 if !title.is_empty() {
195 out.push(format!("SUMMARY:{}", lines::escape(title)));
196 }
197 out.push("TRANSP:OPAQUE".to_string());
198 out.push(format!("{TASK}:{}", one_line(&task.uid)));
199 let turn = turn_of(task);
200 if !turn.is_empty() {
201 out.push(format!("{TURN}:{turn}"));
202 }
203 out.push(tasks::link_line(&Link { uri: format!("uid:{}", task.uid), label: String::new(), rel: BLOCK_OF.to_string() }));
204 if alarm {
205 out.extend(["BEGIN:VALARM".to_string(), "ACTION:DISPLAY".to_string(), format!("DESCRIPTION:{}", lines::escape(title)), format!("TRIGGER:{ALARM_BEFORE}"), "END:VALARM".to_string()]);
206 }
207 out.extend(["END:VEVENT".to_string(), "END:VCALENDAR".to_string()]);
208 Ok(lines::fold(&out))
209}
210
211pub fn moved(text: &str, start: i64, end: i64, zone: &TimeZone) -> Result<String, String> {
216 let (current, _) = agenda::first_times(text, zone).ok_or("no event")?;
217 let repeats = lines::unfold(text).iter().any(|l| matches!(lines::name(l).as_str(), "RRULE" | "RDATE"));
218 agenda::moved(text, current, start, end, repeats, zone).map_err(|e| format!("{e:?}"))
219}
220
221pub fn ended(text: &str, end: i64, zone: &TimeZone) -> Result<String, String> {
223 let (start, _) = agenda::first_times(text, zone).ok_or("no event")?;
224 moved(text, start, end, zone)
225}
226
227pub fn titled(text: &str, title: &str, zone: &TimeZone) -> Option<String> {
231 let wanted = format!("SUMMARY:{}", lines::escape(title.trim()));
232 if lines::unfold(text).iter().any(|l| *l == wanted) {
233 return None;
234 }
235 let mut edit = agenda::edit_of_text(text, zone)?;
236 edit.title = title.trim().to_string();
237 agenda::apply(text, &edit, zone).ok().filter(|written| written != text)
238}
239
240pub fn turned(text: &str, turn: &str) -> Option<String> {
243 let without = agenda::remove_lines(text, |l| lines::name(l) == TURN)?;
244 if turn.is_empty() {
245 return Some(without);
246 }
247 agenda::add_lines(&without, &[format!("{TURN}:{}", one_line(turn))])
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
252pub enum Closing {
253 Keep,
255 EndAt(i64),
257 Delete,
259}
260
261pub fn closing(start: i64, end: i64, at: i64) -> Closing {
264 if end <= at {
265 return Closing::Keep;
266 }
267 if start >= at {
268 return Closing::Delete;
269 }
270 let ends = ((at + 59).div_euclid(60) * 60).max(start + i64::from(SHORTEST) * 60);
271 if ends >= end { Closing::Keep } else { Closing::EndAt(ends) }
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
276pub enum Fix {
277 Delete,
279 EndAt(i64),
281 Title(String),
283}
284
285pub fn upkeep(blocks: &[Occurrence], tasks: &[Task], now: i64) -> Vec<(String, Fix)> {
291 let by_uid: BTreeMap<&str, &Task> = tasks.iter().map(|t| (t.uid.as_str(), t)).collect();
292 let mut seen = BTreeSet::new();
293 let mut out = Vec::new();
294 for block in blocks.iter().filter(|b| !b.task.is_empty() && !b.all_day && !b.recurring) {
295 if !seen.insert(block.key.clone()) {
296 continue;
297 }
298 let Some(task) = by_uid.get(block.task.as_str()) else { continue };
299 let fix = match task.status {
300 Status::Completed | Status::Cancelled => {
301 let at = if task.status == Status::Completed { task.completed.unwrap_or(now) } else { now };
302 match closing(block.start, block.end, at) {
303 Closing::Keep => None,
304 Closing::EndAt(end) => Some(Fix::EndAt(end)),
305 Closing::Delete => Some(Fix::Delete),
306 }
307 }
308 _ if of_a_turn_done(block, task) => (block.start > now).then_some(Fix::Delete),
309 _ if block.end > now && !task.title.trim().is_empty() && block.summary.trim() != task.title.trim() => Some(Fix::Title(task.title.trim().to_string())),
310 _ => None,
311 };
312 out.extend(fix.map(|fix| (block.key.clone(), fix)));
313 }
314 out
315}
316
317pub fn task_link(block_uid: &str) -> Link {
319 Link { uri: format!("uid:{block_uid}"), label: String::new(), rel: TIME_BLOCK.to_string() }
320}
321
322pub fn is_block_link(link: &Link) -> bool {
324 link.rel == TIME_BLOCK
325}
326
327pub fn task_pinned(text: &str, uid: Option<&str>, now: &Zoned) -> Result<String, String> {
331 let source = lines::unfold(text);
332 let is_block_line = |l: &str| lines::name(l) == "LINK" && is_block_link(&tasks::link_of(l));
333 let drop = |l: &str| is_block_line(l) || lines::name(l) == tasks::AT;
334 let wanted = uid.map(task_link);
335 let current: Vec<Link> = source.iter().filter(|l| is_block_line(l)).map(|l| tasks::link_of(l)).collect();
336 let at = source.iter().any(|l| lines::name(l) == tasks::AT);
337 if !at && current.len() == usize::from(wanted.is_some()) && wanted.as_ref().is_none_or(|w| current.contains(w)) {
338 return Ok(text.to_string());
339 }
340 tasks::replace_lines(text, drop, wanted.iter().map(tasks::link_line).collect(), now)
341}
342
343pub fn default_minutes(task: &Task, laid: Option<u32>, default_estimate: u32) -> u32 {
348 let margins = task.margins.before.saturating_add(task.margins.after);
349 let minutes = laid.map(|l| l.saturating_sub(margins)).filter(|m| *m > 0).unwrap_or(if task.estimate > 0 { task.estimate } else { default_estimate });
350 minutes.max(15).div_ceil(5).saturating_mul(5)
351}
352
353#[derive(Debug, Clone, PartialEq, Eq)]
355pub enum Place {
356 Found(Collection),
357 Make(String),
359}
360
361pub fn place_for(chosen: &str, list_account: &str, calendars: &[Collection], makes: bool) -> Place {
367 if let Some(found) = calendars.iter().find(|c| !chosen.is_empty() && format!("{}/{}", c.account, c.id) == chosen && !c.read_only && c.holds("VEVENT")) {
368 return Place::Found(found.clone());
369 }
370 let account = if list_account.is_empty() || list_account == vdir::LOCAL || !makes { vdir::LOCAL } else { list_account };
371 match calendars.iter().find(|c| c.account == account && c.id == CALENDAR && !c.read_only) {
372 Some(found) => Place::Found(found.clone()),
373 None => Place::Make(account.to_string()),
374 }
375}
376
377pub fn kept_back(block: &Collection, list_account: &str, chosen: &str) -> bool {
383 block.account == vdir::LOCAL && !list_account.is_empty() && list_account != vdir::LOCAL && chosen != format!("{}/{}", block.account, block.id)
384}
385
386pub fn make_calendar(account: &str, name: &str) -> Result<Collection, String> {
389 let state = vdir::State { pending: account != vdir::LOCAL, components: vec!["VEVENT".to_string()], ..vdir::State::default() };
391 state.save(&vdir::state_path(account, Kind::Calendars, CALENDAR))?;
392 let dir = vdir::prepare(Kind::Calendars, account, CALENDAR, name, None).map_err(|e| e.to_string())?;
393 Ok(Collection { kind: Kind::Calendars, account: account.to_string(), id: CALENDAR.to_string(), dir, name: name.to_string(), color: None, read_only: false, components: state.components })
394}
395
396pub fn given_times<'a>(tasks: &'a [Task], today: Date, zone: &TimeZone) -> Vec<(&'a Task, i64)> {
400 tasks
401 .iter()
402 .filter(|t| t.status.is_open() && !t.at.is_empty())
403 .filter_map(|t| {
404 let date: Date = t.at.get(..10)?.parse().ok()?;
405 if date < today {
406 return None;
407 }
408 let local: DateTime = t.at.parse().ok()?;
409 Some((t, local.to_zoned(zone.clone()).ok()?.timestamp().as_second()))
410 })
411 .collect()
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
422 fn a_block_kept_here_alone_goes_where_its_task_goes() {
423 let calendar = |account: &str, id: &str| Collection { kind: Kind::Calendars, account: account.into(), id: id.into(), dir: std::path::PathBuf::new(), name: String::new(), color: None, read_only: false, components: Vec::new() };
424 let here = calendar(vdir::LOCAL, CALENDAR);
425 assert!(kept_back(&here, "murena", ""), "a server's list: its block goes there");
426 assert!(!kept_back(&here, vdir::LOCAL, ""), "a list kept here: its block stays here too");
427 assert!(!kept_back(&here, "murena", "local/planned-tasks"), "chosen in Tasks ⚙: it stays");
428 assert!(!kept_back(&calendar("murena", CALENDAR), "murena", ""), "on the server already");
429 }
430
431 fn paris() -> TimeZone {
432 TimeZone::get("Europe/Paris").unwrap()
433 }
434
435 fn at(text: &str) -> i64 {
436 text.parse::<DateTime>().unwrap().to_zoned(paris()).unwrap().timestamp().as_second()
437 }
438
439 fn now() -> Zoned {
440 "2026-10-06T09:00:00+02:00[Europe/Paris]".parse().unwrap()
441 }
442
443 fn task(uid: &str, title: &str) -> Task {
444 Task { uid: uid.into(), title: title.into(), estimate: 30, ..Task::default() }
445 }
446
447 fn calendar(name: &str) -> Collection {
449 let dir = std::env::temp_dir().join(format!("sioul-blocks-{}-{name}", std::process::id()));
450 let _ = std::fs::remove_dir_all(&dir);
451 std::fs::create_dir_all(&dir).unwrap();
452 Collection { kind: Kind::Calendars, account: "a".into(), id: CALENDAR.into(), dir, name: "Planned tasks".into(), color: None, read_only: false, components: vec!["VEVENT".into()] }
453 }
454
455 fn read_back(calendar: &Collection) -> Vec<Occurrence> {
457 read(std::slice::from_ref(calendar), at("2026-10-01T00:00"), at("2026-12-31T00:00"), &paris())
458 }
459
460 #[test]
461 fn a_block_made_read_back_and_tied_both_ways() {
462 let bank = task("bank-1", "Call the bank, about the loan");
463 let text = new_block(&bank, "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45"), false, &now()).unwrap();
464 assert!(text.contains("TRANSP:OPAQUE") && text.contains("X-SIOUL-TASK:bank-1") && text.contains("SUMMARY:Call the bank\\, about the loan"), "{text}");
466 assert!(lines::unfold(&text).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/block-of\";VALUE=UID:bank-1"), "{text}");
467 assert!(!text.contains("VALARM") && !text.contains("X-SIOUL-TURN"), "{text}");
468 assert!(agenda::parse(&text).is_some());
469 let with_alarm = new_block(&bank, "block-2", at("2026-10-06T14:00"), at("2026-10-06T14:45"), true, &now()).unwrap();
470 assert!(with_alarm.contains("BEGIN:VALARM") && with_alarm.contains("TRIGGER:-PT5M"), "{with_alarm}");
471 let place = calendar("made");
473 std::fs::write(place.dir.join("block-1.ics"), &text).unwrap();
474 let found = read_back(&place);
475 assert_eq!(found.len(), 1);
476 assert_eq!((found[0].task.as_str(), found[0].uid.as_str(), found[0].start, found[0].end), ("bank-1", "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45")));
477 assert_eq!(found[0].alarms, Vec::<i64>::new());
478 let task_text = tasks::new_task(&tasks::TaskEdit { title: bank.title.clone(), ..tasks::TaskEdit::default() }, "bank-1", &paris(), &now()).unwrap();
480 let linked = task_pinned(&task_text, Some("block-1"), &now()).unwrap();
481 let read = tasks::task_of_text(&linked, &paris()).unwrap();
482 assert_eq!(read.links, vec![task_link("block-1")]);
483 assert!(lines::unfold(&linked).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/time-block\";VALUE=UID:block-1"), "{linked}");
484 assert_eq!(task_pinned(&linked, Some("block-1"), &now()).unwrap(), linked);
485 let other = tasks::task_of_text(&task_pinned(&linked, Some("block-9"), &now()).unwrap(), &paris()).unwrap();
487 assert_eq!(other.links, vec![task_link("block-9")]);
488 assert!(tasks::task_of_text(&task_pinned(&linked, None, &now()).unwrap(), &paris()).unwrap().links.is_empty());
489 let task = Task { links: read.links.clone(), ..bank.clone() };
491 let event = agenda::event_ref(&text, "block-1.ics", &paris()).unwrap();
492 let (empty_mail, empty_links) = (crate::mailindex::MailIndex::default(), crate::links::LocalLinks::default());
493 let world = crate::links::World { tasks: std::slice::from_ref(&task), events: std::slice::from_ref(&event), contacts: &[], vault: None, drafts: &[], mail: &empty_mail, projects: &[], budget: &[], local: &empty_links, sites: &[] };
494 let edges = world.edges();
495 assert!(edges.iter().any(|e| e.from == crate::links::task_uri("bank-1") && e.to == crate::links::event_uri("block-1")), "{edges:?}");
496 assert!(edges.iter().any(|e| e.from == crate::links::event_uri("block-1") && e.to == crate::links::task_uri("bank-1")), "{edges:?}");
497 }
498
499 #[test]
500 fn a_task_pinned_to_its_first_block_to_come() {
501 let zone = paris();
502 let bank = task("bank", "Call the bank");
503 let block = |key: &str, from: &str, to: &str| Occurrence { key: key.into(), uid: key.into(), summary: "Call the bank".into(), start: at(from), end: at(to), task: "bank".into(), ..Occurrence::default() };
504 let blocks = [block("thursday", "2026-10-08T10:00", "2026-10-08T10:30"), block("morning", "2026-10-06T08:00", "2026-10-06T08:30"), block("tuesday", "2026-10-06T14:00", "2026-10-06T14:45")];
505 let now = at("2026-10-06T09:00");
506 let found = Blocks::of(&blocks, std::slice::from_ref(&bank), now, &zone);
507 let pin = &found.pins["bank"];
509 assert_eq!((pin.key.as_str(), pin.minutes(), pin.date), ("tuesday", 45, Date::constant(2026, 10, 6)));
510 let stranger = Occurrence { task: "elsewhere".into(), ..block("stranger", "2026-10-06T16:00", "2026-10-06T17:00") };
512 let events = without_blocks(vec![blocks[2].clone(), stranger.clone(), Occurrence { key: "dentist".into(), ..Occurrence::default() }], std::slice::from_ref(&bank));
513 assert_eq!(events.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["stranger", "dentist"]);
514 let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:30")), ..bank.clone() };
516 let found = Blocks::of(&blocks, std::slice::from_ref(&done), at("2026-10-06T15:00"), &zone);
517 assert!(found.pins.is_empty());
518 assert_eq!(found.records.get("bank"), Some(&(at("2026-10-06T14:00"), at("2026-10-06T14:45"))));
519 }
520
521 #[test]
522 fn a_block_moved_then_deleted_in_another_application() {
523 let zone = paris();
524 let bank = task("bank", "Call the bank");
525 let place = calendar("elsewhere");
526 let path = place.dir.join("b.ics");
527 let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
528 std::fs::write(&path, &text).unwrap();
529 let now = at("2026-10-06T09:00");
530 assert_eq!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].start, at("2026-10-06T14:00"));
531 let elsewhere = text.replace("DTSTART:20261006T120000Z", "DTSTART;TZID=Europe/Paris:20261007T160000").replace("DTEND:20261006T130000Z", "DTEND;TZID=Europe/Paris:20261007T170000\r\nSEQUENCE:3");
533 std::fs::write(&path, &elsewhere).unwrap();
534 let pin = Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].clone();
535 assert_eq!((pin.start, pin.date), (at("2026-10-07T16:00"), Date::constant(2026, 10, 7)));
536 let back = moved(&elsewhere, at("2026-10-07T10:00"), at("2026-10-07T10:45"), &zone).unwrap();
538 assert_eq!(agenda::first_times(&back, &zone), Some((at("2026-10-07T10:00"), at("2026-10-07T10:45"))));
539 assert!(back.contains("X-SIOUL-TASK:bank") && back.contains("SEQUENCE:4"), "{back}");
540 std::fs::remove_file(&path).unwrap();
542 assert!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins.is_empty());
543 }
544
545 #[test]
546 fn done_or_dropped_frees_what_is_to_come_and_keeps_the_record() {
547 let (start, end) = (at("2026-10-06T14:00"), at("2026-10-06T15:00"));
549 assert_eq!(closing(start, end, at("2026-10-06T13:00")), Closing::Delete, "not begun: it goes");
550 assert_eq!(closing(start, end, at("2026-10-06T14:20") + 30), Closing::EndAt(at("2026-10-06T14:21")), "under way: it ends then");
551 assert_eq!(closing(start, end, start + 30), Closing::EndAt(start + 300), "five minutes at least");
552 assert_eq!(closing(start, end, at("2026-10-06T16:00")), Closing::Keep, "over: the record of the work");
553 let bank = task("bank", "Call the bank");
555 let block = |key: &str, from: &str, to: &str| Occurrence { key: key.into(), summary: "Call the bank".into(), start: at(from), end: at(to), task: "bank".into(), ..Occurrence::default() };
556 let blocks = [block("monday", "2026-10-05T10:00", "2026-10-05T11:00"), block("now", "2026-10-06T14:00", "2026-10-06T15:00"), block("friday", "2026-10-09T09:00", "2026-10-09T10:00")];
557 let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:20")), ..bank.clone() };
558 let fixes = upkeep(&blocks, std::slice::from_ref(&done), at("2026-10-06T16:00"));
559 assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:20"))), ("friday".to_string(), Fix::Delete)]);
560 let dropped = Task { status: Status::Cancelled, ..bank.clone() };
562 let fixes = upkeep(&blocks, std::slice::from_ref(&dropped), at("2026-10-06T14:30"));
563 assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:30"))), ("friday".to_string(), Fix::Delete)]);
564 let text = new_block(&bank, "now", start, end, false, &now()).unwrap();
566 let shorter = ended(&text, at("2026-10-06T14:20"), &paris()).unwrap();
567 assert_eq!(agenda::first_times(&shorter, &paris()), Some((start, at("2026-10-06T14:20"))));
568 }
569
570 #[test]
571 fn the_title_kept_in_step() {
572 let bank = task("bank", "Call the bank");
573 let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
574 let renamed = Task { title: "Call the bank about the loan".into(), ..bank.clone() };
575 let block = Occurrence { key: "b.ics".into(), summary: "Call the bank".into(), start: at("2026-10-06T14:00"), end: at("2026-10-06T15:00"), task: "bank".into(), ..Occurrence::default() };
576 assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T09:00")), vec![("b.ics".to_string(), Fix::Title("Call the bank about the loan".into()))]);
577 let titled = titled(&text, &renamed.title, &paris()).unwrap();
578 assert!(titled.contains("SUMMARY:Call the bank about the loan") && titled.contains("SEQUENCE:1") && titled.contains("X-SIOUL-TASK:bank"), "{titled}");
579 assert_eq!(super::titled(&titled, &renamed.title, &paris()), None, "already so: nothing written");
580 assert!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T16:00")).is_empty());
582 }
583
584 #[test]
585 fn a_block_pins_one_turn_of_a_repeating_task() {
586 let zone = paris();
587 let plants = Task { repeat: "weekly".into(), due: "2026-10-09".into(), ..task("plants", "Water the plants") };
588 let text = new_block(&plants, "p", at("2026-10-08T18:00"), at("2026-10-08T18:30"), false, &now()).unwrap();
589 assert!(text.contains("X-SIOUL-TURN:2026-10-09"), "{text}");
590 let block = Occurrence { key: "p.ics".into(), summary: plants.title.clone(), start: at("2026-10-08T18:00"), end: at("2026-10-08T18:30"), task: "plants".into(), turn: "2026-10-09".into(), ..Occurrence::default() };
591 let now = at("2026-10-06T09:00");
592 assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&plants), now, &zone).pins.contains_key("plants"));
593 let next = Task { due: "2026-10-16".into(), ..plants.clone() };
595 assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&next), now, &zone).pins.is_empty());
596 assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&next), now), vec![("p.ics".to_string(), Fix::Delete)]);
597 let followed = turned(&text, "2026-10-16").unwrap();
599 assert!(followed.contains("X-SIOUL-TURN:2026-10-16") && !followed.contains("2026-10-09"), "{followed}");
600 assert_eq!(turn_of(&task("once", "Once")), "");
602 }
603
604 #[test]
605 fn a_time_given_before_blocks_becomes_one() {
606 let zone = paris();
607 let today = Date::constant(2026, 10, 6);
608 let given = Task { at: "2026-10-06T14:30".into(), ..task("given", "Write the letter") };
609 let yesterday = Task { at: "2026-10-05T10:00".into(), ..task("old", "Old") };
610 let done = Task { at: "2026-10-06T16:00".into(), status: Status::Completed, ..task("done", "Done") };
611 let all = [given.clone(), yesterday, done];
612 let found = given_times(&all, today, &zone);
613 assert_eq!(found.iter().map(|(t, s)| (t.uid.as_str(), *s)).collect::<Vec<_>>(), [("given", at("2026-10-06T14:30"))]);
614 let (task, start) = found[0];
616 let block = new_block(task, "block-given", start, start + i64::from(default_minutes(task, None, 30)) * 60, false, &now()).unwrap();
617 assert_eq!(agenda::first_times(&block, &zone), Some((at("2026-10-06T14:30"), at("2026-10-06T15:00"))));
618 let read = Occurrence { key: "block-given.ics".into(), uid: "block-given".into(), start, end: at("2026-10-06T15:00"), task: "given".into(), ..Occurrence::default() };
619 assert_eq!(Blocks::of(std::slice::from_ref(&read), std::slice::from_ref(&given), at("2026-10-06T09:00"), &zone).pins["given"].start, at("2026-10-06T14:30"));
620 let text = tasks::new_task(&tasks::TaskEdit { title: given.title.clone(), ..tasks::TaskEdit::default() }, "given", &zone, &now()).unwrap();
622 let old = text.replace("END:VTODO", "X-SIOUL-AT:20261006T123000Z\r\nEND:VTODO");
623 assert_eq!(tasks::task_of_text(&old, &zone).unwrap().at, "2026-10-06T14:30");
624 let pinned = task_pinned(&old, Some("block-given"), &now()).unwrap();
625 let read = tasks::task_of_text(&pinned, &zone).unwrap();
626 assert_eq!((read.at.as_str(), read.links.clone()), ("", vec![task_link("block-given")]));
627 assert!(!pinned.contains("X-SIOUL-AT"), "{pinned}");
628 }
629
630 #[test]
631 fn where_blocks_go() {
632 let here = |account: &str, id: &str| Collection { kind: Kind::Calendars, account: account.into(), id: id.into(), dir: std::env::temp_dir(), name: id.into(), color: None, read_only: false, components: vec![] };
633 let calendars = [here("cloud", "personal"), here("cloud", CALENDAR), here("other", "work")];
634 assert_eq!(place_for("", "cloud", &calendars, true), Place::Found(here("cloud", CALENDAR)));
636 assert_eq!(place_for("", "other", &calendars, true), Place::Make("other".into()));
638 assert_eq!(place_for("", vdir::LOCAL, &calendars, true), Place::Make(vdir::LOCAL.into()));
639 assert_eq!(place_for("", "google", &calendars, false), Place::Make(vdir::LOCAL.into()));
640 assert_eq!(place_for("other/work", "cloud", &calendars, true), Place::Found(here("other", "work")));
642 let read_only = [Collection { read_only: true, ..here("other", "work") }, here("cloud", CALENDAR)];
643 assert_eq!(place_for("other/work", "cloud", &read_only, true), Place::Found(here("cloud", CALENDAR)));
644 let errand = Task { margins: crate::demands::Margins { before: 10, after: 10 }, ..task("e", "Errand") };
646 assert_eq!((default_minutes(&errand, Some(53), 30), default_minutes(&errand, None, 30), default_minutes(&task("t", "T"), Some(8), 30)), (35, 30, 15));
647 }
648}