diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c31281..fc6e053 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -38,7 +38,7 @@ jobs: run: npm ci - name: Run unit and integration tests - run: npm test -- --run --reporter=verbose + run: npm test -- --run --reporter=verbose --pool=threads --poolOptions.threads.maxThreads=4 env: CI: true @@ -71,9 +71,27 @@ jobs: - name: Install dependencies run: npm ci + - name: Detect Playwright version + id: playwright-version + run: | + PLAYWRIGHT_VERSION=$(node -p "require('./package.json').devDependencies?.playwright || require('./package.json').dependencies?.playwright || ''") + echo "version=${PLAYWRIGHT_VERSION}" >> $GITHUB_OUTPUT + + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}-${{ hashFiles('**/package-lock.json') }} + - name: Install Playwright browsers + if: steps.playwright-cache.outputs.cache-hit != 'true' run: npx playwright install --with-deps chromium + - name: Install Playwright deps only (if cached) + if: steps.playwright-cache.outputs.cache-hit == 'true' + run: npx playwright install-deps chromium + - name: Start dev server run: | npm run dev & @@ -93,7 +111,7 @@ jobs: SUPABASE_ACCESS_TOKEN: ${{ secrets.SUPABASE_ACCESS_TOKEN }} - name: Run Playwright tests - run: npx playwright test --project=chromium + run: npx playwright test --project=chromium --workers=2 env: CI: true # Add any required env variables for e2e tests diff --git a/.gitignore b/.gitignore index 7bb9f3e..f6ab581 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,9 @@ supabase/migrations/ # Documentation (internal notes) documents/ + +# Playwright +/test-results/ +/playwright-report/ +/playwright/.auth/ +tests/playwright/.auth/ diff --git a/playwright.config.ts b/playwright.config.ts index cf44dae..e0036f3 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,8 +11,8 @@ export default defineConfig({ forbidOnly: !!process.env.CI, /* Retry on CI only */ retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, + /* Use multiple workers for faster CI - GitHub runners have 2 cores */ + workers: process.env.CI ? 2 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: 'html', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ @@ -20,9 +20,9 @@ export default defineConfig({ /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: 'http://localhost:8080', - /* Slow down actions for debugging (set to 0 for normal speed) */ + /* Slow down actions for debugging locally (disabled in CI for speed) */ launchOptions: { - slowMo: 500, // 500ms delay between actions - remove or set to 0 for fast tests + slowMo: process.env.CI ? 0 : 500, // 500ms delay locally for debugging, 0 in CI for speed }, /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ diff --git a/src/components/ProgressRadar.tsx b/src/components/ProgressRadar.tsx index 42757ed..fcc6034 100644 --- a/src/components/ProgressRadar.tsx +++ b/src/components/ProgressRadar.tsx @@ -13,11 +13,22 @@ const ProgressRadar = () => { useEffect(() => { const fetchTotals = async () => { - const { count, error } = await supabase + // Fetch all problems with their categories to filter out non-algorithm problems + const { data, error } = await supabase .from("problems") - .select("id", { count: "exact", head: true }); - if (!error && typeof count === "number") { - setTotalProblems(count); + .select("id, categories!inner(name)"); + + if (!error && data) { + // Filter out System Design and Data Structure Implementations (same logic as technical interview) + const algorithmProblems = data.filter((problem) => { + const categoryName = (problem.categories as { name?: string })?.name || ""; + return ( + categoryName !== "System Design" && + categoryName !== "Data Structure Implementations" && + !problem.id.startsWith("sd_") + ); + }); + setTotalProblems(algorithmProblems.length); } }; fetchTotals(); diff --git a/src/components/coaching/overlay/CorrectCodeDialog.tsx b/src/components/coaching/overlay/CorrectCodeDialog.tsx index e7c8170..474c1a3 100644 --- a/src/components/coaching/overlay/CorrectCodeDialog.tsx +++ b/src/components/coaching/overlay/CorrectCodeDialog.tsx @@ -32,8 +32,13 @@ export const CorrectCodeDialog: React.FC = ({ }; const handleInsert = async () => { + logger.debug('[CorrectCodeDialog] handleInsert called'); + logger.debug('[CorrectCodeDialog] onInsertCode exists:', !!onInsertCode); + logger.debug('[CorrectCodeDialog] isInserting:', isInserting); if (onInsertCode) { + logger.debug('[CorrectCodeDialog] Calling onInsertCode...'); await onInsertCode(); + logger.debug('[CorrectCodeDialog] onInsertCode completed'); onClose(); } }; diff --git a/src/components/flashcards/FlashcardDeckManager.tsx b/src/components/flashcards/FlashcardDeckManager.tsx index 0c91b6b..82e4108 100644 --- a/src/components/flashcards/FlashcardDeckManager.tsx +++ b/src/components/flashcards/FlashcardDeckManager.tsx @@ -29,11 +29,13 @@ import { Star, Clock, Brain, + Play, } from "lucide-react"; import { useFlashcards } from "@/hooks/useFlashcards"; import type { FlashcardDeck } from "@/types/api"; import { useNavigate } from "react-router-dom"; import { toast } from "sonner"; +import { FlashcardReviewInterface } from "./FlashcardReviewInterface"; interface FlashcardDeckManagerProps { userId: string; @@ -43,6 +45,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { const navigate = useNavigate(); const { flashcards, + dueCards, removeFromFlashcards, isRemovingFromFlashcards, isLoading, @@ -52,6 +55,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { const [masteryFilter, setMasteryFilter] = useState("all"); const [selectedCard, setSelectedCard] = useState(null); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showReviewModal, setShowReviewModal] = useState(false); // Filter flashcards based on search and mastery level const filteredCards = flashcards.filter((card) => { @@ -80,15 +84,15 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { const getMasteryLabel = (level: number, reviewCount: number) => { // If card has been reviewed but still shows as level 0, it should be Learning if (level === 0 && reviewCount > 0) { - return { label: "Learning", color: "bg-blue-100 text-blue-800" }; + return { label: "Learning", color: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200" }; } switch (level) { - case 0: return { label: "New", color: "bg-gray-100 text-gray-800" }; - case 1: return { label: "Learning", color: "bg-blue-100 text-blue-800" }; - case 2: return { label: "Good", color: "bg-green-100 text-green-800" }; - case 3: return { label: "Mastered", color: "bg-purple-100 text-purple-800" }; - default: return { label: "Unknown", color: "bg-gray-100 text-gray-800" }; + case 0: return { label: "New", color: "bg-gray-100 text-gray-800 dark:bg-gray-900/40 dark:text-gray-200" }; + case 1: return { label: "Learning", color: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200" }; + case 2: return { label: "Good", color: "bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200" }; + case 3: return { label: "Mastered", color: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-200" }; + default: return { label: "Unknown", color: "bg-gray-100 text-gray-800 dark:bg-gray-900/40 dark:text-gray-200" }; } }; @@ -135,7 +139,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { {/* Stats Summary */} @@ -357,7 +373,14 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { + + {/* Flashcard Review Modal */} + setShowReviewModal(false)} + userId={userId} + /> ); -}; \ No newline at end of file +}; diff --git a/src/features/admin/components/AdminDashboardNew.tsx b/src/features/admin/components/AdminDashboardNew.tsx index 826e165..d12b472 100644 --- a/src/features/admin/components/AdminDashboardNew.tsx +++ b/src/features/admin/components/AdminDashboardNew.tsx @@ -3,15 +3,17 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Users, Activity, Search, ArrowLeft } from "lucide-react"; +import { Users, Activity, Search, ArrowLeft, BarChart3 } from "lucide-react"; import { useNavigate } from "react-router-dom"; // Feature imports import { useAdminDashboard } from "../hooks/useAdminDashboard"; +import { useAdminAnalytics } from "../hooks/useAdminAnalytics"; import { useUserManagement } from "../hooks/useUserManagement"; import { OverviewCards } from "./OverviewCards"; import { UserCard } from "./UserCard"; import { ApiUsageTab } from "./ApiUsageTab"; +import { AnalyticsTab } from "./AnalyticsTab"; import { SetLimitsDialog } from "./SetLimitsDialog"; import { SetCooldownDialog } from "./SetCooldownDialog"; import { AdminDashboardSkeleton } from "./AdminDashboardSkeleton"; @@ -33,12 +35,18 @@ export function AdminDashboardNew() { refetchOverviewStats, } = useAdminDashboard(); + const { analytics, loading: analyticsLoading, refresh: refreshAnalytics } = useAdminAnalytics(); + // User management hook const handleUpdate = useCallback(() => { refetchUserStats(); refetchOverviewStats(); }, [refetchUserStats, refetchOverviewStats]); + const handleRefresh = useCallback(async () => { + await Promise.all([refresh(), refreshAnalytics()]); + }, [refresh, refreshAnalytics]); + const { grantPremium, revokePremium, @@ -120,7 +128,7 @@ export function AdminDashboardNew() { {/* Header */}

