From a603d47a78b10a803a2b67c19647f2d4d0b342e8 Mon Sep 17 00:00:00 2001 From: Irakli Grigolia Date: Sun, 21 Dec 2025 11:02:31 -0500 Subject: [PATCH 1/5] ci: Optimize test performance with parallel execution and Playwright browser caching Add thread pool configuration to unit tests with maxThreads=4 for faster execution. Implement Playwright browser caching in CI workflow to avoid reinstalling browsers on every run. Increase Playwright workers from 1 to 2 to leverage GitHub runner's dual-core CPU. Disable slowMo delay in CI while keeping 500ms locally for debugging. Add FlashcardReviewInterface modal with "Start Review" button showing due card count in --- .github/workflows/test.yml | 16 +++++++++++-- playwright.config.ts | 8 +++---- .../flashcards/FlashcardDeckManager.tsx | 23 +++++++++++++++++++ 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3c31281..7059ac9 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,21 @@ jobs: - name: Install dependencies run: npm ci + - name: Cache Playwright browsers + uses: actions/cache@v4 + id: playwright-cache + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ 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 +105,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/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/flashcards/FlashcardDeckManager.tsx b/src/components/flashcards/FlashcardDeckManager.tsx index 0c91b6b..7ec9040 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) => { @@ -147,6 +151,18 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => {

