Files
Marcos Paulo 15d7f5e873 feat: editor blocks and lint, vault knowledge, calendar plugin, macOS menu
- 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>
2026-10-18 12:36:00 -03:00

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:?}");
}