Guides
Data modeling
Implementing custom calendars

Implementing custom calendars

This recipe explains the implementation of the 4-5-4 calendar (opens in a new tab), a common retail calendar used in the US and Canada. However, the same approach can be used to implement other custom calendars.

Unlike custom time dimension granularities, custom calendars provide more flexibility and can be used when time units have variable lengths, such as the months and quarters in the 4-5-4 calendar. See the custom granularities recipe for more information.

Use case

The 4-5-4 calendar ensures sales comparability between years by dividing the year into months based on a 4 weeks – 5 weeks – 4 weeks format. The layout of the calendar lines up holidays and ensures the same number of Saturdays and Sundays in comparable months. Hence, like days are compared to like days for sales reporting purposes.

Data modeling

The data modeling includes the following steps:

  • Create a calendar cube, e.g., calendar_454.
  • Extend it a number of times, so there's one calendar cube for every time dimension in cubes with facts that needs translation to a custom calendar.
  • Define joins from your cubes with facts to those calendar cubes, e.g., base_orders, and bring relevant calendar attributes as proxy dimensions.

The last two steps require a few lines of code but it can totally be optimized with a Jinja macro if needed.

Calendar table

Consider the following calendar cube. It was generated using a large language model (LLM) and then tested against the official calendar (opens in a new tab). In this example, it's generated on the fly, however, in production, it should be materialized as a table using a data transformation tool:

cubes:
  - name: calendar_454
    public: false
    sql: >
      WITH RECURSIVE fiscal_weeks AS (
          -- Step 1: Define the start of the fiscal years (Sunday closest to Feb 1st)
          SELECT 
              year AS fiscal_year, 
              CASE 
                  WHEN strftime('%w', date_trunc('week', make_date(year, 2, 1)))::INTEGER <= 3 
                  THEN date_trunc('week', make_date(year, 2, 1)) + INTERVAL 6 DAY 
                  ELSE date_trunc('week', make_date(year, 2, 1) + INTERVAL 7 DAY) + INTERVAL 7 DAY
              END AS week_start,
              1 AS week_number,
              1 AS month_number,
              1 AS month_week_count
          FROM range(2015, 2031) t(year)
          
          UNION ALL
          
          -- Step 2: Generate weeks recursively following the 4-5-4 pattern
          SELECT 
              fiscal_year,
              week_start + INTERVAL 7 DAY AS week_start,
              week_number + 1,
              CASE 
                  WHEN month_number = 12 AND ((month_week_count = 4 AND month_number % 3 = 1) OR 
                                              (month_week_count = 5 AND month_number % 3 = 2) OR 
                                              (month_week_count = 4 AND month_number % 3 = 0)) 
                  THEN 1 
                  WHEN month_week_count = 4 AND (month_number % 3 = 1) THEN month_number + 1
                  WHEN month_week_count = 5 AND (month_number % 3 = 2) THEN month_number + 1
                  WHEN month_week_count = 4 AND (month_number % 3 = 0) THEN month_number + 1
                  ELSE month_number
              END AS month_number,
              CASE 
                  WHEN month_week_count = 4 AND (month_number % 3 = 1) THEN 1
                  WHEN month_week_count = 5 AND (month_number % 3 = 2) THEN 1
                  WHEN month_week_count = 4 AND (month_number % 3 = 0) THEN 1
                  ELSE month_week_count + 1
              END AS month_week_count
          FROM fiscal_weeks
          WHERE week_number < 52 OR (week_number = 52 AND (fiscal_year % 5 = 2)) -- Account for 53rd week
      )
 
      SELECT 
          fiscal_year,
          week_number,
          month_number,
          make_timestamp(fiscal_year, month_number, 1, 0, 0, 0) AS fiscal_month_date,
          week_start AS week_start_date,
          make_timestamp(year(week_start + INTERVAL 6 DAY), 
              month(week_start + INTERVAL 6 DAY), 
              day(week_start + INTERVAL 6 DAY), 
              23, 59, 59.999) AS week_end_date
      FROM fiscal_weeks
      ORDER BY fiscal_year, week_number
 
    dimensions:
      - name: retail_year
        sql: fiscal_year
        type: number
 
      - name: week_number
        sql: week_number
        type: number
 
      - name: month_number
        sql: month_number
        type: number
 
      - name: retail_month_date
        sql: fiscal_month_date
        type: time
 
      - name: week_start_date
        sql: week_start_date
        type: time
 
      - name: week_end_date
        sql: week_end_date
        type: time

As you can see, this cube defines week_start_date and week_end_date time dimensions as the start and end dates of the retail week. They can be used to join this cube to cubes with facts.

Auxiliary calendar cubes

We will also extend the calendar_454 cube to create auxiliary calendar cubes for three time dimensions that we'd like to translate to the 4-5-4 calendar:

cubes:
  - name: calendar_454__base_orders__created_at
    extends: calendar_454
 
  - name: calendar_454__base_orders__completed_at
    extends: calendar_454

Cubes with facts

Finally, we define joins from the base_orders cube to the auxiliary calendar cubes. We also bring the week_number and month_number attributes as proxy dimensions:

cubes:
  - name: base_orders
    sql: SELECT * FROM 's3://cube-tutorial/orders.csv'
 
    joins:
      # BEGIN — Joins to calendar tables
      - name: calendar_454__base_orders__created_at
        sql: "{CUBE.created_at} BETWEEN {calendar_454__base_orders__created_at.week_start_date} AND {calendar_454__base_orders__created_at.week_end_date}"
        relationship: many_to_one
 
      - name: calendar_454__base_orders__completed_at
        sql: "{CUBE.completed_at} BETWEEN {calendar_454__base_orders__completed_at.week_start_date} AND {calendar_454__base_orders__completed_at.week_end_date}"
        relationship: many_to_one
      # END — Joins to calendar tables
 
    dimensions:
      - name: id
        sql: id
        type: number
        primary_key: true
 
      - name: status
        sql: status
        type: string
 
      # BEGIN — Regular time dimension + ones derived from calendar table
      - name: created_at
        sql: "{CUBE}.created_at::TIMESTAMP"
        type: time
 
      - name: created_at_retail_month
        sql: "{calendar_454__base_orders__created_at.retail_month_date}"
        type: time
 
      - name: created_at_retail_week
        sql: "{calendar_454__base_orders__created_at.week_number}"
        type: number
 
      - name: completed_at
        sql: "{CUBE}.completed_at::TIMESTAMP"
        type: time
 
      - name: completed_at_retail_month
        sql: "{calendar_454__base_orders__completed_at.retail_month_date}"
        type: time
 
      - name: completed_at_retail_week
        sql: "{calendar_454__base_orders__completed_at.week_number}"
        type: number
      # END — Regular time dimension + ones derived from calendar table
 
    measures:
      - name: count
        type: count
 
      - name: completed_count
        type: count
        filters:
          - sql: "{CUBE}.status = 'completed'"

Result

Querying this data modal would yield the following result: