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
- 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,
for example a project or a specific place.
On the other hand, related tasks like chores
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
@ -86,7 +92,7 @@ To stop time-tracking completely, simply move to the root of all tasks.
`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
- `.TASK`
+ 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
- `|[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)
- `*[TIME]` - add timetracking with the specified offset (empty: list tracked times)
- `:[IND][PROP]` - add property column PROP at IND or end, if it already exists remove property column PROP or IND (1-indexed)
- `::[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]` - close active task and move to parent, with optional state description
- `!TEXT` - set state for current task from text
- `,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)
- `wss://...` - switch or subscribe to relay
- `wss://...` - switch or subscribe to relay (prefix with space to forcibly add a new one)
Property Filters:
- `#TAG` - set tag filter (empty: list all used tags)
- `+TAG` - add tag filter
- `-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.
An active tag or state filter will also set that attribute for newly created tasks.
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.
### Available Columns
- `id`
- `parentid`
- `name`
- `state`
- `hashtags`
- `state` - indicator of current progress
- `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
- `desc` - last note 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.
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
- Dependencies (change from tags to properties so they can be added later? or maybe as a state?)
- Templates
@ -185,3 +196,8 @@ Suggestions welcome!
- TUI: Clear terminal? Refresh on empty prompt after timeout?
- Kanban, GANTT, Calendar
- 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() {
tasks.remove_column(index);
} else {
let value = input[2..].trim().to_string();
tasks.add_or_remove_property_column_at_index(value, index);
tasks.add_or_remove_property_column_at_index(remaining, index);
}
} else if let Some(arg) = arg {
tasks.add_or_remove_property_column(arg);
@ -379,22 +378,27 @@ async fn main() {
None => tasks.clear_filter()
}
Some('*') =>
Some('(') =>
match arg {
Some(arg) => {
if let Ok(num) = arg.parse::<i64>() {
tasks.track_at(Timestamp::from(Timestamp::now().as_u64().saturating_add_signed(num)));
} 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}");
Some(arg) =>
if !tasks.track_from(arg) {
continue;
}
}
None => {
println!("{}", tasks.times_tracked());
continue;
}
}
Some(')') => {
tasks.move_to(None);
if let Some(arg) = arg {
if !tasks.track_from(arg) {
continue;
}
}
}
Some('.') => {
let mut dots = 1;
@ -403,7 +407,8 @@ async fn main() {
dots += 1;
pos = tasks.get_parent(pos).cloned();
}
let slice = &input[dots..];
let slice = input[dots..].trim();
tasks.move_to(pos);
if slice.is_empty() {
if dots > 1 {
@ -423,7 +428,8 @@ async fn main() {
dots += 1;
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() {
tasks.move_to(pos);
} 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);
let mut new_relay = relays.keys().find(|key| key.as_str().starts_with(&input)).cloned();
if new_relay.is_none() {

View File

@ -4,11 +4,11 @@ use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::string::ToString;
use colored::Colorize;
use colored::{ColoredString, Colorize};
use itertools::Either::{Left, Right};
use itertools::Itertools;
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::kinds::{is_hashtag, PROCEDURE_KIND};
@ -72,7 +72,6 @@ impl Task {
}
pub(crate) fn get_dependendees(&self) -> Vec<&EventId> {
// TODO honor properly
self.find_refs(MARKER_DEPENDS).collect()
}
@ -134,7 +133,6 @@ impl Task {
tags.into_iter()
.filter(predicate)
.map(|t| format!("{}", t.content().unwrap()))
.collect::<Vec<String>>()
.join(" ")
})
}
@ -143,24 +141,14 @@ impl Task {
match property {
"id" => Some(self.event.id.to_string()),
"parentid" => self.parent_id().map(|i| i.to_string()),
"state" => Some({
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()
}),
"status" => Some(self.state_or_default().get_colored_label().to_string()),
"name" => Some(self.event.content.clone()),
"desc" => self.descriptions().last().cloned(),
"description" => Some(self.descriptions().join(" ")),
"hashtags" => self.filter_tags(|tag| { is_hashtag(tag) }),
"tags" => self.filter_tags(|_| true),
"alltags" => Some(format!("{:?}", self.tags)),
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
"refs" => Some(format!("{:?}", self.refs.iter().map(|re| format!("{}: {}", re.0, re.1)).collect_vec())),
"props" => Some(format!(
"{:?}",
self.props
@ -181,7 +169,7 @@ impl Task {
}
pub(crate) struct TaskState {
state: State,
pub(crate) state: State,
name: Option<String>,
pub(crate) time: Timestamp,
}
@ -192,6 +180,9 @@ impl TaskState {
pub(crate) fn get_label(&self) -> 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 {
self.name.as_ref().is_some_and(|n| n.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 {
Open,
Done,
@ -220,6 +211,17 @@ pub(crate) enum State {
Pending,
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 {
type Error = ();
@ -237,7 +239,7 @@ impl TryFrom<Kind> for State {
impl State {
pub(crate) fn is_open(&self) -> bool {
match self {
State::Open | State::Procedure => true,
State::Open | State::Pending | State::Procedure => true,
_ => false,
}
}
@ -251,6 +253,16 @@ impl State {
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 {
fn from(value: State) -> Self {

View File

@ -7,13 +7,12 @@ use std::str::FromStr;
use std::sync::mpsc::Sender;
use std::time::Duration;
use chrono::{Local, TimeZone};
use chrono::{DateTime, Local, TimeZone};
use chrono::LocalResult::Single;
use colored::Colorize;
use itertools::Itertools;
use log::{debug, error, info, trace, warn};
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 TagStandard::Hashtag;
@ -68,12 +67,12 @@ impl StateFilter {
StateFilter::Default => {
let state = task.pure_state();
state.is_open() || (state == State::Done && task.parent_id() != None)
},
}
StateFilter::All => true,
StateFilter::State(filter) => task.state().is_some_and(|t| t.matches_label(filter)),
}
}
fn as_option(&self) -> Option<String> {
if let StateFilter::State(str) = self {
Some(str.to_string())
@ -117,7 +116,6 @@ impl Tasks {
history: Default::default(),
properties: vec![
"state".into(),
"progress".into(),
"rtime".into(),
"hashtags".into(),
"rpath".into(),
@ -172,11 +170,7 @@ impl Tasks {
.dedup()
}
/// 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()
}
/// Dynamic time tracking overview for current task or current user.
pub(crate) fn times_tracked(&self) -> String {
match self.get_position() {
None => {
@ -201,23 +195,29 @@ impl Tasks {
}
}
Some(id) => {
let vec = vec![id];
let res =
once(format!("Times tracked on {}", self.get_task_title(&id))).chain(
self.history.iter().flat_map(|(key, set)|
timestamps(set.iter(), &vec)
.tuples::<(_, _)>()
.map(move |((start, _), (end, _))| {
format!("{} - {} by {}", start.to_human_datetime(), end.to_human_datetime(), key)
})
).sorted_unstable()
).join("\n");
drop(vec);
res
let ids = vec![id];
once(format!("Times tracked on {}", self.get_task_title(&id))).chain(
self.history.iter().flat_map(|(key, set)| {
let mut vec = Vec::with_capacity(set.len() / 2);
let mut iter = timestamps(set.iter(), &ids).tuples();
while let Some(((start, _), (end, _))) = iter.next() {
vec.push(format!("{} - {} by {}", start.to_human_datetime(), end.to_human_datetime(), key))
}
iter.into_buffer().for_each(|(stamp, _)|
vec.push(format!("{} started by {}", stamp.to_human_datetime(), key)));
vec
}).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.
fn total_time_tracked(&self, id: EventId) -> u64 {
let mut total = 0;
@ -399,9 +399,15 @@ impl Tasks {
writeln!(lock, "{}", t.descriptions().join("\n"))?;
}
// TODO proper column alignment
// TODO hide empty columns and sorting
writeln!(lock, "{}", self.properties.join("\t").bold())?;
let mut total_time = 0;
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!(
lock,
"{}",
@ -423,10 +429,18 @@ impl Tasks {
"".to_string()
}
}
"progress" => self
.total_progress(task.get_id())
.filter(|_| task.children.len() > 0)
.map_or(String::new(), |p| format!("{:2.0}%", p * 100.0)),
"state" => {
if let Some(task) = task.get_dependendees().iter().filter_map(|id| self.get_by_id(id)).find(|t| t.pure_state().is_open()) {
return format!("Blocked by \"{}\"", task.get_title()).bright_red().to_string()
}
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)),
"rpath" => self.relative_path(task.event.id),
// TODO format strings configurable
@ -585,11 +599,11 @@ impl Tasks {
}
}
/// Creates a task following the current state
/// Sanitizes input
pub(crate) fn make_task(&mut self, input: &str) -> EventId {
let tag: Option<Tag> = self.get_current_task()
.and_then(|t| {
println!("{:?}", t);
if t.pure_state() == State::Procedure {
t.children.iter()
.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())
}
/// 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 {
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 tracking = build_tracking(pos);
// TODO this can lead to funny deletions
self.get_own_history().map(|events| {
if let Some(event) = events.pop_last() {
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) {
self.set_state_for(id, comment, match comment {
"Closed" => State::Closed,
"Done" => State::Done,
_ => State::Open,
});
self.set_state_for(id, comment, comment.into());
}
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)
}
/// Iterates Events to accumulate times tracked
/// Expects a sorted iterator
struct TimesTracked<'a> {
events: Box<dyn Iterator<Item=&'a Event> + 'a>,
ids: &'a Vec<EventId>,
threshold: Option<Timestamp>,
}
impl TimesTracked<'_> {
fn from<'b>(events: impl IntoIterator<Item=&'b Event> + 'b, ids: &'b Vec<EventId>) -> TimesTracked<'b> {
TimesTracked {
events: Box::new(events.into_iter()),
ids,
threshold: Some(Timestamp::now()),
}
}
}
@ -848,6 +877,9 @@ impl Iterator for TimesTracked<'_> {
let mut start: Option<u64> = None;
while let Some(event) = self.events.next() {
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()))
} else {
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
}
#[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]
fn test_depth() {
let mut tasks = stub_tasks();