-
Notifications
You must be signed in to change notification settings - Fork 28
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(aih): add admin dashboard with content completion stats
- 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
1 parent
8413b91
commit 00b4d9f
Showing
4 changed files
with
333 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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') | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |