From d0e7753258e84f184f6695ac05114838195ba155 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Wed, 1 Jul 2026 16:11:13 +0530 Subject: [PATCH] fix: anchor captain overview ranges to the viewer timezone --- .../dashboard/api/captain/assistant.js | 10 +++++-- .../captain/assistant_stats_builder.rb | 27 +++++++++++-------- .../accounts/captain/assistants_controller.rb | 4 +-- .../captain/assistant_stats_builder_spec.rb | 20 ++++++++++++++ 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 724785148..dcd92f735 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -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() }, }); } } diff --git a/enterprise/app/builders/captain/assistant_stats_builder.rb b/enterprise/app/builders/captain/assistant_stats_builder.rb index 84d7a7942..bae4b5640 100644 --- a/enterprise/app/builders/captain/assistant_stats_builder.rb +++ b/enterprise/app/builders/captain/assistant_stats_builder.rb @@ -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. diff --git a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb index b07247604..ef5f44d46 100644 --- a/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb +++ b/enterprise/app/controllers/api/v1/accounts/captain/assistants_controller.rb @@ -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 diff --git a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb index 9ad06bbcf..18b15703a 100644 --- a/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb +++ b/spec/enterprise/builders/captain/assistant_stats_builder_spec.rb @@ -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)