Skip to content
Merged
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
103 changes: 101 additions & 2 deletions app/(dashboard)/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { useEffect, useState } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { Area, AreaChart, Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from "recharts"
import { Area, AreaChart, Bar, BarChart, Line, LineChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Legend } from "recharts"
import {
TrendingUp, Clock, Target, Flame, Brain, Trophy, BookOpen, Calendar
TrendingUp, Clock, Target, Flame, Brain, Trophy, BookOpen, Calendar, MessageSquareText, Layers, ListChecks
} from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
import { cn } from "@/lib/utils"
Expand All @@ -22,11 +22,36 @@ interface StatsData {
weeklyActivity: Array<{ day: string; count: number }>
}

// Historical, per-feature-type usage trend + aggregates. Sourced from the same
// usage_logs table that lib/credits.ts writes to on every credit deduction
// (see app/api/usage/history/route.ts) rather than a separate pipeline. This
// is fetched independently of /api/user/stats so it never adds load to the
// main /dashboard page.
interface UsageHistoryPoint {
date: string
label: string
ai_chat: number
flashcards: number
study_plan: number
}

interface UsageHistoryData {
trend: UsageHistoryPoint[]
aggregates: {
totalSessions: number
decksCreated: number
plansCreated: number
totalCreditActions: number
}
}

export default function AnalyticsPage() {
const { credits, subscription } = useUser()
const [stats, setStats] = useState<StatsData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [period, setPeriod] = useState("week")
const [usageHistory, setUsageHistory] = useState<UsageHistoryData | null>(null)
const [isHistoryLoading, setIsHistoryLoading] = useState(true)

useEffect(() => {
fetch("/api/user/stats")
Expand All @@ -36,6 +61,15 @@ export default function AnalyticsPage() {
.finally(() => setIsLoading(false))
}, [])

useEffect(() => {
setIsHistoryLoading(true)
fetch(`/api/usage/history?period=${period}`)
.then(r => r.json())
.then(setUsageHistory)
.catch(() => setUsageHistory(null))
.finally(() => setIsHistoryLoading(false))
}, [period])

const isTrial = subscription?.trial_active === true

const statsCards = stats ? [
Expand Down Expand Up @@ -171,6 +205,71 @@ export default function AnalyticsPage() {
</Card>
</div>

{/* Usage by Feature Type - aggregate stats sourced from usage_logs (same
source lib/credits.ts writes to on every credit deduction) */}
{isHistoryLoading ? (
<div className="grid gap-4 sm:grid-cols-3">
{[1, 2, 3].map(i => <Skeleton key={i} className="h-24 rounded-xl" />)}
</div>
) : (
<div className="grid gap-4 sm:grid-cols-3">
{[
{ label: "Total AI Sessions", value: usageHistory?.aggregates.totalSessions ?? 0, icon: MessageSquareText, color: "text-violet-500", bgColor: "bg-violet-500/10" },
{ label: "Decks Created", value: usageHistory?.aggregates.decksCreated ?? 0, icon: Layers, color: "text-emerald-500", bgColor: "bg-emerald-500/10" },
{ label: "Plans Created", value: usageHistory?.aggregates.plansCreated ?? 0, icon: ListChecks, color: "text-primary", bgColor: "bg-primary/10" },
].map((stat) => (
<Card key={stat.label} className="border-border bg-card">
<CardContent className="flex items-center gap-4 p-4">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl", stat.bgColor)}>
<stat.icon className={cn("h-6 w-6", stat.color)} />
</div>
<div>
<p className="text-2xl font-bold text-foreground">{stat.value}</p>
<p className="text-sm text-muted-foreground">{stat.label}</p>
</div>
</CardContent>
</Card>
))}
</div>
)}

{/* Credit Usage Trend, segmented by feature type */}
<Card className="border-border bg-card">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4 text-primary" />
Usage Trend by Feature
</CardTitle>
</CardHeader>
<CardContent>
{isHistoryLoading ? (
<Skeleton className="h-[260px]" />
) : usageHistory?.trend && usageHistory.trend.some(d => d.ai_chat + d.flashcards + d.study_plan > 0) ? (
<div className="h-[260px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={usageHistory.trend} margin={{ top: 5, right: 5, bottom: 0, left: -20 }}>
<XAxis dataKey="label" tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} minTickGap={20} />
<YAxis allowDecimals={false} tick={{ fontSize: 11, fill: "hsl(var(--muted-foreground))" }} axisLine={false} tickLine={false} />
<Tooltip contentStyle={{ background: "hsl(var(--card))", border: "1px solid hsl(var(--border))", borderRadius: "8px", fontSize: 12 }} />
<Legend wrapperStyle={{ fontSize: 12 }} />
<Line type="monotone" dataKey="ai_chat" name="AI Chats" stroke="hsl(var(--primary))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="flashcards" name="Flashcards" stroke="hsl(var(--chart-2,142 71% 45%))" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="study_plan" name="Study Plans" stroke="hsl(var(--chart-3,199 89% 48%))" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="h-[260px] flex items-center justify-center">
<div className="text-center">
<TrendingUp className="h-10 w-10 text-muted-foreground mx-auto mb-2 opacity-40" />
<p className="text-sm text-muted-foreground">No usage recorded for this period yet</p>
<p className="text-xs text-muted-foreground">Chats, flashcard sets, and study plans will show up here over time</p>
</div>
</div>
)}
</CardContent>
</Card>

{/* Summary */}
<Card className="border-border bg-card">
<CardHeader>
Expand Down
6 changes: 4 additions & 2 deletions app/api/documents/upload/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"
import { createServerClient } from "@supabase/ssr"
import pdfParse from "pdf-parse/lib/pdf-parse"
import { PDFParse } from "pdf-parse"
import { generateEmbedding } from "@/lib/ai"

export const maxDuration = 60
Expand Down Expand Up @@ -50,8 +50,10 @@ export async function POST(req: NextRequest) {
// 2. Parse PDF Text
const arrayBuffer = await file.arrayBuffer()
const buffer = Buffer.from(arrayBuffer)
const pdfData = await pdfParse(buffer)
const parser = new PDFParse({ data: buffer })
const pdfData = await parser.getText()
const text = pdfData.text
await parser.destroy()

// 3. Create document record in DB
const { data: doc, error: docError } = await supabase
Expand Down
124 changes: 124 additions & 0 deletions app/api/usage/history/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
export const dynamic = "force-dynamic"

import { NextRequest, NextResponse } from "next/server"
import { getUser } from "@/lib/auth-server"
import { getSupabaseAdmin } from "@/lib/supabase-server"
import type { FeatureKey } from "@/types"

// Scoped to the /analytics page only. Deliberately NOT called from the main
// /dashboard data-fetching path (that page and its cards use /api/user/stats)
// so this historical/trend query never adds latency to the main dashboard.

type UsageLogRow = {
feature: string
created_at: string
}

// These are the three features that are actually credit-metered (see
// FeatureKey in types/index.ts and lib/credits.ts::consumeCredit). Every AI
// route that deducts a credit also calls logUsage() for the same feature, so
// usage_logs is the single source of truth for both credit deductions and
// this analytics view — no parallel tracking table.
const CREDIT_FEATURES: FeatureKey[] = ["ai_chat", "flashcards", "study_plan"]

const PERIOD_TO_DAYS: Record<string, number> = {
week: 7,
month: 30,
all: 180, // bounded lookback so the query stays cheap even for old accounts
}

function toDayKey(value: string) {
return new Date(value).toISOString().split("T")[0]
}

function formatLabel(date: Date, days: number) {
return days > 45
? date.toLocaleString("en-US", { month: "short", day: "numeric" })
: date.toLocaleString("en-US", { weekday: "short", day: "numeric" })
}

export async function GET(req: NextRequest) {
try {
const user = await getUser()
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}

const period = req.nextUrl.searchParams.get("period") ?? "month"
const days = PERIOD_TO_DAYS[period] ?? PERIOD_TO_DAYS.month

const since = new Date()
since.setHours(0, 0, 0, 0)
since.setDate(since.getDate() - (days - 1))

const admin = await getSupabaseAdmin()

// Single, narrow, indexed query (idx_usage_logs_user_id + created_at desc)
// limited to the credit-metered features and the selected lookback window.
const { data, error } = await admin
.from("usage_logs")
.select("feature, created_at")
.eq("user_id", user.id)
.in("feature", CREDIT_FEATURES)
.gte("created_at", since.toISOString())
.order("created_at", { ascending: true })

if (error) throw error

const logs = (data ?? []) as UsageLogRow[]

const byDay = new Map<string, Record<FeatureKey, number>>()
for (let i = 0; i < days; i++) {
const d = new Date(since)
d.setDate(since.getDate() + i)
byDay.set(toDayKey(d.toISOString()), { ai_chat: 0, flashcards: 0, study_plan: 0 })
}

const totals: Record<FeatureKey, number> = { ai_chat: 0, flashcards: 0, study_plan: 0 }

for (const log of logs) {
const feature = log.feature as FeatureKey
if (!CREDIT_FEATURES.includes(feature)) continue

totals[feature] += 1

const key = toDayKey(log.created_at)
const bucket = byDay.get(key)
if (bucket) bucket[feature] += 1
}

const trend = Array.from(byDay.entries()).map(([dateKey, counts]) => ({
date: dateKey,
label: formatLabel(new Date(dateKey), days),
ai_chat: counts.ai_chat,
flashcards: counts.flashcards,
study_plan: counts.study_plan,
}))

return NextResponse.json({
period,
days,
trend,
totals,
aggregates: {
totalSessions: totals.ai_chat,
decksCreated: totals.flashcards,
plansCreated: totals.study_plan,
totalCreditActions: totals.ai_chat + totals.flashcards + totals.study_plan,
},
})
} catch (err) {
console.error("[usage/history] Error:", err)
return NextResponse.json(
{
period: "month",
days: 30,
trend: [],
totals: { ai_chat: 0, flashcards: 0, study_plan: 0 },
aggregates: { totalSessions: 0, decksCreated: 0, plansCreated: 0, totalCreditActions: 0 },
error: "Failed to load usage history",
},
{ status: 500 }
)
}
}
Loading
Loading