diff --git a/app/agents/[id]/page.tsx b/app/agents/[id]/page.tsx index adb81840..a521fc89 100644 --- a/app/agents/[id]/page.tsx +++ b/app/agents/[id]/page.tsx @@ -1,11 +1,12 @@ import type { Metadata } from "next" -import Image from "next/image" import Link from "next/link" import { notFound } from "next/navigation" import { Badge } from "@/components/ui/badge" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { buildSevenDayXpSnapshots, getLevelProgress, type XpHistoryEventLike } from "@/lib/agents/xp-history-chart" +import { getXpToNextLevel } from "@/lib/gamification/xp" import { AGENT_OG_SIZE, @@ -84,11 +85,12 @@ export default async function AgentPage({ params }: AgentPageProps) { const { id } = await params // Data loading as required by acceptance criteria - const [metaRes, healthRes, repRes, questRes] = await Promise.all([ + const [metaRes, healthRes, repRes, questRes, xpHistoryRes] = await Promise.all([ fetch(absoluteUrl(`/api/agents/${id}`), { cache: 'no-store' }), fetch(absoluteUrl(`/api/agents/${id}/health`), { cache: 'no-store' }), fetch(absoluteUrl(`/api/protocol/reputation?actorId=${id}`), { cache: 'no-store' }), - fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' }) + fetch(absoluteUrl(`/api/agents/${id}/quest-recommendations`), { cache: 'no-store' }), + fetch(absoluteUrl(`/api/agents/${id}/xp/history?pageSize=100`), { cache: 'no-store' }), ]) const localAgent = findAgentByLookup(id) @@ -142,6 +144,17 @@ export default async function AgentPage({ params }: AgentPageProps) { const initials = agentName.substring(0, 2).toUpperCase() const agentIdStr = agentMetadata.agentId || agentMetadata.id || id const tasksCompleted = agentMetadata.tasksCompleted ?? localAgent?.tasksCompleted ?? 0 + const totalXp = agentMetadata.xp ?? localAgent?.xp ?? 0 + const level = agentMetadata.level ?? localAgent?.level ?? 1 + const xpToNext = agentMetadata.xpToNext ?? getXpToNextLevel(level) + + let xpHistoryEvents: XpHistoryEventLike[] = [] + if (xpHistoryRes.ok) { + const data = await xpHistoryRes.json() + xpHistoryEvents = Array.isArray(data.events) ? data.events : [] + } + const xpSnapshots = buildSevenDayXpSnapshots(xpHistoryEvents) + const xpProgress = getLevelProgress(totalXp, level) let districtName = "Unknown" if (agentMetadata.district) { @@ -219,6 +232,38 @@ export default async function AgentPage({ params }: AgentPageProps) { + + {/* XP Progress */} + + +
+
+ XP Growth + 7-day earned XP snapshot +
+ Level {level} +
+
+ +
+
+
Total XP
+
{totalXp.toLocaleString("en-US")}
+
+
+
+ Next level + {xpToNext > 0 ? `${Math.max(0, xpToNext - totalXp).toLocaleString("en-US")} XP needed` : "Max level"} +
+
+
+
+
+
+ + + + {/* Capabilities */} @@ -291,3 +336,43 @@ export default async function AgentPage({ params }: AgentPageProps) { ) } + + +function XpSparkline({ snapshots }: { snapshots: ReturnType }) { + const width = 560 + const height = 120 + const padding = 12 + const maxXp = Math.max(1, ...snapshots.map((point) => point.xp)) + const step = snapshots.length > 1 ? (width - padding * 2) / (snapshots.length - 1) : 0 + const points = snapshots.map((point, index) => { + const x = padding + index * step + const y = height - padding - (point.xp / maxXp) * (height - padding * 2) + return { ...point, x, y } + }) + const path = points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x},${point.y}`).join(" ") + const areaPath = `${path} L${width - padding},${height - padding} L${padding},${height - padding} Z` + + return ( +
+ + + + {points.map((point) => ( + + + {`${point.label}: ${point.xp} XP`} + + + ))} + +
+ {snapshots.map((point) => ( +
+
{point.label}
+
{point.xp}
+
+ ))} +
+
+ ) +} diff --git a/lib/agents/xp-history-chart.test.ts b/lib/agents/xp-history-chart.test.ts new file mode 100644 index 00000000..523199ef --- /dev/null +++ b/lib/agents/xp-history-chart.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest" +import { buildSevenDayXpSnapshots, getLevelProgress } from "./xp-history-chart" + +describe("XP history chart helpers", () => { + it("pads agents with fewer than seven days of history with zeros", () => { + const snapshots = buildSevenDayXpSnapshots( + [ + { type: "earned", delta: 25, timestamp: "2026-06-28T12:00:00.000Z" }, + { type: "earned", delta: 10, timestamp: "2026-06-30T08:00:00.000Z" }, + ], + new Date("2026-06-30T18:00:00.000Z"), + ) + + expect(snapshots).toHaveLength(7) + expect(snapshots.map((point) => point.date)).toEqual([ + "2026-06-24", + "2026-06-25", + "2026-06-26", + "2026-06-27", + "2026-06-28", + "2026-06-29", + "2026-06-30", + ]) + expect(snapshots.map((point) => point.xp)).toEqual([0, 0, 0, 0, 25, 0, 10]) + }) + + it("aggregates exactly seven daily earned XP data points", () => { + const snapshots = buildSevenDayXpSnapshots( + [ + { type: "earned", delta: 1, timestamp: "2026-06-24T00:00:00.000Z" }, + { type: "earned", delta: 2, timestamp: "2026-06-25T00:00:00.000Z" }, + { type: "earned", delta: 3, timestamp: "2026-06-26T00:00:00.000Z" }, + { type: "earned", delta: 4, timestamp: "2026-06-27T00:00:00.000Z" }, + { type: "earned", delta: 5, timestamp: "2026-06-28T00:00:00.000Z" }, + { type: "earned", delta: 6, timestamp: "2026-06-29T00:00:00.000Z" }, + { type: "earned", delta: 7, timestamp: "2026-06-30T00:00:00.000Z" }, + { type: "decayed", delta: -100, timestamp: "2026-06-30T01:00:00.000Z" }, + ], + new Date("2026-06-30T18:00:00.000Z"), + ) + + expect(snapshots.map((point) => point.xp)).toEqual([1, 2, 3, 4, 5, 6, 7]) + }) + + it("calculates bounded level progress", () => { + expect(getLevelProgress(50, 1).progressPercent).toBe(50) + expect(getLevelProgress(999999, 1).progressPercent).toBe(100) + }) +}) diff --git a/lib/agents/xp-history-chart.ts b/lib/agents/xp-history-chart.ts new file mode 100644 index 00000000..775ee6c7 --- /dev/null +++ b/lib/agents/xp-history-chart.ts @@ -0,0 +1,65 @@ +import { getXpToNextLevel } from "@/lib/gamification/xp" + +export interface XpHistorySnapshot { + date: string + label: string + xp: number +} + +export interface XpHistoryEventLike { + type: "earned" | "decayed" + delta: number + timestamp: string +} + +const DAY_MS = 24 * 60 * 60 * 1000 + +function toUtcDateKey(date: Date): string { + return date.toISOString().slice(0, 10) +} + +function toShortLabel(dateKey: string): string { + const date = new Date(`${dateKey}T00:00:00.000Z`) + return new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" }).format(date) +} + +export function buildSevenDayXpSnapshots( + events: XpHistoryEventLike[], + now: Date = new Date(), +): XpHistorySnapshot[] { + const end = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()) + const start = end - DAY_MS * 6 + const totals = new Map() + + for (let offset = 0; offset < 7; offset += 1) { + totals.set(toUtcDateKey(new Date(start + DAY_MS * offset)), 0) + } + + for (const event of events) { + if (event.type !== "earned" || event.delta <= 0) continue + + const timestamp = new Date(event.timestamp).getTime() + if (!Number.isFinite(timestamp) || timestamp < start || timestamp >= end + DAY_MS) continue + + const key = toUtcDateKey(new Date(timestamp)) + totals.set(key, (totals.get(key) ?? 0) + event.delta) + } + + return Array.from(totals.entries()).map(([date, xp]) => ({ + date, + label: toShortLabel(date), + xp, + })) +} + +export function getLevelProgress(totalXp: number, level: number): { currentLevelXp: number; nextLevelXp: number; progressPercent: number } { + const safeLevel = Math.max(1, Math.floor(level)) + const currentLevelXp = safeLevel <= 1 ? 0 : getXpToNextLevel(safeLevel - 1) + const nextLevelXp = getXpToNextLevel(safeLevel) + if (nextLevelXp <= currentLevelXp) { + return { currentLevelXp, nextLevelXp, progressPercent: 100 } + } + + const progressPercent = Math.min(100, Math.max(0, ((totalXp - currentLevelXp) / (nextLevelXp - currentLevelXp)) * 100)) + return { currentLevelXp, nextLevelXp, progressPercent } +}