fix: anchor captain overview ranges to the viewer timezone

This commit is contained in:
Shivam Mishra
2026-07-01 16:11:13 +05:30
parent 563619c95d
commit d0e7753258
4 changed files with 46 additions and 15 deletions
@@ -1,6 +1,10 @@
/* global axios */
import ApiClient from '../ApiClient';
// Viewer's UTC offset in hours, matching the reports API convention so the
// backend can anchor calendar ranges to the viewer's day.
const getTimezoneOffset = () => -new Date().getTimezoneOffset() / 60;
class CaptainAssistant extends ApiClient {
constructor() {
super('captain/assistants', { accountScoped: true });
@@ -23,12 +27,14 @@ class CaptainAssistant extends ApiClient {
}
getStats({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/stats`, { params: { range } });
return axios.get(`${this.url}/${assistantId}/stats`, {
params: { range, timezone_offset: getTimezoneOffset() },
});
}
getSummary({ assistantId, range }) {
return axios.get(`${this.url}/${assistantId}/summary`, {
params: { range },
params: { range, timezone_offset: getTimezoneOffset() },
});
}
}
@@ -16,10 +16,13 @@ class Captain::AssistantStatsBuilder
# `range` is either a day count ('7', '30', '90') or a named period
# ('this_month', 'last_month'). The previous window mirrors the current one:
# the preceding N days for day ranges, or the preceding month for month ranges.
def initialize(assistant, range = DEFAULT_RANGE)
# `timezone_offset` is the viewer's UTC offset in hours (as the reports API sends
# it), so month/day boundaries anchor to the viewer's day rather than UTC.
def initialize(assistant, range = DEFAULT_RANGE, timezone_offset = nil)
@assistant = assistant
@account = assistant.account
@range = ALLOWED_RANGES.include?(range.to_s) ? range.to_s : DEFAULT_RANGE
@timezone = ActiveSupport::TimeZone[timezone_offset.to_f] || Time.zone
end
def metrics
@@ -34,11 +37,7 @@ class Captain::AssistantStatsBuilder
# Human-readable description of the period the metrics cover, for grounding the
# LLM summary in real dates.
def period
{
label: period_label,
starts_on: current_range.first.to_date,
ends_on: current_range.last.to_date
}
{ label: period_label, starts_on: current_range.first.to_date, ends_on: current_range.last.to_date }
end
private
@@ -75,25 +74,31 @@ class Captain::AssistantStatsBuilder
end
end
# Current time anchored to the viewer's timezone, so calendar boundaries land on
# the viewer's day instead of UTC's.
def now
@now ||= Time.current.in_time_zone(@timezone)
end
def this_month_ranges
start = Time.current.beginning_of_month
elapsed = Time.current - start
start = now.beginning_of_month
elapsed = now - start
previous_start = start - 1.month
# Clamp to the previous month's end so a longer current month can't pull the
# comparison window into the current month and double-count its rows.
previous_end = [previous_start + elapsed, previous_start.end_of_month].min
{ current: start..Time.current, previous: previous_start..previous_end }
{ current: start..now, previous: previous_start..previous_end }
end
def last_month_ranges
start = 1.month.ago.beginning_of_month
start = (now - 1.month).beginning_of_month
previous_start = start - 1.month
{ current: start..start.end_of_month, previous: previous_start..previous_start.end_of_month }
end
def day_ranges
days = range.to_i
{ current: days.days.ago..Time.current, previous: (2 * days).days.ago..days.days.ago }
{ current: (now - days.days)..now, previous: (now - (2 * days).days)..(now - days.days) }
end
# Combines the per-window message counts and reply time with the reporting-event metrics for one window.
@@ -44,11 +44,11 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
end
def stats
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range]).metrics
render json: Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]).metrics
end
def summary
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range]))
result = cached_or_generated_summary(Captain::AssistantStatsBuilder.new(@assistant, params[:range], params[:timezone_offset]))
if result[:error]
render json: { error: result[:error] }, status: :unprocessable_content
@@ -161,6 +161,26 @@ RSpec.describe Captain::AssistantStatsBuilder do
end
end
describe 'timezone anchoring' do
# 2026-07-01 03:00 UTC is still 2026-06-30 in any timezone behind UTC by 4h+.
it 'anchors the this_month window to the supplied offset, not UTC' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
utc = described_class.new(assistant, 'this_month').period
la = described_class.new(assistant, 'this_month', -7).period
expect(utc[:starts_on]).to eq(Date.new(2026, 7, 1))
expect(la[:starts_on]).to eq(Date.new(2026, 6, 1))
expect(la[:ends_on]).to eq(Date.new(2026, 6, 30))
end
end
it 'defaults to UTC when no offset is given' do
travel_to(Time.utc(2026, 7, 1, 3, 0, 0)) do
expect(described_class.new(assistant, 'this_month').period[:starts_on]).to eq(Date.new(2026, 7, 1))
end
end
end
describe '#metrics knowledge' do
before do
create_list(:captain_assistant_response, 3, assistant: assistant, account: account, status: :approved)