+ {/* Start Review Button */} + {/* Stats Summary */} @@ -357,6 +373,13 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => { + + {/* Flashcard Review Modal */} + setShowReviewModal(false)} + userId={userId} + /> ); From 9e534e1ecfcfa3052255261223461d2e34ec7a24 Mon Sep 17 00:00:00 2001 From: Irakli Grigolia Date: Mon, 12 Jan 2026 16:27:43 -0500 Subject: [PATCH 2/5] fix tests --- .gitignore | 6 ++++ tests/e2e/survey/survey.spec.ts | 50 ++++++++++++++++++++++++--------- 2 files changed, 42 insertions(+), 14 deletions(-) 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/tests/e2e/survey/survey.spec.ts b/tests/e2e/survey/survey.spec.ts index 957df2c..1a3e9b1 100644 --- a/tests/e2e/survey/survey.spec.ts +++ b/tests/e2e/survey/survey.spec.ts @@ -209,7 +209,8 @@ test.describe('Survey with Authenticated Fixture', () => { test.describe('Full Survey Completion', () => { test('should complete entire survey flow (all 20 steps)', async ({ page }) => { - // Set up auth + // This flow includes an analyzing step that may take time depending on network. + test.setTimeout(180000); const authHelper = new AuthHelper(page); await page.goto('/'); await authHelper.mockSignIn(); @@ -275,9 +276,7 @@ test.describe('Full Survey Completion', () => { await page.getByRole('button', { name: /Overthinking/i }).click(); await page.getByRole('button', { name: /Continue/i }).click(); - // Step 11: Social Proof (non-question step) - await page.waitForURL(/\/survey\/11/); - await page.getByRole('button', { name: /Continue/i }).click(); + // Step 11 is skipped in-app (Social Proof) // Step 12: Customization Intro (non-question step) await page.waitForURL(/\/survey\/12/); @@ -307,15 +306,28 @@ test.describe('Full Survey Completion', () => { // Step 17: Progress Animation (no footer - auto continues or wait) await page.waitForURL(/\/survey\/17/); - // This step has no footer, wait for it to auto-progress or click if available - await page.waitForURL(/\/survey\/18/, { timeout: 15000 }); + // This step typically auto-progresses, but can be flaky in slower environments. + // If a Continue button is present, click it. + const progressContinueButton = page.getByRole('button', { name: /Continue/i }); + if (await progressContinueButton.isVisible().catch(() => false)) { + await progressContinueButton.click(); + } + // Wait for either the URL to change OR the Step 18 content to appear. + await Promise.race([ + page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), + expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), + ]); // Step 18: Congratulations (non-question step) await expect(page.getByText(/Congratulations/i)).toBeVisible(); - await page.getByRole('button', { name: /Continue/i }).click(); + for (let i = 0; i < 3; i++) { + await page.getByRole('button', { name: /Continue/i }).click(); + if (/\/survey\/19/.test(page.url())) break; + await page.waitForTimeout(500); + } // Step 19: Customized Results (non-question step) - await page.waitForURL(/\/survey\/19/); + await page.waitForURL(/\/survey\/19/, { timeout: 60000, waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: /Continue/i }).click(); // Step 20: Paywall Step (special - has "Start My Journey" button) @@ -466,9 +478,7 @@ test.describe('Full Survey Completion', () => { await page.getByRole('button', { name: /Overthinking/i }).click(); await page.getByRole('button', { name: /Continue/i }).click(); - // Steps 11-12 (non-question) - await page.waitForURL(/\/survey\/11/); - await page.getByRole('button', { name: /Continue/i }).click(); + // Step 11 is skipped in-app (Social Proof) await page.waitForURL(/\/survey\/12/); await page.getByRole('button', { name: /Continue/i }).click(); @@ -492,13 +502,25 @@ test.describe('Full Survey Completion', () => { // Step 17 (auto-progress animation) await page.waitForURL(/\/survey\/17/); - await page.waitForURL(/\/survey\/18/, { timeout: 15000 }); + const stripeProgressContinueButton = page.getByRole('button', { name: /Continue/i }); + if (await stripeProgressContinueButton.isVisible().catch(() => false)) { + await stripeProgressContinueButton.click(); + } + await Promise.race([ + page.waitForURL(/\/survey\/18/, { timeout: 90000, waitUntil: 'domcontentloaded' }), + expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 90000 }), + ]); // Step 18 (congratulations) - await page.getByRole('button', { name: /Continue/i }).click(); + await expect(page.getByText(/Congratulations/i)).toBeVisible({ timeout: 60000 }); + for (let i = 0; i < 3; i++) { + await page.getByRole('button', { name: /Continue/i }).click(); + if (/\/survey\/19/.test(page.url())) break; + await page.waitForTimeout(500); + } // Step 19 (results) - await page.waitForURL(/\/survey\/19/); + await page.waitForURL(/\/survey\/19/, { timeout: 60000, waitUntil: 'domcontentloaded' }); await page.getByRole('button', { name: /Continue/i }).click(); // Step 20 - PAYWALL From d771a84c54f532d49ec000bd00325fd7d3700115 Mon Sep 17 00:00:00 2001 From: Irakli Grigolia Date: Mon, 19 Jan 2026 21:28:52 -0500 Subject: [PATCH 3/5] Add analytics dashboard with comprehensive metrics tracking and visualization Implement new Analytics tab in admin dashboard featuring engagement metrics (DAU/WAU/MAU, retention), AI usage tracking (tokens, cost, sessions), practice performance (attempts, pass rates, streaks), and learning tools analytics (flashcards, behavioral/mock interviews). Add interactive charts with timeline aggregation by day/month/year resolution, breakdown visualizations for features and models, and metric cards with tren --- .../coaching/overlay/CorrectCodeDialog.tsx | 5 + .../coaching/overlay/OverlayActions.tsx | 5 + .../admin/components/AdminDashboardNew.tsx | 21 +- .../admin/components/AnalyticsTab.tsx | 577 +++++++++++++++++ .../__tests__/AdminDashboardNew.test.tsx | 5 +- .../__tests__/AnalyticsTab.test.tsx | 121 ++++ src/features/admin/hooks/useAdminAnalytics.ts | 585 ++++++++++++++++++ src/features/admin/hooks/useAdminDashboard.ts | 112 +++- src/features/admin/types/admin.types.ts | 92 +++ .../problem-solver/hooks/useCodeInsertion.ts | 4 + src/features/profile/ProfilePage.tsx | 84 +-- src/hooks/useCoachingNew.ts | 27 +- supabase/functions/ai-chat/code-analysis.ts | 12 +- supabase/functions/ai-chat/index.ts | 5 + supabase/functions/ai-chat/openai-utils.ts | 116 +++- supabase/functions/ai-coach-chat/index.ts | 1 + 16 files changed, 1699 insertions(+), 73 deletions(-) create mode 100644 src/features/admin/components/AnalyticsTab.tsx create mode 100644 src/features/admin/components/__tests__/AnalyticsTab.test.tsx create mode 100644 src/features/admin/hooks/useAdminAnalytics.ts diff --git a/src/components/coaching/overlay/CorrectCodeDialog.tsx b/src/components/coaching/overlay/CorrectCodeDialog.tsx index e7c8170..05eb8d6 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 () => { + console.log('[CorrectCodeDialog] handleInsert called'); + console.log('[CorrectCodeDialog] onInsertCode exists:', !!onInsertCode); + console.log('[CorrectCodeDialog] isInserting:', isInserting); if (onInsertCode) { + console.log('[CorrectCodeDialog] Calling onInsertCode...'); await onInsertCode(); + console.log('[CorrectCodeDialog] onInsertCode completed'); onClose(); } }; diff --git a/src/components/coaching/overlay/OverlayActions.tsx b/src/components/coaching/overlay/OverlayActions.tsx index 44325f4..b9bf453 100644 --- a/src/components/coaching/overlay/OverlayActions.tsx +++ b/src/components/coaching/overlay/OverlayActions.tsx @@ -62,10 +62,15 @@ export const OverlayActions: React.FC = ({ }; const handleInsertWrapper = async () => { + console.log('[OverlayActions] handleInsertWrapper called'); + console.log('[OverlayActions] onInsertCorrectCode exists:', !!onInsertCorrectCode); if (!onInsertCorrectCode) return; try { + console.log('[OverlayActions] Setting isInserting to true'); setIsInserting(true); + console.log('[OverlayActions] Calling onInsertCorrectCode...'); await onInsertCorrectCode(); + console.log('[OverlayActions] onInsertCorrectCode completed'); } finally { setIsInserting(false); } 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/AnalyticsTab.tsx b/src/features/admin/components/AnalyticsTab.tsx new file mode 100644 index 0000000..7a1d487 --- /dev/null +++ b/src/features/admin/components/AnalyticsTab.tsx @@ -0,0 +1,577 @@ +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 joinTimeline = useMemo( + () => aggregateTimeline(stats.subscriptions.userJoinDates, resolution), + [stats.subscriptions.userJoinDates, resolution] + ); + + const cancellationsTimeline = useMemo( + () => aggregateTimeline(stats.subscriptions.cancellationsDates, resolution), + [stats.subscriptions.cancellationsDates, resolution] + ); + + const premiumsTimeline = useMemo( + () => aggregateTimeline(stats.subscriptions.subscriptionJoinDates, resolution), + [stats.subscriptions.subscriptionJoinDates, resolution] + ); + + const combinedTimeline = useMemo(() => { + 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 = joinTimeline.find((p) => p.date === date)?.count || 0; + const premiums = premiumsTimeline.find((p) => p.date === date)?.count || 0; + const cancels = cancellationsTimeline.find((p) => p.date === date)?.count || 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/__tests__/AdminDashboardNew.test.tsx b/src/features/admin/components/__tests__/AdminDashboardNew.test.tsx index cb2a894..9776641 100644 --- a/src/features/admin/components/__tests__/AdminDashboardNew.test.tsx +++ b/src/features/admin/components/__tests__/AdminDashboardNew.test.tsx @@ -94,6 +94,7 @@ const createQueryBuilder = (data: unknown = [], count: number | null = null) => eq: vi.fn().mockReturnThis(), in: vi.fn().mockReturnThis(), gte: vi.fn().mockReturnThis(), + lte: vi.fn().mockReturnThis(), order: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), single: vi.fn().mockReturnThis(), @@ -358,7 +359,9 @@ describe('AdminDashboardNew', () => { expect(screen.getByText('Admin Dashboard')).toBeInTheDocument(); }); - // May have tabs for Users, Overview, etc. + expect(screen.getByText('Users')).toBeInTheDocument(); + expect(screen.getByText('API Usage')).toBeInTheDocument(); + expect(screen.getByText('Analytics')).toBeInTheDocument(); }); }); diff --git a/src/features/admin/components/__tests__/AnalyticsTab.test.tsx b/src/features/admin/components/__tests__/AnalyticsTab.test.tsx new file mode 100644 index 0000000..4e87cf9 --- /dev/null +++ b/src/features/admin/components/__tests__/AnalyticsTab.test.tsx @@ -0,0 +1,121 @@ +import { render, screen, mockResizeObserver } from "@/test-utils"; +import { describe, it, expect, beforeEach } from "vitest"; +import { AnalyticsTab } from "../AnalyticsTab"; +import type { AdminAnalyticsStats } from "../../types/admin.types"; + +const mockStats: AdminAnalyticsStats = { + engagement: { + dau: 120, + wau: 540, + mau: 2100, + newUsers30d: 320, + retention7d: 0.42, + retention30d: 0.28, + }, + aiUsage: { + tokens30d: 1500000, + cost30d: 420.5, + sessions30d: 680, + avgMessagesPerSession: 6.4, + featureBreakdown: [ + { label: "chat", value: 900000 }, + { label: "coach", value: 600000 }, + ], + modelBreakdown: [ + { label: "gpt-4o-mini", value: 800000 }, + { label: "gpt-4o", value: 700000 }, + ], + dailySeries: [ + { date: "2025-01-01", tokens: 50000, cost: 12, sessions: 20, activeUsers: 12 }, + { date: "2025-01-02", tokens: 62000, cost: 14, sessions: 24, activeUsers: 16 }, + ], + }, + problems: { + attempts30d: 1200, + passRate30d: 0.58, + uniqueProblems30d: 180, + avgAttemptsPerProblem: 6.7, + topProblems: [ + { label: "two-sum", value: 120 }, + { label: "valid-parentheses", value: 95 }, + ], + }, + streaks: { + averageCurrentStreak: 3.2, + maxStreak: 29, + atRiskUsers: 14, + }, + flashcards: { + decksTotal: 430, + reviews30d: 980, + dueNow: 120, + avgMastery: 2.1, + }, + behavioral: { + sessionsStarted30d: 75, + sessionsCompleted30d: 60, + avgScore30d: 82.3, + }, + mockInterviews: { + sessionsStarted30d: 42, + sessionsCompleted30d: 30, + avgScore30d: 79.1, + }, + content: { + questionsAdded30d: 12, + solutionsAdded30d: 24, + storiesAdded30d: 18, + storyReuseRate30d: 0.38, + }, + feedback: { + newFeedback30d: 9, + openCount: 4, + resolvedCount: 5, + }, + subscriptions: { + active: 120, + trialing: 18, + cancelled: 6, + pastDue: 3, + new30d: 25, + churned30d: 4, + userJoinDates: ["2025-01-01T00:00:00Z", "2025-01-02T00:00:00Z"], + cancellationsDates: ["2025-01-03T00:00:00Z"], + subscriptionJoinDates: ["2025-01-01T00:00:00Z"], + userJoinTimeline: [ + { date: "2025-01", count: 2 }, + ], + cancellationsTimeline: [ + { date: "2025-01", count: 1 }, + ], + }, +}; + +describe("AnalyticsTab", () => { + beforeEach(() => { + mockResizeObserver(); + }); + + it("renders key sections and metrics", () => { + render(); + + expect(screen.getByText("Engagement")).toBeInTheDocument(); + expect(screen.getByText("AI Usage")).toBeInTheDocument(); + expect(screen.getByText("Practice & Performance")).toBeInTheDocument(); + expect(screen.getByText("Learning Tools")).toBeInTheDocument(); + expect(screen.getByText("Content & Feedback")).toBeInTheDocument(); + expect(screen.getByText("Subscriptions")).toBeInTheDocument(); + + expect(screen.getByText("DAU")).toBeInTheDocument(); + expect(screen.getByText("Tokens (30d)")).toBeInTheDocument(); + expect(screen.getByText("Attempts (30d)")).toBeInTheDocument(); + expect(screen.getByText("Flashcard Decks")).toBeInTheDocument(); + expect(screen.getByText("User Join & Cancellation Timeline")).toBeInTheDocument(); + }); + + it("shows loading state without sections", () => { + render(); + + expect(screen.queryByText("Engagement")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/admin/hooks/useAdminAnalytics.ts b/src/features/admin/hooks/useAdminAnalytics.ts new file mode 100644 index 0000000..86dbf28 --- /dev/null +++ b/src/features/admin/hooks/useAdminAnalytics.ts @@ -0,0 +1,585 @@ +import { useCallback, useEffect } from "react"; +import { supabase } from "@/integrations/supabase/client"; +import { useAsyncOperation } from "@/shared/hooks/useAsyncOperation"; +import { logger } from "@/utils/logger"; +import type { + AdminAnalyticsStats, + AnalyticsBreakdownItem, + AnalyticsTimeSeriesPoint, + AnalyticsTimelinePoint, +} from "../types/admin.types"; + +interface AiUsageRow { + user_id: string | null; + session_id: string | null; + tokens_total: number | null; + tokens_input: number | null; + tokens_output: number | null; + estimated_cost: number | null; + model: string | null; + feature: string | null; + created_at: string; +} + +interface SessionRow { + user_id: string | null; + created_at: string; +} + +interface AttemptRow { + problem_id: string | null; + status: string | null; +} + +interface StreakRow { + current_streak: number | null; + max_streak: number | null; + last_activity_date: string | null; +} + +interface PracticeSessionRow { + completed_at: string | null; + average_score: number | null; +} + +interface InterviewSessionRow { + status?: string | null; + passed?: boolean | null; + overall_score?: number | null; +} + +interface FeedbackRow { + status: string | null; + created_at: string; +} + +interface SubscriptionRow { + status: string | null; + created_at: string; + updated_at: string; +} + +const DEFAULT_ANALYTICS: AdminAnalyticsStats = { + engagement: { + dau: 0, + wau: 0, + mau: 0, + newUsers30d: 0, + retention7d: 0, + retention30d: 0, + }, + aiUsage: { + tokens30d: 0, + cost30d: 0, + sessions30d: 0, + avgMessagesPerSession: 0, + featureBreakdown: [], + modelBreakdown: [], + dailySeries: [], + }, + problems: { + attempts30d: 0, + passRate30d: 0, + uniqueProblems30d: 0, + avgAttemptsPerProblem: 0, + topProblems: [], + }, + streaks: { + averageCurrentStreak: 0, + maxStreak: 0, + atRiskUsers: 0, + }, + flashcards: { + decksTotal: 0, + reviews30d: 0, + dueNow: 0, + avgMastery: 0, + }, + behavioral: { + sessionsStarted30d: 0, + sessionsCompleted30d: 0, + avgScore30d: 0, + }, + mockInterviews: { + sessionsStarted30d: 0, + sessionsCompleted30d: 0, + avgScore30d: 0, + }, + content: { + questionsAdded30d: 0, + solutionsAdded30d: 0, + storiesAdded30d: 0, + storyReuseRate30d: 0, + }, + feedback: { + newFeedback30d: 0, + openCount: 0, + resolvedCount: 0, + }, + subscriptions: { + active: 0, + trialing: 0, + cancelled: 0, + pastDue: 0, + new30d: 0, + churned30d: 0, + userJoinDates: [], + subscriptionJoinDates: [], + cancellationsDates: [], + userJoinTimeline: [], + cancellationsTimeline: [], + }, +}; + +const startOfDay = (date: Date) => { + const copy = new Date(date); + copy.setHours(0, 0, 0, 0); + return copy; +}; + +const addDays = (date: Date, offset: number) => { + const copy = new Date(date); + copy.setDate(copy.getDate() + offset); + return copy; +}; + +const toDateKey = (date: Date) => date.toISOString().slice(0, 10); + +const average = (values: number[]) => + values.length === 0 + ? 0 + : values.reduce((sum, value) => sum + value, 0) / values.length; + +const buildSeries = (days: number): AnalyticsTimeSeriesPoint[] => { + const today = startOfDay(new Date()); + const series: AnalyticsTimeSeriesPoint[] = []; + + for (let i = days - 1; i >= 0; i -= 1) { + const date = addDays(today, -i); + series.push({ + date: toDateKey(date), + tokens: 0, + cost: 0, + sessions: 0, + activeUsers: 0, + }); + } + + return series; +}; + +const mapToBreakdown = (map: Map, limit = 6): AnalyticsBreakdownItem[] => { + return Array.from(map.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, limit) + .map(([label, value]) => ({ label, value })); +}; + +const getResolutionKey = (date: Date, resolution: "day" | "month" | "year") => { + if (resolution === "year") { + return `${date.getFullYear()}`; + } + if (resolution === "month") { + return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + } + return toDateKey(date); +}; + +const aggregateTimeline = ( + dates: string[], + resolution: "day" | "month" | "year" +): AnalyticsTimelinePoint[] => { + const counts = new Map(); + dates.forEach((value) => { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return; + const key = getResolutionKey(date, resolution); + 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 })); +}; + +export const useAdminAnalytics = () => { + const { data, loading, execute } = useAsyncOperation({ + initialData: DEFAULT_ANALYTICS, + }); + + const fetchAnalytics = useCallback(async (): Promise => { + const now = new Date(); + const start1d = startOfDay(now).toISOString(); + const start7d = startOfDay(addDays(now, -7)).toISOString(); + const start30d = startOfDay(addDays(now, -30)).toISOString(); + + const activitySources = [ + { table: "ai_chat_sessions", dateColumn: "created_at" }, + { table: "coaching_sessions", dateColumn: "started_at" }, + { table: "user_problem_attempts", dateColumn: "created_at" }, + { table: "practice_sessions", dateColumn: "started_at" }, + { table: "behavioral_interview_sessions", dateColumn: "started_at" }, + ]; + + const fetchActiveUsers = async (since: string) => { + const results = await Promise.all( + activitySources.map((source) => + supabase.from(source.table).select("user_id").gte(source.dateColumn, since) + ) + ); + + const ids = new Set(); + results.forEach(({ data: rows, error }) => { + if (error) throw error; + (rows || []).forEach((row) => { + if (row.user_id) ids.add(row.user_id); + }); + }); + + return ids; + }; + + const [active1d, active7d, active30d] = await Promise.all([ + fetchActiveUsers(start1d), + fetchActiveUsers(start7d), + fetchActiveUsers(start30d), + ]); + + const { data: newUsersData, error: newUsersError } = await supabase + .from("user_profiles") + .select("user_id") + .gte("created_at", start30d); + + if (newUsersError) throw newUsersError; + + const newUserIds = new Set((newUsersData || []).map((user) => user.user_id)); + const retained7d = Array.from(newUserIds).filter((id) => active7d.has(id)).length; + const retained30d = Array.from(newUserIds).filter((id) => active30d.has(id)).length; + + const [ + aiUsageResult, + aiChatSessionsResult, + aiChatMessagesResult, + attemptsResult, + streaksResult, + decksCountResult, + decksDueResult, + deckMasteryResult, + reviewsCountResult, + practiceSessionsResult, + mockSessionsResult, + mockScoresResult, + behavioralQuestionsResult, + problemSolutionsResult, + storiesResult, + practiceAnswersResult, + feedbackResult, + userProfilesResult, + subscriptionsResult, + ] = await Promise.all([ + supabase + .from("user_ai_usage") + .select( + "user_id, session_id, tokens_total, tokens_input, tokens_output, estimated_cost, model, feature, created_at" + ) + .gte("created_at", start30d), + supabase + .from("ai_chat_sessions") + .select("id, created_at", { count: "exact" }) + .gte("created_at", start30d), + supabase + .from("ai_chat_messages") + .select("id", { count: "exact" }) + .gte("created_at", start30d), + supabase + .from("user_problem_attempts") + .select("problem_id, status") + .gte("created_at", start30d), + supabase + .from("user_statistics") + .select("current_streak, max_streak, last_activity_date"), + supabase.from("flashcard_decks").select("id", { count: "exact", head: true }), + supabase + .from("flashcard_decks") + .select("id", { count: "exact", head: true }) + .lte("next_review_date", toDateKey(now)), + supabase.from("flashcard_decks").select("mastery_level"), + supabase + .from("flashcard_reviews") + .select("id", { count: "exact", head: true }) + .gte("reviewed_at", start30d), + supabase + .from("practice_sessions") + .select("completed_at, average_score") + .gte("started_at", start30d), + supabase + .from("mock_interviews") + .select("completed_at") + .gte("started_at", start30d), + supabase + .from("mock_interview_answers") + .select("overall_score") + .gte("created_at", start30d), + supabase + .from("behavioral_questions") + .select("id", { count: "exact", head: true }) + .gte("created_at", start30d), + supabase + .from("problem_solutions") + .select("id", { count: "exact", head: true }) + .gte("created_at", start30d), + supabase + .from("user_stories") + .select("id", { count: "exact", head: true }) + .gte("created_at", start30d), + supabase + .from("practice_answers") + .select("id, story_id") + .gte("created_at", start30d), + supabase.from("feedback").select("status, created_at"), + supabase.from("user_profiles").select("created_at"), + supabase.from("user_subscriptions").select("status, created_at, updated_at"), + ]); + + if (aiUsageResult.error) throw aiUsageResult.error; + if (aiChatSessionsResult.error) throw aiChatSessionsResult.error; + if (aiChatMessagesResult.error) throw aiChatMessagesResult.error; + if (attemptsResult.error) throw attemptsResult.error; + if (streaksResult.error) throw streaksResult.error; + if (decksCountResult.error) throw decksCountResult.error; + if (decksDueResult.error) throw decksDueResult.error; + if (deckMasteryResult.error) throw deckMasteryResult.error; + if (reviewsCountResult.error) throw reviewsCountResult.error; + if (practiceSessionsResult.error) throw practiceSessionsResult.error; + if (mockSessionsResult.error) throw mockSessionsResult.error; + if (mockScoresResult.error) throw mockScoresResult.error; + if (behavioralQuestionsResult.error) throw behavioralQuestionsResult.error; + if (problemSolutionsResult.error) throw problemSolutionsResult.error; + if (storiesResult.error) throw storiesResult.error; + if (practiceAnswersResult.error) throw practiceAnswersResult.error; + if (feedbackResult.error) throw feedbackResult.error; + if (userProfilesResult.error) throw userProfilesResult.error; + if (subscriptionsResult.error) throw subscriptionsResult.error; + + const aiUsageRows = (aiUsageResult.data || []) as AiUsageRow[]; + const aiChatSessions = (aiChatSessionsResult.data || []) as SessionRow[]; + const aiChatMessagesCount = aiChatMessagesResult.count || 0; + + const tokens30d = aiUsageRows.reduce( + (sum, row) => sum + (row.tokens_total || 0), + 0 + ); + const cost30d = aiUsageRows.reduce( + (sum, row) => sum + (row.estimated_cost || 0), + 0 + ); + + const sessionIdSet = new Set(); + const featureMap = new Map(); + const modelMap = new Map(); + + const dailySeries = buildSeries(30); + const dailyMap = new Map(); + const dailyUserMap = new Map>(); + const dailySessionMap = new Map>(); + dailySeries.forEach((point) => { + dailyMap.set(point.date, point); + }); + + aiUsageRows.forEach((row) => { + if (row.session_id) sessionIdSet.add(row.session_id); + const feature = row.feature || "unknown"; + const model = row.model || "unknown"; + featureMap.set(feature, (featureMap.get(feature) || 0) + (row.tokens_total || 0)); + modelMap.set(model, (modelMap.get(model) || 0) + (row.tokens_total || 0)); + + const dateKey = row.created_at.slice(0, 10); + const point = dailyMap.get(dateKey); + if (point) { + point.tokens += row.tokens_total || 0; + point.cost += row.estimated_cost || 0; + if (row.session_id) { + const sessionSet = dailySessionMap.get(dateKey) || new Set(); + sessionSet.add(row.session_id); + dailySessionMap.set(dateKey, sessionSet); + } + if (row.user_id) { + const userSet = dailyUserMap.get(dateKey) || new Set(); + userSet.add(row.user_id); + dailyUserMap.set(dateKey, userSet); + } + } + }); + + dailySeries.forEach((point) => { + point.sessions = dailySessionMap.get(point.date)?.size || 0; + point.activeUsers = dailyUserMap.get(point.date)?.size || 0; + }); + + const aiSessions30d = sessionIdSet.size || aiChatSessionsResult.count || 0; + const avgMessagesPerSession = + aiSessions30d > 0 ? aiChatMessagesCount / aiSessions30d : 0; + + const attemptsRows = (attemptsResult.data || []) as AttemptRow[]; + const attempts30d = attemptsRows.length; + const passedAttempts = attemptsRows.filter((row) => row.status === "passed").length; + const uniqueProblems = new Set( + attemptsRows.map((row) => row.problem_id).filter(Boolean) as string[] + ); + const problemMap = new Map(); + attemptsRows.forEach((row) => { + if (!row.problem_id) return; + problemMap.set(row.problem_id, (problemMap.get(row.problem_id) || 0) + 1); + }); + + const streakRows = (streaksResult.data || []) as StreakRow[]; + const currentStreaks = streakRows.map((row) => row.current_streak || 0); + const maxStreak = Math.max(0, ...streakRows.map((row) => row.max_streak || 0)); + const atRiskCutoff = startOfDay(addDays(now, -3)); + const atRiskUsers = streakRows.filter((row) => { + if (!row.last_activity_date) return false; + return ( + (row.current_streak || 0) > 0 && + new Date(row.last_activity_date) < atRiskCutoff + ); + }).length; + + const masteryRows = deckMasteryResult.data || []; + const masteryValues = masteryRows + .map((row) => row.mastery_level) + .filter((value): value is number => typeof value === "number"); + + const practiceSessions = (practiceSessionsResult.data || []) as PracticeSessionRow[]; + const practiceCompletedScores = practiceSessions + .map((session) => session.average_score) + .filter((score): score is number => typeof score === "number"); + + const mockSessions = (mockSessionsResult.data || []) as PracticeSessionRow[]; + const mockScores = (mockScoresResult.data || []) + .map((row) => row.overall_score) + .filter((score): score is number => typeof score === "number"); + + const practiceAnswers = practiceAnswersResult.data || []; + const totalPracticeAnswers = practiceAnswers.length; + const practiceWithStory = practiceAnswers.filter((row) => row.story_id).length; + + const feedbackRows = (feedbackResult.data || []) as FeedbackRow[]; + const feedbackRecent = feedbackRows.filter( + (row) => row.created_at >= start30d + ); + + const subscriptionRows = (subscriptionsResult.data || []) as SubscriptionRow[]; + const newSubscriptions30d = subscriptionRows.filter( + (row) => row.created_at >= start30d + ).length; + const churned30d = subscriptionRows.filter( + (row) => row.status === "cancelled" && row.updated_at >= start30d + ).length; + const userJoinDates = (userProfilesResult.data || []).map( + (row) => row.created_at + ); + const subscriptionJoinDates = subscriptionRows.map((row) => row.created_at); + const cancellationsDates = subscriptionRows + .filter((row) => row.status === "cancelled") + .map((row) => row.updated_at); + + return { + engagement: { + dau: active1d.size, + wau: active7d.size, + mau: active30d.size, + newUsers30d: newUserIds.size, + retention7d: newUserIds.size > 0 ? retained7d / newUserIds.size : 0, + retention30d: newUserIds.size > 0 ? retained30d / newUserIds.size : 0, + }, + aiUsage: { + tokens30d, + cost30d, + sessions30d: aiSessions30d, + avgMessagesPerSession, + featureBreakdown: mapToBreakdown(featureMap), + modelBreakdown: mapToBreakdown(modelMap), + dailySeries, + }, + problems: { + attempts30d, + passRate30d: attempts30d > 0 ? passedAttempts / attempts30d : 0, + uniqueProblems30d: uniqueProblems.size, + avgAttemptsPerProblem: + uniqueProblems.size > 0 ? attempts30d / uniqueProblems.size : 0, + topProblems: mapToBreakdown(problemMap), + }, + streaks: { + averageCurrentStreak: average(currentStreaks), + maxStreak, + atRiskUsers, + }, + flashcards: { + decksTotal: decksCountResult.count || 0, + reviews30d: reviewsCountResult.count || 0, + dueNow: decksDueResult.count || 0, + avgMastery: average(masteryValues), + }, + behavioral: { + sessionsStarted30d: practiceSessions.length, + sessionsCompleted30d: practiceSessions.filter((row) => row.completed_at).length, + avgScore30d: average(practiceCompletedScores), + }, + mockInterviews: { + sessionsStarted30d: mockSessions.length, + sessionsCompleted30d: mockSessions.filter((row) => row.completed_at).length, + avgScore30d: average(mockScores), + }, + content: { + questionsAdded30d: behavioralQuestionsResult.count || 0, + solutionsAdded30d: problemSolutionsResult.count || 0, + storiesAdded30d: storiesResult.count || 0, + storyReuseRate30d: + totalPracticeAnswers > 0 ? practiceWithStory / totalPracticeAnswers : 0, + }, + feedback: { + newFeedback30d: feedbackRecent.length, + openCount: feedbackRows.filter((row) => + ["open", "in_progress"].includes(row.status || "") + ).length, + resolvedCount: feedbackRows.filter((row) => + ["resolved", "closed"].includes(row.status || "") + ).length, + }, + subscriptions: { + active: subscriptionRows.filter((row) => row.status === "active").length, + trialing: subscriptionRows.filter((row) => row.status === "trialing").length, + cancelled: subscriptionRows.filter((row) => row.status === "cancelled").length, + pastDue: subscriptionRows.filter((row) => row.status === "past_due").length, + new30d: newSubscriptions30d, + churned30d, + userJoinDates, + subscriptionJoinDates, + cancellationsDates, + userJoinTimeline: aggregateTimeline(userJoinDates, "month"), + cancellationsTimeline: aggregateTimeline(cancellationsDates, "month"), + }, + }; + }, []); + + const loadAnalytics = useCallback(async () => { + await execute(fetchAnalytics, { + errorMessage: "Failed to load analytics", + onError: (error) => { + logger.error("[AdminAnalytics] Failed to load analytics", { + error: error.message, + }); + }, + }); + }, [execute, fetchAnalytics]); + + useEffect(() => { + loadAnalytics(); + }, [loadAnalytics]); + + return { + analytics: data || DEFAULT_ANALYTICS, + loading, + refresh: loadAnalytics, + }; +}; diff --git a/src/features/admin/hooks/useAdminDashboard.ts b/src/features/admin/hooks/useAdminDashboard.ts index 5cebec2..91ced5c 100644 --- a/src/features/admin/hooks/useAdminDashboard.ts +++ b/src/features/admin/hooks/useAdminDashboard.ts @@ -55,6 +55,97 @@ export function useAdminDashboard(): UseAdminDashboardReturn { .map((s) => s.user_id) ); + const now = new Date(); + const todayStart = new Date(now); + todayStart.setHours(0, 0, 0, 0); + const monthStart = new Date(now.getFullYear(), now.getMonth(), 1); + + const { data: usageRows } = await supabase + .from("user_ai_usage") + .select("user_id, tokens_total, estimated_cost, created_at") + .gte("created_at", monthStart.toISOString()); + + const usageByUserId = new Map< + string, + { tokensToday: number; tokensMonth: number; costToday: number; costMonth: number } + >(); + + for (const row of usageRows || []) { + const userId = row.user_id; + const createdAt = row.created_at ? new Date(row.created_at) : null; + const tokens = row.tokens_total ?? 0; + const cost = row.estimated_cost ?? 0; + + const existing = usageByUserId.get(userId) || { + tokensToday: 0, + tokensMonth: 0, + costToday: 0, + costMonth: 0, + }; + + existing.tokensMonth += tokens; + existing.costMonth += cost; + + if (createdAt && createdAt >= todayStart) { + existing.tokensToday += tokens; + existing.costToday += cost; + } + + usageByUserId.set(userId, existing); + } + + // Pre-fetch last activity dates from all activity sources (more efficient than per-user queries) + const [ + { data: chatActivity }, + { data: coachingActivity }, + { data: problemActivity }, + { data: behavioralActivity }, + { data: technicalActivity }, + { data: systemDesignActivity }, + { data: flashcardActivity }, + ] = await Promise.all([ + supabase.from("ai_chat_sessions").select("user_id, updated_at"), + supabase.from("coaching_sessions").select("user_id, updated_at"), + supabase.from("user_problem_attempts").select("user_id, updated_at"), + supabase.from("behavioral_interview_sessions").select("user_id, started_at"), + supabase.from("technical_interview_sessions").select("user_id, started_at"), + supabase.from("system_design_sessions").select("user_id, updated_at"), + supabase.from("flashcard_reviews").select("deck_id, reviewed_at"), + ]); + + // Get flashcard deck owners for mapping reviews to users + const deckIds = [...new Set((flashcardActivity || []).map((r) => r.deck_id))]; + const { data: deckOwners } = deckIds.length > 0 + ? await supabase.from("flashcard_decks").select("id, user_id").in("id", deckIds) + : { data: [] }; + + const deckToUserMap = new Map((deckOwners || []).map((d) => [d.id, d.user_id])); + + // Build a map of user_id -> most recent activity date + const lastActivityByUser = new Map(); + + const updateLastActivity = (userId: string | null, dateStr: string | null) => { + if (!userId || !dateStr) return; + const date = new Date(dateStr); + if (isNaN(date.getTime())) return; + const existing = lastActivityByUser.get(userId); + if (!existing || date > existing) { + lastActivityByUser.set(userId, date); + } + }; + + // Process all activity sources + (chatActivity || []).forEach((row) => updateLastActivity(row.user_id, row.updated_at)); + (coachingActivity || []).forEach((row) => updateLastActivity(row.user_id, row.updated_at)); + (problemActivity || []).forEach((row) => updateLastActivity(row.user_id, row.updated_at)); + (behavioralActivity || []).forEach((row) => updateLastActivity(row.user_id, row.started_at)); + (technicalActivity || []).forEach((row) => updateLastActivity(row.user_id, row.started_at)); + (systemDesignActivity || []).forEach((row) => updateLastActivity(row.user_id, row.updated_at)); + (flashcardActivity || []).forEach((row) => { + const userId = deckToUserMap.get(row.deck_id); + updateLastActivity(userId || null, row.reviewed_at); + }); + // For each user, fetch their stats const userStatsPromises = (profilesData || []).map(async (profile) => { const userId = profile.user_id; @@ -106,15 +197,20 @@ export function useAdminDashboard(): UseAdminDashboardReturn { .single(); // Get AI usage (today and this month) + const aggregatedUsage = usageByUserId.get(userId); const aiUsage = { - tokens_today: 0, - tokens_month: 0, - cost_today: 0, - cost_month: 0, + tokens_today: aggregatedUsage?.tokensToday ?? 0, + tokens_month: aggregatedUsage?.tokensMonth ?? 0, + cost_today: aggregatedUsage?.costToday ?? 0, + cost_month: aggregatedUsage?.costMonth ?? 0, }; const solvedCount = statsData?.total_solved || passedCount || 0; + // Get last active from pre-computed map (tracks all activities, not just problem solving) + const lastActiveDate = lastActivityByUser.get(userId); + const lastActive = lastActiveDate ? lastActiveDate.toISOString() : null; + return { id: userId, email: profile.email || profile.name || "No email", @@ -123,7 +219,7 @@ export function useAdminDashboard(): UseAdminDashboardReturn { problems_solved: solvedCount, chat_messages: chatCount || 0, coaching_sessions: coachingCount || 0, - last_active: statsData?.last_activity_date || null, + last_active: lastActive, recent_problems: (recentProblems || []).map((p) => p.problem_id), ai_restriction: aiRestriction ? { @@ -244,8 +340,10 @@ export function useAdminDashboard(): UseAdminDashboardReturn { const monthlyPrice = 9.99; const yearlyPrice = 99.99; - (subscriptions || []).forEach((sub: SubscriptionWithProfile) => { - const userEmail = sub.user_profiles?.email; + (subscriptions || []).forEach((sub) => { + const userEmail = ( + sub as unknown as { user_profiles?: { email?: string | null } } + ).user_profiles?.email; // Skip admin users if (adminEmails.includes(userEmail)) { diff --git a/src/features/admin/types/admin.types.ts b/src/features/admin/types/admin.types.ts index db36a65..29b61cf 100644 --- a/src/features/admin/types/admin.types.ts +++ b/src/features/admin/types/admin.types.ts @@ -52,6 +52,98 @@ export interface AdminOverviewStats { mrr: number; } +export interface AnalyticsBreakdownItem { + label: string; + value: number; +} + +export interface AnalyticsTimeSeriesPoint { + date: string; + tokens: number; + cost: number; + sessions: number; + activeUsers: number; +} + +export interface AnalyticsTimelinePoint { + date: string; + count: number; +} + +export type AnalyticsResolution = "day" | "month" | "year"; + +export interface AdminAnalyticsStats { + engagement: { + dau: number; + wau: number; + mau: number; + newUsers30d: number; + retention7d: number; + retention30d: number; + }; + aiUsage: { + tokens30d: number; + cost30d: number; + sessions30d: number; + avgMessagesPerSession: number; + featureBreakdown: AnalyticsBreakdownItem[]; + modelBreakdown: AnalyticsBreakdownItem[]; + dailySeries: AnalyticsTimeSeriesPoint[]; + }; + problems: { + attempts30d: number; + passRate30d: number; + uniqueProblems30d: number; + avgAttemptsPerProblem: number; + topProblems: AnalyticsBreakdownItem[]; + }; + streaks: { + averageCurrentStreak: number; + maxStreak: number; + atRiskUsers: number; + }; + flashcards: { + decksTotal: number; + reviews30d: number; + dueNow: number; + avgMastery: number; + }; + behavioral: { + sessionsStarted30d: number; + sessionsCompleted30d: number; + avgScore30d: number; + }; + mockInterviews: { + sessionsStarted30d: number; + sessionsCompleted30d: number; + avgScore30d: number; + }; + content: { + questionsAdded30d: number; + solutionsAdded30d: number; + storiesAdded30d: number; + storyReuseRate30d: number; + }; + feedback: { + newFeedback30d: number; + openCount: number; + resolvedCount: number; + }; + subscriptions: { + active: number; + trialing: number; + cancelled: number; + pastDue: number; + new30d: number; + churned30d: number; + userJoinDates: string[]; + subscriptionJoinDates: string[]; + cancellationsDates: string[]; + userJoinTimeline: AnalyticsTimelinePoint[]; + cancellationsTimeline: AnalyticsTimelinePoint[]; + }; +} + export interface DialogUserInfo { id: string; email: string; diff --git a/src/features/problem-solver/hooks/useCodeInsertion.ts b/src/features/problem-solver/hooks/useCodeInsertion.ts index d65c578..b444743 100644 --- a/src/features/problem-solver/hooks/useCodeInsertion.ts +++ b/src/features/problem-solver/hooks/useCodeInsertion.ts @@ -128,8 +128,12 @@ export const useCodeInsertion = ({ currentCodeLength: currentCode.length, cursorPosition, }); + console.log('[CodeInsertion] About to call ai-chat insert_snippet...'); + console.log('[CodeInsertion] Current code length:', currentCode.length); + console.log('[CodeInsertion] Snippet code:', snippet.code?.substring(0, 100)); try { + console.log('[CodeInsertion] Invoking supabase.functions.invoke...'); const { data, error } = await supabase.functions.invoke("ai-chat", { body: { action: "insert_snippet", diff --git a/src/features/profile/ProfilePage.tsx b/src/features/profile/ProfilePage.tsx index 661e4c6..d3413f2 100644 --- a/src/features/profile/ProfilePage.tsx +++ b/src/features/profile/ProfilePage.tsx @@ -50,8 +50,8 @@ const Profile = () => { if (statsLoading) { return ( -
-
Loading...
+
+
Loading...
); } @@ -61,77 +61,77 @@ const Profile = () => { initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.6 }} - className="min-h-screen bg-[#FAFAF8] py-16 px-6" + className="min-h-screen bg-background py-16 px-6" >
{/* Back Navigation */} {/* Profile Header */} -
- +
+ - + {initials}
-

+

{displayName}

-

+

Member since {memberSince}

{/* Performance Summary */} -
-

+
+

Performance

-
+
{stats.totalSolved}
-
problems solved
+
problems solved
-
-
+
+
{stats.streak}
-
current streak
+
current streak
-
-
+
+
{stats.maxStreak}
-
max streak
+
max streak
{/* Problem Distribution */} -
-

+
+

Problem Distribution

- Easy - + Easy + {stats.easySolved} / {totalEasy}
-
+
{
- Medium - + Medium + {stats.mediumSolved} / {totalMedium}
-
+
{
- Hard - + Hard + {stats.hardSolved} / {totalHard}
-
+
{
{/* Flashcards Section */} -
+
-

+

Flashcards

-

+

Spaced repetition knowledge system

-
+
{safeFlashcardStats.totalCards}
-
total
+
total
-
+
{safeFlashcardStats.dueToday}
-
due
+
due
-
+
{safeFlashcardStats.masteredCards}
-
mastered
+
mastered
- Overall mastery - + Overall mastery + {flashcardMastery}%
-
+
{ + console.log('[insertCorrectCode] Called'); + console.log('[insertCorrectCode] lastValidation:', !!coachingState.lastValidation); + console.log('[insertCorrectCode] codeToAdd:', coachingState.lastValidation?.codeToAdd?.substring(0, 50)); if (!coachingState.lastValidation?.codeToAdd) return; const codeToInsert = stripCodeFences(coachingState.lastValidation.codeToAdd); + console.log('[insertCorrectCode] codeToInsert:', codeToInsert?.substring(0, 50)); try { const editor = editorRef.current; const before = editor?.getValue() || ""; + console.log('[insertCorrectCode] before code length:', before?.length); + console.log('[insertCorrectCode] codeContainsSnippet:', codeContainsSnippet(before, codeToInsert)); if (!codeContainsSnippet(before, codeToInsert)) { - if (isLargeInsertion(codeToInsert)) { - const lines = codeToInsert.split('\n').filter(l => l.trim().length > 0); - const ok = confirmLargeInsert - ? await confirmLargeInsert({ code: codeToInsert, lineCount: lines.length }) - : window.confirm('The suggested fix looks large and may replace part of your function. Proceed?'); - if (!ok) { - setCoachingState(prev => ({ - ...prev, - feedback: { show: true, type: 'hint', message: 'Insertion canceled. You can paste manually or apply a smaller change.', showConfetti: false }, - })); - return; - } - } else { - logger.debug("Using smart replacement for code correction", { component: "Coaching" }); - } + // Skip large insertion check for coaching - the code is AI-validated and typically small helper functions + // The isLargeInsertion check was triggering for any function definition which is too aggressive + logger.debug("Proceeding with coaching code insertion", { component: "Coaching", codeLength: codeToInsert.length }); logger.debug("Using shared insertion logic for consistency with chat mode", { component: "Coaching" }); + console.log('[insertCorrectCode] onCodeInsert exists:', !!onCodeInsert); if (onCodeInsert) { + console.log('[insertCorrectCode] Calling onCodeInsert...'); await onCodeInsert(codeToInsert); + console.log('[insertCorrectCode] onCodeInsert completed'); } logger.debug("Shared insertion completed successfully", { component: "Coaching" }); diff --git a/supabase/functions/ai-chat/code-analysis.ts b/supabase/functions/ai-chat/code-analysis.ts index 7514f43..e9100f2 100644 --- a/supabase/functions/ai-chat/code-analysis.ts +++ b/supabase/functions/ai-chat/code-analysis.ts @@ -1,4 +1,4 @@ -import { llmText, llmJson, llmJsonFast, llmWithSessionContext, getOrCreateSessionContext, updateSessionContext } from "./openai-utils.ts"; +import { llmText, llmJson, llmJsonFast, llmWithSessionContext, getOrCreateSessionContext, updateSessionContext, UsageContext } from "./openai-utils.ts"; import { CodeSnippet, ChatMessage, ContextualResponse } from "./types.ts"; import { generateModeSpecificPrompt, validateCoachingMode, type CoachingMode } from "./prompts.ts"; @@ -107,6 +107,7 @@ export async function generateConversationResponse( currentCode?: string, sessionId?: string, // context management key options?: { previousResponseId?: string | null; forceNewContext?: boolean; coachingMode?: "socratic" | "comprehensive" }, + usageContext?: UsageContext, ): Promise { // Check if we can use context-aware approach or need to fallback if (!sessionId) { @@ -132,7 +133,7 @@ Analyze their current code and respond naturally based on their question.`; const response = await llmText(fullPrompt, { temperature: 0.3, maxTokens: 220, - }); + }, usageContext); return response || "I'm sorry, I couldn't generate a response. Please try again."; } catch (error) { console.error("[chat] Legacy generation failed:", error); @@ -476,6 +477,11 @@ export async function insertSnippetSmart( usedFallback: boolean; } }> { + console.log("[insertSnippetSmart] Starting", { + codeLength: code?.length || 0, + snippetLength: snippet?.code?.length || 0, + hasExistingCode: (code?.trim()?.length || 0) > 0, + }); const mergePrompt = `You are a smart code merging assistant. Your job is to intelligently merge the current code with the new snippet while PRESERVING as much of the existing code as possible. @@ -519,7 +525,9 @@ Return JSON: }`; try { + console.log("[insertSnippetSmart] Calling llmJson with prompt length:", mergePrompt.length); const raw = await llmJson(mergePrompt, { maxTokens: 1500 }); + console.log("[insertSnippetSmart] llmJson returned, raw length:", raw?.length || 0); const result = JSON.parse(raw || '{}'); if (!result.newCode) { diff --git a/supabase/functions/ai-chat/index.ts b/supabase/functions/ai-chat/index.ts index 6b421b1..8e3c76f 100644 --- a/supabase/functions/ai-chat/index.ts +++ b/supabase/functions/ai-chat/index.ts @@ -505,6 +505,9 @@ serve(async (req) => { } try { + console.log("[ai-chat] Calling insertSnippetSmart..."); + console.log("[ai-chat] Code preview:", code?.substring(0, 100)); + console.log("[ai-chat] Snippet preview:", snippet?.code?.substring(0, 100)); const result = await insertSnippetSmart( code, snippet, @@ -512,6 +515,7 @@ serve(async (req) => { cursorPosition, message || "" ); + console.log("[ai-chat] insertSnippetSmart completed successfully"); return new Response(JSON.stringify(result), { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); @@ -868,6 +872,7 @@ Conversation: ${JSON.stringify(conversationHistory)}`; previousResponseId: typeof previousResponseId === 'string' ? previousResponseId : null, coachingMode: validatedCoachingMode, }, + userId ? { userId, feature: "ai_chat" } : undefined, ), analyzeCodeSnippets( (message || "").slice(0, 800), diff --git a/supabase/functions/ai-chat/openai-utils.ts b/supabase/functions/ai-chat/openai-utils.ts index 5c73ae2..67d4fe1 100644 --- a/supabase/functions/ai-chat/openai-utils.ts +++ b/supabase/functions/ai-chat/openai-utils.ts @@ -1,11 +1,104 @@ // @ts-expect-error - Deno URL import import OpenAI from "https://esm.sh/openai@4"; +// @ts-expect-error - Deno URL import +import { createClient } from "https://esm.sh/@supabase/supabase-js@2"; import { ResponsesApiRequest, ResponsesApiResponse, SessionContext, ContextualResponse } from "./types.ts"; import { logger } from "./utils/logger.ts"; // Ambient declaration for Deno types declare const Deno: { env: { get(name: string): string | undefined } }; +// Usage tracking types +export type UsageContext = { + userId: string; + feature: string; +}; + +type UsageInfo = { + tokensInput: number | null; + tokensOutput: number | null; + tokensTotal: number | null; +}; + +// Supabase admin client for usage logging +const supabaseUrl = Deno.env.get("SUPABASE_URL"); +const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY"); +const supabaseAdmin = (supabaseUrl && supabaseServiceKey) + ? createClient(supabaseUrl, supabaseServiceKey) + : null; + +function estimateTokensFromText(text: string): number { + return Math.max(1, Math.ceil((text || "").length / 4)); +} + +function extractUsageInfoFromResponse(response: unknown): UsageInfo { + const r = response as { + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + }; + const usage = r?.usage; + if (!usage) { + return { tokensInput: null, tokensOutput: null, tokensTotal: null }; + } + return { + tokensInput: typeof usage.prompt_tokens === "number" ? usage.prompt_tokens : null, + tokensOutput: typeof usage.completion_tokens === "number" ? usage.completion_tokens : null, + tokensTotal: typeof usage.total_tokens === "number" ? usage.total_tokens : null, + }; +} + +function normalizeUsageInfo(usage: UsageInfo, prompt: string, output: string): UsageInfo { + const hasAnyUsage = usage.tokensInput !== null || usage.tokensOutput !== null || usage.tokensTotal !== null; + if (hasAnyUsage) { + return { + tokensInput: usage.tokensInput, + tokensOutput: usage.tokensOutput, + tokensTotal: usage.tokensTotal ?? (((usage.tokensInput ?? 0) + (usage.tokensOutput ?? 0)) || null), + }; + } + // Estimate tokens from text if provider didn't return usage + const tokensInput = estimateTokensFromText(prompt); + const tokensOutput = estimateTokensFromText(output); + return { tokensInput, tokensOutput, tokensTotal: tokensInput + tokensOutput }; +} + +async function logUsageToDb(context: UsageContext, model: string, usage: UsageInfo): Promise { + if (!supabaseAdmin) { + console.warn("[usage] Logging disabled: missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY"); + return; + } + if (!context?.userId) { + console.warn("[usage] Logging skipped: missing userId"); + return; + } + if (usage.tokensInput === null && usage.tokensOutput === null && usage.tokensTotal === null) { + console.warn("[usage] Logging skipped: no usage data"); + return; + } + try { + const { error } = await supabaseAdmin.from("user_ai_usage").insert({ + user_id: context.userId, + feature: context.feature, + model, + tokens_input: usage.tokensInput, + tokens_output: usage.tokensOutput, + tokens_total: usage.tokensTotal, + estimated_cost: null, + session_id: null, + }); + if (error) { + console.warn("[usage] Failed to insert:", error.message); + } else { + console.log("[usage] Logged:", { userId: context.userId, feature: context.feature, tokens: usage.tokensTotal }); + } + } catch (e) { + console.warn("[usage] Insert error:", (e as Error)?.message); + } +} + // LLM Provider Configuration const useOpenRouter = !!Deno.env.get("OPENROUTER_API_KEY"); const openrouterModel = Deno.env.get("OPENROUTER_MODEL") || "google/gemini-3-flash-preview"; @@ -154,6 +247,7 @@ export async function llmText( maxTokens?: number; responseFormat?: "json_object" | undefined; }, + usageContext?: UsageContext, ): Promise { const openai = getOpenAI(); const model = configuredModel; @@ -199,7 +293,7 @@ export async function llmText( `[ai-chat] Using Chat Completions API with model=${chatModel} (fallback=${useResponsesApi ? "yes" : "no"})`, ); - const chatRequestParams: unknown = { + const chatRequestParams: Record = { model: chatModel, messages: [{ role: "user", content: prompt }], max_completion_tokens: opts.maxTokens ?? 500, @@ -208,13 +302,26 @@ export async function llmText( : undefined, }; + // Request usage data from OpenRouter when we need to log it + if (useOpenRouter && usageContext) { + chatRequestParams.usage = { include: true }; + } + // Only add temperature for non-GPT-5 models if (!chatModel.startsWith("gpt-5")) { chatRequestParams.temperature = opts.temperature ?? 0.7; } - const chat = await openai.chat.completions.create(chatRequestParams as unknown as { choices: Array<{ message?: { content?: string } }> }); - return chat.choices[0]?.message?.content || ""; + const chat = await (openai.chat.completions as unknown as { create: (body: unknown) => Promise }).create(chatRequestParams); + const content = (chat as { choices?: Array<{ message?: { content?: string } }> }).choices?.[0]?.message?.content || ""; + + // Log usage if context provided + if (usageContext) { + const rawUsage = extractUsageInfoFromResponse(chat); + await logUsageToDb(usageContext, chatModel, normalizeUsageInfo(rawUsage, prompt, content)); + } + + return content; } /** @@ -223,12 +330,13 @@ export async function llmText( export async function llmJson( prompt: string, opts: { temperature?: number; maxTokens?: number }, + usageContext?: UsageContext, ): Promise { return await llmText(prompt, { temperature: opts.temperature, maxTokens: opts.maxTokens, responseFormat: "json_object", - }); + }, usageContext); } /** diff --git a/supabase/functions/ai-coach-chat/index.ts b/supabase/functions/ai-coach-chat/index.ts index 8c235ec..ef02290 100644 --- a/supabase/functions/ai-coach-chat/index.ts +++ b/supabase/functions/ai-coach-chat/index.ts @@ -255,6 +255,7 @@ serve(async (req) => { previousResponseId: typeof previousResponseId === 'string' ? previousResponseId : null, coachingMode: validatedCoachingMode, }, + userId ? { userId, feature: "ai_chat" } : undefined, ), analyzeCodeSnippets( (message || "").slice(0, 800), From f4497bfa45a565a12a2e44241ebd67fab783d8c2 Mon Sep 17 00:00:00 2001 From: Irakli Grigolia Date: Tue, 20 Jan 2026 20:43:02 -0500 Subject: [PATCH 4/5] Fix ProgressRadar algorithm problem filtering and improve UI/UX across multiple components Update ProgressRadar to exclude System Design and Data Structure Implementation problems from total count, matching technical interview filtering logic. Replace console.log with logger.debug in CorrectCodeDialog and OverlayActions. Add dark mode support to flashcard mastery badges and fix back navigation in FlashcardDeckManager. Add LeetCode import functionality to admin problem management with new dialog --- .github/workflows/test.yml | 8 +- src/components/ProgressRadar.tsx | 19 +- .../coaching/overlay/CorrectCodeDialog.tsx | 10 +- .../coaching/overlay/OverlayActions.tsx | 5 - .../flashcards/FlashcardDeckManager.tsx | 16 +- .../components/AdminProblemManagement.tsx | 23 +- .../admin/components/AnalyticsTab.tsx | 24 +- .../admin/components/LeetCodeImportDialog.tsx | 546 ++++++++++++++ .../__tests__/AnalyticsTab.test.tsx | 202 +++++ .../__tests__/LeetCodeImportDialog.test.tsx | 310 ++++++++ .../hooks/__tests__/useLeetCodeImport.test.ts | 451 ++++++++++++ src/features/admin/hooks/useAdminDashboard.ts | 189 +++-- src/features/admin/hooks/useLeetCodeImport.ts | 357 +++++++++ src/features/admin/types/leetcode.types.ts | 113 +++ .../utils/__tests__/leetcode-mapper.test.ts | 225 ++++++ src/features/admin/utils/leetcode-mapper.ts | 136 ++++ .../problem-solver/hooks/useCodeInsertion.ts | 8 +- src/hooks/useCoachingNew.ts | 37 +- supabase/functions/ai-chat/index.ts | 8 +- supabase/functions/ai-chat/openai-utils.ts | 6 +- supabase/functions/leetcode-import/index.ts | 687 ++++++++++++++++++ tests/e2e/survey/survey.spec.ts | 35 +- 22 files changed, 3277 insertions(+), 138 deletions(-) create mode 100644 src/features/admin/components/LeetCodeImportDialog.tsx create mode 100644 src/features/admin/components/__tests__/LeetCodeImportDialog.test.tsx create mode 100644 src/features/admin/hooks/__tests__/useLeetCodeImport.test.ts create mode 100644 src/features/admin/hooks/useLeetCodeImport.ts create mode 100644 src/features/admin/types/leetcode.types.ts create mode 100644 src/features/admin/utils/__tests__/leetcode-mapper.test.ts create mode 100644 src/features/admin/utils/leetcode-mapper.ts create mode 100644 supabase/functions/leetcode-import/index.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7059ac9..fc6e053 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -71,12 +71,18 @@ 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 }}-${{ hashFiles('**/package-lock.json') }} + 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' 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 05eb8d6..474c1a3 100644 --- a/src/components/coaching/overlay/CorrectCodeDialog.tsx +++ b/src/components/coaching/overlay/CorrectCodeDialog.tsx @@ -32,13 +32,13 @@ export const CorrectCodeDialog: React.FC = ({ }; const handleInsert = async () => { - console.log('[CorrectCodeDialog] handleInsert called'); - console.log('[CorrectCodeDialog] onInsertCode exists:', !!onInsertCode); - console.log('[CorrectCodeDialog] isInserting:', isInserting); + logger.debug('[CorrectCodeDialog] handleInsert called'); + logger.debug('[CorrectCodeDialog] onInsertCode exists:', !!onInsertCode); + logger.debug('[CorrectCodeDialog] isInserting:', isInserting); if (onInsertCode) { - console.log('[CorrectCodeDialog] Calling onInsertCode...'); + logger.debug('[CorrectCodeDialog] Calling onInsertCode...'); await onInsertCode(); - console.log('[CorrectCodeDialog] onInsertCode completed'); + logger.debug('[CorrectCodeDialog] onInsertCode completed'); onClose(); } }; diff --git a/src/components/coaching/overlay/OverlayActions.tsx b/src/components/coaching/overlay/OverlayActions.tsx index b9bf453..44325f4 100644 --- a/src/components/coaching/overlay/OverlayActions.tsx +++ b/src/components/coaching/overlay/OverlayActions.tsx @@ -62,15 +62,10 @@ export const OverlayActions: React.FC = ({ }; const handleInsertWrapper = async () => { - console.log('[OverlayActions] handleInsertWrapper called'); - console.log('[OverlayActions] onInsertCorrectCode exists:', !!onInsertCorrectCode); if (!onInsertCorrectCode) return; try { - console.log('[OverlayActions] Setting isInserting to true'); setIsInserting(true); - console.log('[OverlayActions] Calling onInsertCorrectCode...'); await onInsertCorrectCode(); - console.log('[OverlayActions] onInsertCorrectCode completed'); } finally { setIsInserting(false); } diff --git a/src/components/flashcards/FlashcardDeckManager.tsx b/src/components/flashcards/FlashcardDeckManager.tsx index 7ec9040..82e4108 100644 --- a/src/components/flashcards/FlashcardDeckManager.tsx +++ b/src/components/flashcards/FlashcardDeckManager.tsx @@ -84,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" }; } }; @@ -139,7 +139,7 @@ export const FlashcardDeckManager = ({ userId }: FlashcardDeckManagerProps) => {
); -}; \ No newline at end of file +}; 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 index 7a1d487..db71976 100644 --- a/src/features/admin/components/AnalyticsTab.tsx +++ b/src/features/admin/components/AnalyticsTab.tsx @@ -148,23 +148,29 @@ const SkeletonGrid = () => ( 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(stats.subscriptions.userJoinDates, resolution), - [stats.subscriptions.userJoinDates, resolution] + () => aggregateTimeline(joinDates, resolution), + [joinDates, resolution] ); const cancellationsTimeline = useMemo( - () => aggregateTimeline(stats.subscriptions.cancellationsDates, resolution), - [stats.subscriptions.cancellationsDates, resolution] + () => aggregateTimeline(cancellationDates, resolution), + [cancellationDates, resolution] ); const premiumsTimeline = useMemo( - () => aggregateTimeline(stats.subscriptions.subscriptionJoinDates, resolution), - [stats.subscriptions.subscriptionJoinDates, resolution] + () => 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), @@ -174,9 +180,9 @@ export const AnalyticsTab = ({ stats, loading }: AnalyticsTabProps) => { return Array.from(allDates) .sort() .map((date) => { - const joins = joinTimeline.find((p) => p.date === date)?.count || 0; - const premiums = premiumsTimeline.find((p) => p.date === date)?.count || 0; - const cancels = cancellationsTimeline.find((p) => p.date === date)?.count || 0; + const joins = joinMap.get(date) || 0; + const premiums = premiumMap.get(date) || 0; + const cancels = cancelMap.get(date) || 0; return { date, joins, 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 }) + } + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ +