Skip to content

Commit 7f933a7

Browse files
committed
feat(admin): add document table enhancements with sparklines
Documents Page: - Add 'Visitors (7d)' column showing unique users per document - Add 'Trend' column with 7-day view sparkline chart - Update CSV export to include visitor counts Backend: - Enhance listDocuments to include views7d and uniqueUsers7d - Add GET /api/admin/stats/views/batch-trends endpoint - Batch fetch from document_view_stats and document_views_daily Components: - Create Sparkline component (minimal 60x20px SVG chart) - Memoized path calculation, handles edge cases Types: - Add PushSubscriptionAnalytics, PlatformTrendPoint interfaces - Add UserNotificationSubscriptions for user badges - Extend Document with uniqueUsers7d and viewsTrend
1 parent 8634e5e commit 7f933a7

9 files changed

Lines changed: 523 additions & 17 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useMemo } from 'react'
2+
3+
interface SparklineProps {
4+
data: number[]
5+
width?: number
6+
height?: number
7+
className?: string
8+
strokeColor?: string
9+
fillColor?: string
10+
}
11+
12+
/**
13+
* Minimal sparkline chart for inline display
14+
* Shows trend without axes or labels
15+
*/
16+
export function Sparkline({
17+
data,
18+
width = 60,
19+
height = 20,
20+
className = '',
21+
strokeColor = 'currentColor',
22+
fillColor
23+
}: SparklineProps) {
24+
// Calculate points once, derive both paths
25+
const { linePath, areaPath } = useMemo(() => {
26+
if (!data.length) return { linePath: '', areaPath: '' }
27+
28+
const max = Math.max(...data, 1)
29+
const min = Math.min(...data, 0)
30+
const range = max - min || 1
31+
32+
const points = data.map((value, i) => {
33+
const x = (i / Math.max(data.length - 1, 1)) * width
34+
const y = height - ((value - min) / range) * height
35+
return `${x},${y}`
36+
})
37+
38+
const pointsStr = points.join(' L ')
39+
return {
40+
linePath: `M ${pointsStr}`,
41+
areaPath: fillColor ? `M 0,${height} L ${pointsStr} L ${width},${height} Z` : ''
42+
}
43+
}, [data, width, height, fillColor])
44+
45+
if (!data.length) {
46+
return (
47+
<div
48+
className={`text-base-content/30 flex items-center justify-center text-xs ${className}`}
49+
style={{ width, height }}>
50+
51+
</div>
52+
)
53+
}
54+
55+
return (
56+
<svg width={width} height={height} className={className}>
57+
{areaPath && <path d={areaPath} fill={fillColor} fillOpacity={0.2} />}
58+
<path d={linePath} fill="none" stroke={strokeColor} strokeWidth={1.5} strokeLinecap="round" />
59+
</svg>
60+
)
61+
}

packages/admin-dashboard/src/components/charts/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export { DauTrendChart } from './DauTrendChart'
2+
export { PushSubscriptionStats } from './PushSubscriptionStats'
23
export { RetentionCards } from './RetentionCards'
4+
export { Sparkline } from './Sparkline'
35
export { TopViewedDocuments } from './TopViewedDocuments'
46
export { UserLifecycleChart } from './UserLifecycleChart'
57
export { DeviceBreakdown, UserTypeBreakdown, ViewsSummaryCards } from './ViewsSummaryCards'

packages/admin-dashboard/src/pages/documents.tsx

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
22
import type { GetServerSideProps } from 'next'
33
import Head from 'next/head'
4-
import { useCallback, useState } from 'react'
4+
import { useCallback, useMemo, useState } from 'react'
55

66
// Disable static generation - pages require auth which needs client-side router
77
export const getServerSideProps: GetServerSideProps = async () => {
88
return { props: {} }
99
}
1010
import toast from 'react-hot-toast'
11-
import { LuChartBar, LuEye, LuFileText, LuLock, LuUsers } from 'react-icons/lu'
11+
import { LuChartBar, LuEye, LuFileText, LuLock, LuUser, LuUsers } from 'react-icons/lu'
1212

