+ {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.