Admin Dashboard

- @@ -140,6 +148,10 @@ export function AdminDashboardNew() { API Usage + + + Analytics + {/* Users Tab */} @@ -188,6 +200,11 @@ export function AdminDashboardNew() { + + {/* Analytics Tab */} + + + {/* Dialogs */} diff --git a/src/features/admin/components/AdminProblemManagement.tsx b/src/features/admin/components/AdminProblemManagement.tsx index 12f299b..23e7eaa 100644 --- a/src/features/admin/components/AdminProblemManagement.tsx +++ b/src/features/admin/components/AdminProblemManagement.tsx @@ -18,8 +18,9 @@ import { SelectValue, } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; -import { Loader2, Plus, Pencil, Search } from "lucide-react"; +import { Loader2, Plus, Pencil, Search, Download } from "lucide-react"; import { AdminProblemDialog, type Problem } from "@/features/admin/components/AdminProblemDialog"; +import { LeetCodeImportDialog } from "@/features/admin/components/LeetCodeImportDialog"; interface Category { id: string; @@ -31,6 +32,7 @@ const AdminProblemManagement = () => { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isImportDialogOpen, setIsImportDialogOpen] = useState(false); const [selectedProblemId, setSelectedProblemId] = useState(null); // Filter state @@ -98,9 +100,14 @@ const AdminProblemManagement = () => {

Problem Administration

- +
+ + +
{/* Filters */} @@ -219,6 +226,14 @@ const AdminProblemManagement = () => { }} categories={categories} /> + + { + setProblems(prev => [importedProblem, ...prev]); + }} + />
); }; diff --git a/src/features/admin/components/AnalyticsTab.tsx b/src/features/admin/components/AnalyticsTab.tsx new file mode 100644 index 0000000..db71976 --- /dev/null +++ b/src/features/admin/components/AnalyticsTab.tsx @@ -0,0 +1,583 @@ +import { useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; +import { BarChart3, TrendingUp, Users, Calendar, AlertCircle } from "lucide-react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + BarChart, + Bar, + Brush, + AreaChart, + Area, + ComposedChart, + Legend, +} from "recharts"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Button } from "@/components/ui/button"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import type { + AdminAnalyticsStats, + AnalyticsBreakdownItem, + AnalyticsResolution, + AnalyticsTimelinePoint, +} from "../types/admin.types"; + +interface AnalyticsTabProps { + stats: AdminAnalyticsStats; + loading: boolean; +} + +const numberFormatter = new Intl.NumberFormat("en-US", { + notation: "compact", + maximumFractionDigits: 1, +}); + +const percentFormatter = new Intl.NumberFormat("en-US", { + style: "percent", + maximumFractionDigits: 1, +}); + +const formatNumber = (value: number) => numberFormatter.format(value); +const formatPercent = (value: number) => percentFormatter.format(value); + +const MetricCard = ({ + label, + value, + helper, + trend, +}: { + label: string; + value: string; + helper?: string; + trend?: { value: string; positive: boolean }; +}) => ( + +
+ +
+ + {label} + + +
+
{value}
+ {trend && ( +
+ {trend.positive ? "↑" : "↓"} {trend.value} +
+ )} +
+ {helper ?

{helper}

: null} +
+
+); + +const BreakdownList = ({ title, items }: { title: string; items: AnalyticsBreakdownItem[] }) => ( + + + {title} + + + {items.length === 0 ? ( +

No data available

+ ) : ( +
+ {items.map((item) => ( +
+ {item.label} + {formatNumber(item.value)} +
+ ))} +
+ )} +
+
+); + +const aggregateTimeline = ( + dates: string[] | undefined, + resolution: AnalyticsResolution +): AnalyticsTimelinePoint[] => { + if (!dates || !Array.isArray(dates)) { + return []; + } + const counts = new Map(); + dates.forEach((value) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return; + const key = + resolution === "year" + ? `${date.getFullYear()}` + : resolution === "month" + ? `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` + : date.toISOString().slice(0, 10); + counts.set(key, (counts.get(key) || 0) + 1); + }); + + return Array.from(counts.entries()) + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .map(([date, count]) => ({ date, count })); +}; + +const SkeletonGrid = () => ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + + + + + + + + + ))} +
+); + +export const AnalyticsTab = ({ stats, loading }: AnalyticsTabProps) => { + const [resolution, setResolution] = useState("month"); + const joinDates = stats?.subscriptions?.userJoinDates ?? []; + const cancellationDates = stats?.subscriptions?.cancellationsDates ?? []; + const premiumJoinDates = stats?.subscriptions?.subscriptionJoinDates ?? []; + + const joinTimeline = useMemo( + () => aggregateTimeline(joinDates, resolution), + [joinDates, resolution] + ); + + const cancellationsTimeline = useMemo( + () => aggregateTimeline(cancellationDates, resolution), + [cancellationDates, resolution] + ); + + const premiumsTimeline = useMemo( + () => aggregateTimeline(premiumJoinDates, resolution), + [premiumJoinDates, resolution] + ); + + const combinedTimeline = useMemo(() => { + const joinMap = new Map(joinTimeline.map((point) => [point.date, point.count])); + const premiumMap = new Map(premiumsTimeline.map((point) => [point.date, point.count])); + const cancelMap = new Map(cancellationsTimeline.map((point) => [point.date, point.count])); + const allDates = new Set([ + ...joinTimeline.map((p) => p.date), + ...premiumsTimeline.map((p) => p.date), + ...cancellationsTimeline.map((p) => p.date), + ]); + + return Array.from(allDates) + .sort() + .map((date) => { + const joins = joinMap.get(date) || 0; + const premiums = premiumMap.get(date) || 0; + const cancels = cancelMap.get(date) || 0; + return { + date, + joins, + premiums, + cancels, + net: joins - cancels, + }; + }); + }, [joinTimeline, premiumsTimeline, cancellationsTimeline]); + + if (loading) { + return ( +
+ + + + + + + + + +
+ ); + } + + return ( +
+
+

Engagement

+
+ + + + + + +
+
+ +
+

AI Usage

+
+ + + + +
+
+ + + Daily AI Usage + + + + + + + + } + /> + + + + + + +
+ + +
+
+
+ +
+

Practice & Performance

+
+ + + + + +
+
+ + + Top Problems (30d) + + + {stats.problems.topProblems.length === 0 ? ( +

No data available

+ ) : ( + + + + + + } + /> + + + + )} +
+
+ + + Streak Health + + +
+ Avg Current Streak + {stats.streaks.averageCurrentStreak.toFixed(1)} +
+
+ Max Streak + {formatNumber(stats.streaks.maxStreak)} +
+
+ At-Risk Users + {formatNumber(stats.streaks.atRiskUsers)} +
+
+
+
+
+ +
+

