Skip to content

Commit 3e0ed6c

Browse files
committed
feat: add dashboard activity chart and user stats endpoint
Add GET /api/v1/auth/me/stats endpoint returning daily session counts (last 30 days) and status totals across all accessible installations. Add SVG-based ActivityChart component (no external deps). Wire stats into dashboard stat strip and display chart above installation cards.
1 parent 193bb19 commit 3e0ed6c

6 files changed

Lines changed: 208 additions & 7 deletions

File tree

apps/api/src/helprs/modules/identity/router.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,13 @@
88

99
from helprs.core.dependencies import DbSession, GetSettings, get_current_user
1010
from helprs.core.middleware import limiter
11-
from helprs.modules.identity.schemas import TokenResponse, UserResponse
11+
from helprs.modules.identity.schemas import TokenResponse, UserResponse, UserStatsResponse
1212
from helprs.modules.identity.service import (
1313
create_token_pair,
1414
exchange_code_for_token,
1515
fetch_github_user,
1616
get_or_create_user,
17+
get_user_stats,
1718
refresh_tokens,
1819
)
1920

@@ -148,6 +149,19 @@ async def refresh(
148149
return response
149150

150151

152+
@router.get("/me/stats", response_model=UserStatsResponse)
153+
@limiter.limit("30/minute")
154+
async def get_my_stats(
155+
request: Request,
156+
session: DbSession,
157+
settings: GetSettings,
158+
user=Depends(get_current_user), # noqa: B008
159+
):
160+
"""Return session statistics for the authenticated user."""
161+
stats = await get_user_stats(session, user, settings)
162+
return UserStatsResponse(**stats)
163+
164+
151165
@router.get("/me", response_model=UserResponse)
152166
@limiter.limit("30/minute")
153167
async def get_me(

apps/api/src/helprs/modules/identity/schemas.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,20 @@ class TokenResponse(BaseModel):
2424

2525
class RefreshRequest(BaseModel):
2626
pass
27+
28+
29+
class DailyCount(BaseModel):
30+
date: datetime
31+
count: int
32+
33+
34+
class StatusTotals(BaseModel):
35+
completed: int
36+
failed: int
37+
timeout: int
38+
total: int
39+
40+
41+
class UserStatsResponse(BaseModel):
42+
daily_counts: list[DailyCount]
43+
totals: StatusTotals

apps/api/src/helprs/modules/identity/service.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,62 @@ async def refresh_tokens(
152152
raise UnauthorizedError("User not found")
153153

154154
return create_token_pair(user, settings)
155+
156+
157+
async def get_user_stats(
158+
session: AsyncSession,
159+
user: GitHubUser,
160+
settings: "Settings",
161+
) -> dict:
162+
"""Return session stats for the authenticated user across all accessible installations."""
163+
from datetime import UTC, datetime, timedelta
164+
165+
from sqlalchemy import case, cast, func
166+
from sqlalchemy.types import Date
167+
168+
from helprs.modules.container.models import ContainerSession, ContainerStatus
169+
from helprs.modules.installation.service import get_installations_for_user
170+
171+
installations = await get_installations_for_user(session, user, settings)
172+
installation_ids = [i.id for i in installations]
173+
174+
if not installation_ids:
175+
return {
176+
"daily_counts": [],
177+
"totals": {"completed": 0, "failed": 0, "timeout": 0, "total": 0},
178+
}
179+
180+
# Status totals
181+
totals_result = await session.execute(
182+
select(
183+
func.count(ContainerSession.id).label("total"),
184+
func.count(case((ContainerSession.status == ContainerStatus.COMPLETED, 1))).label("completed"),
185+
func.count(case((ContainerSession.status == ContainerStatus.FAILED, 1))).label("failed"),
186+
func.count(case((ContainerSession.status == ContainerStatus.TIMEOUT, 1))).label("timeout"),
187+
).where(ContainerSession.installation_id.in_(installation_ids))
188+
)
189+
row = totals_result.one()
190+
totals = {
191+
"completed": row.completed,
192+
"failed": row.failed,
193+
"timeout": row.timeout,
194+
"total": row.total,
195+
}
196+
197+
# Daily counts (last 30 days)
198+
cutoff = datetime.now(UTC) - timedelta(days=30)
199+
daily_result = await session.execute(
200+
select(
201+
cast(ContainerSession.created_at, Date).label("day"),
202+
func.count(ContainerSession.id).label("count"),
203+
)
204+
.where(
205+
ContainerSession.installation_id.in_(installation_ids),
206+
ContainerSession.created_at >= cutoff,
207+
)
208+
.group_by("day")
209+
.order_by("day")
210+
)
211+
daily_counts = [{"date": row.day, "count": row.count} for row in daily_result.all()]
212+
213+
return {"daily_counts": daily_counts, "totals": totals}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* ActivityChart — simple SVG bar chart for session activity over 30 days.
3+
* No external charting library — just proportional <rect> elements.
4+
*/
5+
6+
interface DailyCount {
7+
date: string
8+
count: number
9+
}
10+
11+
interface ActivityChartProps {
12+
data: DailyCount[]
13+
}
14+
15+
export default function ActivityChart({ data }: ActivityChartProps) {
16+
if (data.length === 0) {
17+
return (
18+
<div className="text-dim text-xs font-mono py-4 text-center">
19+
// no sessions in the last 30 days
20+
</div>
21+
)
22+
}
23+
24+
const maxCount = Math.max(...data.map((d) => d.count), 1)
25+
const barCount = data.length
26+
const chartWidth = 100
27+
const chartHeight = 80
28+
const barGap = 1
29+
const barWidth = Math.max(1, (chartWidth - barGap * (barCount - 1)) / barCount)
30+
31+
return (
32+
<svg
33+
viewBox={`0 0 ${chartWidth} ${chartHeight + 16}`}
34+
className="w-full"
35+
style={{ height: '100px' }}
36+
preserveAspectRatio="none"
37+
>
38+
{data.map((d, i) => {
39+
const barHeight = Math.max(1, (d.count / maxCount) * chartHeight)
40+
const x = i * (barWidth + barGap)
41+
const y = chartHeight - barHeight
42+
43+
return (
44+
<g key={d.date}>
45+
<rect
46+
x={x}
47+
y={y}
48+
width={barWidth}
49+
height={barHeight}
50+
rx={0.5}
51+
fill="var(--color-accent)"
52+
opacity={0.65}
53+
>
54+
<title>{`${d.date}: ${d.count} session${d.count !== 1 ? 's' : ''}`}</title>
55+
</rect>
56+
{/* Show label every 7 bars */}
57+
{i % 7 === 0 && (
58+
<text
59+
x={x + barWidth / 2}
60+
y={chartHeight + 10}
61+
textAnchor="middle"
62+
fill="var(--color-dim)"
63+
fontSize="3"
64+
fontFamily="var(--font-family-mono)"
65+
>
66+
{new Date(d.date).toLocaleDateString('en', { month: 'short', day: 'numeric' })}
67+
</text>
68+
)}
69+
</g>
70+
)
71+
})}
72+
</svg>
73+
)
74+
}

apps/web/src/features/dashboard/InstallationList.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,30 @@
66
import { useState, useEffect, useCallback } from 'react'
77
import { useNavigate } from 'react-router'
88
import { useAuthStore } from '../auth/store'
9-
import { fetchInstallations } from './dashboardApi'
10-
import type { InstallationSummary } from './dashboardApi'
9+
import { fetchInstallations, fetchUserStats } from './dashboardApi'
10+
import type { InstallationSummary, UserStats } from './dashboardApi'
1111
import { Card, Chip, Dot, Overline, StatCard } from '../../shared/components'
12+
import ActivityChart from './ActivityChart'
1213

1314
const INSTALL_URL = `https://github.com/apps/${import.meta.env.VITE_GITHUB_APP_SLUG ?? 'helprs'}/installations/new`
1415

1516
export default function InstallationList() {
1617
const navigate = useNavigate()
1718
const user = useAuthStore((s) => s.user)
1819
const [installations, setInstallations] = useState<InstallationSummary[]>([])
20+
const [stats, setStats] = useState<UserStats | null>(null)
1921
const [loading, setLoading] = useState(true)
2022
const [error, setError] = useState<string | null>(null)
2123

2224
const load = useCallback(async () => {
2325
setLoading(true)
2426
try {
25-
const data = await fetchInstallations()
26-
setInstallations(data.items)
27+
const [instData, statsData] = await Promise.all([
28+
fetchInstallations(),
29+
fetchUserStats().catch(() => null),
30+
])
31+
setInstallations(instData.items)
32+
setStats(statsData)
2733
} catch {
2834
setError('Failed to load installations')
2935
} finally {
@@ -57,10 +63,18 @@ export default function InstallationList() {
5763
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-8">
5864
<StatCard label="Installations" value={installations.length} />
5965
<StatCard label="Configured" value={configured} color={configured > 0 ? 'ok' : 'warn'} sub={`of ${installations.length}`} />
60-
<StatCard label="Total sessions" value={totalSessions} color="accent" />
61-
<StatCard label="Status" value={error ? 'Error' : 'Live'} color={error ? 'danger' : 'ok'} />
66+
<StatCard label="Total sessions" value={stats?.totals.total ?? totalSessions} color="accent" />
67+
<StatCard label="Completed" value={stats?.totals.completed ?? 0} color="ok" />
6268
</div>
6369

70+
{/* Activity chart */}
71+
{stats && stats.daily_counts.length > 0 && (
72+
<Card className="mb-8 px-4 py-3">
73+
<Overline className="mb-3">{'\u25b8'} ACTIVITY {'\u00b7'} last 30 days</Overline>
74+
<ActivityChart data={stats.daily_counts} />
75+
</Card>
76+
)}
77+
6478
{/* Error */}
6579
{error && (
6680
<Card className="mb-6 border-danger/30">

apps/web/src/features/dashboard/dashboardApi.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,29 @@ export async function fetchInstallations(): Promise<InstallationListResponse> {
4949
return resp.json() as Promise<InstallationListResponse>
5050
}
5151

52+
export interface DailyCount {
53+
date: string
54+
count: number
55+
}
56+
57+
export interface StatusTotals {
58+
completed: number
59+
failed: number
60+
timeout: number
61+
total: number
62+
}
63+
64+
export interface UserStats {
65+
daily_counts: DailyCount[]
66+
totals: StatusTotals
67+
}
68+
69+
export async function fetchUserStats(): Promise<UserStats> {
70+
const resp = await apiFetch('/api/v1/auth/me/stats')
71+
if (!resp.ok) throw new Error(`Failed to fetch stats: ${resp.status}`)
72+
return resp.json() as Promise<UserStats>
73+
}
74+
5275
export async function fetchInstallationSessions(
5376
installationId: number,
5477
page: number = 1,

0 commit comments

Comments
 (0)