diff --git a/CLAUDE.md b/CLAUDE.md index 3ccd056..4545a21 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,9 @@ r -e 'tinypkgr::check()' ``` - Two spaces per indent level -- Status: `[ ]` todo, `[/]` in progress, `[x]` done +- Status: `[ ]` todo, `[/]` in progress, `[x]` done, `[!]` blocked - `*` prefix = recurring (preserved across rollovers) +- `[!]` is sticky: rollup gives it precedence over all other statuses, and `roll_day()` / `run_monday()` / `next_day()` preserve it verbatim until a human or agent changes it ## Dependencies diff --git a/DESCRIPTION b/DESCRIPTION index c5b34a9..f7e8d76 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: hacer Title: Plain-Text Nested ToDo Planning -Version: 0.1.4 +Version: 0.1.5 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/R/advance.R b/R/advance.R index fb98c25..db9bace 100644 --- a/R/advance.R +++ b/R/advance.R @@ -23,7 +23,8 @@ .reset_all_status <- function(df) { if (!nrow(df)) return(df) - df$status <- " " + # Blocked tasks survive the weekly reset so attention-needed work isn't lost. + df$status[df$status != "!"] <- " " df } diff --git a/R/cli.R b/R/cli.R index 956b8e1..02bc83d 100644 --- a/R/cli.R +++ b/R/cli.R @@ -224,7 +224,7 @@ next_day <- function(date = Sys.Date(), cfg = todo_config()) { keep_today <- c(keep_today, ln) } else { status <- substr(sub("^\\s*", "", ln), 2, 2) - if (status %in% c("/", "x")) { + if (status %in% c("/", "x", "!")) { keep_today <- c(keep_today, ln) } # Drop [ ] unchecked items diff --git a/R/parse.R b/R/parse.R index 378f135..26dc685 100644 --- a/R/parse.R +++ b/R/parse.R @@ -34,17 +34,17 @@ parse_todo <- function(file, period = NA_character_, indent = NULL) { } # task lines look like: " [x] - *HAss" or " [ ] - Second Floor" stripped <- sub("^\\s+", "", ln) - if (!grepl("^\\[( |/|x)\\]\\s*-", stripped)) next - + if (!grepl("^\\[( |/|x|!)\\]\\s*-", stripped)) next + # level by leading spaces nspaces <- nchar(ln) - nchar(stripped) level <- as.integer(nspaces / indent) - + # status - status <- substr(stripped, 2L, 2L) # " ", "/", or "x" - + status <- substr(stripped, 2L, 2L) # " ", "/", "x", or "!" + # after ] -, detect optional '*' tight or spaced - rest <- sub("^\\[( |/|x)\\]\\s*-\\s*", "", stripped) + rest <- sub("^\\[( |/|x|!)\\]\\s*-\\s*", "", stripped) recur <- FALSE if (grepl("^\\*", rest)) { recur <- TRUE diff --git a/R/propagate.R b/R/propagate.R index bdc1d30..a452498 100644 --- a/R/propagate.R +++ b/R/propagate.R @@ -1,7 +1,9 @@ # R/propagate.R -# Priority order: " " < "/" < "x" +# Priority order: " " < "/" < "x" < "!" +# "!" wins so a blocked task in Daily propagates to the matching task in +# Week/Month/Quarter rather than getting overwritten. .pmax_status <- function(a, b) { - map <- c(" " = 0L, "/" = 1L, "x" = 2L) + map <- c(" " = 0L, "/" = 1L, "x" = 2L, "!" = 3L) out <- a idx <- (map[b] > map[a]) out[idx] <- b[idx] diff --git a/R/rollup.R b/R/rollup.R index dd76108..4428035 100644 --- a/R/rollup.R +++ b/R/rollup.R @@ -11,7 +11,8 @@ rollup_status <- function(df) { for (p in parents) { kids <- df$status[!is.na(df$parent_id) & df$parent_id == p] if (!length(kids)) next - new <- if (all(kids == "x")) "x" + new <- if (any(kids == "!")) "!" + else if (all(kids == "x")) "x" else if (any(kids == "/") || any(kids == "x")) "/" else " " df$status[df$id == p] <- new diff --git a/README.md b/README.md index 51a9b63..a11d8ff 100644 --- a/README.md +++ b/README.md @@ -42,12 +42,14 @@ run_monday() # advances week, archives prior ## Editing rules (syntax) - Two spaces per indent level for sub-tasks. -- Status: `[ ]` = todo, `[/]` = in progress, `[x]` = done. +- Status: `[ ]` = todo, `[/]` = in progress, `[x]` = done, `[!]` = blocked (attention needed). - Recurring: prefix name with `*` (e.g., `[ ] -*Exercise`) → `recur = TRUE`. - Parents auto-roll: + - any child `[!]` → parent `[!]` (blocked bubbles up; takes precedence) - all children `x` → parent `x` - any `/` or mix of `x`/`/`/blank → parent `/` - all blank → parent blank +- Blocked is sticky. `roll_day()`, `run_monday()`, and `next_day()` all preserve `[!]` items verbatim until you explicitly change them. ## Period & carry-over logic diff --git a/inst/tinytest/test_blocked.R b/inst/tinytest/test_blocked.R new file mode 100644 index 0000000..1c33e7e --- /dev/null +++ b/inst/tinytest/test_blocked.R @@ -0,0 +1,191 @@ +# test_blocked.R - Tests for [!] blocked status (parse + rollup + rollover) + +library(hacer) + +tmp_repo <- function() { + d <- tempfile() + dir.create(file.path(d, "this_week"), recursive = TRUE, showWarnings = FALSE) + dir.create(file.path(d, "archive"), recursive = TRUE, showWarnings = FALSE) + d +} + +# ---- Case 1: parse_todo() accepts [!] and reports status = "!" ---- +tmp1 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[!] - Blocked one", + "[ ] - Plain todo" +), tmp1) + +df1 <- hacer:::parse_todo(tmp1, period = "Daily", indent = 2L) +expect_equal(df1$status, c("!", " "), + info = "parse_todo emits '!' for [!] tasks") +unlink(tmp1) + +# ---- Case 2: fix_parents() rolls a parent to [!] when any child is blocked ---- +tmp2 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "#######################################", + "", + "# Monday", + "", + "[ ] - House", + " [!] - Blocked child", + " [ ] - Plain child" +), tmp2) +hacer::fix_parents(file_name = tmp2) +out2 <- readLines(tmp2, warn = FALSE) +parent_line <- grep("- (\\*)?House$", out2, value = TRUE) +expect_equal(substr(sub("^\\s*", "", parent_line), 2, 2), "!", + info = "Parent rolls to [!] when any direct child is blocked") +unlink(tmp2) + +# ---- Case 3: fix_parents() bubbles [!] up across multiple levels ---- +tmp3 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[ ] - Grandparent", + " [ ] - Parent", + " [!] - Blocked grandchild" +), tmp3) +hacer::fix_parents(file_name = tmp3) +out3 <- readLines(tmp3, warn = FALSE) +gp_line <- grep("Grandparent", out3, value = TRUE) +p_line <- grep("- Parent", out3, value = TRUE) +expect_equal(substr(sub("^\\s*", "", gp_line), 2, 2), "!", + info = "Grandparent rolls to [!] across two levels") +expect_equal(substr(sub("^\\s*", "", p_line), 2, 2), "!", + info = "Direct parent rolls to [!]") +unlink(tmp3) + +# ---- Case 4: [!] takes precedence over [x] siblings (would otherwise be done) ---- +tmp4 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[ ] - Project", + " [x] - Done sibling", + " [x] - Another done", + " [!] - Blocked sibling" +), tmp4) +hacer::fix_parents(file_name = tmp4) +out4 <- readLines(tmp4, warn = FALSE) +parent_line4 <- grep("- (\\*)?Project$", out4, value = TRUE) +expect_equal(substr(sub("^\\s*", "", parent_line4), 2, 2), "!", + info = "Blocked overrides 'all done' rollup") +unlink(tmp4) + +# ---- Case 5: [!] takes precedence over [/] siblings ---- +tmp5 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[ ] - Project", + " [/] - In progress", + " [!] - Blocked" +), tmp5) +hacer::fix_parents(file_name = tmp5) +out5 <- readLines(tmp5, warn = FALSE) +parent_line5 <- grep("- (\\*)?Project$", out5, value = TRUE) +expect_equal(substr(sub("^\\s*", "", parent_line5), 2, 2), "!", + info = "Blocked overrides in-progress rollup") +unlink(tmp5) + +# ---- Case 6: blocked + recurring sibling — parent is [!], recurring orthogonal ---- +tmp6 <- tempfile(fileext = ".txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[ ] - Project", + " [ ] -*Recurring child", + " [!] - Blocked child" +), tmp6) +hacer::fix_parents(file_name = tmp6) +out6 <- readLines(tmp6, warn = FALSE) +parent_line6 <- grep("- (\\*)?Project$", out6, value = TRUE) +expect_equal(substr(sub("^\\s*", "", parent_line6), 2, 2), "!", + info = "Recurring sibling does not affect blocked rollup") +unlink(tmp6) + +# ---- Case 7: tasks() reports status = "blocked" for [!] (already covered, sanity check) ---- +repo7 <- tmp_repo() +cfg7 <- list(live_dir = file.path(repo7, "this_week"), indent = 2L) +f7 <- file.path(cfg7$live_dir, "ToDo_250915_Daily.txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[!] - Blocked" +), f7) +df7 <- hacer::tasks(cfg = cfg7) +expect_equal(df7$status, "blocked", + info = "tasks() normalizes [!] to status='blocked'") +unlink(repo7, recursive = TRUE) + +# ---- Case 8: run_monday() preserves [!] across the week boundary ---- +# Build a previous-Monday file set, run roll_day-style advance via run_monday, +# and check that the new week still has [!] tasks. +repo8 <- tmp_repo() +cfg8 <- list( + tz = "America/Chicago", + indent = 2L, + live_dir = file.path(repo8, "this_week"), + archive_dir = file.path(repo8, "archive"), + filename_fmt = "ToDo_%y%m%d_%s.txt", + daily_sections = c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday"), + render_markdown = FALSE, + render_html = FALSE +) + +prev_mon <- as.Date("2025-09-08") # a Monday +this_mon <- as.Date("2025-09-15") + +for (p in c("Daily", "Week", "Month", "Quarter")) { + f <- file.path(cfg8$live_dir, + sprintf("ToDo_%s_%s.txt", format(prev_mon, "%y%m%d"), p)) + writeLines(c( + paste0("# ToDo_", format(prev_mon, "%y%m%d"), "_", p, ".txt"), + "", + "#######################################", + "", + "[!] - Blocked persists", + "[ ] - Plain todo", + "[x] - Done dropped" + ), f) +} + +hacer::run_monday(date = this_mon, cfg = cfg8) + +for (p in c("Daily", "Week", "Month", "Quarter")) { + f <- file.path(cfg8$live_dir, + sprintf("ToDo_%s_%s.txt", format(this_mon, "%y%m%d"), p)) + expect_true(file.exists(f), + info = paste("run_monday creates new", p, "file")) + out <- readLines(f, warn = FALSE) + expect_true(any(grepl("\\[!\\].*Blocked persists", out)), + info = paste("[!] preserved verbatim in new", p)) +} + +unlink(repo8, recursive = TRUE) + +# ---- Case 9: roll_day() preserves [!] (sanity duplicate of test_roll_day Case 4) ---- +repo9 <- tmp_repo() +cfg9 <- list(live_dir = file.path(repo9, "this_week"), indent = 2L) +f9 <- file.path(cfg9$live_dir, "ToDo_250915_Daily.txt") +writeLines(c( + "# ToDo_250915_Daily.txt", + "", + "[!] - Blocked", + " [!] - Blocked nested" +), f9) +hacer::roll_day(date = as.Date("2025-09-16"), cfg = cfg9) +out9 <- readLines(file.path(cfg9$live_dir, "ToDo_250916_Daily.txt"), + warn = FALSE) +expect_true(any(grepl("\\[!\\] - Blocked$", out9)), + info = "roll_day preserves top-level [!]") +expect_true(any(grepl("\\[!\\] - Blocked nested$", out9)), + info = "roll_day preserves nested [!]") +unlink(repo9, recursive = TRUE)