diff --git a/CLAUDE.md b/CLAUDE.md index 71a9404..11df457 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,6 +42,15 @@ r -e 'tinypkgr::check()' - Text files in `this_week/` remain the source of truth. `tasks()` is a read-through projection, not a cache. - Internal callers that need the full schema (sections, period, paths, ordering) should keep using `parse_todo()`. +## Recurring manifest + +`recurring.txt` at the repo root declares recurring tasks with frequencies. `run_monday()` reads it (via `read_recurring()`) and materializes day-by-day rows for Daily plus a flat list for Week/Month/Quarter. Non-recurring user tasks carry forward unchanged. + +- Frequency syntax: weekday letters `M T W R F` (R = Thursday), `*` alias for `MTWRF`, optional week-of-month prefix `1W:`..`5W:`. +- Nested paths via ` > ` separator; intermediate ancestors auto-materialized. +- Internals in `R/recurring.R`: `.parse_freq()`, `.recurring_for_date()`, `.materialize_daily()`, `.materialize_period()`, `.merge_recurring()`. +- Opt-in: missing `recurring.txt` is a no-op, run_monday behaves as pre-0.1.8. + ## Preview mode Every mutator (`roll_day`, `run_monday`, `fix_parents`, `next_day`, `sync_from_daily`, `instantiate_todo`) accepts `preview = TRUE` and returns a `hacer_preview` describing the would-be change without writing. Set `HACER_PREVIEW=1` to flip the default — useful for one-shot agent invocations that should be inspectable before they touch the user's todo repo. Internals live in `R/preview.R`; each mutator builds a `targets` list of `path -> new_lines` and dispatches via `.write_or_preview()`. diff --git a/DESCRIPTION b/DESCRIPTION index d9057a0..37b7fd7 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: hacer Title: Plain-Text Nested ToDo Planning -Version: 0.1.7 +Version: 0.1.8 Authors@R: c( person("Troy", "Hernandez", role = c("aut", "cre"), email = "troy@cornball.ai", diff --git a/NAMESPACE b/NAMESPACE index e348d72..5bd4ca1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -11,6 +11,7 @@ export(open_this_week) export(parse_todo) export(paths_for) export(propagate_from_daily) +export(read_recurring) export(roll_day) export(rollup_status) export(run_monday) diff --git a/R/cli.R b/R/cli.R index 02cf3b3..7dffb7b 100644 --- a/R/cli.R +++ b/R/cli.R @@ -30,6 +30,29 @@ run_monday <- function(date = Sys.Date(), cfg = todo_config(), nxt <- advance_period(daily, week, month, quarter, prev_mon, this_mon) + # Apply recurring manifest if present in the repo. Recurring items become + # generated from the manifest; non-recurring user tasks carry forward. + rec_path <- file.path(dirname(cfg$live_dir), "recurring.txt") + if (file.exists(rec_path)) { + rec <- read_recurring(rec_path) + if (nrow(rec)) { + daily_sections <- if (!is.null(cfg$daily_sections)) cfg$daily_sections + else c("Monday","Tuesday","Wednesday","Thursday","Friday") + nxt$Daily <- .merge_recurring( + .strip_recurring(nxt$Daily), + .materialize_daily(rec, this_mon, daily_sections)) + nxt$Week <- .merge_recurring( + .strip_recurring(nxt$Week), + .materialize_period(rec, "Week")) + nxt$Month <- .merge_recurring( + .strip_recurring(nxt$Month), + .materialize_period(rec, "Month")) + nxt$Quarter <- .merge_recurring( + .strip_recurring(nxt$Quarter), + .materialize_period(rec, "Quarter")) + } + } + dst <- paths_for(this_mon, cfg) targets <- list() for (p in .period_types) { diff --git a/R/instantiate.R b/R/instantiate.R index 997bda7..e1bb024 100644 --- a/R/instantiate.R +++ b/R/instantiate.R @@ -114,11 +114,32 @@ instantiate_todo <- function(repo_dir, "- Edit `hacer_config.R` to tweak paths and options.", paste0("- Current week files live in: `", live_dir, "`"), paste0("- Archive lives in: `", archive_dir, "`"), - "- Run `hacer::run_monday()` each Monday (or set a cron job).", + "- Recurring tasks declared in `recurring.txt`; `run_monday()` materializes them.", "- Edit `.txt` files directly in RStudio; Markdown/HTML mirrors are optional." ) } + recurring_path <- file.path(repo_dir, "recurring.txt") + if (!file.exists(recurring_path) || overwrite) { + targets[[recurring_path]] <- c( + "# recurring.txt - tasks that repeat by frequency", + "#", + "# Format: ", + "# Day codes: M T W R F (R = Thursday)", + "# Combine adjacently: MR = Mon+Thu, MTWRF = every weekday, * = MTWRF", + "# Week-of-month prefix: 1W..5W (e.g. 1W:M = first Monday of month)", + "#", + "# run_monday() reads this file and materializes the recurring rows into", + "# each day section of Daily, plus a flat list in Week/Month/Quarter.", + "", + "M Email", + "M todo", + "MR wiki", + "* Exercise", + "1W:M Bills" + ) + } + result <- .write_or_preview(targets, preview) if (!preview) { diff --git a/R/recurring.R b/R/recurring.R new file mode 100644 index 0000000..c13a6f2 --- /dev/null +++ b/R/recurring.R @@ -0,0 +1,279 @@ +# R/recurring.R +# Recurring-task manifest: a plain-text file declaring which tasks repeat +# and on which weekdays. run_monday() reads this and materializes the +# recurring rows for each day section, so day-by-day duplication in Daily +# is no longer the source of truth. +# +# Format: +# # Comments allowed +# +# +# M Email # Mondays only +# MR wiki # Mon + Thu +# * Exercise # every weekday +# MTWRF Exercise # same as * +# 1W:M Bills # first Monday of month +# MTWR cornball.ai > Lil Casey > Countdown # nested path +# +# Day codes: M T W R F (R = Thursday). Combine adjacently. * means MTWRF. +# Optional week-of-month: 1W..5W, where week N is "the week whose Monday +# has day-of-month in 7*(N-1)+1 .. 7*N". Combine with days via colon. + +.day_codes <- c(M = 1L, T = 2L, W = 3L, R = 4L, F = 5L) + +# Parse one frequency token. Returns list(days = int vec, week_of_month = int or NA). +.parse_freq <- function(token) { + token <- trimws(token) + wom <- NA_integer_ + m <- regmatches(token, regexec("^([1-5])W(?::(.+))?$", token, perl = TRUE))[[1]] + if (length(m) == 3L) { + wom <- as.integer(m[2]) + days_part <- m[3] + if (!nzchar(days_part)) days_part <- "*" + token <- days_part + } + if (token == "*") token <- "MTWRF" + chars <- strsplit(token, "", fixed = TRUE)[[1]] + bad <- chars[!chars %in% names(.day_codes)] + if (length(bad)) { + stop("Bad day code(s) in frequency: ", paste(bad, collapse = "")) + } + days <- unique(unname(.day_codes[chars])) + list(days = sort(days), week_of_month = wom) +} + +#' Read a recurring-task manifest +#' +#' @param path Path to a `recurring.txt` file. +#' @return A `data.frame` with columns `freq`, `days` (list-column of int +#' vectors), `week_of_month` (int or `NA`), `path`, `name`, `parent_path`, +#' `level`, `order`. +#' @export +read_recurring <- function(path) { + if (!file.exists(path)) { + return(.empty_recurring_df()) + } + lines <- readLines(path, warn = FALSE) + freq <- character(); paths <- character() + for (ln in lines) { + s <- sub("\\s*#.*$", "", ln) # strip inline comments + s <- trimws(s) + if (!nzchar(s)) next + parts <- regmatches(s, regexec("^(\\S+)\\s+(.+)$", s))[[1]] + if (length(parts) != 3L) { + stop("Cannot parse recurring line: ", ln) + } + freq <- c(freq, parts[2]) + paths <- c(paths, trimws(parts[3])) + } + if (!length(freq)) return(.empty_recurring_df()) + + parsed <- lapply(freq, .parse_freq) + days <- lapply(parsed, `[[`, "days") + wom <- vapply(parsed, function(x) x$week_of_month, integer(1L)) + names <- vapply(strsplit(paths, " > ", fixed = TRUE), + function(x) x[length(x)], character(1L)) + parent_path <- vapply(strsplit(paths, " > ", fixed = TRUE), function(x) { + if (length(x) <= 1L) NA_character_ + else paste(x[-length(x)], collapse = " > ") + }, character(1L)) + level <- vapply(strsplit(paths, " > ", fixed = TRUE), + function(x) length(x) - 1L, integer(1L)) + data.frame( + freq = freq, + days = I(days), + week_of_month = wom, + path = paths, + name = names, + parent_path = parent_path, + level = level, + order = seq_along(freq), + stringsAsFactors = FALSE + ) +} + +.empty_recurring_df <- function() { + data.frame( + freq = character(), + days = I(list()), + week_of_month = integer(), + path = character(), + name = character(), + parent_path = character(), + level = integer(), + order = integer(), + stringsAsFactors = FALSE + ) +} + +# Day-of-week index 1..5 (Mon..Fri) for a Date. Returns NA for Sat/Sun. +.weekday_idx <- function(date) { + w <- as.POSIXlt(date)$wday # 0=Sun, 1=Mon, ..., 6=Sat + if (w == 0L || w == 6L) NA_integer_ else as.integer(w) +} + +# Week-of-month for a date: floor((day - 1) / 7) + 1 ranges 1..5. +.week_of_month <- function(date) { + ((as.POSIXlt(date)$mday - 1L) %/% 7L) + 1L +} + +# Filter manifest rows that apply on a given date. +.recurring_for_date <- function(rec, date) { + if (!nrow(rec)) return(rec) + dow <- .weekday_idx(date) + if (is.na(dow)) return(rec[FALSE, , drop = FALSE]) + wom <- .week_of_month(date) + hits <- vapply(seq_len(nrow(rec)), function(i) { + if (!(dow %in% rec$days[[i]])) return(FALSE) + if (!is.na(rec$week_of_month[i]) && rec$week_of_month[i] != wom) return(FALSE) + TRUE + }, logical(1L)) + rec[hits, , drop = FALSE] +} + +# Expand a manifest subset into a parse_todo-shaped data.frame for one +# section. Ancestors of nested paths are auto-emitted as recurring containers +# so the tree is well-formed. +.expand_recurring <- function(rec_subset, period, section, start_order = 1L) { + schema_empty <- function() { + data.frame( + id = character(), parent_id = character(), + period = character(), section = character(), + name = character(), recur = logical(), status = character(), + level = integer(), order = integer(), path = character(), + stringsAsFactors = FALSE) + } + if (!nrow(rec_subset)) return(schema_empty()) + + rows <- list() + emitted <- character() + ord <- start_order - 1L + + for (i in seq_len(nrow(rec_subset))) { + parts <- strsplit(rec_subset$path[i], " > ", fixed = TRUE)[[1]] + cur <- character() + for (j in seq_along(parts)) { + cur <- c(cur, parts[j]) + pth <- paste(cur, collapse = " > ") + if (pth %in% emitted) next + ord <- ord + 1L + parent <- if (j == 1L) NA_character_ + else paste(cur[-length(cur)], collapse = " > ") + rows[[length(rows) + 1L]] <- data.frame( + id = pth, parent_id = parent, + period = period, section = section, + name = parts[j], recur = TRUE, status = " ", + level = j - 1L, order = ord, path = pth, + stringsAsFactors = FALSE) + emitted <- c(emitted, pth) + } + } + do.call(rbind, rows) +} + +# Materialize the recurring rows for one Daily file across all day sections. +# Returns a parse_todo-shaped df with multiple sections (one per applicable +# weekday). monday_date is the Monday of the week being generated. +.materialize_daily <- function(rec, monday_date, daily_sections) { + schema_empty <- function() { + data.frame( + id = character(), parent_id = character(), + period = character(), section = character(), + name = character(), recur = logical(), status = character(), + level = integer(), order = integer(), path = character(), + stringsAsFactors = FALSE) + } + if (!nrow(rec)) return(schema_empty()) + + out <- list() + for (k in seq_along(daily_sections)) { + day_date <- monday_date + (k - 1L) + day_name <- daily_sections[k] + rec_today <- .recurring_for_date(rec, day_date) + if (!nrow(rec_today)) next + expanded <- .expand_recurring( + rec_today, period = "Daily", section = day_name, + start_order = (k - 1L) * 1000L + 1L) + out[[length(out) + 1L]] <- expanded + } + if (!length(out)) return(schema_empty()) + do.call(rbind, out) +} + +# Materialize recurring as a flat list for Week/Month/Quarter (no sections). +# All recurring items appear once at their declared path. +.materialize_period <- function(rec, period) { + schema_empty <- function() { + data.frame( + id = character(), parent_id = character(), + period = character(), section = character(), + name = character(), recur = logical(), status = character(), + level = integer(), order = integer(), path = character(), + stringsAsFactors = FALSE) + } + if (!nrow(rec)) return(schema_empty()) + out <- .expand_recurring(rec, period = period, + section = NA_character_, start_order = 1L) + out +} + +# Drop recurring rows from a carry-forward df (after advance_period). +.strip_recurring <- function(df) { + if (!nrow(df)) return(df) + df[!isTRUE_vec(df$recur), , drop = FALSE] +} + +isTRUE_vec <- function(x) { + x <- as.logical(x) + ifelse(is.na(x), FALSE, x) +} + +# Merge recurring (rec) with carry-forward non-recurring (carry). +# Per section (or globally for non-Daily), recurring rows go first in +# manifest order, then carry rows in their existing order. Ancestors are +# synthesized for any orphan paths in carry. +.merge_recurring <- function(carry, rec) { + if (!nrow(rec)) return(carry) + if (!nrow(carry)) { + rec$order <- seq_len(nrow(rec)) + return(rec) + } + # Drop carry rows that are duplicated in rec (rec wins on path). + carry_only <- carry[!(carry$path %in% rec$path), , drop = FALSE] + + # Ensure all ancestor paths present (for orphan carry entries with rec parents). + needed <- character() + for (p in carry_only$path) { + parts <- strsplit(p, " > ", fixed = TRUE)[[1]] + if (length(parts) <= 1L) next + for (j in seq_len(length(parts) - 1L)) { + anc <- paste(parts[seq_len(j)], collapse = " > ") + if (!(anc %in% rec$path) && !(anc %in% carry_only$path)) { + needed <- c(needed, anc) + } + } + } + needed <- unique(needed) + if (length(needed)) { + anc_rows <- lapply(needed, function(p) { + parts <- strsplit(p, " > ", fixed = TRUE)[[1]] + data.frame( + id = p, + parent_id = if (length(parts) == 1L) NA_character_ + else paste(parts[-length(parts)], collapse = " > "), + period = unique(carry$period)[1], + section = NA_character_, + name = parts[length(parts)], + recur = FALSE, status = " ", + level = length(parts) - 1L, + order = max(carry$order, 0L) + 1L, + path = p, stringsAsFactors = FALSE) + }) + carry_only <- rbind(carry_only, do.call(rbind, anc_rows)) + } + + # Ordering: recurring first (preserving its order), then carry. + carry_only$order <- max(rec$order) + carry_only$order + out <- rbind(rec, carry_only) + out +} diff --git a/README.md b/README.md index 5d9cad1..fbb0330 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ run_monday() # advances week, archives prior ``` ~/todo/ ├─ hacer_config.R # edit paths/options here + ├─ recurring.txt # recurring tasks + frequencies (optional) ├─ this_week/ # live files you edit daily │ ├─ todo_yymmdd_daily.txt │ ├─ todo_yymmdd_week.txt @@ -53,6 +54,29 @@ run_monday() # advances week, archives prior - all blank → parent blank - Blocked is sticky. `roll_day()`, `run_monday()`, and `next_day()` all preserve `[!]` items verbatim until you explicitly change them. +## Recurring tasks (`recurring.txt`) + +Declare repeating tasks once, in a single manifest at the repo root. `run_monday()` materializes them into the right day sections of Daily and a flat list in Week/Month/Quarter — so you stop hand-duplicating Exercise across Monday-Friday. + +``` +# ~/todo/recurring.txt +# +M Email # Mondays only +M todo +MR wiki # Mon + Thu +* Exercise # every weekday (alias for MTWRF) +1W:M Bills # first Monday of the month +MTWR cornball.ai > Lil Casey > Countdown # nested path +``` + +- Day codes: `M T W R F` (R = Thursday). Combine adjacently: `MTWRF`, `MR`, `WF`. +- `*` is shorthand for `MTWRF`. +- Optional week-of-month prefix `W:` (e.g. `1W:M` = first Monday of the month). +- Nested paths use ` > ` as the separator; ancestors are auto-materialized as recurring containers. +- Non-recurring one-off tasks (e.g. an ad-hoc `[ ] - CO2`) live in Daily as before; they aren't touched by the manifest. + +The manifest is **opt-in**. If `recurring.txt` is absent, `run_monday()` behaves exactly as it did pre-0.1.8. + ## Period & carry-over logic - Weekly rollover (`run_monday()`): diff --git a/inst/tinytest/test_recurring.R b/inst/tinytest/test_recurring.R new file mode 100644 index 0000000..b9cf2a8 --- /dev/null +++ b/inst/tinytest/test_recurring.R @@ -0,0 +1,192 @@ +# test_recurring.R - Tests for recurring-task manifest + +library(hacer) + +# ---- Frequency parser ---- +expect_equal(hacer:::.parse_freq("M")$days, 1L, + info = "M -> Monday only") +expect_equal(hacer:::.parse_freq("R")$days, 4L, + info = "R -> Thursday only") +expect_equal(hacer:::.parse_freq("MR")$days, c(1L, 4L), + info = "MR -> Mon + Thu") +expect_equal(hacer:::.parse_freq("MTWRF")$days, 1:5, + info = "MTWRF -> all weekdays") +expect_equal(hacer:::.parse_freq("*")$days, 1:5, + info = "* alias for MTWRF") +expect_equal(hacer:::.parse_freq("1W:M")$week_of_month, 1L, + info = "1W:M parses week_of_month") +expect_equal(hacer:::.parse_freq("1W:M")$days, 1L, + info = "1W:M parses Monday") +expect_equal(hacer:::.parse_freq("3W:MR")$week_of_month, 3L, + info = "3W:MR parses week 3") +expect_equal(hacer:::.parse_freq("3W:MR")$days, c(1L, 4L), + info = "3W:MR parses Mon + Thu") +expect_error(hacer:::.parse_freq("Q"), pattern = "Bad day code", + info = "Unknown day code errors") + +# ---- read_recurring ---- +tmp <- tempfile() +writeLines(c( + "# A comment", + "", + "M Email", + "MR wiki # inline comment", + "* Exercise", + "1W:M Bills", + "MTWR cornball.ai > Lil Casey > Countdown" +), tmp) +rec <- read_recurring(tmp) +expect_equal(nrow(rec), 5L, + info = "5 manifest entries (comments and blanks ignored)") +expect_equal(rec$path, + c("Email", "wiki", "Exercise", "Bills", + "cornball.ai > Lil Casey > Countdown"), + info = "Paths parse with > separator") +expect_equal(rec$name, + c("Email", "wiki", "Exercise", "Bills", "Countdown"), + info = "name is leaf component") +expect_equal(rec$level, c(0L, 0L, 0L, 0L, 2L), + info = "level reflects path depth") +expect_equal(rec$parent_path[5], "cornball.ai > Lil Casey", + info = "parent_path drops the leaf") +expect_true(is.na(rec$parent_path[1]), + info = "Top-level entries have NA parent_path") +unlink(tmp) + +# Empty/missing manifest +empty_rec <- read_recurring(tempfile()) +expect_equal(nrow(empty_rec), 0L, + info = "Missing manifest file yields empty df, not error") + +# ---- .recurring_for_date ---- +rec2 <- read_recurring(tmp_rec <- { + f <- tempfile() + writeLines(c( + "M MondayOnly", + "MR MonAndThu", + "* Daily", + "1W:M FirstMonday" + ), f) + f +}) + +# 2025-09-15 is Monday, week 3 of September (15 falls in 15-21 → week 3) +mon_w3 <- as.Date("2025-09-15") +hits <- hacer:::.recurring_for_date(rec2, mon_w3) +expect_true("MondayOnly" %in% hits$path, + info = "MondayOnly matches a Monday") +expect_true("MonAndThu" %in% hits$path, + info = "MonAndThu matches a Monday") +expect_true("Daily" %in% hits$path, + info = "Daily matches a Monday") +expect_false("FirstMonday" %in% hits$path, + info = "1W:M does not match week-3 Monday") + +# 2025-09-01 is a Monday in week 1 of September +mon_w1 <- as.Date("2025-09-01") +hits <- hacer:::.recurring_for_date(rec2, mon_w1) +expect_true("FirstMonday" %in% hits$path, + info = "1W:M matches first-week Monday") + +# Tuesday 2025-09-16: only Daily applies (MondayOnly and MonAndThu don't) +tue <- as.Date("2025-09-16") +hits <- hacer:::.recurring_for_date(rec2, tue) +expect_true("Daily" %in% hits$path) +expect_false("MondayOnly" %in% hits$path) +expect_false("MonAndThu" %in% hits$path) + +# Thursday 2025-09-18: MonAndThu and Daily apply +thu <- as.Date("2025-09-18") +hits <- hacer:::.recurring_for_date(rec2, thu) +expect_true("MonAndThu" %in% hits$path, + info = "MonAndThu matches Thursday") +expect_true("Daily" %in% hits$path) +expect_false("MondayOnly" %in% hits$path) + +unlink(tmp_rec) + +# ---- run_monday integrates the manifest ---- +repo <- tempfile() +dir.create(file.path(repo, "this_week"), recursive = TRUE) +dir.create(file.path(repo, "archive"), recursive = TRUE) + +writeLines(c( + "M Email", + "M todo", + "MR wiki", + "* Exercise", + "MTWR cornball.ai > Lil Casey > Countdown" +), file.path(repo, "recurring.txt")) + +# Sparse prev-week files. Carry-forward takes any non-recurring user tasks. +for (p in c("daily", "week", "month", "quarter")) { + writeLines(c( + paste0("# todo_250915_", p), + "", + "[/] - One-off in progress" + ), file.path(repo, "this_week", sprintf("todo_250915_%s.txt", p))) +} + +cfg <- list( + tz = "UTC", indent = 2L, + live_dir = file.path(repo, "this_week"), + archive_dir = file.path(repo, "archive"), + daily_sections = c("Monday","Tuesday","Wednesday","Thursday","Friday"), + render_markdown = FALSE, render_html = FALSE +) +hacer::run_monday(date = as.Date("2025-09-22"), cfg = cfg) + +new_daily <- readLines(file.path(repo, "this_week", "todo_250922_daily.txt"), + warn = FALSE) +combined <- paste(new_daily, collapse = "\n") + +# Monday section has Email, todo, wiki, Exercise, cornball.ai > Lil Casey > Countdown +mon_idx <- grep("^# Monday\\s*$", new_daily) +fri_idx <- grep("^# Friday\\s*$", new_daily) +mon_section <- paste(new_daily[mon_idx:(fri_idx - 1L)], collapse = "\n") +expect_true(grepl("\\[ \\] -\\*Email", mon_section), + info = "Monday materializes Email") +expect_true(grepl("\\[ \\] -\\*todo", mon_section), + info = "Monday materializes todo") +expect_true(grepl("\\[ \\] -\\*wiki", mon_section), + info = "Monday materializes wiki") +expect_true(grepl("Countdown", mon_section), + info = "Monday materializes nested Countdown") + +# Tuesday section: NOT Email/todo (M-only), NOT wiki (MR-only). Exercise + Countdown only. +tue_idx <- grep("^# Tuesday\\s*$", new_daily) +wed_idx <- grep("^# Wednesday\\s*$", new_daily) +tue_section <- paste(new_daily[tue_idx:(wed_idx - 1L)], collapse = "\n") +expect_false(grepl("Email", tue_section), + info = "Tuesday omits Email (M-only)") +expect_false(grepl("wiki", tue_section), + info = "Tuesday omits wiki (MR-only)") +expect_true(grepl("Exercise", tue_section), + info = "Tuesday includes Exercise (*)") +expect_true(grepl("Countdown", tue_section), + info = "Tuesday includes Countdown (MTWR)") + +# Friday section: ONLY Exercise (Countdown is MTWR no F). +fri_section <- paste(new_daily[fri_idx:length(new_daily)], collapse = "\n") +expect_true(grepl("Exercise", fri_section), + info = "Friday has Exercise") +expect_false(grepl("Countdown", fri_section), + info = "Friday omits Countdown (MTWR no F)") + +# Carry-forward non-recurring task survives +expect_true(grepl("One-off in progress", combined), + info = "Non-recurring carry-forward preserved") + +unlink(repo, recursive = TRUE) + +# ---- instantiate_todo writes recurring.txt ---- +repo2 <- tempfile() +hacer::instantiate_todo(repo2) +expect_true(file.exists(file.path(repo2, "recurring.txt")), + info = "instantiate_todo creates recurring.txt") +rec_seed <- readLines(file.path(repo2, "recurring.txt"), warn = FALSE) +expect_true(any(grepl("M\\s+Email", rec_seed)), + info = "Starter manifest has Email entry") +expect_true(any(grepl("\\*\\s+Exercise", rec_seed)), + info = "Starter manifest has Exercise entry") +unlink(repo2, recursive = TRUE) diff --git a/man/read_recurring.Rd b/man/read_recurring.Rd new file mode 100644 index 0000000..b931559 --- /dev/null +++ b/man/read_recurring.Rd @@ -0,0 +1,18 @@ +% tinyrox says don't edit this manually, but it can't stop you! +\name{read_recurring} +\alias{read_recurring} +\title{Read a recurring-task manifest} +\usage{ +read_recurring(path) +} +\arguments{ +\item{path}{Path to a `recurring.txt` file.} +} +\value{ +A `data.frame` with columns `freq`, `days` (list-column of int + vectors), `week_of_month` (int or `NA`), `path`, `name`, `parent_path`, + `level`, `order`. +} +\description{ +Read a recurring-task manifest +}