Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/sim/lib/billing/core/usage-analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,40 @@ describe('resolveUsageAnalyticsWindow', () => {
}
})

it('keeps the viewer timezone when a partial custom range falls back to the current period', () => {
const window = resolveUsageAnalyticsWindow({
preset: 'custom',
period: period({
source: 'default',
start: new Date(0),
end: new Date(Date.UTC(9999, 11, 31)),
}),
customStart: new Date('2026-08-04'),
timezone: 'Asia/Kolkata',
now: new Date('2026-08-20T12:47:13.250Z'),
})
expect(window.kind === 'range' && window.from).toEqual(new Date('2026-07-21T12:30:00.000Z'))
})

it('ends an unbounded previous period where the current one starts, on the viewer hour', () => {
const unbounded = period({
source: 'default',
start: new Date(0),
end: new Date(Date.UTC(9999, 11, 31)),
})
const args = {
period: unbounded,
timezone: 'Asia/Kolkata',
now: new Date('2026-08-20T12:47:13.250Z'),
}
const current = resolveUsageAnalyticsWindow({ ...args, preset: 'current-period' })
const previous = resolveUsageAnalyticsWindow({ ...args, preset: 'previous-period' })
expect(current.kind === 'range' && previous.kind === 'range').toBe(true)
if (current.kind !== 'range' || previous.kind !== 'range') return
expect(previous.to).toEqual(current.from)
expect(previous.from).toEqual(new Date('2026-06-21T12:30:00.000Z'))
})

it('steps an unbounded period back by the display window, not by its own length', () => {
const window = resolveUsageAnalyticsWindow({
preset: 'previous-period',
Expand Down
48 changes: 30 additions & 18 deletions apps/sim/lib/billing/core/usage-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,23 +211,37 @@ function civilDaysBetween(fromKey: string, toKey: string): number {
}

/**
* The last `days` up to now, starting on the viewer's hour. A start mid-hour would
* leave the window's first hour partial, and a partial hour can never be cached —
* every view would read it from the ledger again. The hour is local, not UTC: in a
* half-hour-offset zone a UTC hour starts halfway through a segment.
* The start of the viewer-local hour an instant falls in. Local, not UTC: in a
* half-hour-offset zone a UTC hour starts halfway through a local one.
*/
function trailingRange(days: number, now: Date, timezone: string): UsageAnalyticsWindow {
const from = new Date(now.getTime() - days * DAY_MS)
function startOfLocalHour(instant: Date, timezone: string): Date {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timezone,
minute: 'numeric',
second: 'numeric',
}).formatToParts(from)
}).formatToParts(instant)
const part = (type: 'minute' | 'second') =>
Number(parts.find((entry) => entry.type === type)?.value ?? 0)
from.setTime(from.getTime() - (part('minute') * 60 + part('second')) * 1000)
from.setUTCMilliseconds(0)
return { kind: 'range', from, to: now }
const start = new Date(instant.getTime() - (part('minute') * 60 + part('second')) * 1000)
Comment thread
waleedlatif1 marked this conversation as resolved.
start.setUTCMilliseconds(0)
return start
}

/**
* The last `days` before `to`, starting on the viewer's hour. A start mid-hour would
* leave the window's first hour partial, and a partial hour can never be cached —
* every view would read it from the ledger again.
*/
function trailingRange(
days: number,
to: Date,
timezone: string
): Extract<UsageAnalyticsWindow, { kind: 'range' }> {
return {
kind: 'range',
from: startOfLocalHour(new Date(to.getTime() - days * DAY_MS), timezone),
to,
}
}

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