15d7f5e873
- notescraft-lint (rules, safe fixes, learned style) and notescraft-blocks (block model and edits); engine lint.* and block.* commands with CLI and MCP parity - Lint tab, underlines, slash menu with preview in Source and Visual, block grip and menu, Settings > Editor and lint, Vim block keys - Visual view: tables, note embeds, %% comments, wrapping property chips - Vault knowledge (Related tab, assistant sources, Settings) and calendar plugin - Sidebar top padding fixed, dot grid follows PaperCraft spacing, right-card tabs no longer cover the collapse button Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
9.3 KiB
Rust
227 lines
9.3 KiB
Rust
//! Agenda queries, day views and the week review.
|
|
#![allow(clippy::unwrap_used, clippy::cast_possible_truncation, clippy::assert_is_empty)]
|
|
|
|
use std::time::Instant;
|
|
|
|
use notescraft_calendar::{
|
|
Filter, GroupKey, Item, Kind, Priority, Query, SortKey, Status, conflicts, free_slots, heat_level, run_query, week_review, year_counts,
|
|
};
|
|
use notescraft_templates::Weekday;
|
|
|
|
fn t(text: &str) -> notescraft_calendar::EventTime {
|
|
text.parse().unwrap()
|
|
}
|
|
|
|
fn item(id: &str, start: &str) -> Item {
|
|
Item {
|
|
id: id.to_owned(),
|
|
kind: Kind::Event,
|
|
title: id.to_owned(),
|
|
start: t(start),
|
|
end: None,
|
|
calendar: None,
|
|
tags: vec![],
|
|
priority: None,
|
|
status: None,
|
|
has_reminder: false,
|
|
reminders: vec![],
|
|
note: None,
|
|
created: None,
|
|
done: false,
|
|
repeats: false,
|
|
source: None,
|
|
tz: None,
|
|
}
|
|
}
|
|
|
|
fn ids(groups: &[notescraft_calendar::Group]) -> Vec<Vec<String>> {
|
|
groups.iter().map(|g| g.items.iter().map(|i| i.id.clone()).collect()).collect()
|
|
}
|
|
|
|
fn sample() -> Vec<Item> {
|
|
let mut a = item("a", "2026-10-20T09:00");
|
|
a.calendar = Some("Work".to_owned());
|
|
a.tags = vec!["team".to_owned(), "Q4".to_owned()];
|
|
a.priority = Some(Priority::High);
|
|
a.has_reminder = true;
|
|
a.note = Some("n/a.md".to_owned());
|
|
a.created = Some(t("2026-01-02"));
|
|
let mut b = item("B", "2026-10-19");
|
|
b.calendar = Some("Home".to_owned());
|
|
b.priority = Some(Priority::Low);
|
|
b.status = Some(Status::Tentative);
|
|
b.kind = Kind::Task;
|
|
let mut c = item("c", "2026-10-27T08:00");
|
|
c.calendar = Some("Work".to_owned());
|
|
c.priority = Some(Priority::Medium);
|
|
c.note = Some("n/0.md".to_owned());
|
|
c.created = Some(t("2025-12-01"));
|
|
vec![a, b, c]
|
|
}
|
|
|
|
fn q(sort: SortKey, descending: bool, group: GroupKey) -> Query {
|
|
Query { filter: Filter::default(), sort, descending, group }
|
|
}
|
|
|
|
#[test]
|
|
fn every_filter_field() {
|
|
let items = sample();
|
|
let run = |filter: Filter| ids(&run_query(&items, &Query { filter, ..Query::default() }, Weekday::Monday)).concat();
|
|
assert_eq!(run(Filter::default()), ["B", "a", "c"]);
|
|
assert_eq!(run(Filter { calendars: vec!["Work".into()], ..Filter::default() }), ["a", "c"]);
|
|
assert_eq!(run(Filter { kinds: vec![Kind::Task], ..Filter::default() }), ["B"]);
|
|
assert_eq!(run(Filter { statuses: vec![Status::Confirmed], ..Filter::default() }), ["a", "c"]);
|
|
assert_eq!(run(Filter { priorities: vec![Priority::High, Priority::Low], ..Filter::default() }), ["B", "a"]);
|
|
assert_eq!(run(Filter { tags: vec!["q4".into()], ..Filter::default() }), ["a"]);
|
|
assert_eq!(run(Filter { has_reminder: Some(true), ..Filter::default() }), ["a"]);
|
|
assert_eq!(run(Filter { has_reminder: Some(false), ..Filter::default() }), ["B", "c"]);
|
|
let range = Filter { from: Some(t("2026-10-20")), to: Some(t("2026-10-26")), ..Filter::default() };
|
|
assert_eq!(run(range), ["a"]);
|
|
let both = Filter { calendars: vec!["Work".into()], priorities: vec![Priority::Medium], ..Filter::default() };
|
|
assert_eq!(run(both), ["c"]);
|
|
}
|
|
|
|
#[test]
|
|
fn every_sort_key_both_ways_with_missing_last() {
|
|
let items = sample();
|
|
let order = |sort, desc| ids(&run_query(&items, &q(sort, desc, GroupKey::None), Weekday::Monday)).concat();
|
|
assert_eq!(order(SortKey::Date, false), ["B", "a", "c"]);
|
|
assert_eq!(order(SortKey::Date, true), ["c", "a", "B"]);
|
|
assert_eq!(order(SortKey::Priority, false), ["a", "c", "B"]);
|
|
assert_eq!(order(SortKey::Priority, true), ["B", "c", "a"]);
|
|
assert_eq!(order(SortKey::Title, false), ["a", "B", "c"]);
|
|
assert_eq!(order(SortKey::LinkedNote, false), ["c", "a", "B"]);
|
|
assert_eq!(order(SortKey::LinkedNote, true), ["a", "c", "B"]);
|
|
assert_eq!(order(SortKey::Created, false), ["c", "a", "B"]);
|
|
assert_eq!(order(SortKey::Created, true), ["a", "c", "B"]);
|
|
}
|
|
|
|
#[test]
|
|
fn grouping_by_day_week_calendar_and_tag() {
|
|
let items = sample();
|
|
let groups = |key, start| run_query(&items, &q(SortKey::Date, false, key), start);
|
|
let day = groups(GroupKey::Day, Weekday::Monday);
|
|
assert_eq!(day.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-19", "2026-10-20", "2026-10-27"]);
|
|
let week = groups(GroupKey::Week, Weekday::Monday);
|
|
assert_eq!(week.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-19", "2026-10-26"]);
|
|
assert_eq!(ids(&week), [vec!["B", "a"], vec!["c"]]);
|
|
let sunday = groups(GroupKey::Week, Weekday::Sunday);
|
|
assert_eq!(sunday.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["2026-10-18", "2026-10-25"]);
|
|
let calendar = groups(GroupKey::Calendar, Weekday::Monday);
|
|
assert_eq!(calendar.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["Home", "Work"]);
|
|
let tag = groups(GroupKey::Tag, Weekday::Monday);
|
|
assert_eq!(tag.iter().map(|g| g.label.as_str()).collect::<Vec<_>>(), ["", "Q4", "team"]);
|
|
assert_eq!(groups(GroupKey::None, Weekday::Monday).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn a_saved_query_survives_json() {
|
|
let saved = notescraft_calendar::SavedFilter {
|
|
name: "Work".into(),
|
|
query: Query {
|
|
filter: Filter { calendars: vec!["Work".into()], from: Some(t("2026-01-01")), ..Filter::default() },
|
|
sort: SortKey::Priority,
|
|
descending: true,
|
|
group: GroupKey::Tag,
|
|
},
|
|
};
|
|
let json = serde_json::to_string(&saved).unwrap();
|
|
assert_eq!(serde_json::from_str::<notescraft_calendar::SavedFilter>(&json).unwrap(), saved);
|
|
assert_eq!(serde_json::from_str::<Query>("{}").unwrap(), Query::default());
|
|
}
|
|
|
|
fn span(id: &str, start: &str, end: &str) -> Item {
|
|
let mut i = item(id, start);
|
|
i.end = Some(t(end));
|
|
i
|
|
}
|
|
|
|
#[test]
|
|
fn overlaps_are_conflicts_touching_and_all_day_are_not() {
|
|
let items = [
|
|
span("a", "2026-10-20T09:00", "2026-10-20T10:00"),
|
|
span("b", "2026-10-20T09:30", "2026-10-20T11:00"),
|
|
span("c", "2026-10-20T10:00", "2026-10-20T10:30"),
|
|
span("d", "2026-10-20T10:45", "2026-10-20T10:50"),
|
|
span("e", "2026-10-20", "2026-10-21"),
|
|
item("f", "2026-10-20T09:15"),
|
|
];
|
|
assert_eq!(conflicts(&items), [(0, 1), (1, 2), (1, 3)]);
|
|
let mut cancelled = items.clone();
|
|
cancelled[1].status = Some(Status::Cancelled);
|
|
assert!(conflicts(&cancelled).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn free_time_between_busy_stretches() {
|
|
let day = t("2026-10-20").date();
|
|
let items = [
|
|
span("a", "2026-10-20T09:00", "2026-10-20T10:00"),
|
|
span("b", "2026-10-20T09:30", "2026-10-20T11:00"),
|
|
span("night", "2026-10-19T22:00", "2026-10-20T08:30"),
|
|
span("other day", "2026-10-21T09:00", "2026-10-21T10:00"),
|
|
span("all day", "2026-10-20", "2026-10-20"),
|
|
];
|
|
let slots: Vec<(u32, u32)> = free_slots(&items, day, 8 * 60, 18 * 60, 30).iter().map(|s| (s.start, s.end)).collect();
|
|
assert_eq!(slots, [(510, 540), (660, 1080)]);
|
|
let long: Vec<(u32, u32)> = free_slots(&items, day, 8 * 60, 18 * 60, 60).iter().map(|s| (s.start, s.end)).collect();
|
|
assert_eq!(long, [(660, 1080)]);
|
|
assert!(free_slots(&[], day, 600, 600, 1).is_empty());
|
|
assert_eq!(free_slots(&[], day, 0, 99_999, 1).len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn year_counts_and_heat() {
|
|
let items = [item("a", "2026-01-01"), span("b", "2026-12-30", "2027-01-02"), item("c", "2025-06-01")];
|
|
let counts = year_counts(&items, 2026);
|
|
assert_eq!(counts.len(), 365);
|
|
assert_eq!((counts[0], counts[363], counts[364]), (1, 1, 1));
|
|
assert_eq!(year_counts(&[], 2024).len(), 366);
|
|
assert_eq!([0, 1, 2, 3, 4, 40].map(heat_level), [0, 1, 2, 2, 3, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn the_week_in_review() {
|
|
let mut done = item("done", "2026-10-21");
|
|
done.kind = Kind::Task;
|
|
done.done = true;
|
|
done.note = Some("x.md".into());
|
|
let mut open = item("open", "2026-10-22");
|
|
open.kind = Kind::Task;
|
|
let mut meeting = span("m", "2026-10-20T09:00", "2026-10-20T10:30");
|
|
meeting.note = Some("x.md".into());
|
|
let mut dropped = item("dropped", "2026-10-23T09:00");
|
|
dropped.status = Some(Status::Cancelled);
|
|
let outside = item("outside", "2026-10-27");
|
|
let review = week_review(&[outside, dropped, open, done, meeting], t("2026-10-19").date());
|
|
assert_eq!(review.week_start, "2026-10-19");
|
|
assert_eq!(review.events.iter().map(|i| i.id.as_str()).collect::<Vec<_>>(), ["m"]);
|
|
assert_eq!((review.tasks_done.len(), review.tasks_open.len(), review.event_minutes), (1, 1, 90));
|
|
assert_eq!(review.notes, ["x.md"]);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "benchmark: run with --ignored --release for the real number"]
|
|
fn ten_thousand_items_filter_and_sort_fast() {
|
|
let items: Vec<Item> = (0..10_000_u32)
|
|
.map(|n| {
|
|
let mut i = item(&format!("e{n}"), &format!("2026-{:02}-{:02}T{:02}:00", 1 + n % 12, 1 + n % 28, n % 24));
|
|
i.calendar = Some(format!("c{}", n % 7));
|
|
i.tags = vec![format!("t{}", n % 13)];
|
|
i.priority = [Some(Priority::Low), None, Some(Priority::High)][(n % 3) as usize];
|
|
i
|
|
})
|
|
.collect();
|
|
let query = Query {
|
|
filter: Filter { tags: vec!["t3".into(), "t5".into()], ..Filter::default() },
|
|
sort: SortKey::Priority,
|
|
descending: false,
|
|
group: GroupKey::Calendar,
|
|
};
|
|
let started = Instant::now();
|
|
let groups = run_query(&items, &query, Weekday::Monday);
|
|
let took = started.elapsed();
|
|
assert!(!groups.is_empty());
|
|
assert!(took.as_millis() < 100, "took {took:?}");
|
|
}
|