Skip to content

Commit

Permalink
feat(aih): add admin dashboard with content completion stats
Browse files Browse the repository at this point in the history
- Implement dashboard page with list and post completion tracking
- Add new completions query library for fetching engagement metrics
- Update admin routing to default to dashboard page
  • Loading branch information
vojtaholik authored and kodiakhq[bot] committed Jan 27, 2025
1 parent 8413b91 commit 00b4d9f
Show file tree
Hide file tree
Showing 4 changed files with 333 additions and 4 deletions.
187 changes: 185 additions & 2 deletions apps/ai-hero/src/app/admin/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,199 @@
import React from 'react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import {
getListCompletionStats,
getPostCompletionStats,
} from '@/lib/completions-query'
import { getServerAuthSession } from '@/server/auth'
import { format } from 'date-fns'
import { BookOpenIcon, GraduationCapIcon } from 'lucide-react'
import { z } from 'zod'

import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@coursebuilder/ui'

function CompletionCount({ count }: { count: number }) {
return (
<div className="flex min-w-[6ch] items-center justify-center text-sm font-bold tabular-nums">
{count}
</div>
)
}

function EmptyState({ type }: { type: 'posts' | 'lists' }) {
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
{type === 'posts' ? (
<BookOpenIcon className="text-muted-foreground/50 h-8 w-8" />
) : (
<GraduationCapIcon className="text-muted-foreground/50 h-8 w-8" />
)}
<p className="text-muted-foreground mt-2 text-sm">No {type} found</p>
</div>
)
}

