Skip to content

Commit dd15a1a

Browse files
committed
improvement(insights): clarify unassigned usage and refresh usage charts
1 parent 9c977fb commit dd15a1a

19 files changed

Lines changed: 549 additions & 145 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { getOrganizationUsageCalendarContract } from '@/lib/api/contracts/organization-usage'
2+
import {
3+
defineInternalJsonRoute,
4+
internalRateLimits,
5+
internalSessionAuth,
6+
} from '@/lib/api/server/routes'
7+
import { getOrganizationUsageCalendar } from '@/lib/billing/application/organization-usage/get-organization-usage-calendar'
8+
import { organizationUsageOperations } from '@/lib/billing/application/organization-usage/operations'
9+
import { organizationUsageErrorPolicy } from '@/app/api/organizations/[id]/usage/error-policy'
10+
11+
export const dynamic = 'force-dynamic'
12+
13+
export const GET = defineInternalJsonRoute({
14+
contract: getOrganizationUsageCalendarContract,
15+
auth: internalSessionAuth,
16+
operation: organizationUsageOperations.readCalendar,
17+
rateLimit: internalRateLimits.none({
18+
reason:
19+
'Authenticated org-admin settings read, gated on enterprise entitlement and billing authority',
20+
}),
21+
errorPolicy: organizationUsageErrorPolicy,
22+
mapInput: ({ params, query }) => ({
23+
organizationId: params.id,
24+
workspaceId: query.workspaceId,
25+
timezone: query.timezone,
26+
}),
27+
useCase: getOrganizationUsageCalendar,
28+
present: (result) => result,
29+
})

‎apps/sim/ee/organization-usage/components/activity-summary.tsx‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,12 @@ import type { OrganizationUsageWindowKey } from '@/hooks/queries/utils/organizat
2525
const CHART_HEIGHT = 180
2626

2727
/**
28-
* Outcome layers, bottom-up. Failed is the status red and sits on the stack where a
29-
* spike reads at a glance; Other (cancelled, paused, unfinished) stays neutral, in a
30-
* gray whose lightness keeps it apart from the red for color-vision deficiency.
28+
* Outcome layers, bottom-up. Failed stays red; cancelled, paused and unfinished
29+
* runs share the neutral remainder.
3130
*/
3231
const OUTCOMES = [
33-
{ id: 'completed', label: 'Completed', color: 'var(--brand-blue)' },
34-
{ id: 'failed', label: 'Failed', color: 'var(--text-error)' },
32+
{ id: 'completed', label: 'Completed', color: 'var(--usage-workflow)' },
33+
{ id: 'failed', label: 'Failed', color: 'var(--usage-failed)' },
3534
{ id: 'other', label: 'Other', color: USAGE_OTHER_COLOR },
3635
] as const
3736

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use client'
2+
3+
import { ChartFrame } from '@sim/emcn'
4+
import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section'
5+
import { UsageHeatmap } from '@/ee/organization-usage/components/usage-heatmap'
6+
import { useOrganizationUsageCalendar } from '@/hooks/queries/organization-usage'
7+
8+
interface UsageCalendarProps {
9+
organizationId: string
10+
timezone: string
11+
workspaceId?: string
12+
}
13+
14+
/** Loads the year separately so its history cannot delay the selected period's chart. */
15+
export function UsageCalendar({ organizationId, timezone, workspaceId }: UsageCalendarProps) {
16+
const calendar = useOrganizationUsageCalendar(organizationId, timezone, workspaceId)
17+
return (
18+
<SettingsSection
19+
label='Usage heatmap'
20+
action={<span className='text-[var(--text-muted)] text-small'>Last 365 days</span>}
21+
>
22+
{calendar.isError || !calendar.data ? (
23+
<ChartFrame
24+
height={172}
25+
loading={calendar.isLoading}
26+
error={calendar.isError ? 'Could not load daily usage' : undefined}
27+
onRetry={() => void calendar.refetch()}
28+
>
29+
{null}
30+
</ChartFrame>
31+
) : (
32+
<UsageHeatmap days={calendar.data.days} />
33+
)}
34+
</SettingsSection>
35+
)
36+
}

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

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,20 @@
11
'use client'
22

