Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
3 changes: 2 additions & 1 deletion R/advance.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
2 changes: 1 addition & 1 deletion R/cli.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions R/parse.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions R/propagate.R
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
3 changes: 2 additions & 1 deletion R/rollup.R
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
191 changes: 191 additions & 0 deletions inst/tinytest/test_blocked.R
Original file line number Diff line number Diff line change
@@ -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)