Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
62 changes: 13 additions & 49 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,53 +1,17 @@
# ================================
# APPLICATION SETTINGS
# ================================
NEXT_PUBLIC_APP_URL='http://localhost:3000'
NODE_ENV=development
APP_NAME="Flash Fathom AI"
APP_DESCRIPTION="Your AI-powered flashcard generator"
APP_VERSION="1.0.0"
APP_HOST=localhost
APP_PORT=3000

# ================================
# POSTGRESQL CONFIGURATION
# ================================
POSTGRES_DB=flashfathom
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_secure_password_here
POSTGRES_PORT=5432

# ================================
# CLERK AUTHENTICATION
# ================================
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY="your nextjs publishable key"
CLERK_SECRET_KEY="your clerk secret key"
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
CLERK_SECRET_KEY=

# Clerk URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/generate
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/
# Razorpay
NEXT_PUBLIC_RAZORPAY_KEY_ID=
RAZORPAY_KEY_ID=
RAZORPAY_KEY_SECRET=

# ================================
# AI APIS
# ================================
GEMINI_API_KEY=""
# Database
DATABASE_URL=

# ================================
# EMAIL CONFIGURATION
# ================================
EMAIL_USER=
EMAIL_PASS=
# Gemini API
GEMINI_API_KEY=

# ================================
# DATABASE URL (Updated for Docker)
# ================================
DATABASE_URL="postgresql://postgres:your_secure_password_here@postgres:5432/flashfathom"

# ================================
# RAZORPAY CONFIGURATION
# ================================
RAZORPAY_KEY_ID="your_razorpay_key_id"
RAZORPAY_KEY_SECRET="your_razorpay_key_secret"
NEXT_PUBLIC_RAZORPAY_KEY_ID="your_razorpay_key_id"
# App Port
APP_PORT=3000
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

# clerk configuration (can include secrets)
/.clerk/
8 changes: 4 additions & 4 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
# ================================
FROM node:18-alpine AS builder

# Install system dependencies
RUN apk add --no-cache openssl libc6-compat postgresql-client
# Install system dependencies, including the required OpenSSL 1.1 library
RUN apk add --no-cache openssl1.1-compat libc6-compat postgresql-client

WORKDIR /app

Expand Down Expand Up @@ -58,8 +58,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
# Enable corepack and install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate

# Install runtime dependencies INCLUDING postgresql-client for pg_isready
RUN apk add --no-cache openssl libc6-compat postgresql-client
# Install runtime dependencies, including the required OpenSSL 1.1 library
RUN apk add --no-cache openssl1.1-compat libc6-compat postgresql-client

# Create non-root user
RUN addgroup --system --gid 1001 flashfathom \
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@vercel/analytics": "^1.5.0",
"axios": "^1.8.2",
"button": "^1.1.1",
"chart.js": "^4.5.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^16.4.7",
Expand All @@ -45,6 +46,7 @@
"nodemailer": "^6.10.0",
"razorpay": "^2.9.6",
"react": "^19.0.0",
"react-chartjs-2": "^5.3.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-toastify": "^11.0.5",
Expand Down
918 changes: 474 additions & 444 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Updated schema.prisma with Payment Gateway Support
generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "debian-openssl-1.1.x", "debian-openssl-3.0.x"]
}

