Skip to content

Commit 9236aaa

Browse files
committed
fix(insights): preserve sub-credit activity in the usage heatmap
1 parent dd15a1a commit 9236aaa

6 files changed

Lines changed: 45 additions & 10 deletions

File tree

‎apps/docs/content/docs/platform/enterprise/usage-tracking.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ Organization owners and admins open **Settings → Organization → Insights**.
1616
| **Last 7 days** / **Last 30 days** | Rolling window ending now. |
1717
| **Custom range** | Selected dates, up to 92 days. |
1818

19-
The period applies to every tab. Daily bars use your browser's timezone, while billing periods begin at a fixed UTC instant. The first and last bars can be partial. Different timezones can split the same total differently across days.
19+
The period applies to every tab's charts and breakdowns. The **Usage heatmap** below the credit chart in Overview and workspace details always shows the last 365 days, including today's partial day. Hover, select, or use the arrow keys to inspect a day. A positive charge below one displayed credit reads **<1 credit** and remains colored.
20+
21+
Daily bars and heatmap cells use your browser's timezone, while billing periods begin at a fixed UTC instant. The first and last bars can be partial. Different timezones can split the same total differently across days.
2022

2123
Spend is in **credits**, where 1 credit = $0.005; the UI rounds displayed credits. [Logs](/logs-debugging/logging) and Logs API costs are in USD, while Billing and run-resource API amounts use their documented credit units. Check the endpoint contract before comparing amounts. See [cost calculation](/platform/costs).
2224

@@ -67,7 +69,7 @@ Each charge has one source. The exact **Agent block** label in Insights refers t
6769

6870
An ordinary Agent block's hosted model calls remain under Workflow. The Workflow base charge applies even without model calls. To assess a workflow using the Sim Chat block, read its Workflow charges and the separately metered Agent block source; neither charge is counted twice.
6971

70-
A hosted-key tool called inside a workflow belongs to Workflow; the same tool called directly belongs to API tool call. Chat panel, API agent, and Inbox charges cannot be separated by source. Dedicated workspace attribution can distinguish them when those surfaces actually name different workspaces. Usage without a workspace appears under **No workspace** and remains part of the organization total.
72+
A hosted-key tool called inside a workflow belongs to Workflow; the same tool called directly belongs to API tool call. Chat panel, API agent, and Inbox charges cannot be separated by source. Dedicated workspace attribution can distinguish them when those surfaces actually name different workspaces. Usage without a workspace link appears under **Unassigned usage** and remains part of the organization total. This includes organization-level activity and retained charges from permanently deleted workspaces; the row cannot distinguish between them.
7173

7274
### Reconcile totals
7375

‎apps/sim/ee/organization-usage/components/usage-heatmap.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useRef, useState } from 'react'
44
import { cn, scrollFadeAttributes, scrollFadeXClass, Tooltip, useScrollEdges } from '@sim/emcn'
55
import type { OrganizationUsageCalendar } from '@/lib/api/contracts/organization-usage'
6-
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
6+
import { formatApportionedCreditCost } from '@/lib/billing/credits/conversion'
77
import { USAGE_PALETTE_CLASS } from '@/ee/organization-usage/constants'
88

