Compare commits

...

7 Commits

4 changed files with 153 additions and 91 deletions

View File

@ -99,7 +99,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
+ match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive) + match by task name prefix: if one or more tasks match, filter / activate (tries case-sensitive then case-insensitive)
+ no match: create & activate task + no match: create & activate task
- `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1) - `.2` - set view depth to `2`, which can be substituted for any number (how many subtask levels to show, default 1)
- `/[TEXT]` - like `.`, but never creates a task - `/[TEXT]` - like `.`, but never creates a task and filters beyond currently visible tasks
- `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task) - `||TASK` - create and activate a new task procedure (where subtasks automatically depend on the previously created task)
- `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up - `|[TASK]` - (un)mark current task as procedure or create a sibling task depending on the current one and move up
@ -109,12 +109,13 @@ Dots and slashes can be repeated to move to parent tasks.
- `::[PROP]` - Sort by property PROP (multiple space-separated values allowed) - `::[PROP]` - Sort by property PROP (multiple space-separated values allowed)
- `([TIME]` - insert timetracking with the specified offset in minutes (empty: list tracked times) - `([TIME]` - insert timetracking with the specified offset in minutes (empty: list tracked times)
- `)[TIME]` - stop timetracking with the specified offset in minutes - convenience helper to move to root (empty: stop now) - `)[TIME]` - stop timetracking with the specified offset in minutes - convenience helper to move to root (empty: stop now)
- `>[TEXT]` - complete active task and move up, with optional state description - `>[TEXT]` - complete active task and move up, with optional status description
- `<[TEXT]` - close active task and move up, with optional state description - `<[TEXT]` - close active task and move up, with optional status description
- `!TEXT` - set state for current task from text and move up - `!TEXT` - set status for current task from text and move up (empty to open)
- `,TEXT` - add text note (comment / description) - `,TEXT` - add text note (comment / description)
- TBI: `*[INT]` - set priority - can also be used in task, with any digit - TBI: `*[INT]` - set priority - can also be used in task creation, with any digit
- `@` - undoes last action (moving in place or upwards or waiting a minute confirms pending actions) - TBI: status history and creation with attribution
- `&` - undo last action (moving in place or upwards confirms pending actions)
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one) - `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters: Property Filters:
@ -123,6 +124,8 @@ Property Filters:
- `+TAG` - add tag filter - `+TAG` - add tag filter
- `-TAG` - remove tag filters - `-TAG` - remove tag filters
- `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all - `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
- `@AUTHOR` - filter by author (`@` for self, id prefix, name prefix)
- TBI: Filter by time
Status descriptions can be used for example for Kanban columns or review flows. Status descriptions can be used for example for Kanban columns or review flows.
An active tag or status filter will also set that attribute for newly created tasks. An active tag or status filter will also set that attribute for newly created tasks.
@ -148,6 +151,11 @@ An active tag or status filter will also set that attribute for newly created ta
For debugging: `props`, `alltags`, `descriptions` For debugging: `props`, `alltags`, `descriptions`
### Notes
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one
## Nostr reference ## Nostr reference
Mostr mainly uses the following NIPs: Mostr mainly uses the following NIPs:
@ -164,17 +172,26 @@ Considering to use Calendar: https://github.com/nostr-protocol/nips/blob/master/
## Plans ## Plans
- Remove state filter when moving up? - Local Database Cache, Negentropy Reconciliation
-> Offline Use!
- Scheduling
- Remove status filter when moving up?
- Task markdown support? - colored - Task markdown support? - colored
- Time tracking: Ability to postpone task and add planned timestamps (calendar entry) - Time tracking: Ability to postpone task and add planned timestamps (calendar entry)
- Parse Hashtag tags from task name - Parse Hashtag tags from task name
- Unified Filter object - Unified Filter object
-> include subtasks of matched tasks -> include subtasks of matched tasks
- Relay Switching
- Speedup: Offline caching & Expiry (no need to fetch potential years of history) - Speedup: Offline caching & Expiry (no need to fetch potential years of history)
+ Fetch most recent tasks first + Fetch most recent tasks first
+ Relay: compress tracked time for old tasks, filter closed tasks + Relay: compress tracked time for old tasks, filter closed tasks
+ Relay: filter out task state updates within few seconds, also on client side + Relay: filter out task status updates within few seconds, also on client side
### Command
- Open Command characters: `_^\=$%~'"`, `{}[]`
- Remove colon from task creation syntax
- reassign undo to `&` and use `@` for people
- maybe use `;` for sorting instead of `::`
### Conceptual ### Conceptual
@ -182,9 +199,10 @@ The following features are not ready to be implemented
because they need conceptualization. because they need conceptualization.
Suggestions welcome! Suggestions welcome!
- Special commands: help, exit, tutorial, change log level
- Duplicate task (subtasks? timetracking?)
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense? - What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Priorities - Dependencies (change from tags to properties so they can be added later? or maybe as a status?)
- Dependencies (change from tags to properties so they can be added later? or maybe as a state?)
- Templates - Templates
- Ownership - Ownership
- Combined formatting and recursion specifiers - Combined formatting and recursion specifiers
@ -194,11 +212,45 @@ Suggestions welcome!
### Interfaces ### Interfaces
- TUI: Clear terminal? Refresh on empty prompt after timeout? - TUI: Clear Terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar - Kanban, GANTT, Calendar
- Web Interface, Messenger integrations - Web Interface, Messenger integrations
## Notes ## Exemplary Workflows
- TBI = To Be Implemented - Freelancer
- `. TASK` - create and enter a new task even if the name matches an existing one - Family Chore management
- Inter-Disciplinary Project Team -> Company with multiple projects and multiple relays
+ Permissions via status or assignment (reassignment?)
+ Tasks can be blocked while having a status (e.g. kanban column)
+ A meeting can be worked on (tracked) before it starts
+ Schedule for multiple people
- Tracking Daily Routines / Habits
### Contexts
A context is a custom set of filters such as status, tags, assignee
so that the visible tasks are always relevant
and newly created tasks are less of a hassle to type out
since they will automatically take on that context.
By automating these contexts based on triggers, scripts or time,
relevant tasks can be surfaced automatically.
#### Example
In the morning, your groggy brain is good at divergent thinking,
and you like to do sports in the morning.
So for that time, mostr can show you tasks tagged for divergent thinking,
since you are easily distracted filter out those that require the internet,
as well as anything sportsy.
After you come back from sports and had breakfast,
for example detected through a period of inactivity on your device,
you are ready for work, so the different work projects are shown and you delve into one.
After 90 minutes you reach a natural low in your focus,
so mostr surfaces break activities -
such as a short walk, a small workout, some instrument practice
or simply grabbing a snack and drink.
After lunch you like to take an extended afternoon break,
so your call list pops up -
you can give a few people a call as you make a market run,
before going for siesta.