export default async function AdminDashboardPage() {
const { ability } = await getServerAuthSession()
if (ability.cannot('manage', 'all')) {
notFound()
}

const [postStats, listStats] = await Promise.all([
getPostCompletionStats(),
getListCompletionStats(),
])

const sortedPostStatusByCreatedAt = [...postStats].sort(
(a, b) =>
new Date(b?.post?.createdAt ?? '').getTime() -
new Date(a?.post?.createdAt ?? '').getTime(),
)

const totalListCompletions = listStats.reduce(
(sum, stat) => sum + stat.fullCompletions,
0,
)

const totalPostCompletions = postStats.reduce(
(sum, stat) => sum + stat.completions,
0,
)

return (
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-5 pt-10 lg:gap-10">
<h1 className="fluid-3xl font-heading font-bold">Coming Soon</h1>
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-5 py-10 lg:gap-10">
<div className="flex flex-col gap-2">
<h1 className="fluid-3xl font-heading font-bold">Dashboard</h1>
<p className="text-muted-foreground">
Track content engagement and completion rates
</p>
</div>

<div className="flex flex-col gap-5">
{listStats.length > 0 && (
<Card>
<CardHeader>
<div className="flex items-center gap-5">
<GraduationCapIcon className="text-muted-foreground h-4 w-4" />
<div className="space-y-1">
<CardTitle className="fluid-lg font-bold">
List Completions
</CardTitle>
<CardDescription>
{totalListCompletions} total completions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="border-t px-0">
<div className="flex flex-col divide-y">
{listStats.map(
({
list,
fullCompletions,
partialCompletions,
totalResources,
}) => (
<div
key={list.id}
className="flex items-center justify-between gap-4 px-3 py-4"
>
<CompletionCount count={fullCompletions} />
<div className="flex-1 space-y-1">
<Link
href={`/${list.fields?.slug}`}
className="truncate overflow-ellipsis text-base font-semibold hover:underline"
>
{list.fields?.title}
</Link>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span>{partialCompletions} in progress</span>
<span></span>
<span>{totalResources} resources</span>
</div>
<div className="mt-2 h-1 rounded-full bg-gray-900">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{
width: `${Math.min(
(fullCompletions /
(fullCompletions + partialCompletions)) *
100,
100,
)}%`,
}}
/>
</div>
</div>
</div>
),
)}
</div>
</CardContent>
</Card>
)}

{/* Posts Section */}
<Card>
<CardHeader>
<div className="flex items-center gap-5">
<BookOpenIcon className="text-muted-foreground h-4 w-4" />
<div className="space-y-1">
<CardTitle className="fluid-lg font-bold">
Post Completions
</CardTitle>
<CardDescription>
{totalPostCompletions} total completions
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="border-t px-0">
{sortedPostStatusByCreatedAt.length > 0 ? (
<div className="flex flex-col divide-y">
{sortedPostStatusByCreatedAt.map(({ post, completions }) => (
<div
key={post.id}
className="flex items-center justify-between gap-4 px-3 py-4"
>
<CompletionCount count={completions} />
<div className="flex-1 space-y-1">
<Link
href={`/${post.fields.slug}`}
className="truncate overflow-ellipsis text-base font-semibold hover:underline"
>
{post.fields.title}
</Link>
<div className="flex items-center gap-2">
<p className="text-muted-foreground text-xs">
{((completions / totalPostCompletions) * 100).toFixed(
1,
)}
% of total
</p>
<p className="text-muted-foreground text-xs">
{format(
new Date(post?.createdAt ?? ''),
'MMM d, yyyy',
)}
</p>
</div>
</div>
</div>
))}
</div>
) : (
<EmptyState type="posts" />
)}
</CardContent>
</Card>
</div>
</main>
)
}
2 changes: 1 addition & 1 deletion apps/ai-hero/src/app/admin/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const AdminLayout = async ({
<nav className="">
<ul>
<li className="divide-border flex flex-col divide-y">
<NavItem href="/admin">
<NavItem href="/admin/dashboard">
<HomeIcon className="h-4 w-4" />
Dashboard
</NavItem>
Expand Down
2 changes: 1 addition & 1 deletion apps/ai-hero/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation'

export default async function AdminPage() {
redirect('/admin/pages')
redirect('/admin/dashboard')
}
146 changes: 146 additions & 0 deletions apps/ai-hero/src/lib/completions-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { db } from '@/db'
import { contentResource, resourceProgress } from '@/db/schema'
import { PostSchema } from '@/lib/posts'
import { and, asc, desc, eq, inArray, or, sql } from 'drizzle-orm'
import { z } from 'zod'

export const PostCompletionStatsSchema = z.object({
post: PostSchema,
completions: z.number(),
})

export const TutorialCompletionStatsSchema = z.object({
tutorial: PostSchema,
totalResources: z.number(),
fullCompletions: z.number(),
partialCompletions: z.number(),
})

export type PostCompletionStats = z.infer<typeof PostCompletionStatsSchema>
export type TutorialCompletionStats = z.infer<
typeof TutorialCompletionStatsSchema
>

export const ListCompletionStatsSchema = z.object({
list: PostSchema,
totalResources: z.number(),
fullCompletions: z.number(),
partialCompletions: z.number(),
})

export type ListCompletionStats = z.infer<typeof ListCompletionStatsSchema>

export async function getPostCompletionStats() {
const posts = await db.query.contentResource.findMany({
where: and(
eq(contentResource.type, 'post'),
inArray(sql`JSON_EXTRACT (${contentResource.fields}, "$.state")`, [
'published',
]),
),
with: {
resources: {
with: {
resource: true,
},
},
tags: {
with: {
tag: true,
},
},
},
})

const parsedPosts = z.array(PostSchema).safeParse(posts)
if (!parsedPosts.success) {
console.error('Error parsing posts', parsedPosts.error)
return []
}

const completionCounts = await Promise.all(
parsedPosts.data.map(async (post) => {
const completions = await db.query.resourceProgress.findMany({
where: eq(resourceProgress.resourceId, post.id),
columns: {
userId: true,
},
})

const stats = {
post,
completions: completions.length,
}

const parsedStats = PostCompletionStatsSchema.safeParse(stats)
if (!parsedStats.success) {
console.error('Error parsing post stats', parsedStats.error)
return null
}

return parsedStats.data
}),
)

return completionCounts.filter(
(stats): stats is PostCompletionStats => stats !== null,
)
}

export async function getListCompletionStats() {
const lists = await db.query.contentResource.findMany({
where: and(
eq(contentResource.type, 'list'),
sql`JSON_EXTRACT(${contentResource.fields}, '$.state') = 'published'`,
// sql`JSON_EXTRACT(${contentResource.fields}, '$.type') IN ('tutorial', 'nextUp', 'workshop')`,
),
orderBy: [
sql`JSON_EXTRACT(${contentResource.fields}, '$.type')`,
desc(contentResource.createdAt),
],
with: {
resources: {
with: {
resource: true,
},
},
},
})

const listStats = await Promise.all(
lists.map(async (list) => {
const resourceIds = list.resources?.map((r) => r.resourceId) || []
const userCompletions = await db.query.resourceProgress.findMany({
where: inArray(resourceProgress.resourceId, resourceIds),
columns: {
userId: true,
resourceId: true,
},
})

const userCompletionMap = userCompletions.reduce<
Record<string, string[]>
>((acc, curr) => {
if (curr.userId && curr.resourceId) {
acc[curr.userId] = [...(acc[curr.userId] || []), curr.resourceId]
}
return acc
}, {})

const fullCompletions = Object.values(userCompletionMap).filter(
(completedResources) =>
resourceIds.every((id) => completedResources.includes(id)),
).length

return {
list,
totalResources: resourceIds.length,
fullCompletions,
partialCompletions:
Object.keys(userCompletionMap).length - fullCompletions,
}
}),
)

return listStats
}

0 comments on commit 00b4d9f

Please sign in to comment.