From ada0b9d20682792731c58ccb321c6149cf6544fb Mon Sep 17 00:00:00 2001 From: Caden Date: Sun, 28 Jun 2026 18:02:21 +0800 Subject: [PATCH] Add profile range filters --- .../__tests__/api/usersProfile.test.ts | 140 +++++++++++++++++ .../src/app/api/users/[username]/route.ts | 143 +++++++++++++++--- .../app/u/[username]/ProfilePageClient.tsx | 85 +++++++++++ .../frontend/src/app/u/[username]/page.tsx | 34 ++++- 4 files changed, 373 insertions(+), 29 deletions(-) diff --git a/packages/frontend/__tests__/api/usersProfile.test.ts b/packages/frontend/__tests__/api/usersProfile.test.ts index 77c048845..7515a4f17 100644 --- a/packages/frontend/__tests__/api/usersProfile.test.ts +++ b/packages/frontend/__tests__/api/usersProfile.test.ts @@ -35,6 +35,7 @@ const mockState = vi.hoisted(() => { submissionId: "dailyBreakdown.submissionId", date: "dailyBreakdown.date", timestampMs: "dailyBreakdown.timestampMs", + activeTimeMs: "dailyBreakdown.activeTimeMs", tokens: "dailyBreakdown.tokens", cost: "dailyBreakdown.cost", inputTokens: "dailyBreakdown.inputTokens", @@ -47,6 +48,7 @@ const mockState = vi.hoisted(() => { const desc = vi.fn(() => "desc"); const and = vi.fn(() => "and"); const gte = vi.fn(() => "gte"); + const lte = vi.fn(() => "lte"); const sql = Object.assign( vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings: Array.from(strings), @@ -88,6 +90,7 @@ const mockState = vi.hoisted(() => { desc, and, gte, + lte, sql, reset() { selectResults.length = 0; @@ -99,6 +102,7 @@ const mockState = vi.hoisted(() => { desc.mockClear(); and.mockClear(); gte.mockClear(); + lte.mockClear(); sql.mockClear(); sql.raw.mockClear(); }, @@ -147,6 +151,7 @@ vi.mock("drizzle-orm", () => ({ sql: mockState.sql, and: mockState.and, gte: mockState.gte, + lte: mockState.lte, })); type ModuleExports = typeof import("../../src/app/api/users/[username]/route"); @@ -432,6 +437,141 @@ describe("GET /api/users/[username]", () => { ]); }); + it("recalculates profile overview stats from daily rows for rolling periods", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-28T12:00:00.000Z")); + + mockState.pushSelectResult([ + { + id: "user-1", + username: "alice", + displayName: "Alice", + avatarUrl: null, + createdAt: "2026-01-01T00:00:00.000Z", + }, + ]); + mockState.pushSelectResult([ + { + totalTokens: 1000, + totalCost: 10, + inputTokens: 600, + outputTokens: 400, + cacheReadTokens: 100, + cacheCreationTokens: 50, + reasoningTokens: 25, + submissionCount: 3, + earliestDate: "2026-01-01", + latestDate: "2026-06-28", + totalActiveTimeMs: 1200000, + sessionCount: 8, + }, + ]); + mockState.pushSelectResult([ + { + sourcesUsed: ["codex", "claude"], + modelsUsed: ["gpt-5.5", "claude-sonnet-4-5"], + updatedAt: new Date("2026-06-28T10:00:00.000Z"), + cliVersion: "2.0.0", + schemaVersion: 2, + }, + ]); + mockState.pushSelectResult([ + { + date: "2026-06-22", + timestampMs: 100, + activeTimeMs: 300000, + tokens: 200, + cost: "2.0000", + inputTokens: 120, + outputTokens: 80, + sourceBreakdown: { + codex: { + tokens: 200, + cost: 2, + input: 120, + output: 80, + cacheRead: 30, + cacheWrite: 10, + reasoning: 5, + messages: 2, + models: { + "gpt-5.5": { + tokens: 200, + cost: 2, + input: 120, + output: 80, + cacheRead: 30, + cacheWrite: 10, + reasoning: 5, + messages: 2, + }, + }, + }, + }, + }, + { + date: "2026-06-28", + timestampMs: 200, + activeTimeMs: 600000, + tokens: 300, + cost: "3.0000", + inputTokens: 180, + outputTokens: 120, + sourceBreakdown: { + claude: { + tokens: 300, + cost: 3, + input: 180, + output: 120, + cacheRead: 40, + cacheWrite: 20, + reasoning: 10, + messages: 3, + models: { + "claude-sonnet-4-5": { + tokens: 300, + cost: 3, + input: 180, + output: 120, + cacheRead: 40, + cacheWrite: 20, + reasoning: 10, + messages: 3, + }, + }, + }, + }, + }, + ]); + mockState.pushExecuteResult([{ rank: 4 }]); + + const response = await GET( + new Request("http://localhost:3000/api/users/alice?period=week"), + { params: Promise.resolve({ username: "alice" }) } + ); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(mockState.gte).toHaveBeenCalledWith(mockState.tables.dailyBreakdown.date, "2026-06-22"); + expect(mockState.lte).toHaveBeenCalledWith(mockState.tables.dailyBreakdown.date, "2026-06-28"); + expect(body.period).toBe("week"); + expect(body.dateRange).toEqual({ start: "2026-06-22", end: "2026-06-28" }); + expect(body.stats).toEqual(expect.objectContaining({ + totalTokens: 500, + totalCost: 5, + inputTokens: 300, + outputTokens: 200, + cacheReadTokens: 70, + cacheWriteTokens: 30, + reasoningTokens: 15, + activeDays: 2, + totalActiveTimeMs: 900000, + sessionCount: 0, + })); + expect(body.clients).toEqual(["codex", "claude"]); + expect(body.models).toEqual(["gpt-5.5", "claude-sonnet-4-5"]); + }); + it("returns submission freshness metadata for the latest submission", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-11T12:00:00.000Z")); diff --git a/packages/frontend/src/app/api/users/[username]/route.ts b/packages/frontend/src/app/api/users/[username]/route.ts index 46650e25d..dd2067dff 100644 --- a/packages/frontend/src/app/api/users/[username]/route.ts +++ b/packages/frontend/src/app/api/users/[username]/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { db, users, submissions, dailyBreakdown } from "@/lib/db"; -import { eq, desc, sql, and, gte } from "drizzle-orm"; +import { eq, desc, sql, and, gte, lte } from "drizzle-orm"; import { AmbiguousUsernameError, USERNAME_LOOKUP_LIMIT, @@ -14,15 +14,62 @@ function normalizeClientId(id: string): string { return LEGACY_CLIENT_ALIASES[id] ?? id; } +const PROFILE_PERIODS = ["all", "week", "month"] as const; +type ProfilePeriod = (typeof PROFILE_PERIODS)[number]; + +interface ProfilePeriodDateRange { + start: string; + end: string; +} + +function toUtcDateString(date: Date): string { + return date.toISOString().slice(0, 10); +} + +function parseProfilePeriod(value: string | null): ProfilePeriod { + return PROFILE_PERIODS.includes(value as ProfilePeriod) + ? (value as ProfilePeriod) + : "all"; +} + +function getProfilePeriodDateRange( + period: ProfilePeriod, + now: Date = new Date() +): ProfilePeriodDateRange | null { + if (period === "all") { + return null; + } + + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const start = new Date(end); + start.setUTCDate(start.getUTCDate() - (period === "week" ? 6 : 29)); + + return { + start: toUtcDateString(start), + end: toUtcDateString(end), + }; +} + +function serializeUpdatedAt(value: Date | string | null | undefined): string | null { + if (!value) { + return null; + } + + return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); +} + export const revalidate = 60; // ISR: revalidate every 60 seconds interface RouteParams { params: Promise<{ username: string }>; } -export async function GET(_request: Request, { params }: RouteParams) { +export async function GET(request: Request, { params }: RouteParams) { try { const { username } = await params; + const { searchParams } = new URL(request.url); + const period = parseProfilePeriod(searchParams.get("period")); + const periodRange = getProfilePeriodDateRange(period); // Find user const matchingUsers = await db @@ -43,11 +90,26 @@ export async function GET(_request: Request, { params }: RouteParams) { } if (username !== user.username) { - return NextResponse.redirect(new URL(`/api/users/${user.username}`, _request.url), 308); + const canonicalUrl = new URL(`/api/users/${user.username}`, request.url); + if (period !== "all") { + canonicalUrl.searchParams.set("period", period); + } + return NextResponse.redirect(canonicalUrl, 308); } const oneYearAgo = new Date(); oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + const profileStartDate = periodRange?.start ?? oneYearAgo.toISOString().split("T")[0]; + const dailyBreakdownFilter = periodRange + ? and( + eq(submissions.userId, user.id), + gte(dailyBreakdown.date, periodRange.start), + lte(dailyBreakdown.date, periodRange.end) + ) + : and( + eq(submissions.userId, user.id), + gte(dailyBreakdown.date, profileStartDate) + ); const [statsResult, latestSubmissionResult, rankResult, dailyData] = await Promise.all([ db @@ -103,6 +165,7 @@ export async function GET(_request: Request, { params }: RouteParams) { .select({ date: dailyBreakdown.date, timestampMs: dailyBreakdown.timestampMs, + activeTimeMs: dailyBreakdown.activeTimeMs, tokens: dailyBreakdown.tokens, cost: dailyBreakdown.cost, inputTokens: dailyBreakdown.inputTokens, @@ -111,12 +174,7 @@ export async function GET(_request: Request, { params }: RouteParams) { }) .from(dailyBreakdown) .innerJoin(submissions, eq(dailyBreakdown.submissionId, submissions.id)) - .where( - and( - eq(submissions.userId, user.id), - gte(dailyBreakdown.date, oneYearAgo.toISOString().split("T")[0]) - ) - ) + .where(dailyBreakdownFilter) .orderBy(dailyBreakdown.date), ]); @@ -153,6 +211,7 @@ export async function GET(_request: Request, { params }: RouteParams) { { date: string; timestampMs: number | null; + activeTimeMs: number; tokens: number; cost: number; inputTokens: number; @@ -175,6 +234,7 @@ export async function GET(_request: Request, { params }: RouteParams) { existing.cost += Number(day.cost); existing.inputTokens += Number(day.inputTokens); existing.outputTokens += Number(day.outputTokens); + existing.activeTimeMs += Number(day.activeTimeMs) || 0; if (day.sourceBreakdown) { for (const [rawClient, data] of Object.entries(day.sourceBreakdown)) { const client = normalizeClientId(rawClient); @@ -332,6 +392,7 @@ export async function GET(_request: Request, { params }: RouteParams) { aggregatedDaily.set(day.date, { date: day.date, timestampMs: day.timestampMs ?? null, + activeTimeMs: Number(day.activeTimeMs) || 0, tokens: Number(day.tokens), cost: Number(day.cost), inputTokens: Number(day.inputTokens), @@ -345,6 +406,33 @@ export async function GET(_request: Request, { params }: RouteParams) { // Calculate max cost for intensity const contributions = Array.from(aggregatedDaily.values()); const maxCost = Math.max(...contributions.map((c) => c.cost), 0); + const periodTotals = contributions.reduce( + (totals, day) => { + totals.totalTokens += day.tokens; + totals.totalCost += day.cost; + totals.inputTokens += day.inputTokens; + totals.outputTokens += day.outputTokens; + totals.totalActiveTimeMs += day.activeTimeMs; + + for (const clientData of Object.values(day.clients)) { + totals.cacheReadTokens += clientData.cacheRead || 0; + totals.cacheWriteTokens += clientData.cacheWrite || 0; + totals.reasoningTokens += clientData.reasoning || 0; + } + + return totals; + }, + { + totalTokens: 0, + totalCost: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 0, + reasoningTokens: 0, + totalActiveTimeMs: 0, + } + ); // Build contribution graph data const graphContributions = contributions.map((day) => { @@ -425,6 +513,11 @@ export async function GET(_request: Request, { params }: RouteParams) { percentage: totalModelCost > 0 ? (data.cost / totalModelCost) * 100 : 0, })) .sort((a, b) => b.cost - a.cost || b.tokens - a.tokens); + const periodClients = Array.from( + new Set(contributions.flatMap((day) => Object.keys(day.clients))) + ); + const periodModels = Array.from(modelUsageMap.keys()).filter((model) => model !== ""); + const isPeriodFiltered = period !== "all"; return NextResponse.json({ user: { @@ -433,33 +526,35 @@ export async function GET(_request: Request, { params }: RouteParams) { displayName: user.displayName, avatarUrl: user.avatarUrl, createdAt: user.createdAt, - rank: rank ? Number(rank) : null, + rank: isPeriodFiltered ? null : rank ? Number(rank) : null, }, stats: { - totalTokens: Number(stats?.totalTokens) || 0, - totalCost: Number(stats?.totalCost) || 0, - inputTokens: Number(stats?.inputTokens) || 0, - outputTokens: Number(stats?.outputTokens) || 0, - cacheReadTokens: Number(stats?.cacheReadTokens) || 0, - cacheWriteTokens: Number(stats?.cacheCreationTokens) || 0, - reasoningTokens: Number(stats?.reasoningTokens) || 0, + totalTokens: isPeriodFiltered ? periodTotals.totalTokens : Number(stats?.totalTokens) || 0, + totalCost: isPeriodFiltered ? periodTotals.totalCost : Number(stats?.totalCost) || 0, + inputTokens: isPeriodFiltered ? periodTotals.inputTokens : Number(stats?.inputTokens) || 0, + outputTokens: isPeriodFiltered ? periodTotals.outputTokens : Number(stats?.outputTokens) || 0, + cacheReadTokens: isPeriodFiltered ? periodTotals.cacheReadTokens : Number(stats?.cacheReadTokens) || 0, + cacheWriteTokens: isPeriodFiltered ? periodTotals.cacheWriteTokens : Number(stats?.cacheCreationTokens) || 0, + reasoningTokens: isPeriodFiltered ? periodTotals.reasoningTokens : Number(stats?.reasoningTokens) || 0, submissionCount: Number(stats?.submissionCount) || 0, activeDays, - totalActiveTimeMs: Number(stats?.totalActiveTimeMs) || 0, - sessionCount: Number(stats?.sessionCount) || 0, + totalActiveTimeMs: isPeriodFiltered ? periodTotals.totalActiveTimeMs : Number(stats?.totalActiveTimeMs) || 0, + // Session count is only stored at submission level, so hide it for rolling ranges. + sessionCount: isPeriodFiltered ? 0 : Number(stats?.sessionCount) || 0, }, dateRange: { - start: stats?.earliestDate || null, - end: stats?.latestDate || null, + start: periodRange?.start ?? stats?.earliestDate ?? null, + end: periodRange?.end ?? stats?.latestDate ?? null, }, - updatedAt: latestSubmission?.updatedAt?.toISOString() || null, + period, + updatedAt: serializeUpdatedAt(latestSubmission?.updatedAt), submissionFreshness: buildSubmissionFreshness({ updatedAt: latestSubmission?.updatedAt, cliVersion: latestSubmission?.cliVersion, schemaVersion: latestSubmission?.schemaVersion, }), - clients: latestSubmission?.sourcesUsed || [], - models: latestSubmission?.modelsUsed || [], + clients: isPeriodFiltered ? periodClients : latestSubmission?.sourcesUsed || [], + models: isPeriodFiltered ? periodModels : latestSubmission?.modelsUsed || [], mcpServers: latestSubmission?.mcpServers || [], modelUsage, contributions: graphContributions, diff --git a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx index ce76707b8..3b839f0e5 100644 --- a/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx +++ b/packages/frontend/src/app/u/[username]/ProfilePageClient.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useMemo } from "react"; +import Link from "next/link"; import styled from "styled-components"; import { Navigation } from "@/components/layout/Navigation"; import { Footer } from "@/components/layout/Footer"; @@ -21,6 +22,8 @@ import { } from "@/components/profile"; import type { TokenContributionData, DailyContribution, ClientType } from "@/lib/types"; +type ProfilePeriod = "all" | "week" | "month"; + interface ProfileData { user: { id: string; @@ -52,6 +55,7 @@ interface ProfileData { mcpServers?: string[]; modelUsage?: ModelUsage[]; contributions: DailyContribution[]; + period?: ProfilePeriod; } interface ProfilePageClientProps { @@ -63,6 +67,7 @@ interface ProfilePageClientProps { export default function ProfilePageClient({ initialData, initialDevices, username }: ProfilePageClientProps) { const [activeTab, setActiveTab] = useState("activity"); const data = initialData; + const period = data.period ?? "all"; const graphData: TokenContributionData | null = useMemo(() => { if (!data || data.contributions.length === 0) return null; @@ -172,6 +177,8 @@ const EARLY_ADOPTERS = ["code-yeongyu", "gtg7784", "qodot"]; lastUpdated={data.updatedAt || undefined} /> + + {activeTab === "activity" && ( @@ -304,3 +311,81 @@ const ActivitySection = styled.div` flex-direction: column; gap: 24px; `; + +const PERIOD_OPTIONS: Array<{ value: ProfilePeriod; label: string }> = [ + { value: "all", label: "All" }, + { value: "week", label: "7d" }, + { value: "month", label: "30d" }, +]; + +function buildProfilePeriodHref(username: string, period: ProfilePeriod): string { + const basePath = `/u/${encodeURIComponent(username)}`; + if (period === "all") { + return basePath; + } + + return `${basePath}?period=${period}`; +} + +function ProfilePeriodSelector({ username, current }: { username: string; current: ProfilePeriod }) { + return ( + + {PERIOD_OPTIONS.map((option) => { + const isActive = current === option.value; + + return ( + + {option.label} + + ); + })} + + ); +} + +const PeriodSelectorContainer = styled.nav` + display: inline-flex; + align-items: center; + width: fit-content; + max-width: 100%; + padding: 4px; + border: 1px solid var(--color-border-default); + border-radius: 8px; + background: var(--color-bg-subtle); + overflow-x: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +`; + +const PeriodLink = styled(Link)<{ $active: boolean }>` + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 56px; + min-height: 32px; + padding: 0 14px; + border-radius: 6px; + color: ${({ $active }) => ($active ? "var(--color-fg-default)" : "var(--color-fg-muted)")}; + background: ${({ $active }) => ($active ? "var(--color-bg-default)" : "transparent")}; + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: background 0.12s, color 0.12s; + + &:hover { + color: var(--color-fg-default); + } + + &:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + } +`; diff --git a/packages/frontend/src/app/u/[username]/page.tsx b/packages/frontend/src/app/u/[username]/page.tsx index b2dd89c6d..9a0fce92b 100644 --- a/packages/frontend/src/app/u/[username]/page.tsx +++ b/packages/frontend/src/app/u/[username]/page.tsx @@ -5,6 +5,9 @@ import ProfilePageClient from './ProfilePageClient'; export const revalidate = 60; +const PROFILE_PERIODS = ["all", "week", "month"] as const; +type ProfilePeriod = (typeof PROFILE_PERIODS)[number]; + // In production: use explicit URL or Vercel auto-URL. // In dev: use 127.0.0.1 to avoid ECONNREFUSED from localhost dual-stack DNS. function getBaseUrl(): string { @@ -13,8 +16,21 @@ function getBaseUrl(): string { || 'http://127.0.0.1:3000'; } -async function getProfileData(username: string) { - const res = await fetch(`${getBaseUrl()}/api/users/${username}`, { +function parseProfilePeriod(value: string | string[] | undefined): ProfilePeriod { + const period = Array.isArray(value) ? value[0] : value; + return PROFILE_PERIODS.includes(period as ProfilePeriod) + ? (period as ProfilePeriod) + : "all"; +} + +async function getProfileData(username: string, period: ProfilePeriod) { + const params = new URLSearchParams(); + if (period !== "all") { + params.set("period", period); + } + + const query = params.toString(); + const res = await fetch(`${getBaseUrl()}/api/users/${username}${query ? `?${query}` : ""}`, { next: { revalidate: 60 }, }); @@ -78,10 +94,18 @@ export async function generateMetadata({ params }: { params: Promise<{ username: }; } -export default async function ProfilePage({ params }: { params: Promise<{ username: string }> }) { +export default async function ProfilePage({ + params, + searchParams, +}: { + params: Promise<{ username: string }>; + searchParams: Promise>; +}) { const { username } = await params; + const resolvedSearchParams = await searchParams; + const period = parseProfilePeriod(resolvedSearchParams.period); const [data, devices] = await Promise.all([ - getProfileData(username), + getProfileData(username, period), getProfileDevices(username), ]); @@ -90,7 +114,7 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna } if (data.user?.username && data.user.username !== username) { - permanentRedirect(`/u/${data.user.username}`); + permanentRedirect(`/u/${data.user.username}${period === "all" ? "" : `?period=${period}`}`); } return ;