Skip to content

Commit e890631

Browse files
icecrasher321claude
andcommitted
fix(usage): surface export failures, make narrow charts scroll
A network-level export failure produced an unhandled rejection and no toast. The action is fire-and-forget, so a dropped connection or a failed blob read was indistinguishable from a click that never registered. `resolveUsageBucket` ceiled its day count, so a 92-day range spanning the autumn fall-back measured 92 days and one hour, counted as 93, and silently rendered weekly bars for the longest range the picker allows. Rounded instead, with a test on both sides of the threshold. The client's custom-range guard only checked that both bounds were present, while the contract — tightened last round — rejects a date that does not exist. A deep link carrying `2026-02-30` therefore satisfied "resolved custom" and every query on the page answered 400, where before it merely returned a shifted window. Tightening one end without the other made a bad link worse; the client now applies the same calendar round-trip, so it falls back as the partial-link guard intends. The contract's own check also now covers the date portion of a datetime, not only the bare form. The export loop omitted `cursorCreatedAt`, whose documentation names this exact caller. Each page therefore resolved its cursor against the primary before reading the replica — up to 99 avoidable round-trips for a capped export. Both chart roots become `overflow-x-auto`. `CHART_MIN_WIDTH` says the chart "scrolls rather than compresses", and the code clipped: below the floor the rightmost bars and axis labels were cut off. That comment was added by this branch during the lift, so the contradiction is this PR's rather than inherited. At or above the floor there is no overflow and nothing renders differently. Applied to both charts because the floor lives in the shared hook. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 963fb9f commit e890631

8 files changed

Lines changed: 99 additions & 27 deletions

File tree