datasource db {
Expand Down Expand Up @@ -49,6 +50,7 @@ model Deck {
user User @relation(fields: [userId], references: [clerkUserId])

@@index([userId])
@@index([name])
}

model Flashcard {
Expand Down Expand Up @@ -82,6 +84,8 @@ model StudySession {

// ✅ EXISTING: Relation points to clerkUserId
user User @relation(fields: [userId], references: [clerkUserId])

@@index([userId, startTime])
}

model StudyRecord {
Expand All @@ -93,6 +97,10 @@ model StudyRecord {
createdAt DateTime @default(now())
flashcard Flashcard @relation(fields: [flashcardId], references: [id])
studySession StudySession @relation(fields: [sessionId], references: [id])

@@index([flashcardId])
@@index([sessionId])
@@index([createdAt])
}

// ✅ NEW: Payment tracking model
Expand Down
46 changes: 46 additions & 0 deletions src/app/(dashboard)/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { currentUser } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { Filters } from "@/components/analytics/Filters";
import { Suspense } from "react";
import { LoadingSpinner } from "@/components/common/LoadingSpinner";
import { RetentionCurve } from "@/components/analytics/RetentionCurve";
import { SessionHeatmap } from "@/components/analytics/SessionHeatmap";
import { SubjectProgress } from "@/components/analytics/SubjectProgress";

export default async function AnalyticsPage({
searchParams,
}: {
searchParams: { [key: string]: string | string[] | undefined };
}) {
const user = await currentUser();
if (!user) {
redirect("/sign-in");
}

return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">In-depth Performance Analytics</h1>

<Filters />

<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mt-6">

<div className="col-span-1">
<Suspense fallback={<LoadingSpinner />}>
<SubjectProgress />
</Suspense>
</div>
<div className="col-span-1">
<Suspense fallback={<LoadingSpinner />}>
<RetentionCurve />
</Suspense>
</div>
<div className="col-span-1 lg:col-span-2">
<Suspense fallback={<LoadingSpinner />}>
<SessionHeatmap />
</Suspense>
</div>
</div>
</div>
);
}
166 changes: 166 additions & 0 deletions src/app/api/analytics/retention-curve/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@

import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/database";
import { currentUser } from "@clerk/nextjs/server";
import { Difficulty } from "@prisma/client";

// Helper function to safely map and validate difficulty parameter
function mapOrValidateDifficulty(difficultyParam: string | null): Difficulty | null {
if (!difficultyParam) return null;

const upperDifficulty = difficultyParam.toUpperCase();
if (upperDifficulty === "EASY") return Difficulty.EASY;
if (upperDifficulty === "MEDIUM") return Difficulty.MEDIUM;
if (upperDifficulty === "HARD") return Difficulty.HARD;

return null; // Invalid difficulty
}

export async function GET(req: NextRequest) {
const user = await currentUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, {
status: 401,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
}
});
}

const { searchParams } = new URL(req.url);
const subject = searchParams.get("subject")?.trim();
const dateRange = searchParams.get("dateRange");
const difficulty = searchParams.get("difficulty");

// Validate and normalize difficulty parameter
const safeDifficulty = mapOrValidateDifficulty(difficulty);
if (difficulty && !safeDifficulty) {
return NextResponse.json(
{
error: "Invalid difficulty value. Must be one of: EASY, MEDIUM, HARD"
},
{
status: 400,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
}
}
);
}

// Parse and validate dateRange parameter
let dateFilter: { gte?: Date; lte?: Date } | undefined = undefined;
if (dateRange) {
try {
// Check if it's a single date or date range (comma-separated)
const dates = dateRange.split(',').map(d => d.trim());

if (dates.length === 1) {
// Single date - use as gte
const date = new Date(dates[0]);
if (isNaN(date.getTime())) {
throw new Error("Invalid date format");
}
dateFilter = { gte: date };
} else if (dates.length === 2) {
// Date range - use first as gte, second as lte
const startDate = new Date(dates[0]);
const endDate = new Date(dates[1]);

if (isNaN(startDate.getTime()) || isNaN(endDate.getTime())) {
throw new Error("Invalid date format");
}

if (startDate > endDate) {
return NextResponse.json(
{
error: "Start date must be before or equal to end date"
},
{
status: 400,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
}
}
);
}

dateFilter = { gte: startDate, lte: endDate };
} else {
throw new Error("Invalid date range format");
}
} catch (error) {
return NextResponse.json(
{
error: "Invalid date format. Use ISO date format (YYYY-MM-DD) or comma-separated range (YYYY-MM-DD,YYYY-MM-DD)"
},
{
status: 400,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
}
}
);
}
}

try {
const studyRecords = await prisma.studyRecord.findMany({
where: {
flashcard: {
userId: user.id,
deck: subject ? {
is: {
name: {
equals: subject,
mode: "insensitive"
}
}
} : undefined,
difficulty: safeDifficulty ? { equals: safeDifficulty } : undefined,
},
createdAt: dateFilter,
},
orderBy: { createdAt: "asc" },
select: {
createdAt: true,
isCorrect: true,
},
});
const retentionData = studyRecords.reduce((acc: { [key: string]: { correct: number; total: number } }, record: { createdAt: Date; isCorrect: boolean }) => {
const date = new Date(record.createdAt).toLocaleDateString();
if (!acc[date]) {
acc[date] = { correct: 0, total: 0 };
}
acc[date].total++;
if (record.isCorrect) {
acc[date].correct++;
}
return acc;
}, {} as { [key: string]: { correct: number; total: number } });

const labels = Object.keys(retentionData);
const retention = Object.values(retentionData).map((d: { correct: number; total: number }) => (d.correct / d.total) * 100);

return NextResponse.json({ labels, retention }, {
status: 200,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
},
});
} catch (error) {
console.error("Failed to compute retention curve:", error);
return NextResponse.json(
{
error: "Failed to compute retention",
details: error instanceof Error ? error.message : "Unknown error"
},
{
status: 500,
headers: {
"Cache-Control": "no-cache, no-store, must-revalidate"
},
}
);
}
}
Loading