How We Track Visit Frequency — The Technical Breakdown
Technical companion to Visit Frequency in The Numbers That Matter series.
Visit frequency — visits per member per month — becomes actionable when you can compute per-member baselines, detect anomalies, and identify the habit threshold empirically.
Per-member rolling baseline
SELECT
m.id,
COUNT(v.id) AS visits_last_90_days,
COUNT(v.id) / 3.0 AS avg_monthly_frequency
FROM members m
LEFT JOIN visits v ON v.memberId = m.id
AND v.checkInAt >= NOW() - INTERVAL '90 days'
WHERE m.membershipExpiresAt >= NOW()
GROUP BY m.id;
The 90-day window smooths out vacations and holidays, giving a baseline that represents typical behavior.
Detecting frequency changes
Compare the last 30 days against the prior 60:
WITH member_frequency AS (
SELECT
m.id,
COUNT(v.id) FILTER (WHERE v.checkInAt >= NOW() - INTERVAL '30 days') AS recent_visits,
COUNT(v.id) FILTER (
WHERE v.checkInAt >= NOW() - INTERVAL '90 days'
AND v.checkInAt < NOW() - INTERVAL '30 days'
) / 2.0 AS baseline_monthly_visits
FROM members m
LEFT JOIN visits v ON v.memberId = m.id
AND v.checkInAt >= NOW() - INTERVAL '90 days'
WHERE m.membershipExpiresAt >= NOW()
GROUP BY m.id
)
SELECT *,
CASE
WHEN baseline_monthly_visits > 0
THEN recent_visits / baseline_monthly_visits
ELSE NULL
END AS frequency_ratio
FROM member_frequency
WHERE baseline_monthly_visits >= 2
ORDER BY frequency_ratio ASC;
A frequency_ratio below 0.5 means the member's recent frequency dropped to less than half their baseline. That's your at-risk list.
Finding the habit threshold
Compare the median frequency of churned vs. retained members:
WITH churned AS (
SELECT m.id,
COUNT(v.id) / GREATEST(
EXTRACT(EPOCH FROM (m.membershipEndDate - m.membershipStartDate)) / 2592000, 1
) AS avg_monthly_frequency
FROM members m
JOIN visits v ON v.memberId = m.id
WHERE m.membershipEndDate IS NOT NULL
GROUP BY m.id, m.membershipEndDate, m.membershipStartDate
),
retained AS (
SELECT m.id,
COUNT(v.id) / GREATEST(
EXTRACT(EPOCH FROM (NOW() - m.membershipStreakStart)) / 2592000, 1
) AS avg_monthly_frequency
FROM members m
JOIN visits v ON v.memberId = m.id AND v.checkInAt >= m.membershipStreakStart
WHERE m.membershipStreakStart IS NOT NULL
GROUP BY m.id, m.membershipStreakStart
)
SELECT 'churned' AS segment, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY avg_monthly_frequency) AS median_freq FROM churned
UNION ALL
SELECT 'retained', PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY avg_monthly_frequency) FROM retained;
The gap between these medians reveals your danger zone.
Traffic heatmap for staffing
SELECT
EXTRACT(DOW FROM checkInAt) AS day_of_week,
EXTRACT(HOUR FROM checkInAt) AS hour,
COUNT(*) AS visit_count
FROM visits
WHERE checkInAt >= NOW() - INTERVAL '90 days'
GROUP BY EXTRACT(DOW FROM checkInAt), EXTRACT(HOUR FROM checkInAt)
ORDER BY day_of_week, hour;
Storage approach
We don't precompute frequency. A composite index on (memberId, checkInAt) makes the queries above fast enough for real-time computation. Dashboard aggregates are cached with a short TTL.
Back to the operator post: Visit Frequency — what to do with this metric →