1313
import { StatCard } from '@/components/cards/StatCard'
14+
import { Sparkline } from '@/components/charts'
1415
import { ActionsDropdown, DeleteModal } from '@/components/documents'
1516
import { AdminLayout } from '@/components/layout/AdminLayout'
1617
import { Header } from '@/components/layout/Header'
@@ -20,6 +21,7 @@ import { SearchInput } from '@/components/ui/SearchInput'
2021
import { useTableParams } from '@/hooks/useTableParams'
2122
import {
2223
deleteDocument,
24+
fetchBatchDocumentTrends,
2325
fetchDocuments,
2426
fetchDocumentStats,
2527
updateDocumentFlags
@@ -54,6 +56,16 @@ export default function DocumentsPage() {
5456
queryFn: fetchDocumentStats
5557
})
5658

59+
// Get document slugs for batch trend fetch
60+
const docSlugs = useMemo(() => data?.data?.map((d) => d.docId) || [], [data?.data])
61+
62+
// Fetch sparkline trends for current page
63+
const { data: trends } = useQuery({
64+
queryKey: ['admin', 'documents', 'trends', docSlugs],
65+
queryFn: () => fetchBatchDocumentTrends(docSlugs, 7),
66+
enabled: docSlugs.length > 0
67+
})
68+
5769
// Mutation for updating document flags
5870
const updateMutation = useMutation({
5971
mutationFn: ({
@@ -131,6 +143,7 @@ export default function DocumentsPage() {
131143
{ key: 'owner', header: 'Owner' },
132144
{ key: 'memberCount', header: 'Members' },
133145
{ key: 'views7d', header: 'Views (7d)' },
146+
{ key: 'uniqueUsers7d', header: 'Visitors (7d)' },
134147
{ key: 'isPrivate', header: 'Private' },
135148
{ key: 'readOnly', header: 'Read Only' },
136149
{ key: 'versionCount', header: 'Versions' },
@@ -221,6 +234,32 @@ export default function DocumentsPage() {
221234
</div>
222235
)
223236
},
237+
{
238+
key: 'uniqueUsers7d',
239+
header: 'Visitors (7d)',
240+
sortable: true,
241+
render: (doc: Document) => (
242+
<div className="flex items-center gap-1.5">
243+
<LuUser className="text-base-content/40 h-4 w-4" />
244+
<span className="text-sm font-medium">
245+
{doc.uniqueUsers7d !== undefined ? doc.uniqueUsers7d.toLocaleString() : '—'}
246+
</span>
247+
</div>
248+
)
249+
},
250+
{
251+
key: 'trend',
252+
header: 'Trend',
253+
render: (doc: Document) => (
254+
<Sparkline
255+
data={trends?.[doc.docId] || []}
256+
width={60}
257+
height={20}
258+
strokeColor="oklch(var(--p))"
259+
fillColor="oklch(var(--p))"
260+
/>
261+
)
262+
},
224263
{
225264
key: 'versionCount',
226265
header: 'Versions',

packages/admin-dashboard/src/services/api.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,21 @@ export async function fetchDocumentViewStats(slug: string): Promise<DocumentView
217217
return fetchApi(`/api/admin/documents/${slug}/views`)
218218
}
219219

220+
/**
221+
* Fetch batch document trends for sparklines
222+
*/
223+
export async function fetchBatchDocumentTrends(
224+
slugs: string[],
225+
days = 7
226+
): Promise<Record<string, number[]>> {
227+
if (slugs.length === 0) return {}
228+
const params = new URLSearchParams({
229+
slugs: slugs.join(','),
230+
days: String(days)
231+
})
232+
return fetchApi(`/api/admin/stats/views/batch-trends?${params.toString()}`)
233+
}
234+
220235
// =============================================================================
221236
// User Retention Analytics API (Phase 8)
222237
// =============================================================================

0 commit comments

Comments
 (0)