Skip to content

Commit 07fe089

Browse files
committed
fix(insights): keep usage windows on the viewer's local hour
1 parent 7481589 commit 07fe089

2 files changed

Lines changed: 64 additions & 18 deletions

File tree

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,40 @@ describe('resolveUsageAnalyticsWindow', () => {
307307
}
308308
})
309309

310+
it('keeps the viewer timezone when a partial custom range falls back to the current period', () => {
311+
const window = resolveUsageAnalyticsWindow({
312+
preset: 'custom',
313+
period: period({
314+
source: 'default',
315+
start: new Date(0),
316+
end: new Date(Date.UTC(9999, 11, 31)),
317+
}),
318+
customStart: new Date('2026-08-04'),
319+
timezone: 'Asia/Kolkata',
320+
now: new Date('2026-08-20T12:47:13.250Z'),
321+
})
322+
expect(window.kind === 'range' && window.from).toEqual(new Date('2026-07-21T12:30:00.000Z'))
323+
})
324+
325+
it('ends an unbounded previous period where the current one starts, on the viewer hour', () => {
326+
const unbounded = period({
327+
source: 'default',
328+
start: new Date(0),
329+
end: new Date(Date.UTC(9999, 11, 31)),
330+
})
331+
const args = {
332+
period: unbounded,
333+
timezone: 'Asia/Kolkata',
334+
now: new Date('2026-08-20T12:47:13.250Z'),
335+
}
336+
const current = resolveUsageAnalyticsWindow({ ...args, preset: 'current-period' })
337+
const previous = resolveUsageAnalyticsWindow({ ...args, preset: 'previous-period' })
338+
expect(current.kind === 'range' && previous.kind === 'range').toBe(true)
339+
if (current.kind !== 'range' || previous.kind !== 'range') return
340+
expect(previous.to).toEqual(current.from)
341+
expect(previous.from).toEqual(new Date('2026-06-21T12:30:00.000Z'))
342+
})
343+
310344
it('steps an unbounded period back by the display window, not by its own length', () => {
311345
const window = resolveUsageAnalyticsWindow({
312346
preset: 'previous-period',

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -211,23 +211,37 @@ function civilDaysBetween(fromKey: string, toKey: string): number {
211211
}
212212

213213
/**
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.
214+
* The start of the viewer-local hour an instant falls in. Local, not UTC: in a
215+
* half-hour-offset zone a UTC hour starts halfway through a local one.
218216
*/
219-
function trailingRange(days: number, now: Date, timezone: string): UsageAnalyticsWindow {
220-
const from = new Date(now.getTime() - days * DAY_MS)
217+
function startOfLocalHour(instant: Date, timezone: string): Date {
221218
const parts = new Intl.DateTimeFormat('en-US', {
222219
timeZone: timezone,
223220
minute: 'numeric',
224221
second: 'numeric',
225-
}).formatToParts(from)
222+
}).formatToParts(instant)
226223
const part = (type: 'minute' | 'second') =>
227224
Number(parts.find((entry) => entry.type === type)?.value ?? 0)
228-
from.setTime(from.getTime() - (part('minute') * 60 + part('second')) * 1000)
229-
from.setUTCMilliseconds(0)
230-
return { kind: 'range', from, to: now }
225+
const start = new Date(instant.getTime() - (part('minute') * 60 + part('second')) * 1000)
226+
start.setUTCMilliseconds(0)
227+
return start
228+
}
229+
230+
/**
231+
* The last `days` before `to`, starting on the viewer's hour. A start mid-hour would
232+
* leave the window's first hour partial, and a partial hour can never be cached —
233+
* every view would read it from the ledger again.
234+
*/
235+
function trailingRange(
236+
days: number,
237+
to: Date,
238+
timezone: string
239+
): Extract<UsageAnalyticsWindow, { kind: 'range' }> {
240+
return {
241+
kind: 'range',
242+
from: startOfLocalHour(new Date(to.getTime() - days * DAY_MS), timezone),
243+
to,
244+
}
231245
}
232246

233247
/**
@@ -254,14 +268,12 @@ export function resolveUsageAnalyticsWindow({
254268
const previous = resolvePreviousPeriod(period)
255269
if (previous) return { kind: 'period', period: previous }
256270
// An open period has no meaningful predecessor — deriving one from its length
257-
// reaches back eight millennia — so it steps back by the display window instead.
271+
// reaches back eight millennia — so it steps back by the display window instead,
272+
// ending exactly where the current period's window starts: no hour is counted in
273+
// both, and both ends fall on the viewer's hour, so every segment can settle.
258274
if (isUnboundedPeriod(period)) {
259-
const to = new Date(now.getTime() - UNBOUNDED_PERIOD_DISPLAY_DAYS * DAY_MS)
260-
return {
261-
kind: 'range',
262-
from: new Date(to.getTime() - UNBOUNDED_PERIOD_DISPLAY_DAYS * DAY_MS),
263-
to,
264-
}
275+
const current = trailingRange(UNBOUNDED_PERIOD_DISPLAY_DAYS, now, timezone)
276+
return trailingRange(UNBOUNDED_PERIOD_DISPLAY_DAYS, current.from, timezone)
265277
}
266278
// A stripe period carries no rule for deriving its predecessor, so fall back to
267279
// a range of the same length rather than inventing stamps that would match
@@ -282,7 +294,7 @@ export function resolveUsageAnalyticsWindow({
282294
// through the same branch, which is what keeps an unbounded period from being
283295
// scanned in full here as well.
284296
if (!customStart || !customEnd) {
285-
return resolveUsageAnalyticsWindow({ preset: 'current-period', period, now })
297+
return resolveUsageAnalyticsWindow({ preset: 'current-period', period, timezone, now })
286298
}
287299
/**
288300
* The picker offers calendar days and sends `YYYY-MM-DD`, which arrives here

0 commit comments

Comments
 (0)