season_bounds <- tribble(
~season, ~start_month, ~start_day, ~end_month, ~end_day,
"spring", 3L, 1L, 5L, 31L,
"summer", 6L, 1L, 8L, 31L,
"autumn", 9L, 1L, 11L, 30L,
"winter", 12L, 1L, 2L, 28L
)
event_plan <- tribble(
~event_id, ~entry_id, ~trigger, ~time_text, ~granularity, ~status, ~rule,
"EV01", "R01", "outreach window", "this spring", "season", "planned",
"entry-year spring",
"EV02", "R02", "orientation held", "March 2", "day", "reported",
"entry-year month day",
"EV03", "R02", "registration closes", "March 15", "day", "planned",
"entry-year month day",
"EV04", "R03", "12-week course", "September 14 for 12 weeks", "duration",
"planned", "entry-year month day plus weeks",
"EV05", "R04", "applicant check-in", "last Monday", "day", "reported",
"previous weekday from entry date",
"EV06", "R05", "pilot lasted", "six weeks", "duration", "reported",
"duration without start",
"EV07", "R06", "review meeting", "October 22", "day", "planned",
"entry-year month day",
"EV08", "R07", "September work", "September", "month", "reported",
"entry-year month"
)
make_date <- function(year, month, day) {
as.Date(sprintf("%04d-%02d-%02d", year, month, day))
}
parse_month_day <- function(entry_date, time_text) {
parts <- str_match(time_text, "\\b([A-Z][a-z]+)\\s+(\\d{1,2})\\b")
make_date(
as.integer(format(entry_date, "%Y")),
match(parts[, 2], month.name),
as.integer(parts[, 3])
)
}
parse_month_interval <- function(entry_date, time_text) {
month_number <- match(time_text, month.name)
start <- make_date(as.integer(format(entry_date, "%Y")), month_number, 1L)
end <- seq(start, by = "1 month", length.out = 2L)[2] - 1L
list(start = start, end = end)
}
previous_weekday <- function(entry_date, weekday) {
weekday_names <- c(
"Sunday", "Monday", "Tuesday", "Wednesday",
"Thursday", "Friday", "Saturday"
)
current_day <- as.POSIXlt(entry_date)$wday + 1L
target_day <- match(weekday, weekday_names)
days_back <- (current_day - target_day) %% 7L
if (days_back == 0L) {
days_back <- 7L
}
entry_date - days_back
}
normalize_event <- function(rule, entry_date, time_text) {
entry_year <- as.integer(format(entry_date, "%Y"))
if (rule == "entry-year spring") {
bounds <- season_bounds |>
filter(season == "spring")
return(list(
start = make_date(entry_year, bounds$start_month, bounds$start_day),
end = make_date(entry_year, bounds$end_month, bounds$end_day),
placed = TRUE,
reason_unplaced = NA_character_
))
}
if (rule == "entry-year month day") {
start <- parse_month_day(entry_date, time_text)
return(list(
start = start,
end = start,
placed = TRUE,
reason_unplaced = NA_character_
))
}
if (rule == "entry-year month day plus weeks") {
start <- parse_month_day(entry_date, time_text)
return(list(
start = start,
end = start + 12L * 7L - 1L,
placed = TRUE,
reason_unplaced = NA_character_
))
}
if (rule == "previous weekday from entry date") {
start <- previous_weekday(entry_date, "Monday")
return(list(
start = start,
end = start,
placed = TRUE,
reason_unplaced = NA_character_
))
}
if (rule == "entry-year month") {
interval <- parse_month_interval(entry_date, time_text)
return(list(
start = interval$start,
end = interval$end,
placed = TRUE,
reason_unplaced = NA_character_
))
}
list(
start = as.Date(NA),
end = as.Date(NA),
placed = FALSE,
reason_unplaced = "duration has no start date"
)
}
event_rows <- event_plan |>
left_join(program_entries, by = join_by(entry_id)) |>
mutate(
normalized = pmap(
list(rule, entry_date, time_text),
normalize_event
),
start = as.Date(map_chr(normalized, \(item) as.character(item$start))),
end = as.Date(map_chr(normalized, \(item) as.character(item$end))),
placed = map_lgl(normalized, "placed"),
reason_unplaced = map_chr(
normalized,
\(item) {
if (is.na(item$reason_unplaced)) {
NA_character_
} else {
item$reason_unplaced
}
}
),
narrative_order = match(entry_id, program_entries$entry_id)
) |>
select(
event_id, entry_id, narrative_order, entry_date, trigger, time_text,
granularity, status, rule, start, end, placed, reason_unplaced
)
kable(
event_rows,
col.names = c(
"Event ID", "Entry ID", "Narrative order", "Entry date", "Event trigger",
"Time text", "Granularity", "Status", "Rule", "Start", "End", "Placed",
"Reason unplaced"
),
caption = "Normalized event records with separate document dates and event intervals",
row.names = FALSE
)