diff --git a/NAMESPACE b/NAMESPACE index 19ae27e..378c1aa 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -7,20 +7,27 @@ export(bizday_of_period) export(bizdays_between) export(breaks_quarters) export(detect_hidden_logicals) +export(filter_complete_periods) export(guess_col_fmts) export(is_bizday) export(is_ytd_comparable) export(label_quarters_short) +export(last_complete_period_end) +export(last_complete_period_start) export(local_calendar) export(most_recent_monday) export(most_recent_sunday) export(mutate_cagrs) export(normalize_logicals) export(passed_colnames) +export(period_end_date) +export(period_is_complete) +export(period_start_date) export(periodic_bizdays) export(plot_accounts_by_status) export(py_dates) export(rename_cols_for_display) +export(scale_alpha_logical) export(scale_x_integer) export(scale_y_integer) export(set_calendar) diff --git a/NEWS.md b/NEWS.md index 2133e29..65b98f8 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,37 @@ +# mcrutils (development version) + +## New +- Period-completeness toolkit for reporting on partial periods: + - `period_is_complete()` tests whether the period (week/month/quarter/year) + containing each date is complete as of a cutoff date. Completeness is + business-day aware via a QuantLib calendar (default `"WeekendsOnly"`); pass + a national calendar such as `"UnitedStates"` for holiday accuracy, or + `"Null"` for pure calendar-day semantics. + - `period_start_date()` and `period_end_date()` return the canonical first and + last calendar day of the period containing each date. + - `filter_complete_periods()` keeps only rows whose period is complete; + `as_of` defaults to the maximum of the date column. + - `last_complete_period_start()` and `last_complete_period_end()` return the + boundaries of the most recent complete period as of a cutoff. + - `scale_alpha_logical()`, a ggplot2 alpha scale for fading incomplete + periods (pairs with `period_is_complete()`). +- Completeness uses an inclusive convention: a period is complete once its end + date is reached (not strictly after it). For pure calendar-day semantics use + `calendar = "Null"` (the QuantLib id is `"Null"`, not `"NullCalendar"`). + `NA` dates propagate to `NA` (and are dropped by `filter_complete_periods()`). +- `period_is_complete()`, `last_complete_period_start()`, and + `last_complete_period_end()` require an explicit `as_of`; there is no + wall-clock default, so completeness is never judged against the current date + unless you ask for it (pass `Sys.Date()` to opt in). `filter_complete_periods()` + still defaults `as_of` to the maximum of its date column. + +## Behavior changes +- `most_recent_monday()` / `most_recent_sunday()`: the `ref_date` argument is + renamed `as_of`, matching the period-completeness functions (both name the + date that state is evaluated as of). The default (current date) is unchanged + and positional calls are unaffected, but callers passing `ref_date =` by name + must switch to `as_of =`. + # mcrutils 0.0.0.9013 ## Behavior changes diff --git a/R/most_recent_day_of_week.R b/R/most_recent_day_of_week.R index c029771..8428ecf 100644 --- a/R/most_recent_day_of_week.R +++ b/R/most_recent_day_of_week.R @@ -3,8 +3,8 @@ #' This function returns the date of the most recent Sunday or Monday that #' is on or before the current date or a specified reference date. #' -#' @param ref_date A date object or a string representing a date. -#' Defaults to NULL, which uses the current date. +#' @param as_of A date object or a string representing the date to evaluate +#' relative to. Defaults to NULL, which uses the current date. #' #' @return A Date object representing the most recent Sunday or Monday #' @examples @@ -17,26 +17,26 @@ NULL #' @rdname most_recent_x_day #' @export -most_recent_monday <- function(ref_date = NULL) { - if (!is.null(ref_date)) { - ref_date <- as.Date(ref_date) +most_recent_monday <- function(as_of = NULL) { + if (!is.null(as_of)) { + as_of <- as.Date(as_of) } else { - ref_date <- Sys.Date() + as_of <- Sys.Date() } - weekday <- as.integer(format(ref_date, "%u")) # 1 = Monday, 7 = Sunday + weekday <- as.integer(format(as_of, "%u")) # 1 = Monday, 7 = Sunday days_to_subtract <- ifelse(weekday == 1, 0, weekday - 1) - ref_date - days_to_subtract + as_of - days_to_subtract } #' @rdname most_recent_x_day #' @export -most_recent_sunday <- function(ref_date = NULL) { - if (!is.null(ref_date)) { - ref_date <- as.Date(ref_date) +most_recent_sunday <- function(as_of = NULL) { + if (!is.null(as_of)) { + as_of <- as.Date(as_of) } else { - ref_date <- Sys.Date() + as_of <- Sys.Date() } - weekday <- as.integer(format(ref_date, "%u")) # 1 = Monday, 7 = Sunday + weekday <- as.integer(format(as_of, "%u")) # 1 = Monday, 7 = Sunday days_to_subtract <- ifelse(weekday == 7, 0, weekday) - ref_date - days_to_subtract + as_of - days_to_subtract } diff --git a/R/period_completeness.R b/R/period_completeness.R new file mode 100644 index 0000000..1997ece --- /dev/null +++ b/R/period_completeness.R @@ -0,0 +1,368 @@ +#' Validate a period unit +#' +#' @param unit A period unit string. +#' @return The validated `unit`, or an error. +#' @keywords internal +#' @noRd +validate_period_unit <- function(unit) { + rlang::arg_match0( + unit, + values = c("week", "month", "quarter", "year"), + arg_nm = "unit" + ) +} + +#' Business days remaining between a cutoff and a period end +#' +#' Vectorized count of business days in the half-open interval `(from, to]`, +#' clamped so [qlcal::businessDaysBetween()] never receives `from > to`, and +#' masked to `0L` whenever the cutoff has already reached or passed the end. +#' +#' @param from Cutoff date(s) (the reference position, e.g. `as_of`). +#' @param to Period end date(s). +#' @param calendar (character) A QuantLib calendar id. +#' @return An integer vector of business days remaining. +#' @keywords internal +#' @noRd +n_bizdays_remaining <- function(from, to, calendar) { + from <- as.Date(from) + to <- as.Date(to) + + n <- max(length(from), length(to)) + from <- rep_len(from, n) + to <- rep_len(to, n) + + # qlcal errors on NA (and out-of-range) dates, so substitute a safe in-range + # placeholder for NA endpoints and restore NA in the result afterwards. + na_mask <- is.na(from) | is.na(to) + placeholder <- as.Date("2000-01-01") + from[na_mask] <- placeholder + to[na_mask] <- placeholder + + # clamp so qlcal never sees from > to; the mask below fixes those elements + from_clamped <- pmin(from, to) + + remaining <- bizdays_between( + from = from_clamped, + to = to, + calendar = calendar, + include_first = FALSE, + include_last = TRUE + ) + + remaining <- as.integer(remaining) + remaining[from >= to] <- 0L + remaining[na_mask] <- NA_integer_ + remaining +} + +#' Start or end date of the period containing each date +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' `period_start_date()` and `period_end_date()` return the first and last +#' *calendar* day of the period containing each `date`. They are thin, coercing, +#' vectorized wrappers around [lubridate::floor_date()] and +#' [lubridate::ceiling_date()] that always return a [Date] (never a datetime) +#' and validate `unit`. +#' +#' The returned dates are always canonical calendar boundaries. Business-day +#' calendars are used only to *judge completeness* (see [period_is_complete()]), +#' never to compute a boundary date. +#' +#' @param date A vector of dates (Date or coercible with [lubridate::as_date()]). +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`, so the +#' package defers to the session's lubridate week convention. +#' @return A [Date] vector the same length as `date`. +#' @seealso [period_is_complete()], [last_complete_period_end()] +#' @name period_bounds +#' @examples +#' period_start_date(as.Date("2025-05-14"), "month") # 2025-05-01 +#' period_end_date(as.Date("2025-05-14"), "month") # 2025-05-31 +#' period_end_date(as.Date("2025-05-14"), "quarter") # 2025-06-30 +NULL + +#' @rdname period_bounds +#' @export +period_start_date <- function(date, unit = "month", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + date <- lubridate::as_date(date) + as.Date(lubridate::floor_date(date, unit = unit, week_start = week_start)) +} + +#' @rdname period_bounds +#' @export +period_end_date <- function(date, unit = "month", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + date <- lubridate::as_date(date) + ceiling <- lubridate::ceiling_date( + date, + unit = unit, + week_start = week_start, + change_on_boundary = TRUE + ) + as.Date(ceiling) - 1L +} + +#' Is the period containing each date complete as of a cutoff? +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' For each date in `date`, tests whether the period containing it is complete +#' as of `as_of` (typically `max(order_date)` or a report end date). A period is +#' complete once no business days remain after `as_of` up to and including the +#' period end, evaluated in `calendar`. +#' +#' Choosing `calendar` selects the completeness policy: +#' \itemize{ +#' \item `"WeekendsOnly"` (default) treats Mon-Fri as working days with no +#' holidays -- a period is complete once only weekends remain. +#' \item A national calendar such as `"UnitedStates"` additionally treats +#' holidays as non-working, giving holiday-accurate completeness. +#' \item `"Null"` treats every calendar day as a working day, so completeness +#' reduces to "every calendar day of the period has elapsed" +#' (`period_end_date(date) <= as_of`). +#' } +#' See [qlcal::calendars] for valid ids. +#' +#' @param date A vector of dates (Date or coercible with [as.Date()]); each value +#' identifies the period to test. +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param as_of Reference cutoff date (Date or coercible), length 1 or the length +#' of `date`. Required---there is no default, so completeness is never judged +#' silently against the current date. Pass `max()` of your data's date column +#' for completeness relative to how current the data is, or [Sys.Date()] for +#' the current date. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return A logical vector the same length as `date`. +#' @seealso [filter_complete_periods()], [last_complete_period_end()], +#' [scale_alpha_logical()], [period_end_date()] +#' @export +#' @examples +#' # As of 2025-05-30 (a Friday), April and May are complete under the +#' # weekends-only calendar -- May's only remaining day, the 31st, is a Saturday -- +#' # but June is not. Returns c(TRUE, TRUE, FALSE). +#' period_is_complete( +#' as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), +#' unit = "month", +#' as_of = as.Date("2025-05-30") +#' ) +#' +#' # Pure calendar-day semantics: May is not complete until 2025-05-31 elapses. +#' period_is_complete( +#' as.Date("2025-05-15"), +#' as_of = as.Date("2025-05-30"), +#' calendar = "Null" +#' ) +period_is_complete <- function(date, unit = "month", as_of, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + if (missing(as_of)) { + cli::cli_abort(c( + "{.arg as_of} is required.", + "i" = "Supply the reference cutoff date, e.g. {.code max()} of your data's date column, or {.code Sys.Date()} for the current date." + )) + } + as_of <- as.Date(as_of) + end <- period_end_date(date, unit = unit, week_start = week_start) + + if (!length(as_of) %in% c(1L, length(end))) { + cli::cli_abort(c( + "{.arg as_of} must be length 1 or the same length as {.arg date}.", + "x" = "{.arg as_of} has length {length(as_of)}; {.arg date} has length {length(end)}." + )) + } + + n_bizdays_remaining(from = as_of, to = end, calendar = calendar) == 0L +} + +#' Keep only rows whose period is complete as of a cutoff +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' Filters `data` to the rows whose period (per [period_is_complete()]) is +#' complete as of `as_of`. Useful for dropping the in-progress trailing period +#' from a summary or year-over-year comparison. +#' +#' @param data A data frame. +#' @param date_col <[`data-masked`][dplyr::dplyr_data_masking]> The date column +#' whose period completeness is tested. +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param as_of Reference cutoff date, a single Date (or coercible). Defaults to +#' `NULL`, in which case it is set to the maximum of `date_col` so completeness +#' is judged against how current the data actually is. An explicit `as_of` +#' *earlier* than the maximum of `date_col` is an error, because it would drop +#' periods the data demonstrably completes; use [period_is_complete()] directly +#' for point-in-time completeness. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return `data` filtered to rows in complete periods. +#' @seealso [period_is_complete()] +#' @export +#' @examples +#' df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) +#' # as_of defaults to max(day) = 2025-06-12, so the partial June is dropped +#' nrow(filter_complete_periods(df, day, unit = "month")) +filter_complete_periods <- function(data, date_col, unit = "month", as_of = NULL, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + + dates <- as.Date(dplyr::pull(data, {{ date_col }})) + # NA when there are no non-missing dates; rows then all read incomplete (NA) + data_max <- if (all(is.na(dates))) as.Date(NA) else max(dates, na.rm = TRUE) + + if (is.null(as_of)) { + as_of <- data_max + } else { + as_of <- as.Date(as_of) + if (length(as_of) != 1L) { + cli::cli_abort(c( + "{.arg as_of} must be a single date, or {.code NULL} to use the maximum of {.arg date_col}.", + "x" = "{.arg as_of} has length {length(as_of)}." + )) + } + if (!is.na(data_max) && as_of < data_max) { + cli::cli_abort(c( + "{.arg as_of} ({.val {as_of}}) is earlier than the maximum of {.arg date_col} ({.val {data_max}}).", + "x" = "This would drop periods that are demonstrably complete in the data.", + "i" = "Use {.fn period_is_complete} directly for point-in-time completeness." + )) + } + } + + dplyr::filter( + data, + period_is_complete( + {{ date_col }}, + unit = unit, + as_of = as_of, + calendar = calendar, + week_start = week_start + ) + ) +} + +#' Boundary of the most recent complete period as of a cutoff +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' `last_complete_period_end()` returns the (canonical calendar) end date of the +#' most recent period that is complete as of `as_of`; `last_complete_period_start()` +#' returns that period's start date. Completeness is judged with +#' [period_is_complete()], so `calendar` selects the policy exactly as it does +#' there. +#' +#' @param as_of Reference cutoff date(s) (Date or coercible). Required---there is +#' no default. Use the latest date in your data for completeness relative to how +#' current the data is, or [Sys.Date()] for the current date. +#' @param unit Period size, one of `"week"`, `"month"`, `"quarter"`, +#' or `"year"`. +#' @param calendar (character) A QuantLib calendar id (see [qlcal::calendars]). +#' Defaults to `"WeekendsOnly"`. +#' @param week_start Integer 1-7 giving the first day of the week (only used when +#' `unit = "week"`). Defaults to `getOption("lubridate.week.start", 7)`. +#' @return A [Date] vector the same length as `as_of`. +#' @seealso [period_is_complete()], [period_end_date()] +#' @name last_complete_period +#' @examples +#' # 2025-05-30 falls in Q2 2025, which is still in progress, so the most recent +#' # complete quarter ends 2025-03-31. +#' last_complete_period_end(as.Date("2025-05-30"), unit = "quarter") +#' last_complete_period_start(as.Date("2025-05-30"), unit = "quarter") +NULL + +#' @rdname last_complete_period +#' @export +last_complete_period_end <- function(as_of, unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + unit <- validate_period_unit(unit) + if (missing(as_of)) { + cli::cli_abort(c( + "{.arg as_of} is required.", + "i" = "Supply the reference cutoff date, e.g. the latest date in your data, or {.code Sys.Date()} for the current date." + )) + } + as_of <- as.Date(as_of) + + end_current <- period_end_date(as_of, unit = unit, week_start = week_start) + # canonical end of the immediately prior period + end_prior <- period_start_date(as_of, unit = unit, week_start = week_start) - 1L + + complete_now <- period_is_complete( + as_of, + unit = unit, + as_of = as_of, + calendar = calendar, + week_start = week_start + ) + + # dplyr::if_else preserves the Date class (base ifelse would strip it) + dplyr::if_else(complete_now, end_current, end_prior) +} + +#' @rdname last_complete_period +#' @export +last_complete_period_start <- function(as_of, unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7)) { + end <- last_complete_period_end( + as_of, + unit = unit, + calendar = calendar, + week_start = week_start + ) + period_start_date(end, unit = unit, week_start = week_start) +} + +#' A ggplot2 alpha scale for complete versus incomplete periods +#' +#' @description +#' `r lifecycle::badge("experimental")` +#' +#' A manual alpha scale mapping `FALSE`/`TRUE` to two opacity levels, with the +#' guide hidden. Pairs with [period_is_complete()] to fade the in-progress +#' period in bar or column charts, e.g. +#' `ggplot2::aes(alpha = period_is_complete(date, as_of = cutoff))`. +#' +#' @param false_alpha Alpha applied to `FALSE` (incomplete) values. Defaults to +#' 0.5. +#' @param true_alpha Alpha applied to `TRUE` (complete) values. Defaults to 1. +#' @return A ggplot2 scale object. +#' @seealso [period_is_complete()] +#' @export +#' @examples +#' library(ggplot2) +#' df <- data.frame( +#' month = as.Date(c("2025-04-01", "2025-05-01", "2025-06-01")), +#' value = c(10, 12, 4) +#' ) +#' ggplot(df, aes(month, value, +#' alpha = period_is_complete(month, as_of = as.Date("2025-06-15")) +#' )) + +#' geom_col() + +#' scale_alpha_logical() +scale_alpha_logical <- function(false_alpha = 0.5, true_alpha = 1) { + ggplot2::scale_alpha_manual( + values = c("FALSE" = false_alpha, "TRUE" = true_alpha), + guide = "none" + ) +} diff --git a/man/filter_complete_periods.Rd b/man/filter_complete_periods.Rd new file mode 100644 index 0000000..6a00192 --- /dev/null +++ b/man/filter_complete_periods.Rd @@ -0,0 +1,55 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{filter_complete_periods} +\alias{filter_complete_periods} +\title{Keep only rows whose period is complete as of a cutoff} +\usage{ +filter_complete_periods( + data, + date_col, + unit = "month", + as_of = NULL, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{data}{A data frame.} + +\item{date_col}{<\code{\link[dplyr:dplyr_data_masking]{data-masked}}> The date column +whose period completeness is tested.} + +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{as_of}{Reference cutoff date, a single Date (or coercible). Defaults to +\code{NULL}, in which case it is set to the maximum of \code{date_col} so completeness +is judged against how current the data actually is. An explicit \code{as_of} +\emph{earlier} than the maximum of \code{date_col} is an error, because it would drop +periods the data demonstrably completes; use \code{\link[=period_is_complete]{period_is_complete()}} directly +for point-in-time completeness.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +\code{data} filtered to rows in complete periods. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +Filters \code{data} to the rows whose period (per \code{\link[=period_is_complete]{period_is_complete()}}) is +complete as of \code{as_of}. Useful for dropping the in-progress trailing period +from a summary or year-over-year comparison. +} +\examples{ +df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) +# as_of defaults to max(day) = 2025-06-12, so the partial June is dropped +nrow(filter_complete_periods(df, day, unit = "month")) +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}} +} diff --git a/man/last_complete_period.Rd b/man/last_complete_period.Rd new file mode 100644 index 0000000..2a716a1 --- /dev/null +++ b/man/last_complete_period.Rd @@ -0,0 +1,57 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{last_complete_period} +\alias{last_complete_period} +\alias{last_complete_period_end} +\alias{last_complete_period_start} +\title{Boundary of the most recent complete period as of a cutoff} +\usage{ +last_complete_period_end( + as_of, + unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) + +last_complete_period_start( + as_of, + unit = "month", + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{as_of}{Reference cutoff date(s) (Date or coercible). Required---there is +no default. Use the latest date in your data for completeness relative to how +current the data is, or \code{\link[=Sys.Date]{Sys.Date()}} for the current date.} + +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +A \link[lubridate:date_utils]{lubridate::Date} vector the same length as \code{as_of}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +\code{last_complete_period_end()} returns the (canonical calendar) end date of the +most recent period that is complete as of \code{as_of}; \code{last_complete_period_start()} +returns that period's start date. Completeness is judged with +\code{\link[=period_is_complete]{period_is_complete()}}, so \code{calendar} selects the policy exactly as it does +there. +} +\examples{ +# 2025-05-30 falls in Q2 2025, which is still in progress, so the most recent +# complete quarter ends 2025-03-31. +last_complete_period_end(as.Date("2025-05-30"), unit = "quarter") +last_complete_period_start(as.Date("2025-05-30"), unit = "quarter") +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}}, \code{\link[=period_end_date]{period_end_date()}} +} diff --git a/man/most_recent_x_day.Rd b/man/most_recent_x_day.Rd index 9c291c0..45529fa 100644 --- a/man/most_recent_x_day.Rd +++ b/man/most_recent_x_day.Rd @@ -6,13 +6,13 @@ \alias{most_recent_sunday} \title{Get the date of the prior Sunday or Monday} \usage{ -most_recent_monday(ref_date = NULL) +most_recent_monday(as_of = NULL) -most_recent_sunday(ref_date = NULL) +most_recent_sunday(as_of = NULL) } \arguments{ -\item{ref_date}{A date object or a string representing a date. -Defaults to NULL, which uses the current date.} +\item{as_of}{A date object or a string representing the date to evaluate +relative to. Defaults to NULL, which uses the current date.} } \value{ A Date object representing the most recent Sunday or Monday diff --git a/man/period_bounds.Rd b/man/period_bounds.Rd new file mode 100644 index 0000000..c674fe4 --- /dev/null +++ b/man/period_bounds.Rd @@ -0,0 +1,54 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{period_bounds} +\alias{period_bounds} +\alias{period_start_date} +\alias{period_end_date} +\title{Start or end date of the period containing each date} +\usage{ +period_start_date( + date, + unit = "month", + week_start = getOption("lubridate.week.start", 7) +) + +period_end_date( + date, + unit = "month", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{date}{A vector of dates (Date or coercible with \code{\link[lubridate:as_date]{lubridate::as_date()}}).} + +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}, so the +package defers to the session's lubridate week convention.} +} +\value{ +A \link[lubridate:date_utils]{lubridate::Date} vector the same length as \code{date}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +\code{period_start_date()} and \code{period_end_date()} return the first and last +\emph{calendar} day of the period containing each \code{date}. They are thin, coercing, +vectorized wrappers around \code{\link[lubridate:round_date]{lubridate::floor_date()}} and +\code{\link[lubridate:round_date]{lubridate::ceiling_date()}} that always return a \link[lubridate:date_utils]{lubridate::Date} (never a datetime) +and validate \code{unit}. + +The returned dates are always canonical calendar boundaries. Business-day +calendars are used only to \emph{judge completeness} (see \code{\link[=period_is_complete]{period_is_complete()}}), +never to compute a boundary date. +} +\examples{ +period_start_date(as.Date("2025-05-14"), "month") # 2025-05-01 +period_end_date(as.Date("2025-05-14"), "month") # 2025-05-31 +period_end_date(as.Date("2025-05-14"), "quarter") # 2025-06-30 +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}}, \code{\link[=last_complete_period_end]{last_complete_period_end()}} +} diff --git a/man/period_is_complete.Rd b/man/period_is_complete.Rd new file mode 100644 index 0000000..3dea5ca --- /dev/null +++ b/man/period_is_complete.Rd @@ -0,0 +1,77 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{period_is_complete} +\alias{period_is_complete} +\title{Is the period containing each date complete as of a cutoff?} +\usage{ +period_is_complete( + date, + unit = "month", + as_of, + calendar = "WeekendsOnly", + week_start = getOption("lubridate.week.start", 7) +) +} +\arguments{ +\item{date}{A vector of dates (Date or coercible with \code{\link[=as.Date]{as.Date()}}); each value +identifies the period to test.} + +\item{unit}{Period size, one of \code{"week"}, \code{"month"}, \code{"quarter"}, +or \code{"year"}.} + +\item{as_of}{Reference cutoff date (Date or coercible), length 1 or the length +of \code{date}. Required---there is no default, so completeness is never judged +silently against the current date. Pass \code{max()} of your data's date column +for completeness relative to how current the data is, or \code{\link[=Sys.Date]{Sys.Date()}} for +the current date.} + +\item{calendar}{(character) A QuantLib calendar id (see \link[qlcal:calendars]{qlcal::calendars}). +Defaults to \code{"WeekendsOnly"}.} + +\item{week_start}{Integer 1-7 giving the first day of the week (only used when +\code{unit = "week"}). Defaults to \code{getOption("lubridate.week.start", 7)}.} +} +\value{ +A logical vector the same length as \code{date}. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +For each date in \code{date}, tests whether the period containing it is complete +as of \code{as_of} (typically \code{max(order_date)} or a report end date). A period is +complete once no business days remain after \code{as_of} up to and including the +period end, evaluated in \code{calendar}. + +Choosing \code{calendar} selects the completeness policy: +\itemize{ +\item \code{"WeekendsOnly"} (default) treats Mon-Fri as working days with no +holidays -- a period is complete once only weekends remain. +\item A national calendar such as \code{"UnitedStates"} additionally treats +holidays as non-working, giving holiday-accurate completeness. +\item \code{"Null"} treats every calendar day as a working day, so completeness +reduces to "every calendar day of the period has elapsed" +(\code{period_end_date(date) <= as_of}). +} +See \link[qlcal:calendars]{qlcal::calendars} for valid ids. +} +\examples{ +# As of 2025-05-30 (a Friday), April and May are complete under the +# weekends-only calendar -- May's only remaining day, the 31st, is a Saturday -- +# but June is not. Returns c(TRUE, TRUE, FALSE). +period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + unit = "month", + as_of = as.Date("2025-05-30") +) + +# Pure calendar-day semantics: May is not complete until 2025-05-31 elapses. +period_is_complete( + as.Date("2025-05-15"), + as_of = as.Date("2025-05-30"), + calendar = "Null" +) +} +\seealso{ +\code{\link[=filter_complete_periods]{filter_complete_periods()}}, \code{\link[=last_complete_period_end]{last_complete_period_end()}}, +\code{\link[=scale_alpha_logical]{scale_alpha_logical()}}, \code{\link[=period_end_date]{period_end_date()}} +} diff --git a/man/scale_alpha_logical.Rd b/man/scale_alpha_logical.Rd new file mode 100644 index 0000000..fde99c4 --- /dev/null +++ b/man/scale_alpha_logical.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/period_completeness.R +\name{scale_alpha_logical} +\alias{scale_alpha_logical} +\title{A ggplot2 alpha scale for complete versus incomplete periods} +\usage{ +scale_alpha_logical(false_alpha = 0.5, true_alpha = 1) +} +\arguments{ +\item{false_alpha}{Alpha applied to \code{FALSE} (incomplete) values. Defaults to +0.5.} + +\item{true_alpha}{Alpha applied to \code{TRUE} (complete) values. Defaults to 1.} +} +\value{ +A ggplot2 scale object. +} +\description{ +\ifelse{html}{\href{https://lifecycle.r-lib.org/articles/stages.html#experimental}{\figure{lifecycle-experimental.svg}{options: alt='[Experimental]'}}}{\strong{[Experimental]}} + +A manual alpha scale mapping \code{FALSE}/\code{TRUE} to two opacity levels, with the +guide hidden. Pairs with \code{\link[=period_is_complete]{period_is_complete()}} to fade the in-progress +period in bar or column charts, e.g. +\code{ggplot2::aes(alpha = period_is_complete(date, as_of = cutoff))}. +} +\examples{ +library(ggplot2) +df <- data.frame( + month = as.Date(c("2025-04-01", "2025-05-01", "2025-06-01")), + value = c(10, 12, 4) +) +ggplot(df, aes(month, value, + alpha = period_is_complete(month, as_of = as.Date("2025-06-15")) +)) + + geom_col() + + scale_alpha_logical() +} +\seealso{ +\code{\link[=period_is_complete]{period_is_complete()}} +} diff --git a/tests/testthat/test-most_recent_day_of_week.R b/tests/testthat/test-most_recent_day_of_week.R index 0cfa29f..8bfdb0b 100644 --- a/tests/testthat/test-most_recent_day_of_week.R +++ b/tests/testthat/test-most_recent_day_of_week.R @@ -15,3 +15,8 @@ test_that("most_recent_monday() works", { as.Date(c("2025-12-29", "2026-01-05", "2026-01-05")) ) }) + +test_that("the reference date is passed via the as_of argument", { + expect_equal(most_recent_monday(as_of = "2024-06-15"), as.Date("2024-06-10")) + expect_equal(most_recent_sunday(as_of = "2024-06-15"), as.Date("2024-06-09")) +}) diff --git a/tests/testthat/test-period_completeness.R b/tests/testthat/test-period_completeness.R new file mode 100644 index 0000000..8c931c7 --- /dev/null +++ b/tests/testthat/test-period_completeness.R @@ -0,0 +1,321 @@ +# period_start_date() / period_end_date() ------------------------------------ + +test_that("period boundaries return canonical calendar dates", { + expect_equal(period_start_date(as.Date("2025-05-14"), "month"), as.Date("2025-05-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "month"), as.Date("2025-05-31")) + expect_equal(period_start_date(as.Date("2025-05-14"), "quarter"), as.Date("2025-04-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "quarter"), as.Date("2025-06-30")) + expect_equal(period_start_date(as.Date("2025-05-14"), "year"), as.Date("2025-01-01")) + expect_equal(period_end_date(as.Date("2025-05-14"), "year"), as.Date("2025-12-31")) +}) + +test_that("period_end_date() is correct on a period-boundary date", { + # 2025-01-01 is a month boundary; its period end is still 2025-01-31 + expect_equal(period_end_date(as.Date("2025-01-01"), "month"), as.Date("2025-01-31")) +}) + +test_that("period boundaries handle leap February and coerce strings", { + expect_equal(period_end_date("2024-02-10", "month"), as.Date("2024-02-29")) + expect_equal(period_end_date("2025-02-10", "month"), as.Date("2025-02-28")) +}) + +test_that("period boundaries are vectorized and return Date", { + out <- period_end_date(as.Date(c("2025-01-15", "2025-02-15")), "month") + expect_s3_class(out, "Date") + expect_equal(out, as.Date(c("2025-01-31", "2025-02-28"))) +}) + +test_that("week boundaries respect week_start", { + # 2025-07-03 is a Thursday + expect_equal( + period_start_date(as.Date("2025-07-03"), "week", week_start = 1), # Monday + as.Date("2025-06-30") + ) + expect_equal( + period_start_date(as.Date("2025-07-03"), "week", week_start = 7), # Sunday + as.Date("2025-06-29") + ) +}) + +# period_is_complete() ------------------------------------------------------- + +test_that("period_is_complete() is TRUE when as_of is on the period end", { + expect_true(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-31"))) +}) + +test_that("a month ending on a weekend is complete after its last business day", { + # May 2025 ends Saturday the 31st; the last business day is Friday the 30th + expect_true(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-30"))) + expect_false(period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-29"))) +}) + +test_that("calendar = 'Null' reduces to pure calendar-day completeness", { + # May is not complete until every calendar day has elapsed + expect_false( + period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-30"), calendar = "Null") + ) + expect_true( + period_is_complete(as.Date("2025-05-15"), "month", as.Date("2025-05-31"), calendar = "Null") + ) + + # equivalence to period_end_date(date) <= as_of across a date sweep + set.seed(1) + dates <- as.Date("2025-01-01") + sample(0:400, 60) + as_of <- as.Date("2025-06-10") + expect_equal( + period_is_complete(dates, "month", as_of, calendar = "Null"), + period_end_date(dates, "month") <= as_of + ) +}) + +test_that("period_is_complete() is vectorized correctly over a span of periods", { + # Regression guard: as_of (2025-05-30) is the last business day of its own + # month, yet a future month must still read incomplete. A naive implementation + # that tests as_of's own period instead of each date's period would wrongly + # mark future periods complete here. + expect_equal( + period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + "month", + as_of = as.Date("2025-05-30") + ), + c(TRUE, TRUE, FALSE) + ) +}) + +test_that("a holiday calendar completes a period earlier than weekends-only", { + # Week of 2025-06-30..2025-07-06 (Monday start); Fri 2025-07-04 is a US holiday. + # As of Thursday 2025-07-03 only Fri (holiday) + weekend remain. + thu <- as.Date("2025-07-03") + expect_true( + period_is_complete(thu, "week", thu, calendar = "UnitedStates", week_start = 1) + ) + expect_false( + period_is_complete(thu, "week", thu, calendar = "WeekendsOnly", week_start = 1) + ) +}) + +test_that("period_is_complete() errors on a length-mismatched as_of", { + expect_error( + period_is_complete( + as.Date(c("2025-01-15", "2025-02-15", "2025-03-15")), + "month", + as_of = as.Date(c("2025-01-01", "2025-02-01")) + ), + "length 1 or the same length" + ) +}) + +test_that("period_is_complete() errors on an invalid unit", { + expect_error( + period_is_complete(as.Date("2025-05-15"), unit = "fortnight"), + class = "rlang_error" + ) +}) + +test_that("period_is_complete() requires an explicit as_of", { + # There is no wall-clock default; the caller must state the reference. + expect_error(period_is_complete(as.Date("2025-05-15"), "month"), "is required") +}) + +# filter_complete_periods() -------------------------------------------------- + +test_that("filter_complete_periods() drops a partial trailing month (common case)", { + # The common case: a daily series that runs only partway into the latest + # month. April and May are fully elapsed, but June is populated only through + # the 12th, so the whole partial month is judged incomplete and dropped. + # as_of defaults to max(day) = 2025-06-12. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + out <- filter_complete_periods(df, day, unit = "month") + + expect_equal(nrow(out), 61L) # all of April (30) + May (31) + expect_equal(min(out$day), as.Date("2025-04-01")) + expect_equal(max(out$day), as.Date("2025-05-31")) + expect_true(all(out$day < as.Date("2025-06-01"))) # no partial-June rows survive +}) + +test_that("filter_complete_periods() honours an as_of later than the data max", { + # Assert the data is current through 2025-06-30 even though its last row is the + # 12th; the (data-partial) June then counts as a complete period and is kept. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + out <- filter_complete_periods(df, day, unit = "month", as_of = as.Date("2025-06-30")) + + expect_equal(nrow(out), 73L) # April (30) + May (31) + partial June (12) + expect_true(any(out$day >= as.Date("2025-06-01"))) +}) + +test_that("filter_complete_periods() completes a trailing period when as_of is in the next period", { + # Data collection for June is done (last row 2026-06-28, a Sunday) and we are + # now in July. With as_of in the next period, June is a complete period and is + # kept in full -- even though its final Mon/Tue (29th, 30th) carry no rows. + df <- data.frame(day = seq(as.Date("2026-05-01"), as.Date("2026-06-28"), by = "day")) + + kept <- filter_complete_periods(df, day, unit = "month", as_of = as.Date("2026-07-02")) + expect_equal(nrow(kept), 59L) # all of May (31) + June 1-28 (28) + expect_equal(max(kept$day), as.Date("2026-06-28")) + + # Contrast: with the default as_of = max(day) = 2026-06-28, June still has two + # business days remaining (Mon 29th, Tue 30th), so the June rows are dropped. + default_kept <- filter_complete_periods(df, day, unit = "month") + expect_equal(nrow(default_kept), 31L) # May only + expect_true(all(default_kept$day < as.Date("2026-06-01"))) +}) + +test_that("filter_complete_periods() rejects an as_of earlier than the data max", { + # An as_of before the data's max would drop periods the data demonstrably + # completes, so it is an error. + df <- data.frame(day = seq(as.Date("2025-04-01"), as.Date("2025-06-12"), by = "day")) + expect_error( + filter_complete_periods(df, day, unit = "month", as_of = as.Date("2025-05-15")), + "earlier than the maximum" + ) +}) + +# last_complete_period_end() / last_complete_period_start() ------------------- + +test_that("last_complete_period_end() steps back over an in-progress period", { + # 2025-05-30 is in Q2 2025, still in progress -> last complete quarter is Q1 + end <- last_complete_period_end(as.Date("2025-05-30"), "quarter") + expect_s3_class(end, "Date") + expect_equal(end, as.Date("2025-03-31")) + expect_equal( + last_complete_period_start(as.Date("2025-05-30"), "quarter"), + as.Date("2025-01-01") + ) +}) + +test_that("last_complete_period_end() returns the current period end when it is complete", { + # As of 2025-03-31 (a Monday, the last day of Q1), Q1 is complete + expect_equal( + last_complete_period_end(as.Date("2025-03-31"), "quarter"), + as.Date("2025-03-31") + ) +}) + +test_that("last_complete_period_end() is vectorized over as_of", { + out <- last_complete_period_end( + as.Date(c("2025-05-30", "2025-08-15")), + "quarter" + ) + expect_equal(out, as.Date(c("2025-03-31", "2025-06-30"))) +}) + +test_that("last_complete_period_*() require an explicit as_of", { + # No wall-clock default; missingness also propagates through _start to _end. + expect_error(last_complete_period_end(unit = "month"), "is required") + expect_error(last_complete_period_start(unit = "month"), "is required") +}) + +# scale_alpha_logical() ------------------------------------------------------ + +test_that("scale_alpha_logical() returns a ggplot2 scale", { + expect_s3_class(scale_alpha_logical(), "Scale") +}) + +test_that("scale_alpha_logical() maps FALSE/TRUE to the supplied alpha levels", { + p <- ggplot2::ggplot( + data.frame(x = 1:2, done = c(FALSE, TRUE)), + ggplot2::aes(x, x, alpha = done) + ) + + ggplot2::geom_point() + + scale_alpha_logical(false_alpha = 0.2, true_alpha = 0.9) + built <- ggplot2::ggplot_build(p)$data[[1]] + expect_setequal(built$alpha, c(0.2, 0.9)) +}) + +# NA handling ---------------------------------------------------------------- + +test_that("period_is_complete() returns NA for NA dates", { + expect_equal( + period_is_complete( + as.Date(c("2025-04-15", NA, "2025-06-15")), + "month", + as_of = as.Date("2025-06-30") + ), + c(TRUE, NA, TRUE) + ) +}) + +test_that("filter_complete_periods() drops NA-date rows without error", { + # default as_of = max(day, na.rm) = 2025-06-15: April complete, NA dropped, + # June still in progress + df <- data.frame(day = as.Date(c("2025-04-15", NA, "2025-06-15"))) + out <- filter_complete_periods(df, day, unit = "month") + expect_equal(out$day, as.Date("2025-04-15")) +}) + +test_that("filter_complete_periods() returns no rows for an all-NA date column", { + df <- data.frame(day = as.Date(c(NA, NA))) + expect_no_warning(out <- filter_complete_periods(df, day, unit = "month")) + expect_equal(nrow(out), 0L) +}) + +# additional argument / unit coverage ---------------------------------------- + +test_that("period_is_complete() accepts a vector as_of matching date", { + expect_equal( + period_is_complete( + as.Date(c("2025-01-15", "2025-02-15", "2025-06-15")), + "month", + as_of = as.Date(c("2025-01-31", "2025-02-15", "2025-06-30")) + ), + c(TRUE, FALSE, TRUE) + ) +}) + +test_that("period_end_date() respects week_start", { + # 2025-07-03 is a Thursday + expect_equal(period_end_date("2025-07-03", "week", week_start = 1), as.Date("2025-07-06")) # Sun + expect_equal(period_end_date("2025-07-03", "week", week_start = 7), as.Date("2025-07-05")) # Sat +}) + +test_that("period_is_complete() handles the year unit", { + # year: 2024-12-31 is a business day (Tuesday), so the year is not complete + # until it elapses + expect_false(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-30"))) + expect_true(period_is_complete(as.Date("2024-06-15"), "year", as.Date("2024-12-31"))) +}) + +test_that("the period family rejects units outside week/month/quarter/year", { + # "day" is intentionally not a valid period unit for completeness reasoning + expect_error(period_start_date(as.Date("2025-05-15"), "day"), class = "rlang_error") + expect_error(period_end_date(as.Date("2025-05-15"), "day"), class = "rlang_error") + expect_error(period_is_complete(as.Date("2025-05-15"), unit = "day"), class = "rlang_error") + expect_error(period_start_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") + expect_error(period_end_date(as.Date("2025-05-15"), "fortnight"), class = "rlang_error") +}) + +test_that("filter_complete_periods() errors on a length > 1 as_of", { + df <- data.frame(day = as.Date(c("2025-04-15", "2025-05-15"))) + expect_error( + filter_complete_periods( + df, day, + unit = "month", + as_of = as.Date(c("2025-05-31", "2025-06-30")) + ), + "single date" + ) +}) + +test_that("last_complete_period_*() work for the default month unit", { + # 2025-05-15 is mid-May, so the last complete month is April + expect_equal(last_complete_period_end(as.Date("2025-05-15")), as.Date("2025-04-30")) + expect_equal(last_complete_period_start(as.Date("2025-05-15")), as.Date("2025-04-01")) +}) + +# internal helper ------------------------------------------------------------ + +test_that("n_bizdays_remaining() recycles both directions and propagates NA", { + expect_equal( + n_bizdays_remaining(as.Date("2025-06-02"), as.Date(c("2025-06-02", "2025-06-03")), "Null"), + c(0L, 1L) + ) + expect_equal( + n_bizdays_remaining(as.Date(c("2025-06-02", "2025-06-03")), as.Date("2025-06-03"), "Null"), + c(1L, 0L) + ) + expect_equal( + n_bizdays_remaining(as.Date(c("2025-06-02", NA)), as.Date("2025-06-10"), "Null"), + c(8L, NA_integer_) + ) +}) diff --git a/vignettes/mcrutils.Rmd b/vignettes/mcrutils.Rmd index 0d44758..8bb3fe9 100644 --- a/vignettes/mcrutils.Rmd +++ b/vignettes/mcrutils.Rmd @@ -81,7 +81,8 @@ dates <- seq(as.Date("2022-01-01"), as.Date("2022-06-30"), by = "day") orders <- data.frame( account_id = sample(letters[1:10], n, replace = TRUE), order_date = sample(dates, n, replace = TRUE) -) |> arrange(order_date) +) |> + arrange(order_date) orders |> glimpse() ``` @@ -100,7 +101,12 @@ printed output here). ```{r} orders |> - accounts_by_status(account_id, order_date, by = "month", with_counts = TRUE) |> + accounts_by_status( + account_id, + order_date, + by = "month", + with_counts = TRUE + ) |> select(period_start, starts_with("n_")) ``` @@ -129,7 +135,12 @@ library(dplyr, warn.conflicts = FALSE) library(tidyr) example_sales |> - accounts_by_status(account_id, order_date, with_counts = TRUE, by = "quarter") |> + accounts_by_status( + account_id, + order_date, + with_counts = TRUE, + by = "quarter" + ) |> select(period_start, starts_with("n_")) |> # negate the lost counts for visualization mutate(across(contains("lost"), ~ -.x)) |> @@ -138,15 +149,17 @@ example_sales |> mutate(status = stringr::str_remove(status, "n_")) |> ggplot(aes(period_start, count, color = status)) + geom_line(linewidth = 1.2) + - scale_color_manual(values = c( - "active" = "#1f78b4", - "new" = "#33a02c", - "returning" = "#a6cee3", - "temporarily_lost" = "#fb9a99", - "terminally_lost" = "#e31a1c", - "regained" = "#b2df8a", - "cumulative" = "#999999" - )) + + scale_color_manual( + values = c( + "active" = "#1f78b4", + "new" = "#33a02c", + "returning" = "#a6cee3", + "temporarily_lost" = "#fb9a99", + "terminally_lost" = "#e31a1c", + "regained" = "#b2df8a", + "cumulative" = "#999999" + ) + ) + theme_minimal() ``` @@ -170,7 +183,8 @@ You can suppress the dashed lines for incomplete periods with with ```{r plot_accounts_2} example_sales |> plot_accounts_by_status( - account_id, order_date, + account_id, + order_date, by = "quarter", force_final_period_complete = TRUE, include_cumulative = FALSE @@ -178,38 +192,6 @@ example_sales |> ``` -## CAGR -`mutate_cagrs()` adds columns with compound annual growth rates (CAGRs) for a -vector of values over specified time periods, optionally grouped by one or more -variables. - -Here we'll first aggregate the `example_sales` data to get monthly sales volume -by market, then use `mutate_cagrs()` to calculate 1-, 2-, and 3-month CAGRs for -each market. - -```{r} -library(lubridate, warn.conflicts = FALSE) - -example_sales |> - group_by( - market, - month = lubridate::floor_date(order_date, unit = "month") - ) |> - summarize( - volume = sum(units_ordered), - .groups = "drop_last" - ) |> - mutate_cagrs( - volume, - month, - group_vars = market, - periods = c(1:3) - ) |> - # peek at the first 4 rows for each market - slice_head(n = 4, by = market) -``` - - ## Business day evaluation `mcrutils` provides several functions for working with business days, including `is_bizday()`, `adjust_to_bizday()`, `bizdays_between()`, `periodic_bizdays()`, @@ -264,6 +246,7 @@ close, we just need to eliminate the space in "United States"). ```{r head_example_sales} library(dplyr, warn.conflicts = FALSE) +library(lubridate, warn.conflicts = FALSE) library(purrr) library(stringr) @@ -288,7 +271,8 @@ bizday_lookup <- tibble( tidyr::expand_grid(calendar = unique(sales$calendar)) |> mutate( adjusted_date = purrr::map2_vec( - .data$date, .data$calendar, + .data$date, + .data$calendar, \(date, calendar) adjust_to_bizday(date, calendar) ), # calculate the business day of month for each date in each market @@ -317,7 +301,10 @@ Now we can join the lookup table to the sales data. ```{r} sales_with_bizday <- sales |> - left_join(bizday_lookup, by = c("order_date" = "date", "calendar" = "calendar")) + left_join( + bizday_lookup, + by = c("order_date" = "date", "calendar" = "calendar") + ) head(sales_with_bizday) ``` @@ -353,32 +340,8 @@ global_cum_daily_sales |> theme_minimal() ``` -Or we can look by-market as well, we just need to add another grouping variable -for the market, then facet the plot. - -```{r} -regional_cum_daily_sales <- sales_with_bizday |> - filter(order_date < ymd("2024-11-18")) |> - filter(month(order_date) == 11) |> - group_by(year = year(order_date), bizday_of_month, market) |> - summarise(units_ordered = sum(units_ordered), .groups = "drop") |> - group_by(year, market) |> - mutate(cumulative_units_ordered = cumsum(units_ordered)) - -head(regional_cum_daily_sales) -``` - -```{r} -regional_cum_daily_sales |> - ggplot(aes(bizday_of_month, cumulative_units_ordered, color = factor(year))) + - geom_line(linewidth = 1.2) + - facet_wrap(~market, ncol = 1) + - labs( - title = "Cumulative Units Ordered by Business Day of Month", - color = "Year" - ) + - theme_minimal() -``` +To break this out by market, add `market` as a grouping variable and +`facet_wrap(~ market)` to the plot. ## Year-to-date helpers @@ -434,6 +397,153 @@ c("2024-01-01", "2024-02-29", "2025-07-15") |> + +## Period completeness + +The most recent period in a report is often still in progress, so its aggregate is +partial and shouldn't be read---or compared---as if it were finished. Note that our churn chart above shows the final incomplete period as dashed for just this reason. `mcrutils` provides various utilities to compute period completeness with a business-day-aware notion of when a +period is "complete." + +`period_start_date()` and `period_end_date()` return the canonical first and last +calendar day of the period containing each date: + +```{r} +period_end_date(as.Date(c("2024-11-15", "2024-12-20")), unit = "month") +``` + +`period_is_complete()` tests whether the period containing each date is complete +as of a cutoff date, `as_of`. It is business-day aware: a period whose only +remaining days are weekends counts as complete. As of Friday 2025-05-30, for +instance, April and May are complete---May's only unelapsed day, the 31st, is a +Saturday---but June is not: + +```{r} +period_is_complete( + as.Date(c("2025-04-15", "2025-05-15", "2025-06-15")), + unit = "month", + as_of = as.Date("2025-05-30") +) +``` + +Pass a national `calendar` so public holidays, not just weekends, count as +non-working. Take the work week of 2025-06-30, whose last weekday is Friday the +4th---U.S. Independence Day. As of Thursday the 3rd, only that holiday and the +weekend remain, so the week is already complete under `"UnitedStates"` but not +under the default `"WeekendsOnly"`: + +```{r} +thursday <- as.Date("2025-07-03") +period_is_complete( + thursday, + "week", + as_of = thursday, + calendar = "UnitedStates", + week_start = 1 +) +period_is_complete( + thursday, + "week", + as_of = thursday, + calendar = "WeekendsOnly", + week_start = 1 +) +``` + +(Use `calendar = "Null"` to ignore business days and require every calendar day +of the period to have elapsed instead.) + +A common use case would be in a recurring report where the data is refereshed +e.g. daily, and we want to provide summaries for each period. We can use +`period_is_complete()` to flag each period. Assuming the query was run on +2025-12-21, we can group the `example_sales` data by month and summarize the +total units ordered and the last order date in each month, then flag whether +each month is complete as of the query date. + +```{r} +query_date <- as.Date("2025-12-21") + +monthly <- example_sales |> + group_by(month = period_start_date(order_date, "month")) |> + summarise( + units = sum(units_ordered), + last_order_in_period = max(order_date), + .groups = "drop" + ) |> + mutate( + complete = period_is_complete( + month, + unit = "month", + as_of = query_date, + calendar = "UnitedStates" + ) + ) + +tail(monthly) +``` + +Mapping the completeness flag to `alpha` with `scale_alpha_logical()` fades the +in-progress month so it reads as provisional: + +```{r period-completeness-alpha} +library(ggplot2) + +monthly |> + ggplot(aes(month, units, alpha = complete)) + + geom_col() + + scale_alpha_logical() + + labs(title = "Monthly units ordered", subtitle = "in-progress month faded") + + theme_minimal() +``` + +To drop the incomplete periods instead of flagging them, +`filter_complete_periods()` applies the same test as a row filter, defaulting +`as_of` to the latest date in the data: + +```{r} +example_sales |> + filter_complete_periods(order_date, unit = "month") |> + summarise(last_order_kept = max(order_date)) +``` + +Finally, `last_complete_period_end()` and `last_complete_period_start()` give the +boundaries of the most recent complete period as of a cutoff, which is handy for as passing to a filter or for annotations: + +```{r} +last_complete_period_end(max(example_sales$order_date), unit = "month") +``` + + +## CAGR +`mutate_cagrs()` adds columns with compound annual growth rates (CAGRs) for a +vector of values over specified time periods, optionally grouped by one or more +variables. + +Here we'll first aggregate the `example_sales` data to get monthly sales volume +by market, then use `mutate_cagrs()` to calculate 1-, 2-, and 3-month CAGRs for +each market. + +```{r} + +example_sales |> + group_by( + market, + month = lubridate::floor_date(order_date, unit = "month") + ) |> + summarize( + volume = sum(units_ordered), + .groups = "drop_last" + ) |> + mutate_cagrs( + volume, + month, + group_vars = market, + periods = c(1:3) + ) |> + # peek at the first 4 rows for each market + slice_head(n = 4, by = market) +``` + + # Visualization ## Auto-formatted datattables @@ -451,9 +561,9 @@ with an option to preserve selected acronyms in uppercase. ```{r guess_col_fmts_example, screenshot.opts=list(vwidth= 800, vheight = 600), message=FALSE} tribble( - ~market_name, ~ytd_revenue, ~cagr_pct, ~num_accounts, - "North", 1023.2456, 0.0512, 145, - "West", 150.2397, 0.1034, 28 + ~market_name , ~ytd_revenue , ~cagr_pct , ~num_accounts , + "North" , 1023.2456 , 0.0512 , 145 , + "West" , 150.2397 , 0.1034 , 28 ) |> rename_cols_for_display(all_caps = c("YTD", "CAGR")) |> auto_dt(numeric_digits = 1, pct_digits = 0)