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 make_calendar(account: &str, name: &str) -> Result<Collection, String> {
380 let dir = vdir::prepare(Kind::Calendars, account, CALENDAR, name, None).map_err(|e| e.to_string())?;
381 let state = vdir::State { pending: account != vdir::LOCAL, components: vec!["VEVENT".to_string()], ..vdir::State::default() };
382 state.save(&vdir::state_path(account, Kind::Calendars, CALENDAR))?;
383 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 })
384}
385
386pub fn given_times<'a>(tasks: &'a [Task], today: Date, zone: &TimeZone) -> Vec<(&'a Task, i64)> {
390 tasks
391 .iter()
392 .filter(|t| t.status.is_open() && !t.at.is_empty())
393 .filter_map(|t| {
394 let date: Date = t.at.get(..10)?.parse().ok()?;
395 if date < today {
396 return None;
397 }
398 let local: DateTime = t.at.parse().ok()?;
399 Some((t, local.to_zoned(zone.clone()).ok()?.timestamp().as_second()))
400 })
401 .collect()
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn paris() -> TimeZone {
409 TimeZone::get("Europe/Paris").unwrap()
410 }
411
412 fn at(text: &str) -> i64 {
413 text.parse::<DateTime>().unwrap().to_zoned(paris()).unwrap().timestamp().as_second()
414 }
415
416 fn now() -> Zoned {
417 "2026-10-06T09:00:00+02:00[Europe/Paris]".parse().unwrap()
418 }
419
420 fn task(uid: &str, title: &str) -> Task {
421 Task { uid: uid.into(), title: title.into(), estimate: 30, ..Task::default() }
422 }
423
424 fn calendar(name: &str) -> Collection {
426 let dir = std::env::temp_dir().join(format!("sioul-blocks-{}-{name}", std::process::id()));
427 let _ = std::fs::remove_dir_all(&dir);
428 std::fs::create_dir_all(&dir).unwrap();
429 Collection { kind: Kind::Calendars, account: "a".into(), id: CALENDAR.into(), dir, name: "Planned tasks".into(), color: None, read_only: false, components: vec!["VEVENT".into()] }
430 }
431
432 fn read_back(calendar: &Collection) -> Vec<Occurrence> {
434 read(std::slice::from_ref(calendar), at("2026-10-01T00:00"), at("2026-12-31T00:00"), &paris())
435 }
436
437 #[test]
438 fn a_block_made_read_back_and_tied_both_ways() {
439 let bank = task("bank-1", "Call the bank, about the loan");
440 let text = new_block(&bank, "block-1", at("2026-10-06T14:00"), at("2026-10-06T14:45"), false, &now()).unwrap();
441 assert!(text.contains("TRANSP:OPAQUE") && text.contains("X-SIOUL-TASK:bank-1") && text.contains("SUMMARY:Call the bank\\, about the loan"), "{text}");
443 assert!(lines::unfold(&text).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/block-of\";VALUE=UID:bank-1"), "{text}");
444 assert!(!text.contains("VALARM") && !text.contains("X-SIOUL-TURN"), "{text}");
445 assert!(agenda::parse(&text).is_some());
446 let with_alarm = new_block(&bank, "block-2", at("2026-10-06T14:00"), at("2026-10-06T14:45"), true, &now()).unwrap();
447 assert!(with_alarm.contains("BEGIN:VALARM") && with_alarm.contains("TRIGGER:-PT5M"), "{with_alarm}");
448 let place = calendar("made");
450 std::fs::write(place.dir.join("block-1.ics"), &text).unwrap();
451 let found = read_back(&place);
452 assert_eq!(found.len(), 1);
453 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")));
454 assert_eq!(found[0].alarms, Vec::<i64>::new());
455 let task_text = tasks::new_task(&tasks::TaskEdit { title: bank.title.clone(), ..tasks::TaskEdit::default() }, "bank-1", &paris(), &now()).unwrap();
457 let linked = task_pinned(&task_text, Some("block-1"), &now()).unwrap();
458 let read = tasks::task_of_text(&linked, &paris()).unwrap();
459 assert_eq!(read.links, vec![task_link("block-1")]);
460 assert!(lines::unfold(&linked).iter().any(|l| l == "LINK;LINKREL=\"tag:aurelienpierre.com,2026:sioul/rel/time-block\";VALUE=UID:block-1"), "{linked}");
461 assert_eq!(task_pinned(&linked, Some("block-1"), &now()).unwrap(), linked);
462 let other = tasks::task_of_text(&task_pinned(&linked, Some("block-9"), &now()).unwrap(), &paris()).unwrap();
464 assert_eq!(other.links, vec![task_link("block-9")]);
465 assert!(tasks::task_of_text(&task_pinned(&linked, None, &now()).unwrap(), &paris()).unwrap().links.is_empty());
466 let task = Task { links: read.links.clone(), ..bank.clone() };
468 let event = agenda::event_ref(&text, "block-1.ics", &paris()).unwrap();
469 let (empty_mail, empty_links) = (crate::mailindex::MailIndex::default(), crate::links::LocalLinks::default());
470 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: &[] };
471 let edges = world.edges();
472 assert!(edges.iter().any(|e| e.from == crate::links::task_uri("bank-1") && e.to == crate::links::event_uri("block-1")), "{edges:?}");
473 assert!(edges.iter().any(|e| e.from == crate::links::event_uri("block-1") && e.to == crate::links::task_uri("bank-1")), "{edges:?}");
474 }
475
476 #[test]
477 fn a_task_pinned_to_its_first_block_to_come() {
478 let zone = paris();
479 let bank = task("bank", "Call the bank");
480 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() };
481 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")];
482 let now = at("2026-10-06T09:00");
483 let found = Blocks::of(&blocks, std::slice::from_ref(&bank), now, &zone);
484 let pin = &found.pins["bank"];
486 assert_eq!((pin.key.as_str(), pin.minutes(), pin.date), ("tuesday", 45, Date::constant(2026, 10, 6)));
487 let stranger = Occurrence { task: "elsewhere".into(), ..block("stranger", "2026-10-06T16:00", "2026-10-06T17:00") };
489 let events = without_blocks(vec![blocks[2].clone(), stranger.clone(), Occurrence { key: "dentist".into(), ..Occurrence::default() }], std::slice::from_ref(&bank));
490 assert_eq!(events.iter().map(|e| e.key.as_str()).collect::<Vec<_>>(), ["stranger", "dentist"]);
491 let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:30")), ..bank.clone() };
493 let found = Blocks::of(&blocks, std::slice::from_ref(&done), at("2026-10-06T15:00"), &zone);
494 assert!(found.pins.is_empty());
495 assert_eq!(found.records.get("bank"), Some(&(at("2026-10-06T14:00"), at("2026-10-06T14:45"))));
496 }
497
498 #[test]
499 fn a_block_moved_then_deleted_in_another_application() {
500 let zone = paris();
501 let bank = task("bank", "Call the bank");
502 let place = calendar("elsewhere");
503 let path = place.dir.join("b.ics");
504 let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
505 std::fs::write(&path, &text).unwrap();
506 let now = at("2026-10-06T09:00");
507 assert_eq!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].start, at("2026-10-06T14:00"));
508 let elsewhere = text.replace("DTSTART:20261006T120000Z", "DTSTART;TZID=Europe/Paris:20261007T160000").replace("DTEND:20261006T130000Z", "DTEND;TZID=Europe/Paris:20261007T170000\r\nSEQUENCE:3");
510 std::fs::write(&path, &elsewhere).unwrap();
511 let pin = Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins["bank"].clone();
512 assert_eq!((pin.start, pin.date), (at("2026-10-07T16:00"), Date::constant(2026, 10, 7)));
513 let back = moved(&elsewhere, at("2026-10-07T10:00"), at("2026-10-07T10:45"), &zone).unwrap();
515 assert_eq!(agenda::first_times(&back, &zone), Some((at("2026-10-07T10:00"), at("2026-10-07T10:45"))));
516 assert!(back.contains("X-SIOUL-TASK:bank") && back.contains("SEQUENCE:4"), "{back}");
517 std::fs::remove_file(&path).unwrap();
519 assert!(Blocks::of(&read_back(&place), std::slice::from_ref(&bank), now, &zone).pins.is_empty());
520 }
521
522 #[test]
523 fn done_or_dropped_frees_what_is_to_come_and_keeps_the_record() {
524 let (start, end) = (at("2026-10-06T14:00"), at("2026-10-06T15:00"));
526 assert_eq!(closing(start, end, at("2026-10-06T13:00")), Closing::Delete, "not begun: it goes");
527 assert_eq!(closing(start, end, at("2026-10-06T14:20") + 30), Closing::EndAt(at("2026-10-06T14:21")), "under way: it ends then");
528 assert_eq!(closing(start, end, start + 30), Closing::EndAt(start + 300), "five minutes at least");
529 assert_eq!(closing(start, end, at("2026-10-06T16:00")), Closing::Keep, "over: the record of the work");
530 let bank = task("bank", "Call the bank");
532 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() };
533 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")];
534 let done = Task { status: Status::Completed, completed: Some(at("2026-10-06T14:20")), ..bank.clone() };
535 let fixes = upkeep(&blocks, std::slice::from_ref(&done), at("2026-10-06T16:00"));
536 assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:20"))), ("friday".to_string(), Fix::Delete)]);
537 let dropped = Task { status: Status::Cancelled, ..bank.clone() };
539 let fixes = upkeep(&blocks, std::slice::from_ref(&dropped), at("2026-10-06T14:30"));
540 assert_eq!(fixes, vec![("now".to_string(), Fix::EndAt(at("2026-10-06T14:30"))), ("friday".to_string(), Fix::Delete)]);
541 let text = new_block(&bank, "now", start, end, false, &now()).unwrap();
543 let shorter = ended(&text, at("2026-10-06T14:20"), &paris()).unwrap();
544 assert_eq!(agenda::first_times(&shorter, &paris()), Some((start, at("2026-10-06T14:20"))));
545 }
546
547 #[test]
548 fn the_title_kept_in_step() {
549 let bank = task("bank", "Call the bank");
550 let text = new_block(&bank, "b", at("2026-10-06T14:00"), at("2026-10-06T15:00"), false, &now()).unwrap();
551 let renamed = Task { title: "Call the bank about the loan".into(), ..bank.clone() };
552 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() };
553 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()))]);
554 let titled = titled(&text, &renamed.title, &paris()).unwrap();
555 assert!(titled.contains("SUMMARY:Call the bank about the loan") && titled.contains("SEQUENCE:1") && titled.contains("X-SIOUL-TASK:bank"), "{titled}");
556 assert_eq!(super::titled(&titled, &renamed.title, &paris()), None, "already so: nothing written");
557 assert!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&renamed), at("2026-10-06T16:00")).is_empty());
559 }
560
561 #[test]
562 fn a_block_pins_one_turn_of_a_repeating_task() {
563 let zone = paris();
564 let plants = Task { repeat: "weekly".into(), due: "2026-10-09".into(), ..task("plants", "Water the plants") };
565 let text = new_block(&plants, "p", at("2026-10-08T18:00"), at("2026-10-08T18:30"), false, &now()).unwrap();
566 assert!(text.contains("X-SIOUL-TURN:2026-10-09"), "{text}");
567 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() };
568 let now = at("2026-10-06T09:00");
569 assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&plants), now, &zone).pins.contains_key("plants"));
570 let next = Task { due: "2026-10-16".into(), ..plants.clone() };
572 assert!(Blocks::of(std::slice::from_ref(&block), std::slice::from_ref(&next), now, &zone).pins.is_empty());
573 assert_eq!(upkeep(std::slice::from_ref(&block), std::slice::from_ref(&next), now), vec![("p.ics".to_string(), Fix::Delete)]);
574 let followed = turned(&text, "2026-10-16").unwrap();
576 assert!(followed.contains("X-SIOUL-TURN:2026-10-16") && !followed.contains("2026-10-09"), "{followed}");
577 assert_eq!(turn_of(&task("once", "Once")), "");
579 }
580
581 #[test]
582 fn a_time_given_before_blocks_becomes_one() {
583 let zone = paris();
584 let today = Date::constant(2026, 10, 6);
585 let given = Task { at: "2026-10-06T14:30".into(), ..task("given", "Write the letter") };
586 let yesterday = Task { at: "2026-10-05T10:00".into(), ..task("old", "Old") };
587 let done = Task { at: "2026-10-06T16:00".into(), status: Status::Completed, ..task("done", "Done") };
588 let all = [given.clone(), yesterday, done];
589 let found = given_times(&all, today, &zone);
590 assert_eq!(found.iter().map(|(t, s)| (t.uid.as_str(), *s)).collect::<Vec<_>>(), [("given", at("2026-10-06T14:30"))]);
591 let (task, start) = found[0];
593 let block = new_block(task, "block-given", start, start + i64::from(default_minutes(task, None, 30)) * 60, false, &now()).unwrap();
594 assert_eq!(agenda::first_times(&block, &zone), Some((at("2026-10-06T14:30"), at("2026-10-06T15:00"))));
595 let read = Occurrence { key: "block-given.ics".into(), uid: "block-given".into(), start, end: at("2026-10-06T15:00"), task: "given".into(), ..Occurrence::default() };
596 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"));
597 let text = tasks::new_task(&tasks::TaskEdit { title: given.title.clone(), ..tasks::TaskEdit::default() }, "given", &zone, &now()).unwrap();
599 let old = text.replace("END:VTODO", "X-SIOUL-AT:20261006T123000Z\r\nEND:VTODO");
600 assert_eq!(tasks::task_of_text(&old, &zone).unwrap().at, "2026-10-06T14:30");
601 let pinned = task_pinned(&old, Some("block-given"), &now()).unwrap();
602 let read = tasks::task_of_text(&pinned, &zone).unwrap();
603 assert_eq!((read.at.as_str(), read.links.clone()), ("", vec![task_link("block-given")]));
604 assert!(!pinned.contains("X-SIOUL-AT"), "{pinned}");
605 }
606
607 #[test]
608 fn where_blocks_go() {
609 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![] };
610 let calendars = [here("cloud", "personal"), here("cloud", CALENDAR), here("other", "work")];
611 assert_eq!(place_for("", "cloud", &calendars, true), Place::Found(here("cloud", CALENDAR)));
613 assert_eq!(place_for("", "other", &calendars, true), Place::Make("other".into()));
615 assert_eq!(place_for("", vdir::LOCAL, &calendars, true), Place::Make(vdir::LOCAL.into()));
616 assert_eq!(place_for("", "google", &calendars, false), Place::Make(vdir::LOCAL.into()));
617 assert_eq!(place_for("other/work", "cloud", &calendars, true), Place::Found(here("other", "work")));
619 let read_only = [Collection { read_only: true, ..here("other", "work") }, here("cloud", CALENDAR)];
620 assert_eq!(place_for("other/work", "cloud", &read_only, true), Place::Found(here("cloud", CALENDAR)));
621 let errand = Task { margins: crate::demands::Margins { before: 10, after: 10 }, ..task("e", "Errand") };
623 assert_eq!((default_minutes(&errand, Some(53), 30), default_minutes(&errand, None, 30), default_minutes(&task("t", "T"), Some(8), 30)), (35, 30, 15));
624 }
625}