Skip to content

Commit 3955310

Browse files
committed
fix(insights): align activity summary window to request timezone
The activity insight attached its range summary by resolving DateFrom and DateTo as UTC midnights, but the dashboard derives those dates from the report's local-time range. A non-UTC user got a summary covering a different window than the dashboard being summarized. Carry the caller's IANA timezone through the insight request and resolve the summary's range in that zone, so the summary spans the same local-day window as the activity report. The frontend forwards the dashboard timezone; an empty value falls back to UTC.
1 parent 6eae255 commit 3955310

6 files changed

Lines changed: 101 additions & 16 deletions

File tree

frontend/src/lib/api/types/insights.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,7 @@ export interface GenerateInsightRequest {
3333
project?: string;
3434
prompt?: string;
3535
agent?: AgentName;
36+
// IANA timezone the date range is expressed in, so the server's activity
37+
// summary covers the same local-day window as the dashboard. Omit for UTC.
38+
timezone?: string;
3639
}

frontend/src/lib/components/activity/ActivityInsight.svelte

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
import type { Insight, InsightsResponse } from "../../api/types.js";
1414
import { LightbulbIcon, PlusIcon } from "../../icons.js";
1515
16-
let { dateFrom, dateTo }: { dateFrom: string; dateTo: string } = $props();
16+
let {
17+
dateFrom,
18+
dateTo,
19+
timezone = "",
20+
}: { dateFrom: string; dateTo: string; timezone?: string } = $props();
1721
1822
let insight: Insight | null = $state(null);
1923
let loading = $state(false);
@@ -110,6 +114,7 @@
110114
type: "daily_activity",
111115
date_from: dateFrom,
112116
date_to: dateTo,
117+
timezone,
113118
agent: "claude",
114119
},
115120
(p) => {

frontend/src/lib/components/activity/ActivityPage.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,11 @@
146146
otherwise fetch an insight for the wrong span while the report loads). -->
147147
{#if activity.report}
148148
<div class="chart-panel">
149-
<ActivityInsight dateFrom={insightFrom} dateTo={insightTo} />
149+
<ActivityInsight
150+
dateFrom={insightFrom}
151+
dateTo={insightTo}
152+
timezone={activity.timezone}
153+
/>
150154
</div>
151155
{/if}
152156
</div>

internal/server/huma_routes_insights.go

Lines changed: 31 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -335,33 +335,50 @@ func (s *Server) humaGenerateInsight(
335335
}
336336

337337
// activityRangeSummary resolves the requested range into an activity report and
338-
// condenses it into a RangeSummary for the insight prompt. The range is the
339-
// half-open span [DateFrom 00:00 UTC, DateTo+1day 00:00 UTC). It excludes
340-
// automated sessions so the summary reflects the same interactive-only work
341-
// BuildPrompt's prompt focuses on; the two otherwise select sessions
342-
// differently (this uses the activity report's half-open UTC window with an
343-
// ended_at fallback, BuildPrompt uses ListSessions' calendar-date match on the
344-
// start date), so the summary is a range-level overview, not a row-for-row
345-
// mirror of BuildPrompt's session list.
338+
// condenses it into a RangeSummary for the insight prompt. The range spans the
339+
// local days [DateFrom, DateTo] in req.Timezone (empty means UTC): the bounds
340+
// are that zone's midnights, matching the activity dashboard the dates were
341+
// derived from, so a non-UTC viewer's summary covers the window the dashboard
342+
// shows rather than a UTC-shifted one. It excludes automated sessions so the
343+
// summary reflects the same interactive-only work BuildPrompt's prompt focuses
344+
// on; the two otherwise select sessions differently (this uses the activity
345+
// report's half-open window with an ended_at fallback, BuildPrompt uses
346+
// ListSessions' calendar-date match on the start date), so the summary is a
347+
// range-level overview, not a row-for-row mirror of BuildPrompt's session list.
346348
func (s *Server) activityRangeSummary(
347349
ctx context.Context, req generateInsightRequest,
348350
) (*insight.RangeSummary, error) {
349-
dateTo, err := time.Parse("2006-01-02", req.DateTo)
351+
tz := req.Timezone
352+
if tz == "" {
353+
tz = "UTC"
354+
}
355+
loc, err := time.LoadLocation(tz)
356+
if err != nil {
357+
return nil, fmt.Errorf("loading timezone %q: %w", tz, err)
358+
}
359+
// Local midnights of DateFrom and the day after DateTo bound the half-open
360+
// window, expressed as absolute instants so ResolveQuery's custom-range
361+
// parse keeps them exact; Timezone drives only the bucket calendar.
362+
from, err := time.ParseInLocation("2006-01-02", req.DateFrom, loc)
363+
if err != nil {
364+
return nil, fmt.Errorf("parsing date_from %q: %w", req.DateFrom, err)
365+
}
366+
toDay, err := time.ParseInLocation("2006-01-02", req.DateTo, loc)
350367
if err != nil {
351368
return nil, fmt.Errorf("parsing date_to %q: %w", req.DateTo, err)
352369
}
353-
to := dateTo.AddDate(0, 0, 1).Format("2006-01-02")
370+
to := toDay.AddDate(0, 0, 1)
354371
q, err := activity.ResolveQuery(activity.QueryInput{
355372
Preset: "custom",
356-
From: req.DateFrom + "T00:00:00Z",
357-
To: to + "T00:00:00Z",
358-
Timezone: "UTC",
373+
From: from.UTC().Format(time.RFC3339),
374+
To: to.UTC().Format(time.RFC3339),
375+
Timezone: tz,
359376
}, time.Now())
360377
if err != nil {
361378
return nil, fmt.Errorf("resolving activity range: %w", err)
362379
}
363380
r, err := s.db.GetActivityReport(ctx, db.AnalyticsFilter{
364-
Timezone: "UTC",
381+
Timezone: tz,
365382
Project: req.Project,
366383
ExcludeAutomated: true,
367384
}, q)

internal/server/insights.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type generateInsightRequest struct {
1717
Project string `json:"project,omitempty"`
1818
Prompt string `json:"prompt,omitempty"`
1919
Agent string `json:"agent,omitempty"`
20+
// Timezone is the IANA zone the caller's date range is expressed in, so
21+
// the attached activity summary covers the same local-day window as the
22+
// activity dashboard the dates were derived from. Empty means UTC.
23+
Timezone string `json:"timezone,omitempty"`
2024
}
2125

2226
func insightGenerateClientMessage(
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package server
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"go.kenn.io/agentsview/internal/db"
11+
)
12+
13+
// TestActivityRangeSummaryUsesRequestTimezone confirms the insight activity
14+
// summary resolves its window in the request timezone, so a non-UTC viewer's
15+
// summary covers the same local-day window as the activity dashboard the dates
16+
// were derived from. A session whose only instant is 2026-06-16T02:00:00Z is
17+
// the local day 2026-06-15 in America/New_York (UTC-4 in June) but the UTC day
18+
// 2026-06-16, so the June-15 summary must include it under New York and
19+
// exclude it under UTC. Before the fix the window was always UTC, so the New
20+
// York request would have wrongly excluded the session.
21+
func TestActivityRangeSummaryUsesRequestTimezone(t *testing.T) {
22+
srv := testServer(t, 0)
23+
ctx := context.Background()
24+
ts := "2026-06-16T02:00:00Z"
25+
require.NoError(t, srv.db.UpsertSession(db.Session{
26+
ID: "x", Project: "proj", Machine: "test", Agent: "claude",
27+
StartedAt: &ts, EndedAt: &ts, MessageCount: 1,
28+
RelationshipType: "root", DataVersion: 1,
29+
}))
30+
require.NoError(t, srv.db.ReplaceSessionMessages("x", []db.Message{{
31+
SessionID: "x", Ordinal: 0, Role: "assistant", Content: "x",
32+
Timestamp: ts, Model: "m1",
33+
}}))
34+
35+
ny, err := srv.activityRangeSummary(ctx, generateInsightRequest{
36+
Type: "daily_activity", DateFrom: "2026-06-15", DateTo: "2026-06-15",
37+
Timezone: "America/New_York",
38+
})
39+
require.NoError(t, err)
40+
require.NotNil(t, ny)
41+
assert.Equal(t, 1, ny.Sessions,
42+
"New York June-15 window covers the 02:00Z instant (22:00 local)")
43+
44+
utc, err := srv.activityRangeSummary(ctx, generateInsightRequest{
45+
Type: "daily_activity", DateFrom: "2026-06-15", DateTo: "2026-06-15",
46+
Timezone: "UTC",
47+
})
48+
require.NoError(t, err)
49+
require.NotNil(t, utc)
50+
assert.Equal(t, 0, utc.Sessions,
51+
"UTC June-15 window ends at June 16 00:00Z, before the instant")
52+
}

0 commit comments

Comments
 (0)