99
const LEVEL_CLASSES = [
@@ -34,7 +34,7 @@ interface UsageHeatmapProps {
3434
}
3535

3636
function formatDayUsage(day: OrganizationUsageCalendar['days'][number]) {
37-
return `${formatCreditsLabel(day.credits)} on ${DAY_FORMAT.format(new Date(`${day.date}T00:00:00Z`))}`
37+
return `${formatApportionedCreditCost(day.credits, day.hasCost)} on ${DAY_FORMAT.format(new Date(`${day.date}T00:00:00Z`))}`
3838
}
3939

4040
/** Calendar dates stay in UTC for layout; they already describe the viewer's local days. */
@@ -52,6 +52,7 @@ export function UsageHeatmap({ days }: UsageHeatmapProps) {
5252
const weeks = Math.ceil((offset + days.length) / 7)
5353
const peak = Math.max(...days.map((day) => day.credits), 1)
5454
const total = days.reduce((sum, day) => sum + day.credits, 0)
55+
const hasCost = days.some((day) => day.hasCost)
5556
const months = days.flatMap((day, index) => {
5657
const date = new Date(`${day.date}T00:00:00Z`)
5758
if (date.getUTCDate() !== 1) return []
@@ -99,8 +100,9 @@ export function UsageHeatmap({ days }: UsageHeatmapProps) {
99100
>
100101
{days.map((day, index) => {
101102
const position = index + offset
102-
const level =
103-
day.credits === 0 ? 0 : Math.min(4, Math.ceil(Math.sqrt(day.credits / peak) * 4))
103+
const level = day.hasCost
104+
? Math.max(1, Math.min(4, Math.ceil(Math.sqrt(day.credits / peak) * 4)))
105+
: 0
104106
const label = formatDayUsage(day)
105107
return (
106108
<Tooltip.Root key={day.date}>
@@ -160,7 +162,7 @@ export function UsageHeatmap({ days }: UsageHeatmapProps) {
160162
<span aria-live='polite'>
161163
{selectedDay
162164
? formatDayUsage(selectedDay)
163-
: `${formatCreditsLabel(total)} in the last 365 days`}
165+
: `${formatApportionedCreditCost(total, hasCost)} in the last 365 days`}
164166
</span>
165167
<div
166168
className='flex items-center gap-1'

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,13 @@ export type OrganizationUsageEventPage = z.output<typeof organizationUsageEvents
310310

311311
const organizationUsageCalendarResponseSchema = z.object({
312312
days: z
313-
.array(z.object({ date: z.iso.date(), credits: z.number().int().nonnegative() }))
313+
.array(
314+
z.object({
315+
date: z.iso.date(),
316+
credits: z.number().int().nonnegative(),
317+
hasCost: z.boolean(),
318+
})
319+
)
314320
.length(365),
315321
})
316322
export type OrganizationUsageCalendar = z.output<typeof organizationUsageCalendarResponseSchema>

‎apps/sim/lib/billing/application/organization-usage/get-organization-usage-calendar.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ export const getOrganizationUsageCalendar = defineAuthorizedOrganizationUsageUse
3838
const credits = apportionCredits(
3939
days.map((day) => ({ key: day, dollars: dollars.get(day) ?? 0 }))
4040
)
41-
return { days: days.map((date) => ({ date, credits: credits[date] ?? 0 })) }
41+
return {
42+
days: days.map((date) => ({
43+
date,
44+
credits: credits[date] ?? 0,
45+
hasCost: (dollars.get(date) ?? 0) > 0,
46+
})),
47+
}
4248
},
4349
})

‎apps/sim/lib/billing/application/organization-usage/get-organization-usage-overview.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,25 @@ describe('getOrganizationUsageOverview', () => {
114114
expect(result.days.filter((entry) => entry.credits === 0)).toHaveLength(364)
115115
})
116116

117+
it('distinguishes paid calendar days from empty days when credits round to zero', async () => {
118+
const previousDay = new Date(NOW.getTime() - 86_400_000).toISOString().slice(0, 10)
119+
mocks.readUsageDays.mockResolvedValue([
120+
[previousDay, [{ key: 'workflow', cost: 0.002, events: 1 }]],
121+
[day, [{ key: 'workflow', cost: 0.002, events: 1 }]],
122+
])
123+
const result = await getOrganizationUsageCalendar.execute({
124+
principal: session,
125+
input: { organizationId: 'org-1', timezone: 'UTC' },
126+
})
127+
expect(result.days.find((entry) => entry.date === day)).toEqual({
128+
date: day,
129+
credits: 0,
130+
hasCost: true,
131+
})
132+
expect(result.days[0]).toMatchObject({ credits: 0, hasCost: false })
133+
expect(result.days.reduce((sum, entry) => sum + entry.credits, 0)).toBe(1)
134+
})
135+
117136
it('reads names only for the kept rows, however large a tie at the cutoff', async () => {
118137
mocks.readUsageGroups.mockResolvedValue(
119138
Array.from({ length: 12 }, (_, index) => ({

‎packages/emcn/src/components/charts/bar-chart.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ function BarChartComponent(props: BarChartProps) {
127127
const tickValues = resolveBarTickValues(Math.max(...totals, 0))
128128
const maxValue = tickValues[tickValues.length - 1]
129129
const tickLabels = tickValues.map((value) =>
130-
unit ? formatBarValue(value, unit) : formatChartCompactNumber(value)
130+
value === 0 ? '0' : unit ? formatBarValue(value, unit) : formatChartCompactNumber(value)
131131
)
132132
const padding = resolveChartPadding(tickLabels)
133133
const chartWidth = width - padding.left - padding.right

0 commit comments

Comments
 (0)