Learning Tools

+
+ + + + +
+
+ + + + +
+
+ +
+

Content & Feedback

+
+ + + + + +
+
+ +
+

Subscriptions

+
+ + + + + + +
+ + + User Join & Cancellation Timeline + + +
+ {(["year", "month", "day"] as const).map((option) => ( + + ))} +
+
+
+
+

+ + Growth Performance +

+

+ Analyzing registration and conversion velocity over time +

+
+
+
+ Joins +
+
+ Premiums +
+
+ Cancels +
+
+ Net +
+
+
+ +
+ {combinedTimeline.length === 0 ? ( +
+

No data available for the selected period

+
+ ) : ( + + + + + + + + + + + + + + + + + } + /> + + + + + + + + )} +
+
+
+
+
+
+ ); +}; diff --git a/src/features/admin/components/LeetCodeImportDialog.tsx b/src/features/admin/components/LeetCodeImportDialog.tsx new file mode 100644 index 0000000..3646f4f --- /dev/null +++ b/src/features/admin/components/LeetCodeImportDialog.tsx @@ -0,0 +1,546 @@ +import React, { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { + Loader2, + Download, + Save, + AlertCircle, + Trash2, + ExternalLink, +} from "lucide-react"; +import { useLeetCodeImport } from "../hooks/useLeetCodeImport"; +import { getDifficultyColorClass } from "../utils/leetcode-mapper"; +import type { Problem } from "./AdminProblemDialog"; + +interface LeetCodeImportDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onImported: (problem: Problem) => void; +} + +export function LeetCodeImportDialog({ + open, + onOpenChange, + onImported, +}: LeetCodeImportDialogProps) { + const [url, setUrl] = useState(""); + const [activeTab, setActiveTab] = useState("details"); + + const { + state, + fetchProblem, + saveProblem, + resetState, + updatePreviewProblem, + updatePreviewTestCase, + updatePreviewSolution, + removePreviewTestCase, + removePreviewSolution, + } = useLeetCodeImport(); + + const handleClose = () => { + setUrl(""); + setActiveTab("details"); + resetState(); + onOpenChange(false); + }; + + const handleFetch = async () => { + await fetchProblem(url); + }; + + const handleSave = async () => { + if (!state.previewData) return; + + const success = await saveProblem( + state.previewData.problem, + state.previewData.testCases, + state.previewData.solutions + ); + + if (success) { + const savedProblem: Problem = { + id: state.previewData.problem.id, + title: state.previewData.problem.title, + difficulty: state.previewData.problem.difficulty, + category_id: state.previewData.problem.category_id || "", + description: state.previewData.problem.description, + function_signature: state.previewData.problem.function_signature, + companies: state.previewData.problem.companies, + examples: state.previewData.problem.examples, + constraints: state.previewData.problem.constraints, + hints: state.previewData.problem.hints, + recommended_time_complexity: + state.previewData.problem.recommended_time_complexity || "", + recommended_space_complexity: + state.previewData.problem.recommended_space_complexity || "", + }; + onImported(savedProblem); + handleClose(); + } + }; + + const problem = state.previewData?.problem; + const testCases = state.previewData?.testCases || []; + const solutions = state.previewData?.solutions || []; + const categories = state.previewData?.categories || []; + + return ( + + + + Import from LeetCode + + Paste a LeetCode problem URL to fetch and import the problem with + AI-generated test cases and solutions. + + + + {!state.previewData ? ( + // URL Input Phase +
+
+ +
+ setUrl(e.target.value)} + disabled={state.fetchingProblem} + onKeyDown={(e) => { + if (e.key === "Enter" && !state.fetchingProblem) { + handleFetch(); + } + }} + /> + +
+

+ Example: https://leetcode.com/problems/reverse-integer/ +

+
+ + {state.error && ( + + + {state.error} + + )} + + {state.fetchingProblem && ( +
+ +

+ Fetching problem from LeetCode and generating test cases & + solutions... +

+

+ This may take a moment +

+
+ )} +
+ ) : ( + // Preview/Edit Phase + +
+ + Details + + Test Cases ({testCases.length}) + + + Solutions ({solutions.length}) + + +
+ + {problem?.difficulty} + + + View on LeetCode + + +
+
+ + {state.error && ( + + + {state.error} + + )} + + + +
+
+ + +
+
+ + + updatePreviewProblem({ title: e.target.value }) + } + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +