GuideApril 23, 2026·By Crew

How We Track Time Between Visits — The Technical Breakdown

Technical companion to Time Between Visits in The Numbers That Matter series.


Time Between Visits (TBV) is the average gap in days between a member's consecutive visits. The engineering challenge is computing a per-member rolling baseline and detecting deviations from it efficiently.

Computing TBV for a single member

Using window functions to pair consecutive visits:

SELECT
  member_id,
  checked_in_at,
  LAG(checked_in_at) OVER (
    PARTITION BY member_id ORDER BY checked_in_at
  ) AS previous_visit,
  EXTRACT(DAY FROM
    checked_in_at - LAG(checked_in_at) OVER (
      PARTITION BY member_id ORDER BY checked_in_at
    )
  ) AS days_between
FROM visits
WHERE member_id = :member_id
  AND status = 'completed'
ORDER BY checked_in_at;

The average of the days_between column is that member's baseline TBV.

Computing baseline TBV for all members

WITH visit_gaps AS (
  SELECT
    member_id,
    EXTRACT(EPOCH FROM (
      checked_in_at - LAG(checked_in_at) OVER (
        PARTITION BY member_id ORDER BY checked_in_at
      )
    )) / 86400.0 AS days_between
  FROM visits
  WHERE status = 'completed'
    AND checked_in_at >= NOW() - INTERVAL '90 days'
)
SELECT
  member_id,
  ROUND(AVG(days_between), 1) AS avg_tbv_days,
  ROUND(STDDEV(days_between), 1) AS tbv_stddev,
  COUNT(*) AS visit_gaps_measured
FROM visit_gaps
WHERE days_between IS NOT NULL
GROUP BY member_id
HAVING COUNT(*) >= 3;  -- need enough data points for a meaningful baseline

The HAVING >= 3 filter ensures you're only computing baselines for members with enough visit history. Standard deviation is included because it tells you how consistent the member is — a low stddev means the baseline is reliable, a high stddev means the member is naturally erratic and anomaly detection should be less sensitive.

Denormalize for performance

Store the baseline on the member record and refresh nightly:

Member
  avg_time_between_visits:  Decimal?   -- rolling 90-day average in days
  tbv_stddev:               Decimal?   -- standard deviation
  last_visit_at:            DateTime?  -- denormalized from most recent visit
  tbv_updated_at:           DateTime

last_visit_at is updated on every check-in. The other fields are updated by a nightly job. This means the "current gap" — days since last visit — is always available as:

current_gap = today - last_visit_at

And the deviation ratio is:

deviation = current_gap / avg_time_between_visits

The early warning query

Find all members whose current gap exceeds their baseline by a configurable multiplier:

SELECT
  m.id,
  m.name,
  m.avg_time_between_visits,
  EXTRACT(DAY FROM NOW() - m.last_visit_at) AS days_since_last,
  ROUND(
    EXTRACT(DAY FROM NOW() - m.last_visit_at) /
    NULLIF(m.avg_time_between_visits, 0), 1
  ) AS deviation_ratio
FROM members m
WHERE m.membership_streak_start IS NOT NULL   -- active members only
  AND m.avg_time_between_visits IS NOT NULL    -- has a baseline
  AND m.last_visit_at IS NOT NULL
  AND EXTRACT(DAY FROM NOW() - m.last_visit_at) >
      m.avg_time_between_visits * 2.0          -- 2x threshold
ORDER BY deviation_ratio DESC;

Adjust the 2.0 multiplier to control sensitivity. For high-frequency members (baseline of 3 days), 2x triggers at 6 days — reasonable. For low-frequency members (baseline of 14 days), 2x triggers at 28 days — also reasonable. The multiplier scales naturally.

Tiered risk classification

SELECT
  m.id,
  m.name,
  CASE
    WHEN m.last_visit_at IS NULL THEN 'no_data'
    WHEN current_gap <= m.avg_time_between_visits * 1.2 THEN 'healthy'
    WHEN current_gap <= m.avg_time_between_visits * 2.0 THEN 'at_risk'
    WHEN current_gap <= m.avg_time_between_visits * 3.0 THEN 'critical'
    ELSE 'likely_churned'
  END AS risk_tier
FROM (
  SELECT *,
    EXTRACT(DAY FROM NOW() - last_visit_at) AS current_gap
  FROM members
  WHERE membership_streak_start IS NOT NULL
) m;

This gives you four buckets to drive different outreach strategies. The Manager dashboard can show counts per tier as a health summary.

Measuring event impact

To see whether an event or promotion actually pulled members back sooner:

WITH before_event AS (
  SELECT member_id, AVG(days_between) AS avg_tbv_before
  FROM visit_gaps
  WHERE visit_date BETWEEN event_date - 30 AND event_date
  GROUP BY member_id
),
after_event AS (
  SELECT member_id, AVG(days_between) AS avg_tbv_after
  FROM visit_gaps
  WHERE visit_date BETWEEN event_date AND event_date + 30
  GROUP BY member_id
)
SELECT
  ROUND(AVG(b.avg_tbv_before), 1) AS avg_tbv_before,
  ROUND(AVG(a.avg_tbv_after), 1) AS avg_tbv_after,
  ROUND(AVG(a.avg_tbv_after) - AVG(b.avg_tbv_before), 1) AS delta
FROM before_event b
JOIN after_event a ON b.member_id = a.member_id;

A negative delta means members visited more frequently after the event. A positive delta means the event had no lasting impact (or coincided with a natural downturn).

Deduplication note

If a member visits twice in one day, those are technically two visits with a 0-day gap. Depending on your use case, you may want to deduplicate by day:

SELECT DISTINCT member_id, DATE(checked_in_at) AS visit_date
FROM visits
WHERE status = 'completed'

Using visit_date instead of raw timestamps prevents same-day visits from artificially deflating the TBV baseline.


Back to the main article: Time Between Visits — Your Early Warning System for Churn