GuideApril 23, 2026·By Crew

How We Calculate Peak vs. Off-Peak Ratio — The Technical Breakdown

Technical companion to Peak vs. Off-Peak Ratio in The Numbers That Matter series.


Peak vs. off-peak ratio quantifies how unevenly your traffic distributes across your operating hours. The math is straightforward — the nuance is in defining what "peak" and "off-peak" mean for your specific club.

Defining peak windows

Peak and off-peak aren't universal — they're club-specific and potentially seasonal. The cleanest approach is a configuration table:

CREATE TABLE peak_windows (
  id          UUID PRIMARY KEY,
  club_id     UUID REFERENCES clubs(id),
  day_of_week INTEGER,        -- 0=Sunday, 6=Saturday
  start_hour  INTEGER,        -- 0-23
  end_hour    INTEGER,        -- 0-23
  label       TEXT DEFAULT 'peak'  -- 'peak', 'off_peak', 'shoulder'
);

A club might define Friday 8pm–1am and Saturday 6pm–1am as peak, weekdays 12pm–5pm as off-peak, and everything else as shoulder. The configuration is editable by the Manager without code changes.

The basic ratio

WITH visit_classification AS (
  SELECT
    v.id,
    CASE
      WHEN pw.label = 'peak' THEN 'peak'
      WHEN pw.label = 'off_peak' THEN 'off_peak'
      ELSE 'shoulder'
    END AS period_type
  FROM visits v
  LEFT JOIN peak_windows pw ON
    pw.club_id = v.club_id
    AND pw.day_of_week = EXTRACT(DOW FROM v.checked_in_at)
    AND EXTRACT(HOUR FROM v.checked_in_at) >= pw.start_hour
    AND EXTRACT(HOUR FROM v.checked_in_at) < pw.end_hour
  WHERE v.status = 'completed'
    AND v.checked_in_at >= NOW() - INTERVAL '30 days'
)
SELECT
  COUNT(*) FILTER (WHERE period_type = 'peak') AS peak_visits,
  COUNT(*) FILTER (WHERE period_type = 'off_peak') AS offpeak_visits,
  ROUND(
    COUNT(*) FILTER (WHERE period_type = 'peak')::decimal /
    NULLIF(COUNT(*) FILTER (WHERE period_type = 'off_peak'), 0), 1
  ) AS peak_to_offpeak_ratio
FROM visit_classification;

A result of 3.2 means 3.2× as many visits during peak as off-peak.

Hourly distribution (the full picture)

The ratio is a summary. The hourly breakdown is where actionable insight lives:

SELECT
  EXTRACT(DOW FROM checked_in_at) AS day_of_week,
  EXTRACT(HOUR FROM checked_in_at) AS hour,
  COUNT(*) AS visits,
  ROUND(
    COUNT(*)::decimal / SUM(COUNT(*)) OVER () * 100, 1
  ) AS pct_of_total
FROM visits
WHERE status = 'completed'
  AND checked_in_at >= NOW() - INTERVAL '90 days'
GROUP BY day_of_week, hour
ORDER BY day_of_week, hour;

This gives you a 7×24 grid (or whatever your operating hours span) showing exactly where traffic concentrates. The data can power a heatmap visualization on the Manager dashboard.

Normalizing for available hours

A fair comparison accounts for the fact that you might have 20 peak hours per week and 40 off-peak hours. Raw visit counts favor whichever window is larger. Visits per hour normalizes:

WITH window_hours AS (
  SELECT
    label,
    SUM(end_hour - start_hour) AS total_hours_per_week
  FROM peak_windows
  WHERE club_id = :club_id
  GROUP BY label
)
SELECT
  vc.period_type,
  COUNT(*) AS total_visits,
  wh.total_hours_per_week,
  ROUND(
    COUNT(*)::decimal / (wh.total_hours_per_week * 4.3), 1  -- 4.3 weeks/month
  ) AS visits_per_hour
FROM visit_classification vc
JOIN window_hours wh ON vc.period_type = wh.label
GROUP BY vc.period_type, wh.total_hours_per_week;

Now you're comparing apples to apples: visits per hour during peak vs. visits per hour during off-peak.

Revenue dimension

Traffic distribution is half the story. Revenue distribution might tell a different one:

SELECT
  period_type,
  COUNT(*) AS visits,
  ROUND(AVG(v.total_revenue), 2) AS avg_rpv,
  SUM(v.total_revenue) AS total_revenue,
  ROUND(
    SUM(v.total_revenue)::decimal /
    SUM(SUM(v.total_revenue)) OVER () * 100, 1
  ) AS pct_of_revenue
FROM visit_classification vc
JOIN visits v ON vc.id = v.id
GROUP BY period_type;

If off-peak has 25% of visits but only 15% of revenue, off-peak visitors are spending less per visit — which might mean your off-peak crowd is more price-sensitive, or your add-on offerings aren't positioned for that audience.

Tracking the ratio over time

To see whether your demand-shifting efforts are working:

SELECT
  DATE_TRUNC('week', checked_in_at) AS week,
  COUNT(*) FILTER (WHERE period_type = 'peak') AS peak,
  COUNT(*) FILTER (WHERE period_type = 'off_peak') AS offpeak,
  ROUND(
    COUNT(*) FILTER (WHERE period_type = 'peak')::decimal /
    NULLIF(COUNT(*) FILTER (WHERE period_type = 'off_peak'), 0), 1
  ) AS ratio
FROM visit_classification vc
JOIN visits v ON vc.id = v.id
GROUP BY week
ORDER BY week;

A declining ratio over weeks means off-peak is growing relative to peak — your pricing experiments or promotions are working.

Auto-detecting peak windows

If you don't want to configure peak windows manually, you can derive them from the data:

SELECT
  day_of_week,
  hour,
  visits,
  CASE
    WHEN visits > avg_visits * 1.5 THEN 'peak'
    WHEN visits < avg_visits * 0.5 THEN 'off_peak'
    ELSE 'shoulder'
  END AS auto_classification
FROM (
  SELECT
    EXTRACT(DOW FROM checked_in_at) AS day_of_week,
    EXTRACT(HOUR FROM checked_in_at) AS hour,
    COUNT(*) AS visits,
    AVG(COUNT(*)) OVER () AS avg_visits
  FROM visits
  WHERE status = 'completed'
    AND checked_in_at >= NOW() - INTERVAL '90 days'
  GROUP BY day_of_week, hour
) hourly;

Hours with 1.5× the average are auto-classified as peak. Below 0.5× is off-peak. Everything else is shoulder. This can seed the initial configuration, which the Manager then refines.


Back to the main article: Peak vs. Off-Peak Ratio — You Might Not Have a Demand Problem