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