fix: anchor captain overview ranges to the viewer timezone
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user