Compare commits

...

7 Commits

4 changed files with 157 additions and 78 deletions

View File

@ -54,10 +54,16 @@ Using subtasks has two main advantages:
- ability to accumulate time tracked - ability to accumulate time tracked
- swiftly navigate between related tasks - swiftly navigate between related tasks
Managing a project with subtasks makes it continuously visible,
which is helpful if you want to be able to track time on the project itself
without a specific task,
Thus subtasks can be very useful for specific contexts, Thus subtasks can be very useful for specific contexts,
for example a project or a specific place. for example a project or a specific place.
On the other hand, related tasks like chores On the other hand, related tasks like chores
should be grouped with a tag instead. should be grouped with a tag instead.
Similarly for projects which are only sporadically worked on
when a specific task comes up, so they do not clutter the list.
### Collaboration ### Collaboration
@ -86,7 +92,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
`TASK` creation syntax: `NAME: TAG1 TAG2 ...` `TASK` creation syntax: `NAME: TAG1 TAG2 ...`
- `TASK` - create task - `TASK` - create task (prefix with space if you want a task to start with a command character)
- `.` - clear filters and reload - `.` - clear filters and reload
- `.TASK` - `.TASK`
+ activate task by id + activate task by id
@ -96,34 +102,38 @@ To stop time-tracking completely, simply move to the root of all tasks.
- `/[TEXT]` - like `.`, but never creates a task - `/[TEXT]` - like `.`, but never creates a task
- `|[TASK]` - (un)mark current task as procedure or 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 and activate a new task procedure (where subtasks automatically depend on the previously created task)
Dots can be repeated to move to parent tasks. Dots and slashes can be repeated to move to parent tasks.
- `:[IND][COL]` - add property column COL at IND or end, if it already exists remove property column COL or IND (1-indexed) - `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (1-indexed)
- `*[TIME]` - add timetracking with the specified offset (empty: list tracked times) - `::[PROP]` - Sort by property PROP
- `([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)
- `>[TEXT]` - complete active task and move to parent, with optional state description - `>[TEXT]` - complete active task and move to parent, with optional state description
- `<[TEXT]` - close active task and move to parent, with optional state description - `<[TEXT]` - close active task and move to parent, with optional state description
- `!TEXT` - set state for current task from text - `!TEXT` - set state for current task from text
- `,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
- `@` - undoes last action (moving in place or upwards or waiting a minute confirms pending actions) - `@` - undoes last action (moving in place or upwards or waiting a minute confirms pending actions)
- `wss://...` - switch or subscribe to relay - `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters: Property Filters:
- `#TAG` - set tag filter (empty: list all used tags) - `#TAG` - set tag filter (empty: list all used tags)
- `+TAG` - add tag filter - `+TAG` - add tag filter
- `-TAG` - remove tag filters - `-TAG` - remove tag filters
- `?STATE` - filter by state (type or description) - plain `?` to reset, `??` to show all - `?STATUS` - filter by status (type or description) - plain `?` to reset, `??` to show all
State 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 state 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.
### Available Columns ### Available Columns
- `id` - `id`
- `parentid` - `parentid`
- `name` - `name`
- `state` - `state` - indicator of current progress
- `hashtags` - `status` - pure task status
- `hashtags` - list of hashtags set for the task
- `tags` - values of all nostr tags associated with the event, except event tags - `tags` - values of all nostr tags associated with the event, except event tags
- `desc` - last note on the task - `desc` - last note on the task
- `description` - accumulated notes on the task - `description` - accumulated notes on the task
@ -171,6 +181,7 @@ The following features are not ready to be implemented
because they need conceptualization. because they need conceptualization.
Suggestions welcome! Suggestions welcome!
- What if I want to postpone a procedure, i.e. make it pending, or move it across kanban, does this make sense?
- Priorities - Priorities
- Dependencies (change from tags to properties so they can be added later? or maybe as a state?) - Dependencies (change from tags to properties so they can be added later? or maybe as a state?)
- Templates - Templates
@ -185,3 +196,8 @@ Suggestions welcome!
- 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
- TBI = To Be Implemented
- `. TASK` - create and enter a new task even if the name matches an existing one

View File

@ -284,8 +284,7 @@ async fn main() {
if remaining.is_empty() { if remaining.is_empty() {
tasks.remove_column(index); tasks.remove_column(index);
} else { } else {
let value = input[2..].trim().to_string(); tasks.add_or_remove_property_column_at_index(remaining, index);
tasks.add_or_remove_property_column_at_index(value, index);
} }
} else if let Some(arg) = arg { } else if let Some(arg) = arg {
tasks.add_or_remove_property_column(arg); tasks.add_or_remove_property_column(arg);
@ -379,21 +378,26 @@ async fn main() {
None => tasks.clear_filter() None => tasks.clear_filter()
} }
Some('*') => Some('(') =>
match arg { match arg {
Some(arg) => { Some(arg) =>
if let Ok(num) = arg.parse::<i64>() { if !tasks.track_from(arg) {
tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num))); continue;
} else if let Ok(date) = DateTime::parse_from_rfc3339(arg) {
tasks.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
} else {
warn!("Cannot parse {arg}");
}
} }
None => { None => {
println!("{}", tasks.times_tracked()); println!("{}", tasks.times_tracked());
continue; continue;
} }
}
Some(')') => {
tasks.move_to(None);
if let Some(arg) = arg {
if !tasks.track_from(arg) {
continue;
}
}
} }
Some('.') => { Some('.') => {
@ -403,7 +407,8 @@ async fn main() {
dots += 1; dots += 1;
pos = tasks.get_parent(pos).cloned(); pos = tasks.get_parent(pos).cloned();
} }
let slice = &input[dots..]; let slice = input[dots..].trim();
tasks.move_to(pos); tasks.move_to(pos);
if slice.is_empty() { if slice.is_empty() {
if dots > 1 { if dots > 1 {
@ -423,7 +428,8 @@ async fn main() {
dots += 1; dots += 1;
pos = tasks.get_parent(pos).cloned(); pos = tasks.get_parent(pos).cloned();
} }
let slice = &input[dots..].to_ascii_lowercase(); let slice = &input[dots..].trim().to_ascii_lowercase();
if slice.is_empty() { if slice.is_empty() {
tasks.move_to(pos); tasks.move_to(pos);
} else if let Ok(depth) = slice.parse::<i8>() { } else if let Ok(depth) = slice.parse::<i8>() {
@ -447,7 +453,7 @@ async fn main() {
} }
_ => _ =>
if Regex::new("^wss?://").unwrap().is_match(&input) { 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(); let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned();
if new_relay.is_none() { if new_relay.is_none() {

View File

@ -4,11 +4,11 @@ use std::collections::{BTreeSet, HashSet};
use std::fmt; use std::fmt;
use std::string::ToString; use std::string::ToString;
use colored::Colorize; use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right}; use itertools::Either::{Left, Right};
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventBuilder, EventId, Kind, Tag, TagStandard, Timestamp}; use nostr_sdk::{Event, EventId, Kind, Tag, TagStandard, Timestamp};
use crate::helpers::some_non_empty; use crate::helpers::some_non_empty;
use crate::kinds::{is_hashtag, PROCEDURE_KIND}; use crate::kinds::{is_hashtag, PROCEDURE_KIND};
@ -72,7 +72,6 @@ impl Task {
} }
pub(crate) fn get_dependendees(&self) -> Vec<&EventId> { pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
// TODO honor properly
self.find_refs(MARKER_DEPENDS).collect() self.find_refs(MARKER_DEPENDS).collect()
} }
@ -134,7 +133,6 @@ impl Task {
tags.into_iter() tags.into_iter()
.filter(predicate) .filter(predicate)
.map(|t| format!("{}", t.content().unwrap())) .map(|t| format!("{}", t.content().unwrap()))
.collect::<Vec<String>>()
.join(" ") .join(" ")
}) })
} }
@ -143,17 +141,7 @@ impl Task {
match property { match property {
"id" => Some(self.event.id.to_string()), "id" => Some(self.event.id.to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()), "parentid" => self.parent_id().map(|i| i.to_string()),
"state" => Some({ "status" => Some(self.state_or_default().get_colored_label().to_string()),
let state = self.state_or_default();
let label = state.get_label();
match state.state {
State::Open => label.green(),
State::Done => label.bright_black(),
State::Closed => label.magenta(),
State::Pending => label.yellow(),
State::Procedure => label.blue(),
}.to_string()
}),
"name" => Some(self.event.content.clone()), "name" => Some(self.event.content.clone()),
"desc" => self.descriptions().last().cloned(), "desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")), "description" => Some(self.descriptions().join(" ")),
@ -181,7 +169,7 @@ impl Task {
} }
pub(crate) struct TaskState { pub(crate) struct TaskState {
state: State, pub(crate) state: State,
name: Option<String>, name: Option<String>,
pub(crate) time: Timestamp, pub(crate) time: Timestamp,
} }
@ -192,6 +180,9 @@ impl TaskState {
pub(crate) fn get_label(&self) -> String { pub(crate) fn get_label(&self) -> String {
self.name.clone().unwrap_or_else(|| self.state.to_string()) self.name.clone().unwrap_or_else(|| self.state.to_string())
} }
pub(crate) fn get_colored_label(&self) -> ColoredString {
self.state.colorize(&self.get_label())
}
pub(crate) fn matches_label(&self, label: &str) -> bool { pub(crate) fn matches_label(&self, label: &str) -> bool {
self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label)) self.name.as_ref().is_some_and(|n| n.eq_ignore_ascii_case(label))
|| self.state.to_string().eq_ignore_ascii_case(label) || self.state.to_string().eq_ignore_ascii_case(label)
@ -212,7 +203,7 @@ impl Display for TaskState {
} }
} }
#[derive(Debug, Copy, Clone, PartialEq)] #[derive(Debug, Copy, Clone, PartialEq, Ord, PartialOrd, Eq)]
pub(crate) enum State { pub(crate) enum State {
Open, Open,
Done, Done,
@ -220,6 +211,17 @@ pub(crate) enum State {
Pending, Pending,
Procedure, Procedure,
} }
impl From<&str> for State {
fn from(value: &str) -> Self {
match value {
"Closed" => State::Closed,
"Done" => State::Done,
"Pending" => State::Pending,
"Proc" | "Procedure" | "List" => State::Procedure,
_ => State::Open,
}
}
}
impl TryFrom<Kind> for State { impl TryFrom<Kind> for State {
type Error = (); type Error = ();
@ -237,7 +239,7 @@ impl TryFrom<Kind> for State {
impl State { impl State {
pub(crate) fn is_open(&self) -> bool { pub(crate) fn is_open(&self) -> bool {
match self { match self {
State::Open | State::Procedure => true, State::Open | State::Pending | State::Procedure => true,
_ => false, _ => false,
} }
} }
@ -251,6 +253,16 @@ impl State {
State::Procedure => PROCEDURE_KIND, State::Procedure => PROCEDURE_KIND,
} }
} }
pub(crate) fn colorize(&self, str: &str) -> ColoredString {
match self {
State::Open => str.green(),
State::Done => str.bright_black(),
State::Closed => str.magenta(),
State::Pending => str.yellow(),
State::Procedure => str.blue(),
}
}
} }
impl From<State> for Kind { impl From<State> for Kind {
fn from(value: State) -> Self { fn from(value: State) -> Self {

View File

@ -7,13 +7,12 @@ use std::str::FromStr;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::time::Duration; use std::time::Duration;
use chrono::{Local, TimeZone}; use chrono::{DateTime, Local, TimeZone};
use chrono::LocalResult::Single; use chrono::LocalResult::Single;
use colored::Colorize; use colored::Colorize;
use itertools::Itertools; use itertools::Itertools;
use log::{debug, error, info, trace, warn}; use log::{debug, error, info, trace, warn};
use nostr_sdk::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, TagStandard, Timestamp, UncheckedUrl, Url}; use nostr_sdk::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, TagStandard, Timestamp, UncheckedUrl, Url};
use nostr_sdk::base64::write::StrConsumer;
use nostr_sdk::prelude::Marker; use nostr_sdk::prelude::Marker;
use TagStandard::Hashtag; use TagStandard::Hashtag;
@ -68,7 +67,7 @@ impl StateFilter {
StateFilter::Default => { StateFilter::Default => {
let state = task.pure_state(); let state = task.pure_state();
state.is_open() || (state == State::Done && task.parent_id() != None) state.is_open() || (state == State::Done && task.parent_id() != None)
}, }
StateFilter::All => true, StateFilter::All => true,
StateFilter::State(filter) => task.state().is_some_and(|t| t.matches_label(filter)), StateFilter::State(filter) => task.state().is_some_and(|t| t.matches_label(filter)),
} }
@ -117,7 +116,6 @@ impl Tasks {
history: Default::default(), history: Default::default(),
properties: vec![ properties: vec![
"state".into(), "state".into(),
"progress".into(),
"rtime".into(), "rtime".into(),
"hashtags".into(), "hashtags".into(),
"rpath".into(), "rpath".into(),
@ -172,11 +170,7 @@ impl Tasks {
.dedup() .dedup()
} }
/// Total time in seconds tracked on this task by the current user. /// Dynamic time tracking overview for current task or current user.
pub(crate) fn time_tracked(&self, id: EventId) -> u64 {
TimesTracked::from(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![id]).sum::<Duration>().as_secs()
}
pub(crate) fn times_tracked(&self) -> String { pub(crate) fn times_tracked(&self) -> String {
match self.get_position() { match self.get_position() {
None => { None => {
@ -201,23 +195,29 @@ impl Tasks {
} }
} }
Some(id) => { Some(id) => {
let vec = vec![id]; let ids = vec![id];
let res =
once(format!("Times tracked on {}", self.get_task_title(&id))).chain( once(format!("Times tracked on {}", self.get_task_title(&id))).chain(
self.history.iter().flat_map(|(key, set)| self.history.iter().flat_map(|(key, set)| {
timestamps(set.iter(), &vec) let mut vec = Vec::with_capacity(set.len() / 2);
.tuples::<(_, _)>() let mut iter = timestamps(set.iter(), &ids).tuples();
.map(move |((start, _), (end, _))| { while let Some(((start, _), (end, _))) = iter.next() {
format!("{} - {} by {}", start.to_human_datetime(), end.to_human_datetime(), key) vec.push(format!("{} - {} by {}", start.to_human_datetime(), end.to_human_datetime(), key))
}) }
).sorted_unstable() iter.into_buffer().for_each(|(stamp, _)|
).join("\n"); vec.push(format!("{} started by {}", stamp.to_human_datetime(), key)));
drop(vec); vec
res }).sorted_unstable()
).join("\n")
} }
} }
} }
/// Total time in seconds tracked on this task by the current user.
pub(crate) fn time_tracked(&self, id: EventId) -> u64 {
TimesTracked::from(self.history.get(&self.sender.pubkey()).into_iter().flatten(), &vec![id]).sum::<Duration>().as_secs()
}
/// Total time in seconds tracked on this task and its subtasks by all users. /// Total time in seconds tracked on this task and its subtasks by all users.
fn total_time_tracked(&self, id: EventId) -> u64 { fn total_time_tracked(&self, id: EventId) -> u64 {
let mut total = 0; let mut total = 0;
@ -399,9 +399,15 @@ impl Tasks {
writeln!(lock, "{}", t.descriptions().join("\n"))?; writeln!(lock, "{}", t.descriptions().join("\n"))?;
} }
// TODO proper column alignment // TODO proper column alignment
// TODO hide empty columns and sorting
writeln!(lock, "{}", self.properties.join("\t").bold())?; writeln!(lock, "{}", self.properties.join("\t").bold())?;
let mut total_time = 0; let mut total_time = 0;
for task in self.current_tasks() { for task in self.current_tasks() {
let progress =
self
.total_progress(task.get_id())
.filter(|_| task.children.len() > 0);
let prog_string = progress.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0));
writeln!( writeln!(
lock, lock,
"{}", "{}",
@ -423,10 +429,18 @@ impl Tasks {
"".to_string() "".to_string()
} }
} }
"progress" => self "state" => {
.total_progress(task.get_id()) if let Some(task) = task.get_dependendees().iter().filter_map(|id| self.get_by_id(id)).find(|t| t.pure_state().is_open()) {
.filter(|_| task.children.len() > 0) return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string()
.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0)), }
let state = task.state_or_default();
if state.state.is_open() && progress.is_some_and(|p| p > 0.1) {
state.state.colorize(&prog_string)
} else {
state.get_colored_label()
}.to_string()
}
"progress" => prog_string.clone(),
"path" => self.get_task_path(Some(task.event.id)), "path" => self.get_task_path(Some(task.event.id)),
"rpath" => self.relative_path(task.event.id), "rpath" => self.relative_path(task.event.id),
// TODO format strings configurable // TODO format strings configurable
@ -585,11 +599,11 @@ impl Tasks {
} }
} }
/// Creates a task following the current state
/// Sanitizes input /// Sanitizes input
pub(crate) fn make_task(&mut self, input: &str) -> EventId { pub(crate) fn make_task(&mut self, input: &str) -> EventId {
let tag: Option<Tag> = self.get_current_task() let tag: Option<Tag> = self.get_current_task()
.and_then(|t| { .and_then(|t| {
println!("{:?}", t);
if t.pure_state() == State::Procedure { if t.pure_state() == State::Procedure {
t.children.iter() t.children.iter()
.filter_map(|id| self.get_by_id(id)) .filter_map(|id| self.get_by_id(id))
@ -633,10 +647,25 @@ impl Tasks {
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())
} }
/// Parse string and set tracking
/// Returns false if parsing failed
pub(crate) fn track_from(&mut self, str: &str) -> bool {
if let Ok(num) = str.parse::<i64>() {
self.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num * 60)));
} else if let Ok(date) = DateTime::parse_from_rfc3339(str) {
self.track_at(Timestamp::from(date.to_utc().timestamp() as u64));
} else {
warn!("Cannot parse {str}");
return false;
}
true
}
pub(crate) fn track_at(&mut self, time: Timestamp) -> EventId { pub(crate) fn track_at(&mut self, time: Timestamp) -> EventId {
info!("Tracking \"{:?}\" from {}", self.position.map(|id| self.get_task_title(&id)), time.to_human_datetime()); 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
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
self.get_own_history().map(|events| { self.get_own_history().map(|events| {
if let Some(event) = events.pop_last() { if let Some(event) = events.pop_last() {
if event.kind.as_u16() == TRACKING_KIND && if event.kind.as_u16() == TRACKING_KIND &&
@ -712,11 +741,7 @@ impl Tasks {
} }
pub(crate) fn set_state_for_with(&mut self, id: EventId, comment: &str) { pub(crate) fn set_state_for_with(&mut self, id: EventId, comment: &str) {
self.set_state_for(id, comment, match comment { self.set_state_for(id, comment, comment.into());
"Closed" => State::Closed,
"Done" => State::Done,
_ => State::Open,
});
} }
pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId { pub(crate) fn set_state_for(&mut self, id: EventId, comment: &str, state: State) -> EventId {
@ -828,15 +853,19 @@ fn timestamps<'a>(events: impl Iterator<Item=&'a Event>, ids: &'a Vec<EventId>)
.skip_while(|element| element.1 == None) .skip_while(|element| element.1 == None)
} }
/// Iterates Events to accumulate times tracked
/// Expects a sorted iterator
struct TimesTracked<'a> { struct TimesTracked<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>, events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a Vec<EventId>, ids: &'a Vec<EventId>,
threshold: Option<Timestamp>,
} }
impl TimesTracked<'_> { impl TimesTracked<'_> {
fn from<'b>(events: impl IntoIterator<Item=&'b Event> + 'b, ids: &'b Vec<EventId>) -> TimesTracked<'b> { fn from<'b>(events: impl IntoIterator<Item=&'b Event> + 'b, ids: &'b Vec<EventId>) -> TimesTracked<'b> {
TimesTracked { TimesTracked {
events: Box::new(events.into_iter()), events: Box::new(events.into_iter()),
ids, ids,
threshold: Some(Timestamp::now()),
} }
} }
} }
@ -848,6 +877,9 @@ impl Iterator for TimesTracked<'_> {
let mut start: Option<u64> = None; let mut start: Option<u64> = None;
while let Some(event) = self.events.next() { while let Some(event) = self.events.next() {
if matching_tag_id(event, self.ids).is_some() { if matching_tag_id(event, self.ids).is_some() {
if self.threshold.is_some_and(|th| event.created_at > th) {
continue;
}
start = start.or(Some(event.created_at.as_u64())) start = start.or(Some(event.created_at.as_u64()))
} else { } else {
if let Some(stamp) = start { if let Some(stamp) = start {
@ -855,7 +887,8 @@ impl Iterator for TimesTracked<'_> {
} }
} }
} }
return start.map(|stamp| Duration::from_secs(Timestamp::now().as_u64() - stamp)); let now = self.threshold.unwrap_or(Timestamp::now()).as_u64();
return start.filter(|t| t < &now).map(|stamp| Duration::from_secs(now.saturating_sub(stamp)));
} }
} }
@ -918,6 +951,18 @@ mod tasks_test {
// TODO test received events // TODO test received events
} }
#[test]
#[ignore]
fn test_timestamps() {
let mut tasks = stub_tasks();
let zero = EventId::all_zeros();
tasks.move_to(Some(zero));
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)
// TODO Does not show both future and current tracking properly, need to split by now
}
#[test] #[test]
fn test_depth() { fn test_depth() {
let mut tasks = stub_tasks(); let mut tasks = stub_tasks();