Skip to content

Commit 78c4a3c

Browse files
committed
fix(insights): align rolling windows to the viewer's hour and clear stale legend highlights
1 parent 9a63668 commit 78c4a3c

3 files changed

Lines changed: 38 additions & 15 deletions

File tree

‎apps/sim/ee/organization-usage/hooks/use-legend-highlight.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,21 @@ import { useState } from 'react'
55
/**
66
* Hover and click highlighting shared by a chart and its legend.
77
*
8-
* A hover or selection whose series the current window no longer draws is ignored:
9-
* otherwise every layer would stay dimmed after a period change, with no legend entry
10-
* left to clear it.
8+
* A hover or selection whose series the current window no longer draws is cleared
9+
* during render: otherwise every layer would stay dimmed after a period change, with
10+
* no legend entry left to clear it, and a returning series would bring it back.
1111
*/
1212
export function useLegendHighlight(seriesIds: readonly string[]) {
1313
const [hoveredId, setHoveredId] = useState<string | null>(null)
1414
const [selectedId, setSelectedId] = useState<string | null>(null)
15-
const isDrawn = (id: string | null): id is string => id !== null && seriesIds.includes(id)
16-
const activeSelection = isDrawn(selectedId) ? selectedId : null
17-
const highlightedId = isDrawn(hoveredId) ? hoveredId : activeSelection
15+
if (hoveredId !== null && !seriesIds.includes(hoveredId)) setHoveredId(null)
16+
if (selectedId !== null && !seriesIds.includes(selectedId)) setSelectedId(null)
17+
const highlightedId = hoveredId ?? selectedId
1818
return {
1919
highlightedId,
2020
legendProps: {
2121
highlightedId,
22-
selectedId: activeSelection,
22+
selectedId,
2323
onHighlight: setHoveredId,
2424
onSelect: setSelectedId,
2525
},

‎apps/sim/lib/billing/core/usage-analytics.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,20 @@ describe('usageWindowLedgerFilter', () => {
147147
describe('resolveUsageAnalyticsWindow', () => {
148148
const now = new Date('2026-08-20T12:00:00.000Z')
149149

150+
it('starts a rolling window on the viewer-local hour, so its first segment settles', () => {
151+
const window = resolveUsageAnalyticsWindow({
152+
preset: '7d',
153+
period: period(),
154+
timezone: 'Asia/Kolkata',
155+
now: new Date('2026-08-20T12:47:13.250Z'),
156+
})
157+
expect(window.kind === 'range' && window.from).toEqual(new Date('2026-08-13T12:30:00.000Z'))
158+
const [first] = usageWindowSegments(window, 'Asia/Kolkata', {
159+
now: window.kind === 'range' ? window.to : undefined,
160+
})
161+
expect(first?.settled).toBe(true)
162+
})
163+
150164
it('keeps current-period a period so it matches the billing page', () => {
151165
const window = resolveUsageAnalyticsWindow({ preset: 'current-period', period: period(), now })
152166
expect(window).toEqual({ kind: 'period', period: period() })

‎apps/sim/lib/billing/core/usage-analytics.ts‎

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -211,13 +211,22 @@ function civilDaysBetween(fromKey: string, toKey: string): number {
211211
}
212212

213213
/**
214-
* The last `days` up to now, starting on the hour. A start mid-hour would leave the
215-
* window's first hour partial, and a partial hour can never be cached — every view
216-
* would read it from the ledger again.
214+
* The last `days` up to now, starting on the viewer's hour. A start mid-hour would
215+
* leave the window's first hour partial, and a partial hour can never be cached —
216+
* every view would read it from the ledger again. The hour is local, not UTC: in a
217+
* half-hour-offset zone a UTC hour starts halfway through a segment.
217218
*/
218-
function trailingRange(days: number, now: Date): UsageAnalyticsWindow {
219+
function trailingRange(days: number, now: Date, timezone: string): UsageAnalyticsWindow {
219220
const from = new Date(now.getTime() - days * DAY_MS)
220-
from.setUTCMinutes(0, 0, 0)
221+
const parts = new Intl.DateTimeFormat('en-US', {
222+
timeZone: timezone,
223+
minute: 'numeric',
224+
second: 'numeric',
225+
}).formatToParts(from)
226+
const part = (type: 'minute' | 'second') =>
227+
Number(parts.find((entry) => entry.type === type)?.value ?? 0)
228+
from.setTime(from.getTime() - (part('minute') * 60 + part('second')) * 1000)
229+
from.setUTCMilliseconds(0)
221230
return { kind: 'range', from, to: now }
222231
}
223232

@@ -239,7 +248,7 @@ export function resolveUsageAnalyticsWindow({
239248
switch (preset) {
240249
case 'current-period':
241250
return isUnboundedPeriod(period)
242-
? trailingRange(UNBOUNDED_PERIOD_DISPLAY_DAYS, now)
251+
? trailingRange(UNBOUNDED_PERIOD_DISPLAY_DAYS, now, timezone)
243252
: { kind: 'period', period }
244253
case 'previous-period': {
245254
const previous = resolvePreviousPeriod(period)
@@ -265,9 +274,9 @@ export function resolveUsageAnalyticsWindow({
265274
}
266275
}
267276
case '7d':
268-
return trailingRange(7, now)
277+
return trailingRange(7, now, timezone)
269278
case '30d':
270-
return trailingRange(30, now)
279+
return trailingRange(30, now, timezone)
271280
case 'custom': {
272281
// A partial selection is not a range, so it falls back to the current period —
273282
// through the same branch, which is what keeps an unbounded period from being

0 commit comments

Comments
 (0)