View File

@ -7,6 +7,7 @@ pub fn some_non_empty(str: &str) -> Option<String> {
if str.is_empty() { None } else { Some(str.to_string()) } if str.is_empty() { None } else { Some(str.to_string()) }
} }
// TODO as macro so that log comes from appropriate module
pub fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> { pub fn or_print<T, U: Display>(result: Result<T, U>) -> Option<T> {
match result { match result {
Ok(value) => Some(value), Ok(value) => Some(value),

View File

@ -13,7 +13,7 @@ use std::sync::mpsc::RecvTimeoutError;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::time::Duration; use std::time::Duration;
use colored::Colorize; use colored::{ColoredString, Colorize};
use env_logger::Builder; use env_logger::Builder;
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, LevelFilter, trace, warn}; use log::{debug, error, info, LevelFilter, trace, warn};
@ -23,7 +23,6 @@ use xdg::BaseDirectories;
use crate::helpers::*; use crate::helpers::*;
use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND}; use crate::kinds::{KINDS, PROPERTY_COLUMNS, TRACKING_KIND};
use crate::MostrMessage::AddTasks;
use crate::task::{MARKER_DEPENDS, MARKER_PARENT, State}; use crate::task::{MARKER_DEPENDS, MARKER_PARENT, State};
use crate::tasks::{PropertyCollection, StateFilter, Tasks}; use crate::tasks::{PropertyCollection, StateFilter, Tasks};
@ -80,7 +79,9 @@ impl EventSender {
debug!("Flushing {} events from queue", self.queue.borrow().len()); debug!("Flushing {} events from queue", self.queue.borrow().len());
let values = self.clear(); let values = self.clear();
self.url.as_ref().map(|url| { self.url.as_ref().map(|url| {
or_print(self.tx.send(AddTasks(url.clone(), values))); self.tx.send(MostrMessage::AddTasks(url.clone(), values)).inspect_err(|e| {
error!("Nostr communication thread failure, changes will not be persisted: {}", e)
})
}); });
} }
/// Sends all pending events if there is a non-tracking event /// Sends all pending events if there is a non-tracking event
@ -149,6 +150,7 @@ async fn main() {
let client = Client::new(&keys); let client = Client::new(&keys);
info!("My public key: {}", keys.public_key()); info!("My public key: {}", keys.public_key());
// TODO use NewRelay message for all relays
match var("MOSTR_RELAY") { match var("MOSTR_RELAY") {
Ok(relay) => { Ok(relay) => {
or_print(client.add_relay(relay).await); or_print(client.add_relay(relay).await);
@ -237,19 +239,26 @@ async fn main() {
let mut queue: Option<(Url, Vec<Event>)> = None; let mut queue: Option<(Url, Vec<Event>)> = None;
loop { loop {
// TODO invalid acknowledgement from bucket relay slows sending down let result = rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY));
match rx.recv_timeout(Duration::from_secs(INACTVITY_DELAY)) { match result {
Ok(AddTasks(url, mut events)) => { Ok(MostrMessage::NewRelay(url)) => {
if 1 == 2 { if client.add_relay(&url).await.unwrap() {
client.connect_relay("").await; match client.connect_relay(&url).await {
Ok(()) => info!("Connected to {url}"),
Err(e) => warn!("Unable to connect to relay {url}: {e}")
} }
debug!("Queueing {:?}", &events); } else {
warn!("Relay {url} already added");
}
}
Ok(MostrMessage::AddTasks(url, mut events)) => {
trace!("Queueing {:?}", &events);
if let Some((queue_url, mut queue_events)) = queue { if let Some((queue_url, mut queue_events)) = queue {
if queue_url == url { if queue_url == url {
queue_events.append(&mut events); queue_events.append(&mut events);
queue = Some((queue_url, queue_events)); queue = Some((queue_url, queue_events));
} else { } else {
info!("Sending {} events due to relay change", queue_events.len()); info!("Sending {} events to {url} due to relay change", queue_events.len());
client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await; client.batch_event_to(vec![queue_url], queue_events, RelaySendOptions::new()).await;
queue = None; queue = None;
} }
@ -259,19 +268,19 @@ async fn main() {
queue = Some((url, events)) queue = Some((url, events))
} }
} }
Err(RecvTimeoutError::Timeout) => if let Some((url, events)) = queue { Ok(MostrMessage::Flush) | Err(RecvTimeoutError::Timeout) => if let Some((url, events)) = queue {
info!("Sending {} events due to inactivity", events.len()); info!("Sending {} events to {url} due to {:?}", events.len(), result);
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await; client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
queue = None; queue = None;
} }
arg => { Err(err) => {
debug!("Finalizing nostr communication thread because of {:?}", arg); debug!("Finalizing nostr communication thread because of {:?}", err);
break break;
} }
} }
} }
if let Some((url, events)) = queue { if let Some((url, events)) = queue {
info!("Sending {} events before exiting", events.len()); info!("Sending {} events to {url} before exiting", events.len());
client.batch_event_to(vec![url], events, RelaySendOptions::new()).await; client.batch_event_to(vec![url], events, RelaySendOptions::new()).await;
} }
info!("Shutting down nostr communication thread"); info!("Shutting down nostr communication thread");
@ -289,18 +298,19 @@ async fn main() {
let mut lines = stdin().lines(); let mut lines = stdin().lines();
loop { loop {
trace!("All Root Tasks:\n{}", relays.iter().map(|(url, tasks)|
format!("{}: [{}]", url, tasks.children_of(None).map(|id| tasks.get_task_title(id)).join("; "))).join("\n"));
println!(); println!();
selected_relay.as_ref().and_then(|url| relays.get(url)).inspect(|tasks| { let tasks = selected_relay.as_ref().and_then(|url| relays.get(url)).unwrap_or(&local_tasks);
print!( print!(
"{}", "{} {}) ",
selected_relay.as_ref().map_or("TEMP".to_string(), |url| url.to_string()).bright_black().italic(),
format!( format!(
"{} {}{}) ", "{}{}",
selected_relay.as_ref().map_or("local".to_string(), |url| url.to_string()),
tasks.get_task_path(tasks.get_position()), tasks.get_task_path(tasks.get_position()),
tasks.get_prompt_suffix() tasks.get_prompt_suffix()
).italic() ).bold()
); );
});
stdout().flush().unwrap(); stdout().flush().unwrap();
match lines.next() { match lines.next() {
Some(Ok(input)) => { Some(Ok(input)) => {
@ -312,10 +322,13 @@ async fn main() {
.. ..
} = notification } = notification
{ {
print_event(&event); debug!(
"At {} found {} kind {} content \"{}\" tags {:?}",
event.created_at, event.id, event.kind, event.content, event.tags.iter().map(|tag| tag.as_vec()).collect_vec()
);
match relays.get_mut(&relay_url) { match relays.get_mut(&relay_url) {
Some(tasks) => tasks.add(*event), Some(tasks) => tasks.add(*event),
None => warn!("Event received from unknown relay {relay_url}: {:?}", event) None => warn!("Event received from unknown relay {relay_url}: {:?}", *event)
} }
count += 1; count += 1;
} }
@ -387,7 +400,7 @@ async fn main() {
tasks.move_up(); tasks.move_up();
} }
Some('@') => { Some('@') | Some('&') => {
tasks.undo(); tasks.undo();
} }
@ -462,19 +475,16 @@ async fn main() {
None => tasks.clear_filter() None => tasks.clear_filter()
} }
Some('(') => Some('(') => {
match arg { if let Some(arg) = arg {
Some(arg) =>
if !tasks.track_from(arg) { if !tasks.track_from(arg) {
continue; continue;
} }
None => { }
println!("{}", tasks.times_tracked()); println!("{}", tasks.times_tracked());
continue; continue;
} }
}
Some(')') => { Some(')') => {
tasks.move_to(None); tasks.move_to(None);
if let Some(arg) = arg { if let Some(arg) = arg {
@ -541,22 +551,22 @@ async fn main() {
_ => _ =>
if Regex::new("^wss?://").unwrap().is_match(&input.trim()) { if Regex::new("^wss?://").unwrap().is_match(&input.trim()) {
tasks.move_to(None); tasks.move_to(None);
let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned(); if let Some((url, tasks)) = relays.iter().find(|(key, _)| key.as_str().starts_with(&input)) {
if new_relay.is_none() { selected_relay = Some(url.clone());
if let Some(url) = or_print(Url::parse(&input)) { or_print(tasks.print_tasks());
warn!("Connecting to {url} while running not yet supported"); continue;
//new_relay = Some(url.clone()); }
//relays.insert(url.clone(), tasks_for_url(Some(url.clone()))); match Url::parse(&input) {
//if client.add_relay(url).await.unwrap() { Err(e) => warn!("Failed to parse url \"{input}\": {}", e),
// relays.insert(url.clone(), tasks_for_url(Some(url.clone()))); Ok(url) => match tx.send(MostrMessage::NewRelay(url.clone())) {
// client.connect().await; Err(e) => error!("Nostr communication thread failure, cannot add relay \"{url}\": {e}"),
//} Ok(_) => {
info!("Connecting to {url}");
selected_relay = Some(url.clone());
relays.insert(url.clone(), tasks_for_url(Some(url)));
} }
} }
if new_relay.is_some() {
selected_relay = new_relay;
} }
//or_print(tasks.print_tasks());
continue; continue;
} else { } else {
tasks.filter_or_create(&input); tasks.filter_or_create(&input);
@ -577,10 +587,3 @@ async fn main() {
info!("Submitting pending updates..."); info!("Submitting pending updates...");
or_print(sender.await); or_print(sender.await);
} }
fn print_event(event: &Event) {
debug!(
"At {} found {} kind {} \"{}\" {:?}",
event.created_at, event.id, event.kind, event.content, event.tags
);
}

View File

@ -28,6 +28,7 @@ pub(crate) struct Tasks {
tasks: TaskMap, tasks: TaskMap,
/// History of active tasks by PubKey /// History of active tasks by PubKey
history: HashMap<PublicKey, BTreeSet<Event>>, history: HashMap<PublicKey, BTreeSet<Event>>,
/// The task properties currently visible /// The task properties currently visible
properties: Vec<String>, properties: Vec<String>,
/// The task properties sorted by /// The task properties sorted by
@ -336,7 +337,7 @@ impl Tasks {
self.position.and_then(|id| self.get_by_id(&id)) self.position.and_then(|id| self.get_by_id(&id))
} }
pub(crate) fn children_of(&self, id: Option<EventId>) -> impl IntoIterator<Item=&EventId> + '_ { pub(crate) fn children_of(&self, id: Option<EventId>) -> impl Iterator<Item=&EventId> + '_ {
self.tasks self.tasks
.values() .values()
.filter(move |t| t.parent_id() == id.as_ref()) .filter(move |t| t.parent_id() == id.as_ref())
@ -349,9 +350,10 @@ impl Tasks {
} }
let res: Vec<&Task> = self.resolve_tasks(self.view.iter()); let res: Vec<&Task> = self.resolve_tasks(self.view.iter());
if res.len() > 0 { if res.len() > 0 {
// Currently ignores filter when it matches nothing // Currently ignores filtered view when it matches nothing
return res; return res;
} }
// TODO use ChildrenIterator
self.resolve_tasks(self.children_of(self.position)).into_iter() self.resolve_tasks(self.children_of(self.position)).into_iter()
.filter(|t| { .filter(|t| {
// TODO apply filters in transit // TODO apply filters in transit
@ -686,7 +688,7 @@ impl Tasks {
) )
} }
fn get_task_title(&self, id: &EventId) -> String { pub(crate) fn get_task_title(&self, id: &EventId) -> String {
self.tasks.get(id).map_or(id.to_string(), |t| t.get_title()) self.tasks.get(id).map_or(id.to_string(), |t| t.get_title())
} }
@ -698,14 +700,14 @@ impl Tasks {
} else if let Ok(date) = DateTime::parse_from_rfc3339(str) { } else if let Ok(date) = DateTime::parse_from_rfc3339(str) {
self.track_at(Timestamp::from(date.to_utc().timestamp() as u64)); self.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
} else { } else {
warn!("Cannot parse {str}"); warn!("Cannot parse time from {str}");
return false; return false;
} }
true true
} }
pub(crate) fn track_at(&mut self, time: Timestamp) -> EventId { pub(crate) fn track_at(&mut self, time: Timestamp) -> EventId {
info!("{} from {}", self.position.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), time.to_human_datetime()); // TODO omit seconds info!("{} from {}", self.position.map_or(String::from("Stopping time-tracking"), |id| format!("Tracking \"{}\"", self.get_task_title(&id))), time.to_human_datetime());
let pos = self.get_position(); let pos = self.get_position();
let tracking = build_tracking(pos); let tracking = build_tracking(pos);
// TODO this can lead to funny deletions // TODO this can lead to funny deletions
@ -723,6 +725,7 @@ impl Tasks {
self.submit(tracking.custom_created_at(time)) self.submit(tracking.custom_created_at(time))
} }
/// Sign and queue the event to the relay, returning its id
fn submit(&mut self, builder: EventBuilder) -> EventId { fn submit(&mut self, builder: EventBuilder) -> EventId {
let event = self.sender.submit(builder).unwrap(); let event = self.sender.submit(builder).unwrap();
let id = event.id; let id = event.id;
@ -844,7 +847,9 @@ pub trait PropertyCollection<T> {
fn add_or_remove_at(&mut self, value: T, index: usize); fn add_or_remove_at(&mut self, value: T, index: usize);
} }
impl<T> PropertyCollection<T> for Vec<T> impl<T> PropertyCollection<T> for Vec<T>
where T: Display, T: Eq, T: Clone { where
T: Display + Eq + Clone,
{
fn remove_at(&mut self, index: usize) { fn remove_at(&mut self, index: usize) {
let col = self.remove(index); let col = self.remove(index);
info!("Removed property column \"{col}\""); info!("Removed property column \"{col}\"");
@ -1052,20 +1057,20 @@ mod tasks_test {
tasks.make_task_and_enter("proc: tags", State::Procedure); tasks.make_task_and_enter("proc: tags", State::Procedure);
let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)])); let side = tasks.submit(build_task("side", vec![tasks.make_event_tag(&tasks.get_current_task().unwrap().event, MARKER_DEPENDS)]));
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new()); assert_eq!(tasks.get_current_task().unwrap().children, HashSet::<EventId>::new());
let subid = tasks.make_task("sub"); let sub_id = tasks.make_task("sub");
assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([subid])); assert_eq!(tasks.get_current_task().unwrap().children, HashSet::from([sub_id]));
let sub = tasks.get_by_id(&subid).unwrap(); assert_eq!(tasks.len(), 3);
let sub = tasks.get_by_id(&sub_id).unwrap();
assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new()); assert_eq!(sub.get_dependendees(), Vec::<&EventId>::new());
} }
#[test] #[test]
fn test_tracking() { fn test_tracking() {
let mut tasks = stub_tasks(); let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
//let task = tasks.make_task("task");
tasks.track_at(Timestamp::from(0)); tasks.track_at(Timestamp::from(0));
assert_eq!(tasks.history.len(), 1); assert_eq!(tasks.history.len(), 1);
let zero = EventId::all_zeros();
tasks.move_to(Some(zero)); tasks.move_to(Some(zero));
let now: Timestamp = Timestamp::now() - 2u64; let now: Timestamp = Timestamp::now() - 2u64;
@ -1085,10 +1090,11 @@ mod tasks_test {
fn test_timestamps() { fn test_timestamps() {
let mut tasks = stub_tasks(); let mut tasks = stub_tasks();
let zero = EventId::all_zeros(); let zero = EventId::all_zeros();
tasks.move_to(Some(zero)); tasks.move_to(Some(zero));
tasks.track_at(Timestamp::from(Timestamp::now().as_u64() + 100)); tasks.track_at(Timestamp::from(Timestamp::now().as_u64() + 100));
assert_eq!(timestamps(tasks.history.values().nth(0).unwrap().into_iter(), &vec![&zero]).collect_vec().len(), 2) assert_eq!(timestamps(tasks.history.values().nth(0).unwrap().into_iter(), &vec![&zero]).collect_vec().len(), 2)
// TODO Does not show both future and current tracking properly, need to split by now // TODO Does not show both future and current tracking properly, need to split by current time
} }