discourse/app/models/user_visit.rb
Krzysztof Kotlarek e4020c8ee5
FIX: Correct visit counter calculations for stacked report data (#38362)
What is the problem?

When the `reporting_improvements` flag is enabled, the visits report
returns stacked chart data with separate desktop and mobile series. The
`Report` model's `valueAt` and `valueFor` methods operate directly on
`this.data`, which in stacked mode is an array of series objects (each
with a `label` and nested `data` array) rather than a flat array of `{
x, y }` points. This means the dashboard activity metrics counters
(today, yesterday, 7-day, 30-day) fail to compute correct values for the
visits report.

Additionally, the legacy warning banner in `admin-report-new.gjs`
rendered outside the `showHeader` conditional, causing it to appear even
when headers are hidden (e.g. in counters mode on the dashboard).

What is the solution?

A new `combinedData` getter on the JS `Report` model detects stacked
data (entries with a `label` property) and flattens all series into a
single array, summing `y` values for matching dates. The `valueAt` and
`valueFor` methods now use `combinedData` instead of `data` directly.
The legacy warning in `admin-report-new.gjs` is moved inside the
`showHeader` block. A system test is added to verify visit counters
display correctly with the flag both on and off.

---------

Co-authored-by: Alan Guo Xiang Tan <gxtan1990@gmail.com>
2026-03-10 14:30:49 +08:00

99 lines
3.2 KiB
Ruby

# frozen_string_literal: true
class UserVisit < ActiveRecord::Base
belongs_to :user
def self.counts_by_day_query(start_date, end_date, group_id = nil)
result = where("visited_at >= ? and visited_at <= ?", start_date.to_date, end_date.to_date)
if group_id
result = result.joins("INNER JOIN users ON users.id = user_visits.user_id")
result = result.joins("INNER JOIN group_users ON group_users.user_id = users.id")
result = result.where("group_users.group_id = ?", group_id)
end
result.group(:visited_at).order(:visited_at)
end
def self.count_by_active_users(start_date, end_date)
sql = <<~SQL
WITH dau AS (
SELECT date_trunc('day', user_visits.visited_at)::DATE AS date,
count(distinct user_visits.user_id) AS dau
FROM user_visits
WHERE user_visits.visited_at::DATE >= :start_date::DATE AND user_visits.visited_at <= :end_date::DATE
GROUP BY date_trunc('day', user_visits.visited_at)::DATE
ORDER BY date_trunc('day', user_visits.visited_at)::DATE
)
SELECT date, dau,
(SELECT count(distinct user_visits.user_id)
FROM user_visits
WHERE user_visits.visited_at::DATE BETWEEN dau.date - 29 AND dau.date
) AS mau
FROM dau
SQL
DB.query_hash(sql, start_date: start_date, end_date: end_date)
end
# A count of visits in a date range by day
def self.by_day(start_date, end_date, group_id = nil)
counts_by_day_query(start_date, end_date, group_id).count
end
def self.mobile_by_day(start_date, end_date, group_id = nil)
counts_by_day_query(start_date, end_date, group_id).where(mobile: true).count
end
def self.counts_by_day_and_mobile(start_date, end_date, group_id: nil)
sql = <<~SQL
SELECT
visited_at,
mobile,
COUNT(*) AS visit_count,
SUM(COUNT(*)) OVER () AS total
FROM user_visits
#{"INNER JOIN group_users ON group_users.user_id = user_visits.user_id" if group_id}
WHERE visited_at >= :start_date AND visited_at <= :end_date
#{"AND group_users.group_id = :group_id" if group_id}
GROUP BY visited_at, mobile
ORDER BY visited_at
SQL
params = { start_date: start_date, end_date: end_date, prev_start: start_date - 30.days }
params[:group_id] = group_id.to_i if group_id
DB.query(sql, **params)
end
def self.ensure_consistency!
DB.exec <<~SQL
UPDATE user_stats u set days_visited =
(
SELECT COUNT(*) FROM user_visits v WHERE v.user_id = u.user_id
)
WHERE days_visited <>
(
SELECT COUNT(*) FROM user_visits v WHERE v.user_id = u.user_id
)
SQL
end
end
# == Schema Information
#
# Table name: user_visits
#
# id :integer not null, primary key
# user_id :integer not null
# visited_at :date not null
# posts_read :integer default(0)
# mobile :boolean default(FALSE)
# time_read :integer default(0), not null
#
# Indexes
#
# index_user_visits_on_user_id_and_visited_at (user_id,visited_at) UNIQUE
# index_user_visits_on_user_id_and_visited_at_and_time_read (user_id,visited_at,time_read)
# index_user_visits_on_visited_at_and_mobile (visited_at,mobile)
#