3-
import type { ComponentType } from 'react'
4-
import { Avatar, cn, disclosureChevronClass, formatChartCompactNumber } from '@sim/emcn'
5-
import { ArrowRight, ChevronDown } from '@sim/emcn/icons'
3+
import { type ComponentType, useId, useState } from 'react'
4+
import {
5+
Avatar,
6+
Button,
7+
cn,
8+
disclosureChevronClass,
9+
FloatingTooltip,
10+
formatChartCompactNumber,
11+
OverflowText,
12+
Popover,
13+
PopoverContent,
14+
PopoverTrigger,
15+
useFloatingTooltip,
16+
} from '@sim/emcn'
17+
import { ArrowRight, ChevronDown, CircleInfo } from '@sim/emcn/icons'
618
import {
719
AnthropicIcon,
820
AzureIcon,
@@ -91,6 +103,7 @@ const PROVIDER_ICONS: Readonly<Record<string, ComponentType<{ className?: string
91103

92104
interface UsageConsumerRowProps {
93105
row: OrganizationUsageBreakdownRow
106+
info?: string
94107
/** Member rows lead with the member's avatar where other rows show a provider mark. */
95108
isMember: boolean
96109
/** BYOK rows carry no cost, so tokens are the only usage they can show. */
@@ -124,12 +137,46 @@ const TRAILING_SLOT_CLASSES = {
124137
*/
125138
export const USAGE_ROW_CLASSES = 'flex w-full items-center gap-2.5 rounded-lg p-2 text-left'
126139

140+
interface UsageInfoProps {
141+
label: string
142+
info: string
143+
}
144+
145+
function UsageInfo({ label, info }: UsageInfoProps) {
146+
const [open, setOpen] = useState(false)
147+
const tooltipId = useId()
148+
const tooltip = useFloatingTooltip(() => !open, { revalidateKey: open })
149+
150+
return (
151+
<Popover open={open} onOpenChange={setOpen}>
152+
<PopoverTrigger asChild>
153+
<Button
154+
variant='quiet'
155+
size='icon'
156+
aria-label={label}
157+
aria-describedby={!open && tooltip.state.visible ? tooltipId : undefined}
158+
{...tooltip.handlers}
159+
>
160+
<CircleInfo className='size-[14px]' />
161+
</Button>
162+
</PopoverTrigger>
163+
<PopoverContent appearance='tooltip' aria-label={label}>
164+
{info}
165+
</PopoverContent>
166+
{!open && (
167+
<FloatingTooltip id={tooltipId} role='tooltip' label={info} state={tooltip.state} />
168+
)}
169+
</Popover>
170+
)
171+
}
172+
127173
/**
128174
* A tabular row, not `SettingsResourceRow` — tabular columns are the sanctioned
129175
* exception in `sim-settings-pages.md`, alongside billing invoices and credit usage.
130176
*/
131177
function UsageConsumerRow({
132178
row,
179+
info,
133180
isMember,
134181
showTokensOnly,
135182
onSelect,
@@ -158,7 +205,14 @@ function UsageConsumerRow({
158205
) : (
159206
ProviderIcon && <ProviderIcon className='size-[14px] shrink-0 text-[var(--text-icon)]' />
160207
)}
161-
<span className='min-w-0 flex-1 truncate text-[var(--text-body)] text-sm'>{row.label}</span>
208+
<div className='flex min-w-0 flex-1 items-center gap-1'>
209+
<OverflowText
210+
label={row.label}
211+
className='text-[var(--text-body)] text-sm'
212+
focusTarget={onSelect ? 'nearest-interactive' : undefined}
213+
/>
214+
{info && <UsageInfo label={`About ${row.label.toLowerCase()}`} info={info} />}
215+
</div>
162216
<div
163217
className='h-[4px] w-[64px] shrink-0 overflow-hidden rounded-full bg-[var(--border)]'
164218
aria-hidden='true'
@@ -243,9 +297,14 @@ export function UsageConsumers({
243297
<UsageConsumerRow
244298
key={`${dimension}-${row.id}`}
245299
row={row}
300+
info={
301+
dimension === 'workspace' && !row.id
302+
? 'Usage without a workspace link, including organization-level activity and permanently deleted workspaces.'
303+
: undefined
304+
}
246305
isMember={dimension === 'member'}
247306
showTokensOnly={showTokensOnly}
248-
{...(onExpandOther && trailingSlot ? { reservedTrailing: trailingSlot } : {})}
307+
{...(trailingSlot ? { reservedTrailing: trailingSlot } : {})}
249308
{...(onSelectRow && row.id ? { onSelect: onSelectRow } : {})}
250309
{...(rowActions && row.id ? { actions: rowActions(row) } : {})}
251310
/>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
'use client'
2+
3+
import { useRef, useState } from 'react'
4+
import { cn, scrollFadeAttributes, scrollFadeXClass, Tooltip, useScrollEdges } from '@sim/emcn'
5+
import type { OrganizationUsageCalendar } from '@/lib/api/contracts/organization-usage'
6+
import { formatCreditsLabel } from '@/lib/billing/credits/conversion'
7+
import { USAGE_PALETTE_CLASS } from '@/ee/organization-usage/constants'
8+
9+
const LEVEL_CLASSES = [
10+
'bg-[var(--surface-active)]',
11+
'bg-[color-mix(in_srgb,var(--usage-workflow)_25%,var(--surface-2))]',
12+
'bg-[color-mix(in_srgb,var(--usage-workflow)_50%,var(--surface-2))]',
13+
'bg-[color-mix(in_srgb,var(--usage-workflow)_75%,var(--surface-2))]',
14+
'bg-[var(--usage-workflow)]',
15+
] as const
16+
const WEEKDAY_LABELS = ['Mon', '', 'Wed', '', 'Fri', '', 'Sun'] as const
17+
const ARROW_OFFSETS: Record<string, number> = {
18+
ArrowLeft: -7,
19+
ArrowRight: 7,
20+
ArrowUp: -1,
21+
ArrowDown: 1,
22+
}
23+
const DAY_FORMAT = new Intl.DateTimeFormat('en-US', {
24+
timeZone: 'UTC',
25+
weekday: 'long',
26+
month: 'long',
27+
day: 'numeric',
28+
year: 'numeric',
29+
})
30+
const MONTH_FORMAT = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', month: 'short' })
31+
32+
interface UsageHeatmapProps {
33+
days: OrganizationUsageCalendar['days']
34+
}
35+
36+
function formatDayUsage(day: OrganizationUsageCalendar['days'][number]) {
37+
return `${formatCreditsLabel(day.credits)} on ${DAY_FORMAT.format(new Date(`${day.date}T00:00:00Z`))}`
38+
}
39+
40+
/** Calendar dates stay in UTC for layout; they already describe the viewer's local days. */
41+
export function UsageHeatmap({ days }: UsageHeatmapProps) {
42+
const scrollRef = useRef<HTMLDivElement>(null)
43+
const gridRef = useRef<HTMLDivElement>(null)
44+
const edges = useScrollEdges(scrollRef, { axis: 'x' })
45+
const [focusedIndex, setFocusedIndex] = useState<number | null>(null)
46+
const [selectedDate, setSelectedDate] = useState<string | null>(null)
47+
const selectedDay = days.find((day) => day.date === selectedDate)
48+
const first = days[0]
49+
if (!first) return null
50+
const firstDate = new Date(`${first.date}T00:00:00Z`)
51+
const offset = (firstDate.getUTCDay() + 6) % 7
52+
const weeks = Math.ceil((offset + days.length) / 7)
53+
const peak = Math.max(...days.map((day) => day.credits), 1)
54+
const total = days.reduce((sum, day) => sum + day.credits, 0)
55+
const months = days.flatMap((day, index) => {
56+
const date = new Date(`${day.date}T00:00:00Z`)
57+
if (date.getUTCDate() !== 1) return []
58+
const column = Math.floor((index + offset) / 7)
59+
if (column > weeks - 3) return []
60+
return [{ date: day.date, column, label: MONTH_FORMAT.format(date) }]
61+
})
62+
63+
return (
64+
<div className={cn('flex min-w-0 flex-col gap-3', USAGE_PALETTE_CLASS)}>
65+
<div
66+
ref={scrollRef}
67+
className={cn('overflow-x-auto pb-1', scrollFadeXClass)}
68+
{...scrollFadeAttributes(edges)}
69+
>
70+
<div className='min-w-[660px] max-w-[860px] p-1'>
71+
<div
72+
className='mb-2 ml-8 grid gap-[3px] text-[var(--text-muted)] text-small'
73+
style={{ gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))` }}
74+
aria-hidden='true'
75+
>
76+
{months.map((month) => (
77+
<span key={month.date} style={{ gridColumn: `${month.column + 1} / span 3` }}>
78+
{month.label}
79+
</span>
80+
))}
81+
</div>
82+
<div className='relative'>
83+
<div
84+
className='absolute inset-y-0 left-0 grid w-6 grid-rows-7 gap-[3px] text-[var(--text-muted)] text-small'
85+
aria-hidden='true'
86+
>
87+
{WEEKDAY_LABELS.map((label, index) => (
88+
<span key={index} className='flex items-center'>
89+
{label}
90+
</span>
91+
))}
92+
</div>
93+
<div
94+
ref={gridRef}
95+
role='group'
96+
aria-label='Daily credit usage. Use arrow keys to move between days, Home for the first day, or End for today.'
97+
className='ml-8 grid gap-[3px]'
98+
style={{ gridTemplateColumns: `repeat(${weeks}, minmax(0, 1fr))` }}
99+
>
100+
{days.map((day, index) => {
101+
const position = index + offset
102+
const level =
103+
day.credits === 0 ? 0 : Math.min(4, Math.ceil(Math.sqrt(day.credits / peak) * 4))
104+
const label = formatDayUsage(day)
105+
return (
106+
<Tooltip.Root key={day.date}>
107+
<Tooltip.Trigger asChild>
108+
<button
109+
type='button'
110+
aria-label={label}
111+
aria-pressed={day.date === selectedDate}
112+
data-day-index={index}
113+
tabIndex={index === (focusedIndex ?? days.length - 1) ? 0 : -1}
114+
className={cn(
115+
'aspect-square rounded-[3px] outline-offset-2 hover:outline hover:outline-1 hover:outline-[var(--text-tertiary)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--text-primary)]',
116+
LEVEL_CLASSES[level],
117+
day.date === selectedDate &&
118+
'outline outline-2 outline-[var(--text-primary)]'
119+
)}
120+
style={{
121+
gridColumn: Math.floor(position / 7) + 1,
122+
gridRow: (position % 7) + 1,
123+
}}
124+
onClick={() =>
125+
setSelectedDate((current) => (current === day.date ? null : day.date))
126+
}
127+
onFocus={() => setFocusedIndex(index)}
128+
onKeyDown={(event) => {
129+
if (event.key === 'Escape' && selectedDate) {
130+
event.preventDefault()
131+
setSelectedDate(null)
132+
return
133+
}
134+
const delta = ARROW_OFFSETS[event.key]
135+
const next =
136+
event.key === 'Home'
137+
? 0
138+
: event.key === 'End'
139+
? days.length - 1
140+
: delta === undefined
141+
? null
142+
: Math.max(0, Math.min(days.length - 1, index + delta))
143+
if (next === null) return
144+
event.preventDefault()
145+
gridRef.current
146+
?.querySelector<HTMLButtonElement>(`[data-day-index="${next}"]`)
147+
?.focus()
148+
}}
149+
/>
150+
</Tooltip.Trigger>
151+
<Tooltip.Content>{label}</Tooltip.Content>
152+
</Tooltip.Root>
153+
)
154+
})}
155+
</div>
156+
</div>
157+
</div>
158+
</div>
159+
<div className='flex flex-wrap items-center justify-between gap-2 text-[var(--text-muted)] text-small'>
160+
<span aria-live='polite'>
161+
{selectedDay
162+
? formatDayUsage(selectedDay)
163+
: `${formatCreditsLabel(total)} in the last 365 days`}
164+
</span>
165+
<div
166+
className='flex items-center gap-1'
167+
aria-label='Color intensity: less to more daily usage'
168+
>
169+
<span className='mr-1'>Less</span>
170+
{LEVEL_CLASSES.map((className) => (
171+
<span
172+
key={className}
173+
className={cn('size-3 rounded-[3px]', className)}
174+
aria-hidden='true'
175+
/>
176+
))}
177+
<span className='ml-1'>More</span>
178+
</div>
179+
</div>
180+
</div>
181+
)
182+
}

0 commit comments

Comments
 (0)