Why ti fills your calendar, and what it refuses to fill
Author
Alejandro Hagan
The problem with gaps
Transactional data has holes in it. Stores close on holidays, no one orders on a Sunday, a product sells nothing for a week. Your table has no row for those days — not a row with a zero, no row at all.
That is fine until you compare periods. Consider a week of margin data with three days missing:
The row labelled 2024-01-03 reports a change against 2024-01-01. But those are not consecutive days — January 2nd is missing. lag() gives you the previous row, and you asked for the previous day. On a table with gaps those are different questions.
The problem compounds as the gaps widen. 2024-01-06 is compared against 2024-01-03, three days earlier.
What ti does instead
Every ti calculation begins by building a complete calendar for the requested time unit, then joining your data onto it. Missing days appear as rows with a zero value:
Now January 2nd exists, holds a zero, and the day-over-day comparison lines up with the actual calendar.
The missing_date_indicator column
Zero-filling is useful for arithmetic and dangerous for interpretation. A day with no sales and a day with genuinely zero margin look identical once both are zeroes.
So ti labels them. Every completed calendar carries a missing_date_indicator column: 1 if the row was invented to fill a gap, 0 if it came from your data.
filled |>count(missing_date_indicator)
# A tibble: 2 × 2
missing_date_indicator n
<dbl> <int>
1 0 7
2 1 4
Use it to keep the two cases apart. To count only trading days:
Both numbers are legitimate; they answer different questions. Averaging over the calendar tells you daily run rate including closures. Averaging over trading days tells you how a trading day performs. The indicator lets you choose deliberately rather than by accident.
What ti refuses to fill
Calendar completion is bounded. ti fills gaps inside the observed date range and does not extend beyond it. There is no back-filling to the start of the year and no padding forward to today.
The series runs from the first observed date to the last, and no further. Data you never had does not get invented.
This bounding is applied per group. Each group’s calendar is completed within that group’s own first and last observation, which matters whenever groups enter or leave at different times:
# A tibble: 2 × 4
store first_date last_date n_rows
<chr> <dttm> <dttm> <int>
1 A 2024-01-01 00:00:00 2024-03-01 00:00:00 61
2 B 2024-03-01 00:00:00 2024-03-05 00:00:00 5
Store B opens in March and its series starts in March. It is not given two months of zeroes to match store A. A store that opened in March did not have a bad January — it had no January, and padding it would corrupt every average and year-over-year comparison that touches it.
Inspecting gaps before you calculate
A ti object reports what it found before you run anything. Printing one summarises the calendar it intends to build, including how many dates were missing:
── Day over day ────────────────────────────────────────────────────────────────
Function: `dod` was executed
── Description: ──
This creates a full day `sum()` of the previous day amount and compares it with
the full day `sum()` current day amount from the start of the standard calendar
day to the end of the day
── Calendar: ──
• The calendar aggregated order_date to the day time unit
• A standard calendar is created with 0 groups
• Calendar ranges from 2024-01-01 to 2024-01-11
• 3 days were missing and replaced with 0
• New date column date was created from order_date
If the missing-date count is far higher than you expect, that is usually a signal about the data rather than the calculation — a wrong grouping column, a filter applied too early, or a date column that is not the one you meant.
A related tool: calendar attributes
augment_standard_calendar() solves a different problem and is worth knowing about. It does not fill gaps — it annotates the rows you already have with calendar context: period boundaries, day counts, and how far through each period a date sits.
# A tibble: 5 × 5
order_date month_end_date days_complete_in_month days_remaining_in_month
<date> <date> <drtn> <drtn>
1 2024-01-01 2024-01-31 0 days 30 days
2 2024-01-03 2024-01-31 2 days 28 days
3 2024-01-06 2024-01-31 5 days 25 days
4 2024-01-07 2024-01-31 6 days 24 days
5 2024-01-08 2024-01-31 7 days 23 days
# ℹ 1 more variable: weekend_indicator <dbl>
These columns are what you need for pacing and run-rate work — projecting a partial month to a full month, or explaining a shortfall by weekend placement rather than performance. The full set covers year, quarter, month and week boundaries, day-of-week and month labels, and complete/remaining day counts for each period.
Key points
dplyr::lag() gives you the previous row; period comparisons need the previous date. On gappy data these differ.
ti completes the calendar before calculating, so comparisons align to real dates.
missing_date_indicator marks invented rows — filter on it to separate closures from genuine zeroes.
Completion is bounded by the observed range and never extends past it.
Bounds are per group, so late-starting groups are not zero-padded backwards.
Print the object to see the missing-date count before running the query.