diff --git a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx index 31897f23c6..8dfb44451d 100644 --- a/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx +++ b/apps/web/src/components/usage-analytics/UsageAnalyticsDashboard.tsx @@ -28,6 +28,7 @@ import { UsageAnalyticsSidebar, type PersonalView, } from './UsageAnalyticsSidebar'; +import { UsageProfileHero } from './UsageProfileHero'; import { EMPTY_FILTERS, defaultGranularityForPeriod, @@ -38,6 +39,7 @@ import { useUsageSummary, useUsageTable, useUsageTimeseries, + useUsageProfile, type UsageFilters, type ViewAs, } from './hooks'; @@ -274,6 +276,13 @@ export function UsageAnalyticsDashboard({ limit: 500, }); + // Profile hook - only enabled for personal context + const { + data: usageProfile, + isLoading: usageProfileLoading, + error: usageProfileError, + } = useUsageProfile(context === 'personal'); + // Resolve user ID -> email for labels whenever there is an effective org // scope. Key off `effectiveOrgId` (not the prop `organizationId`) so that // future paths which surface user-dimension data in personal-with-org mode @@ -536,6 +545,13 @@ export function UsageAnalyticsDashboard({
+ {context === 'personal' && ( + + )} {isOrgContext && organizationId && ( diff --git a/apps/web/src/components/usage-analytics/UsageProfileHero.tsx b/apps/web/src/components/usage-analytics/UsageProfileHero.tsx new file mode 100644 index 0000000000..729998e769 --- /dev/null +++ b/apps/web/src/components/usage-analytics/UsageProfileHero.tsx @@ -0,0 +1,482 @@ +'use client'; +import { useMemo, useState } from 'react'; +import { Card, CardContent } from '@/components/ui/card'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { getInitialsFromName, formatLargeNumber } from '@/lib/utils'; +import { type UsageProfile } from './types'; + +type UsageProfileHeroProps = { + profile: UsageProfile | undefined; + loading: boolean; + error: Error | null; +}; + +type HeatmapRange = '30d' | '90d' | '1y'; + +const HEATMAP_RANGES: { value: HeatmapRange; label: string }[] = [ + { value: '30d', label: 'Past 30 days' }, + { value: '90d', label: 'Past 90 days' }, + { value: '1y', label: 'Past year' }, +]; + +function computeStreaks(dailyActivity: { date: string; tokens: number }[]): { + currentStreak: number; + longestStreak: number; +} { + if (dailyActivity.length === 0) { + return { currentStreak: 0, longestStreak: 0 }; + } + + // Sort by date ascending + const sorted = [...dailyActivity].sort((a, b) => a.date.localeCompare(b.date)); + + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const todayStr = today.toISOString().slice(0, 10); + + // Convert to a map for quick lookup + const dayMap = new Map(); + for (const day of sorted) { + dayMap.set(day.date, day.tokens); + } + + // Build a list of all days in range with 0 for missing days + const startDate = new Date(sorted[0].date); + const endDate = new Date(todayStr); + + const allDays: string[] = []; + const current = new Date(startDate); + while (current <= endDate) { + allDays.push(current.toISOString().slice(0, 10)); + current.setUTCDate(current.getUTCDate() + 1); + } + + // Compute current streak: consecutive active days ending today + let currentStreak = 0; + for (let i = allDays.length - 1; i >= 0; i--) { + if ((dayMap.get(allDays[i]) ?? 0) > 0) { + currentStreak++; + } else { + break; + } + } + + // Compute longest streak: max consecutive active days + let longestStreak = 0; + let runningStreak = 0; + for (const dateStr of allDays) { + if ((dayMap.get(dateStr) ?? 0) > 0) { + runningStreak++; + longestStreak = Math.max(longestStreak, runningStreak); + } else { + runningStreak = 0; + } + } + + return { currentStreak, longestStreak }; +} + +export function UsageProfileHero({ profile, loading, error }: UsageProfileHeroProps) { + const fullName = profile?.name ?? ''; + const email = profile?.email ?? ''; + const initials = loading ? '' : getInitialsFromName(fullName) || getInitialsFromName(email); + + const { currentStreak, longestStreak } = useMemo(() => { + return computeStreaks(profile?.dailyActivity ?? []); + }, [profile?.dailyActivity]); + + return ( +
+ + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// Profile Header +// --------------------------------------------------------------------------- + +type ProfileHeaderProps = { + initials?: string; + name?: string; + imageUrl?: string; +}; + +function ProfileHeader({ initials = '', name, imageUrl }: ProfileHeaderProps) { + return ( +
+ + + {initials} + +
+ {name ? ( +

{name}

+ ) : ( + + )} +

Personal usage

+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Usage Profile Stats +// --------------------------------------------------------------------------- + +type UsageProfileStatsProps = { + lifetimeTokens?: number; + peakTokens?: number; + currentStreak: number; + longestStreak: number; + loading: boolean; +}; + +function UsageProfileStats({ + lifetimeTokens, + peakTokens, + currentStreak, + longestStreak, + loading, +}: UsageProfileStatsProps) { + return ( + + +
+ + + + +
+
+
+ ); +} + +type KpiTileProps = { + label: string; + value?: string; + loading: boolean; +}; + +function KpiTile({ label, value, loading }: KpiTileProps) { + return ( +
+
{label}
+
+ {loading ? : (value ?? '—')} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Token Activity Heatmap +// --------------------------------------------------------------------------- + +type TokenActivityHeatmapProps = { + dailyActivity: { date: string; tokens: number }[]; + loading: boolean; + error: string | null; +}; + +function TokenActivityHeatmap({ dailyActivity, loading, error }: TokenActivityHeatmapProps) { + const [range, setRange] = useState('1y'); + + const heatmapData = useMemo(() => { + // Build a map for quick lookup + const dayMap = new Map(); + for (const day of dailyActivity) { + dayMap.set(day.date, day.tokens); + } + + // Determine date range + const days = range === '30d' ? 30 : range === '90d' ? 90 : 365; + const endDate = new Date(); + endDate.setUTCHours(0, 0, 0, 0); + const startDate = new Date(endDate); + startDate.setUTCDate(startDate.getUTCDate() - days); + + // Build all days in the range + const allDays: { date: string; tokens: number }[] = []; + const current = new Date(startDate); + while (current <= endDate) { + const dateStr = current.toISOString().slice(0, 10); + allDays.push({ date: dateStr, tokens: dayMap.get(dateStr) ?? 0 }); + current.setUTCDate(current.getUTCDate() + 1); + } + + return organizeHeatmap(allDays); + }, [dailyActivity, range]); + + const maxTokens = useMemo(() => { + return Math.max(1, ...heatmapData.flatMap(week => week.map(d => d?.tokens ?? 0))); + }, [heatmapData]); + + const intensityForTokens = (tokens: number) => { + if (tokens === 0) return 'bg-muted/30'; + const ratio = tokens / maxTokens; + if (ratio < 0.2) return 'bg-chart-2/30'; + if (ratio < 0.4) return 'bg-chart-2/60'; + if (ratio < 0.6) return 'bg-chart-1/70'; + return 'bg-chart-1'; + }; + + return ( + + +
+

Token activity

+ setRange(v as HeatmapRange)}> + + {HEATMAP_RANGES.map(r => ( + + {r.label} + + ))} + + +
+ + {loading ? ( + + ) : ( + <> + {error &&

{error}

} + + + + + )} +
+
+ ); +} + +function HeatmapSkeleton() { + return ( +
+ ); +} + +type HeatmapGridProps = { + data: Array>; + intensityForTokens: (tokens: number) => string; +}; + +function HeatmapGrid({ data, intensityForTokens }: HeatmapGridProps) { + const monthHeaders = useMemo(() => { + const headers: Array<{ month: string; colspan: number }> = []; + let currentMonth = ''; + let colspan = 0; + + data.forEach(week => { + const validDay = week.find(d => d !== null); + if (!validDay) return; + + const date = new Date(validDay.date + 'T00:00:00Z'); + const monthName = date.toLocaleDateString('en-US', { month: 'short', timeZone: 'UTC' }); + + if (monthName !== currentMonth) { + if (currentMonth && colspan > 0) { + headers.push({ month: currentMonth, colspan }); + } + currentMonth = monthName; + colspan = 1; + } else { + colspan++; + } + }); + + if (currentMonth && colspan > 0) { + headers.push({ month: currentMonth, colspan }); + } + + return headers; + }, [data]); + + const formatTooltipDate = (dateString: string) => { + const dateObj = new Date(dateString + 'T00:00:00Z'); + const monthNames = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + const month = monthNames[dateObj.getUTCMonth()]; + const day = dateObj.getUTCDate(); + const year = dateObj.getUTCFullYear(); + return `${month} ${day}, ${year}`; + }; + + return ( +
+
+
+ {monthHeaders.map((header, index) => ( +
+ {header.month} +
+ ))} +
+ +
+
+ {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((label, row) => ( +
+ {label} +
+ ))} +
+ +
+ {data.map((week, weekIndex) => + week.map((day, dayIndex) => { + if (!day) { + return ( +
+ ); + } + + const cell = ( +
+ ); + + if (day.tokens === 0) + return ( + + {cell} + +
+
{formatTooltipDate(day.date)}
+
No usage
+
+
+
+ ); + + return ( + + {cell} + +
+
{formatTooltipDate(day.date)}
+
+ {formatLargeNumber(day.tokens)} token{day.tokens !== 1 ? 's' : ''} +
+
+
+
+ ); + }) + )} +
+
+
+
+ ); +} + +function organizeHeatmap( + dailyActivity: { date: string; tokens: number }[] +): Array> { + const result: Array> = []; + + if (dailyActivity.length === 0) return result; + + // Build weeks starting from Sunday before startDate + const firstDate = new Date(dailyActivity[0].date + 'T00:00:00Z'); + const firstDayOfWeek = firstDate.getUTCDay(); + + // Add null padding for days before the first date + const firstWeek: Array<{ date: string; tokens: number } | null> = []; + for (let i = 0; i < firstDayOfWeek; i++) { + firstWeek.push(null); + } + + let currentWeek = firstWeek; + for (const day of dailyActivity) { + currentWeek.push(day); + if (currentWeek.length === 7) { + result.push(currentWeek); + currentWeek = []; + } + } + + // Add the last incomplete week if needed + if (currentWeek.length > 0) { + while (currentWeek.length < 7) { + currentWeek.push(null); + } + result.push(currentWeek); + } + + return result; +} diff --git a/apps/web/src/components/usage-analytics/hooks.ts b/apps/web/src/components/usage-analytics/hooks.ts index a88d43a8eb..6be489a340 100644 --- a/apps/web/src/components/usage-analytics/hooks.ts +++ b/apps/web/src/components/usage-analytics/hooks.ts @@ -230,3 +230,11 @@ export function useResolveOrgUsers(organizationId: string | null, userIds: strin ) ); } + +export function useUsageProfile(enabled: boolean = true) { + const trpc = useTRPC(); + return useQuery({ + ...trpc.usageAnalytics.getProfile.queryOptions(), + enabled, + }); +} diff --git a/apps/web/src/components/usage-analytics/types.ts b/apps/web/src/components/usage-analytics/types.ts index fedf04a660..a0d2dc28f6 100644 --- a/apps/web/src/components/usage-analytics/types.ts +++ b/apps/web/src/components/usage-analytics/types.ts @@ -41,6 +41,8 @@ export type UsageBreakdown = RouterOutputs['usageAnalytics']['getBreakdown']; export type UsageTable = RouterOutputs['usageAnalytics']['getTable']; +export type UsageProfile = RouterOutputs['usageAnalytics']['getProfile']; + export const PERIOD_LABELS: Record = { today: 'Today', yesterday: 'Yesterday', diff --git a/apps/web/src/routers/usage-analytics-router.ts b/apps/web/src/routers/usage-analytics-router.ts index 6804827e20..00c8c55552 100644 --- a/apps/web/src/routers/usage-analytics-router.ts +++ b/apps/web/src/routers/usage-analytics-router.ts @@ -625,6 +625,21 @@ const BreakdownOutputSchema = z.object({ effectiveGranularity: GranularitySchema, }); +// --------------------------------------------------------------------------- +// Profile (lifetime tokens, peak, daily activity for personal hero) +// --------------------------------------------------------------------------- + +const ProfileOutputSchema = z.object({ + name: z.string(), + email: z.string(), + imageUrl: z.string(), + lifetimeTokens: z.number(), + peakTokens: z.number(), + dailyActivity: z.array(z.object({ date: z.string(), tokens: z.number() })), +}); + +type ProfileOutput = z.infer; + // --------------------------------------------------------------------------- // Table // --------------------------------------------------------------------------- @@ -1081,6 +1096,119 @@ export const usageAnalyticsRouter = createTRPCRouter({ }; }), + /** + * Get personal usage profile data for the hero section. + * Returns lifetime tokens, daily peak, and recent daily activity for heatmap. + */ + getProfile: baseProcedure + .output(ProfileOutputSchema) + .query(async ({ ctx }): Promise => { + const config = resolveSnowflakeConfig(); + + // Return zeros/empty when Snowflake config is unavailable. + if (!config) { + return { + name: ctx.user.google_user_name ?? '', + email: ctx.user.google_user_email ?? '', + imageUrl: ctx.user.google_user_image_url ?? '', + lifetimeTokens: 0, + peakTokens: 0, + dailyActivity: [], + }; + } + + const table = 'MICRODOLLAR_USAGE_DAILY'; + const userId = ctx.user.id; + + // Compute lifetime totals and peak. + const lifetimeStmt = ` + SELECT + COALESCE(SUM(total_tokens), 0), + COALESCE(MAX(total_tokens), 0) + FROM ${table} + WHERE kilo_user_id = ? + AND organization_id = '' + `; + const lifetimeBindings: SnowflakeBinding[] = [{ type: 'TEXT', value: userId }]; + + const lifetimeRows = await timedSnowflakeQuery( + { + route: 'usageAnalytics.getProfile', + queryLabel: 'profile_lifetime', + scope: 'user', + period: 'all-time', + }, + signal => + executeSnowflakeStatement({ + config, + statement: lifetimeStmt, + bindings: lifetimeBindings, + timeoutSeconds: Math.ceil(defaultTimeoutForScope('user') / 1000), + signal, + }) + ); + + const lifetimeTokens = toSafeNumber(lifetimeRows[0]?.[0] ?? 0); + const peakTokens = toSafeNumber(lifetimeRows[0]?.[1] ?? 0); + + // Get daily activity for the last 365 days (UTC calendar days). + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const todayStr = today.toISOString().slice(0, 10); + + const yearAgo = new Date(today); + yearAgo.setUTCFullYear(yearAgo.getUTCFullYear() - 1); + const yearAgoStr = yearAgo.toISOString().slice(0, 10); + + const activityStmt = ` + SELECT + usage_date, + COALESCE(SUM(total_tokens), 0) + FROM ${table} + WHERE kilo_user_id = ? + AND organization_id = '' + AND usage_date >= ? + AND usage_date <= ? + GROUP BY usage_date + ORDER BY usage_date + `; + + const activityRows = await timedSnowflakeQuery( + { + route: 'usageAnalytics.getProfile', + queryLabel: 'profile_activity', + scope: 'user', + period: `${yearAgoStr}/${todayStr}`, + }, + signal => + executeSnowflakeStatement({ + config, + statement: activityStmt, + bindings: [ + { type: 'TEXT', value: userId }, + { type: 'TEXT', value: yearAgoStr }, + { type: 'TEXT', value: todayStr }, + ], + timeoutSeconds: Math.ceil(defaultTimeoutForScope('user') / 1000), + signal, + }) + ); + + const dailyActivity = activityRows.map(row => ({ + date: row[0] ?? '', + tokens: toSafeNumber(row[1]), + })); + + return { + name: ctx.user.google_user_name ?? '', + email: ctx.user.google_user_email ?? '', + imageUrl: ctx.user.google_user_image_url ?? '', + lifetimeTokens, + peakTokens, + dailyActivity, + }; + }), + /** * Look up user names and emails for a set of user IDs that belong to an org. * Used by the UI to decorate per-user breakdowns, filters, and table rows.