Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 88 additions & 3 deletions app/agents/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -84,11 +85,12 @@
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)
Expand Down Expand Up @@ -142,6 +144,17 @@
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) {
Expand Down Expand Up @@ -219,6 +232,38 @@
</Card>
</div>


{/* XP Progress */}
<Card className="bg-slate-950/80 border-slate-800">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="font-mono uppercase tracking-wider text-sm text-slate-300">XP Growth</CardTitle>
<CardDescription className="mt-1 text-xs text-slate-500">7-day earned XP snapshot</CardDescription>
</div>
<Badge className="border-cyan-400/50 bg-cyan-400/10 px-3 py-1 font-mono text-cyan-200">Level {level}</Badge>
</div>
</CardHeader>
<CardContent>
<div className="mb-4 grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-3">
<div className="font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500">Total XP</div>
<div className="mt-1 font-pixel text-xl text-cyan-300">{totalXp.toLocaleString("en-US")}</div>
</div>
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-3 sm:col-span-2">
<div className="mb-2 flex items-center justify-between font-mono text-[10px] uppercase tracking-[0.2em] text-slate-500">
<span>Next level</span>
<span>{xpToNext > 0 ? `${Math.max(0, xpToNext - totalXp).toLocaleString("en-US")} XP needed` : "Max level"}</span>
</div>
<div className="h-3 overflow-hidden rounded-full bg-slate-800" aria-label={`Level ${level} progress`}>
<div className="h-full rounded-full bg-gradient-to-r from-cyan-400 to-emerald-300" style={{ width: `${xpProgress.progressPercent}%` }} />
</div>
</div>
</div>
<XpSparkline snapshots={xpSnapshots} />
</CardContent>
</Card>

{/* Capabilities */}
<Card className="bg-slate-950/80 border-slate-800">
<CardHeader className="pb-3">
Expand Down Expand Up @@ -291,3 +336,43 @@
)
}



function XpSparkline({ snapshots }: { snapshots: ReturnType<typeof buildSevenDayXpSnapshots> }) {

Check warning on line 341 in app/agents/[id]/page.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Mark the props of the component as read-only.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AZ8ZvFWPaauKZWBXq8EA&open=AZ8ZvFWPaauKZWBXq8EA&pullRequest=424
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 (
<div className="h-40 rounded-xl border border-slate-800 bg-slate-900/40 p-3">
<svg className="h-28 w-full overflow-visible" viewBox={`0 0 ${width} ${height}`} role="img" aria-label="Seven-day XP earned sparkline" preserveAspectRatio="none">
<path d={areaPath} fill="rgba(34, 211, 238, 0.12)" />
<path d={path} fill="none" stroke="#22d3ee" strokeLinecap="round" strokeLinejoin="round" strokeWidth="4" vectorEffect="non-scaling-stroke" />
{points.map((point) => (
<g key={point.date}>
<circle cx={point.x} cy={point.y} r="4" fill="#030712" stroke="#67e8f9" strokeWidth="2" vectorEffect="non-scaling-stroke">
<title>{`${point.label}: ${point.xp} XP`}</title>
</circle>
</g>
))}
</svg>
<div className="grid grid-cols-7 gap-1 font-mono text-[10px] text-slate-500">
{snapshots.map((point) => (
<div key={point.date} className="min-w-0 text-center" title={`${point.label}: ${point.xp} XP`}>
<div className="truncate">{point.label}</div>
<div className="text-cyan-300">{point.xp}</div>
</div>
))}
</div>
</div>
)
}
49 changes: 49 additions & 0 deletions lib/agents/xp-history-chart.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
65 changes: 65 additions & 0 deletions lib/agents/xp-history-chart.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>()

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 }
}
Loading