apps/sim/components/charts/bar-chart.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,14 @@ function BarChartComponent({
168168
<div
169169
ref={containerRef}
170170
className={cn(
171-
'w-full overflow-hidden',
171+
/*
172+
`overflow-x-auto`, not `overflow-hidden`: `useChartWidth` floors the SVG at
173+
CHART_MIN_WIDTH, so in a narrower container the chart is wider than its box.
174+
Hiding that silently cut off the rightmost bars and axis labels — and
175+
contradicted the constant's own note that the chart "scrolls rather than
176+
compresses". At or above the floor there is no overflow and nothing changes.
177+
*/
178+
'w-full overflow-x-auto',
172179
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
173180
)}
174181
>

apps/sim/components/charts/line-chart.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,7 +222,14 @@ function LineChartComponent({
222222
<div
223223
ref={containerRef}
224224
className={cn(
225-
'w-full overflow-hidden',
225+
/*
226+
`overflow-x-auto`, not `overflow-hidden`: `useChartWidth` floors the SVG at
227+
CHART_MIN_WIDTH, so in a narrower container the chart is wider than its box.
228+
Hiding that silently cut off the rightmost bars and axis labels — and
229+
contradicted the constant's own note that the chart "scrolls rather than
230+
compresses". At or above the floor there is no overflow and nothing changes.
231+
*/
232+
'w-full overflow-x-auto',
226233
!hasExternalWrapper && 'rounded-[11px] border bg-card p-4 shadow-sm'
227234
)}
228235
>

apps/sim/ee/organization-usage/components/usage-monitoring.tsx

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -181,27 +181,36 @@ export function UsageMonitoring({ organizationId, workspaceId }: UsageMonitoring
181181
if (window.startDate) params.set('startDate', window.startDate)
182182
if (window.endDate) params.set('endDate', window.endDate)
183183

184-
// boundary-raw-fetch: downloads a CSV blob and reads X-Export-Truncated before saving — a plain anchor navigation can do neither
185-
const response = await fetch(
186-
`/api/organizations/${organizationId}/usage/export?${params.toString()}`
187-
)
188-
if (!response.ok) {
184+
/**
185+
* Wrapped because the action is fire-and-forget: `onSelect` cannot await this, so
186+
* a rejection — a dropped connection, a blob read that fails — became an unhandled
187+
* promise and the button appeared to do nothing at all.
188+
*/
189+
try {
190+
// boundary-raw-fetch: downloads a CSV blob and reads X-Export-Truncated before saving — a plain anchor navigation can do neither
191+
const response = await fetch(
192+
`/api/organizations/${organizationId}/usage/export?${params.toString()}`
193+
)
194+
if (!response.ok) {
195+
toast.error('Failed to export usage')
196+
return
197+
}
198+
if (response.headers.get('X-Export-Truncated') === '1') {
199+
toast.info('Export truncated — narrow the date range to see everything')
200+
}
201+
202+
const blob = await response.blob()
203+
const url = URL.createObjectURL(blob)
204+
const link = document.createElement('a')
205+
link.href = url
206+
link.download = `organization-usage-${new Date().toISOString().slice(0, 10)}.csv`
207+
document.body.appendChild(link)
208+
link.click()
209+
link.remove()
210+
URL.revokeObjectURL(url)
211+
} catch {
189212
toast.error('Failed to export usage')
190-
return
191-
}
192-
if (response.headers.get('X-Export-Truncated') === '1') {
193-
toast.info('Export truncated — narrow the date range to see everything')
194213
}
195-
196-
const blob = await response.blob()
197-
const url = URL.createObjectURL(blob)
198-
const link = document.createElement('a')
199-
link.href = url
200-
link.download = `organization-usage-${new Date().toISOString().slice(0, 10)}.csv`
201-
document.body.appendChild(link)
202-
link.click()
203-
link.remove()
204-
URL.revokeObjectURL(url)
205214
}
206215

207216
/**

apps/sim/ee/organization-usage/hooks/use-usage-window.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import {
1212
} from '@/ee/organization-usage/search-params'
1313
import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organization-usage-keys'
1414

15+
/** A `YYYY-MM-DD` that survives a calendar round-trip, matching the contract's rule. */
16+
function isCalendarDate(value: string | null): value is string {
17+
if (!value) return false
18+
const datePart = /^(\d{4}-\d{2}-\d{2})/.exec(value)?.[1]
19+
if (!datePart) return false
20+
return new Date(`${datePart}T00:00:00.000Z`).toISOString().slice(0, 10) === datePart
21+
}
22+
1523
/**
1624
* The panel's URL state, resolved into the window every query is keyed on.
1725
*
@@ -22,8 +30,17 @@ export function useUsageWindow() {
2230
const [state, setState] = useQueryStates(organizationUsageParsers, organizationUsageUrlKeys)
2331
const timezone = getBrowserTimezone()
2432

33+
/**
34+
* Both bounds present *and* real calendar dates.
35+
*
36+
* The contract rejects a date that does not exist (`2026-02-30` parses and rolls
37+
* forward, so it has to be refused rather than silently shifted). Without the same
38+
* check here, a deep link carrying one satisfied this guard and every query on the
39+
* page answered 400 — the partial-link fallback exists precisely so a bad link
40+
* degrades to the default window instead.
41+
*/
2542
const isResolvedCustom =
26-
state.preset === 'custom' && Boolean(state.startDate) && Boolean(state.endDate)
43+
state.preset === 'custom' && isCalendarDate(state.startDate) && isCalendarDate(state.endDate)
2744
const preset: UsageWindowPreset =
2845
state.preset === 'custom' && !isResolvedCustom ? DEFAULT_USAGE_PRESET : state.preset
2946

apps/sim/lib/api/contracts/organization-usage.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,12 @@ const isoDateSchema = z
5454
.refine(
5555
(value) => {
5656
if (!value) return true
57-
const parsed = new Date(value)
58-
if (Number.isNaN(parsed.getTime())) return false
59-
// Bare `YYYY-MM-DD` parses as UTC, so compare against the UTC serialization.
60-
return !/^\d{4}-\d{2}-\d{2}$/.test(value) || parsed.toISOString().slice(0, 10) === value
57+
if (Number.isNaN(Date.parse(value))) return false
58+
// The leading `YYYY-MM-DD` of either form, so `2026-02-30T00:00:00` is rejected
59+
// rather than only the bare `2026-02-30`.
60+
const datePart = /^(\d{4}-\d{2}-\d{2})/.exec(value)?.[1]
61+
if (!datePart) return true
62+
return new Date(`${datePart}T00:00:00.000Z`).toISOString().slice(0, 10) === datePart
6163
},
6264
{ message: 'Expected a real calendar date such as 2026-08-01' }
6365
)

apps/sim/lib/billing/application/organization-usage/export-organization-usage-events.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,14 @@ export const exportOrganizationUsageEvents = defineAuthorizedOrganizationUsageUs
6868

6969
const rows: OrganizationUsageExportRow[] = []
7070
let cursor: string | undefined
71+
/**
72+
* The cursor row's timestamp, carried forward from the page that produced it.
73+
*
74+
* Without it `getUsageLogs` resolves the cursor with a lookup on the primary
75+
* before it can read the replica — once per page, up to a hundred times for a
76+
* capped export. This loop is the exact case that option was added for.
77+
*/
78+
let cursorCreatedAt: Date | undefined
7179
let truncated = false
7280

7381
while (rows.length < USAGE_EXPORT_SAFETY_CAP) {
@@ -78,6 +86,7 @@ export const exportOrganizationUsageEvents = defineAuthorizedOrganizationUsageUs
7886
...(input.source?.length ? { source: input.source } : {}),
7987
limit: EXPORT_PAGE_SIZE,
8088
...(cursor ? { cursor } : {}),
89+
...(cursorCreatedAt ? { cursorCreatedAt } : {}),
8190
// Each page would otherwise repeat the same cursor-independent aggregate
8291
// for a total this export never reads.
8392
includeSummary: false,
@@ -94,7 +103,9 @@ export const exportOrganizationUsageEvents = defineAuthorizedOrganizationUsageUs
94103
}
95104

96105
if (!page.pagination.hasMore || !page.pagination.nextCursor) break
106+
const cursorRow = page.logs.find((log) => log.id === page.pagination.nextCursor)
97107
cursor = page.pagination.nextCursor
108+
cursorCreatedAt = cursorRow ? new Date(cursorRow.createdAt) : undefined
98109
if (rows.length >= USAGE_EXPORT_SAFETY_CAP) {
99110
truncated = true
100111
break

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,22 @@ describe('resolveUsageBucket', () => {
296296
expect(resolveUsageBucket({ kind: 'range', from, to: days(200) })).toBe('week')
297297
expect(resolveUsageBucket({ kind: 'range', from, to: days(500) })).toBe('month')
298298
})
299+
300+
it('keeps daily bars for the maximum range across a DST transition', () => {
301+
// 92 calendar days spanning the autumn fall-back is 92 days and one hour. Ceiling
302+
// that called it 93 and silently demoted the longest legal custom range to weekly
303+
// bars — a granularity change with no cause the reader could see.
304+
const dstSpan = {
305+
kind: 'range' as const,
306+
from,
307+
to: new Date(from.getTime() + 92 * 86_400_000 + 3_600_000),
308+
}
309+
expect(resolveUsageBucket(dstSpan)).toBe('day')
310+
// A genuinely longer range still steps up.
311+
expect(
312+
resolveUsageBucket({ kind: 'range', from, to: new Date(from.getTime() + 93 * 86_400_000) })
313+
).toBe('week')
314+
})
299315
})
300316

301317
describe('densifyUsageSeries', () => {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -300,7 +300,10 @@ export function resolvePreviousPeriod(period: ResolvedUsagePeriod): ResolvedUsag
300300
*/
301301
export function resolveUsageBucket(window: UsageAnalyticsWindow): UsageBucket {
302302
const { start, end } = usageWindowBounds(window)
303-
const days = Math.max(1, Math.ceil((end.getTime() - start.getTime()) / DAY_MS))
303+
// Rounded, not ceiled: a 92-day range spanning the autumn transition is 92 days and
304+
// one hour, which `ceil` called 93 — quietly demoting the longest legal custom range
305+
// from daily bars to weekly ones.
306+
const days = Math.max(1, Math.round((end.getTime() - start.getTime()) / DAY_MS))
304307
if (days <= 92) return 'day'
305308
if (days <= 400) return 'week'
306309
return 'month'

0 commit comments

Comments
 (0)