diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf321d5a..597e474b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,7 +29,38 @@ jobs: - name: Format check run: | - bun format || (bun format:write && git diff && exit 1) + bun format:check || (bun format && git diff && exit 1) - name: Run checks run: bun check + + e2e: + name: E2E Tests + runs-on: ubuntu-latest + needs: check # Run after checks pass + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.5" + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Install Playwright browsers + run: bunx playwright install --with-deps chromium + + - name: Run E2E tests + run: cd e2e/web && bun run test:e2e:ci + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: e2e/web/test-results/ + retention-days: 7 diff --git a/.prettierignore b/.prettierignore index 874ea7bd..01d08b7d 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,6 +2,7 @@ node_modules **/dist **/build **/.next +**/.next-e2e **/out **/.turbo coverage @@ -9,3 +10,5 @@ pnpm-lock.yaml *.tsbuildinfo **/*.tsbuildinfo +# E2E generated files +e2e/web/features/.generated diff --git a/CLAUDE.md b/CLAUDE.md index e0761d53..906c03f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -## Important: DO NOT Run Build or Dev Commands +## Quick Reference -**⚠️ NEVER run `bun build` or `bun dev` unless explicitly requested by the user.** +**This project uses Bun** as the package manager and runtime. Use `bun` instead of `npm`, `yarn`, or `pnpm`. -For planning, prefer concise style, don't include full code example, only core changes. Name it according to the feature being added. +| Task | Command | +| ---------------------- | ---------------------------------------------- | +| Validate all | `bun check` | +| Run unit tests | `bun run test` | +| Run E2E tests | `cd e2e/web && bun run test:e2e` | +| E2E (fast, dev server) | `cd e2e/web && E2E_PORT=3000 bun run test:e2e` | +| Filter package | `bun check --filter @dashframe/web` | +| Storybook | `bun storybook` | -The user manages their own development environment. Only run these commands if the user specifically asks you to. +**⚠️ NEVER run `bun build` or `bun dev` unless explicitly requested.** User manages their own dev environment. + +**Planning style**: Concise, no full code examples, only core changes. Name plans by feature. ## Essential Commands @@ -131,51 +140,6 @@ import { useDataSources, useDataSourceMutations } from "@dashframe/core"; This keeps components backend-agnostic. The backend implementation is selected via `NEXT_PUBLIC_DATA_BACKEND` env var. -### Encryption for Sensitive Data - -**Client-side encryption** protects sensitive fields (API keys, connection strings) in IndexedDB. Uses Web Crypto API with: - -- **Algorithm**: AES-256-GCM (authenticated encryption) -- **Key derivation**: PBKDF2 with SHA-256, 100,000 iterations -- **Key storage**: CryptoKey cached in memory only (cleared on page reload) -- **Salt storage**: IndexedDB settings table (persistent, per-browser instance) - -**Key management functions** (from `@dashframe/core`): - -```typescript -import { - initializeEncryption, - isEncryptionInitialized, - unlockEncryption, - isEncryptionUnlocked, - lockEncryption, - migrateToEncryption, -} from "@dashframe/core"; - -// First-time setup -await initializeEncryption("user-passphrase"); - -// Subsequent sessions -if (await isEncryptionInitialized()) { - await unlockEncryption("user-passphrase"); -} - -// Check if key is available -if (isEncryptionUnlocked()) { - // Can access encrypted data -} - -// Lock encryption (clears key from memory) -lockEncryption(); -``` - -**Important**: - -- User must unlock encryption each session (passphrase not stored) -- Encryption key required before accessing/modifying DataSources with API keys -- Protected routes (e.g., `/data-sources`) trigger unlock modal automatically -- Migration utility (`migrateToEncryption`) encrypts existing plaintext data on first setup - ## Critical Gotchas ### Vega-Lite SSR @@ -233,7 +197,7 @@ See `apps/web/lib/trpc/rate-limiter.ts` and `middleware/rate-limit.ts` for imple ### Other Gotchas -- **Notion API Keys**: Encrypted at rest in IndexedDB. User must unlock encryption with passphrase each session. +- **Notion API Keys**: Stored in IndexedDB. Treat as sensitive - don't commit to version control. - **Turborepo Cache**: Run `turbo build --force` if seeing stale builds ## Development Best Practices @@ -324,12 +288,11 @@ Before implementing any UI changes, follow this component-first approach: ### Architecture Principles - **DataFrame is the contract**: All sources convert to it, all visualizations consume it -- **Functional > OOP**: Converter functions, not classes with methods - **Zustand + Immer**: State management with automatic persistence - **Type safety everywhere**: Leverage TypeScript strict mode -- **Flowler Coding Style**: Focus on readability, maintainability, and simplicity -- **Test driven development**: Write tests for critical logic, especially when it comes to handling data. For features that has spec, make sure to include a test plan as well. -- **Detect Code Smells Early**: Watch for signs of complexity, duplication, or poor separation of concerns. Ask questions if something feels off, help user create tasks to refactor or improve code quality if found tech debt that might not be directly related to the feature being implemented. +- **Fowler Coding Style**: Readability, maintainability, simplicity +- **Test driven development**: Write tests for critical logic. Features with specs need test plans. +- **Detect Code Smells Early**: Watch for complexity, duplication, poor separation. Flag tech debt. ## Testing @@ -337,28 +300,24 @@ Before implementing any UI changes, follow this component-first approach: ```bash # Run all unit tests -bun test +bun run test # Run tests in watch mode -bun test:watch +bun run test:watch # Run tests with coverage report -bun test:coverage +bun run test:coverage # Run coverage for specific package -bun test:coverage --filter @dashframe/types +bun run test:coverage --filter @dashframe/types # Run E2E tests cd e2e/web -bun test +bun run test:e2e # Run E2E tests in UI mode cd e2e/web -bun test:ui - -# Generate E2E step definitions (BDD) -cd e2e/web -bun bddgen +bun run test:ui ``` ### Testing Philosophy @@ -375,359 +334,202 @@ DashFrame follows **test-driven development** for critical logic: DashFrame uses **Vitest** for unit and integration tests. Tests are colocated with source files using `.test.ts` or `.test.tsx` extensions. -#### Basic Structure - -```typescript -/** - * Unit tests for encoding-helpers module - * - * Tests cover: - * - fieldEncoding() - Creating field encoding strings - * - metricEncoding() - Creating metric encoding strings - * - parseEncoding() - Parsing encoding strings - */ -import { describe, expect, it } from "vitest"; -import { fieldEncoding, metricEncoding } from "./encoding-helpers"; - -describe("encoding-helpers", () => { - describe("fieldEncoding()", () => { - it("should create field encoding with correct format", () => { - const result = fieldEncoding("field-id"); - expect(result).toBe("field:field-id"); - }); - - it("should handle edge cases", () => { - // Test edge cases, errors, validation - }); - }); -}); -``` +**Key Conventions:** -#### Key Conventions +- File header comment documenting what's tested +- Nested `describe` blocks (one per function) +- "should" format for test names +- Mock factories for reusable test data +- No `console.log` in committed tests +- `beforeEach`/`afterEach` for cleanup -1. **File header comment**: Document what functions/features are tested -2. **Nested describe blocks**: One per function/feature, organize related tests -3. **Clear test names**: Use "should" format describing expected behavior -4. **Mock factories**: Create reusable factory functions for test data -5. **No console.log**: Remove debugging statements before committing -6. **Test isolation**: Use `beforeEach`/`afterEach` for cleanup +### React Hook Testing -#### Mock Data Factories +Use `@testing-library/react` with Vitest: -Create reusable factories to reduce boilerplate: +- **Always use `act()`** for state updates and async operations +- **Use `waitFor()`** for async assertions +- **Clear mocks** with `vi.clearAllMocks()` in `beforeEach` +- **Mock external deps**: `@dashframe/core`, `next/navigation`, etc. -```typescript -/** - * Helper to create a mock NumberAnalysis - */ -function createNumberColumn( - name: string, - options?: { - hasVariance?: boolean; - uniqueCount?: number; - }, -): ColumnAnalysis { - return { - name, - type: "number", - semantic: "measure", - hasVariance: options?.hasVariance ?? true, - uniqueCount: options?.uniqueCount ?? 100, - // ... other required fields - }; -} -``` - -### React Hook Testing Patterns - -Use `@testing-library/react` for testing React hooks: +### E2E Testing Patterns -```typescript -import { renderHook, act, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +DashFrame uses **Playwright** with custom fixtures for E2E tests. -// Mock dependencies -const mockCreate = vi.fn(); -vi.mock("@dashframe/core", () => ({ - useInsightMutations: () => ({ create: mockCreate }), -})); +#### E2E Directory Structure -describe("useCreateInsight", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); +``` +e2e/web/ +├── tests/ # Test specs +│ ├── csv-to-chart.spec.ts # CSV upload → chart workflow +│ ├── json-to-chart.spec.ts # JSON upload → chart workflow +│ ├── error-handling.spec.ts # Error cases (empty files, invalid formats) +│ ├── chart-editing.spec.ts # Chart type switching +│ └── dashboard.spec.ts # Dashboard creation & management +├── lib/ +│ └── test-fixtures.ts # Custom Playwright fixtures +├── fixtures/ # Test data files +│ ├── sales_data.csv # 5 rows: Date, Product, Category, Sales, Quantity +│ ├── products_data.csv # 4 rows: Product, Price, Supplier (for joins) +│ └── users_data.json # 5 users: id, name, email, age, department +├── support/ +│ └── port-finder.ts # Smart port allocation (3100-3120) +└── playwright.config.ts +``` - it("should create insight from table", async () => { - const { result } = renderHook(() => useCreateInsight()); +#### Custom Fixtures - let insightId: string | null = null; - await act(async () => { - insightId = await result.current.createInsightFromTable("table-1"); - }); +Tests use custom fixtures from `lib/test-fixtures.ts` for reusable actions: - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - baseTableId: "table-1", - }), - ); - expect(insightId).toBeTruthy(); - }); +```typescript +import { expect, test } from "../lib/test-fixtures"; + +test("upload CSV and create chart", async ({ + page, + homePage, // Navigate to home, verify loaded + uploadFile, // Upload from fixtures directory + waitForChart, // Wait for chart SVG to render +}) => { + await homePage(); + await uploadFile("sales_data.csv"); + // ... rest of test }); ``` -#### Hook Testing Best Practices +**Available fixtures:** -- **Always use `act()`**: Wrap state updates and async operations -- **Use `waitFor()`**: For async assertions that may take time -- **Clear mocks**: Use `vi.clearAllMocks()` in `beforeEach` -- **Test stability**: Verify `useCallback` memoization with reference equality -- **Mock external dependencies**: Mock `@dashframe/core`, `next/navigation`, etc. +- `homePage()` - Navigate to home page and verify loaded +- `uploadFile(fileName)` - Upload file from `fixtures/` directory +- `uploadBuffer(name, content, mimeType)` - Upload in-memory content (for error testing) +- `waitForChart()` - Wait for chart data and SVG to render -### E2E Testing Patterns +#### Running Tests + +**Production Build Mode (Default)** - Full CI simulation: -DashFrame uses **Playwright** with **playwright-bdd** for behavior-driven E2E tests. +```bash +cd e2e/web +bun run test:e2e +``` -#### Feature Files (Gherkin) +- Builds to isolated `.next-e2e` directory +- Auto-finds available port (3100-3120) +- 3-minute build timeout -Located in `e2e/web/features/workflows/*.feature`: +**Dev Server Mode (Fast)** - Use existing dev server: -```gherkin -Feature: Core Workflow: CSV to Chart - As a new user - I want to upload a CSV and create a chart immediately - So that I can see value in the product quickly +```bash +# Terminal 1 +bun dev - @core @workflow - Scenario: Upload CSV and create a suggested chart - Given I am on the DashFrame home page - When I upload the "sales_data.csv" file - Then I should be redirected to the insight configuration page - And I should see chart suggestions - When I click "Create" on the first suggestion - Then I should be redirected to the visualization page - And I should see the chart rendered +# Terminal 2 +cd e2e/web +E2E_PORT=3000 bun run test:e2e ``` -#### Step Definitions - -Located in `e2e/web/steps/*.steps.ts`: +#### Filtering Tests -```typescript -import { Given, When, Then } from "@playwright/test/steps"; -import { expect } from "@playwright/test"; +```bash +# Run specific test file +bun run test:e2e csv-to-chart -Given("I am on the DashFrame home page", async ({ page }) => { - await page.goto("/"); - await expect(page).toHaveURL("/"); -}); +# Run tests matching pattern +bun run test:e2e --grep "upload" -When("I upload the {string} file", async ({ page }, filename: string) => { - const fileInput = page.locator('input[type="file"]'); - await fileInput.setInputFiles(`./fixtures/${filename}`); -}); +# Run specific describe block +bun run test:e2e --grep "Error Handling" ``` #### E2E Best Practices -- **Use semantic selectors**: Prefer `getByRole`, `getByLabel`, `getByText` over CSS selectors -- **Wait for navigation**: Use `page.waitForURL()` for route transitions -- **Reuse steps**: Import common steps from `common.steps.ts` -- **Tag scenarios**: Use `@workflow`, `@core`, `@visualization` for filtering -- **Add fixtures**: Place test data files in `e2e/web/fixtures/` +- **Use semantic selectors**: Prefer `getByRole`, `getByLabel`, `getByText` +- **Use fixtures**: Add reusable actions to `lib/test-fixtures.ts` +- **Wait for navigation**: Use `expect(page).toHaveURL()` with timeout +- **Add test data**: Place files in `fixtures/` directory +- **Use exact matching**: Add `{ exact: true }` when multiple elements match (e.g., headings) +- **Handle UI variations**: Use conditional checks when button text varies between states -### Snapshot Testing +#### CI Configuration -Use snapshot tests to catch regressions in chart configurations: +E2E in CI (`.github/workflows/ci.yml`): -```typescript -import { describe, expect, it } from "vitest"; -import { suggestByChartType } from "./suggest-charts"; +- **Browser**: Chromium only +- **Workers**: Single worker (avoids port conflicts) +- **Retries**: 2 retries on failure +- **Artifacts**: Results retained 7 days -describe("bar chart suggestions", () => { - it("should generate categorical X + numerical Y encoding", () => { - const suggestions = suggestByChartType("barY", analysis, insight); +#### Debugging - // Snapshot captures: chart type, encoding (x, y, color), labels - expect(suggestions[0]).toMatchSnapshot(); - }); -}); +```bash +bun run test:ui # Playwright UI mode +bun run test:headed # See browser +bun run test:debug # Step through with debugger +bun run test:html # HTML report after run ``` -**When to use snapshots**: +**On failure, Playwright captures**: screenshots, videos, traces (on retry). -- Chart configuration generation (encoding, transforms, labels) -- Complex object outputs where structure matters -- Visual regression detection for data transformations +### Snapshot Testing -**Run tests to generate snapshots**: +Use for chart configs, complex objects, and data transformation regression testing. ```bash -bun test chart-suggestions.snapshot.test.ts +bun run test chart-suggestions.snapshot.test.ts # Generate/update snapshots ``` -Snapshots are saved in `__snapshots__/` directories. Review changes carefully in PRs. +Snapshots saved in `__snapshots__/`. Review changes carefully in PRs. ### Coverage Requirements -**Coverage targets**: 80% for branches, functions, lines, and statements. +**Target: 80%** for branches, functions, lines, statements (configured in `vitest.config.ts`). -Configured in `vitest.config.ts`: +**Testing priority:** -```typescript -coverage: { - provider: "v8", - reporter: ["text", "json", "html"], - thresholds: { - branches: 80, - functions: 80, - lines: 80, - statements: 80, - }, -} -``` - -**Priority for testing**: - -1. **HIGH**: Data operations (converters, analyzers, DataFrame logic) -2. **HIGH**: Business logic (chart suggestions, encoding validation, metric computation) -3. **MEDIUM**: React hooks (data fetching, state management) -4. **MEDIUM**: Utilities (helpers, type guards, formatting) -5. **LOW**: UI components (prefer E2E tests for user flows) +1. **HIGH**: Data operations, business logic (converters, chart suggestions) +2. **MEDIUM**: React hooks, utilities +3. **LOW**: UI components (prefer E2E) ### Mock Strategies -#### Mocking Vitest Functions - -```typescript -import { vi } from "vitest"; - -// Mock entire module -vi.mock("@dashframe/core", () => ({ - useDataSources: vi.fn(), - getDataFrame: vi.fn(), -})); - -// Mock with implementation -const mockFetch = vi.fn().mockResolvedValue({ data: [] }); - -// Mock console methods (suppress expected errors) -beforeEach(() => { - vi.spyOn(console, "error").mockImplementation(() => {}); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); -``` - -#### Mocking External Dependencies - -Common mocks for DashFrame: +Common mocks for DashFrame tests: ```typescript -// Mock @dashframe/core vi.mock("@dashframe/core", () => ({ useDataSources: () => [], - getDataFrame: vi.fn(), useInsightMutations: () => ({ create: vi.fn() }), })); -// Mock next/navigation vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), - usePathname: () => "/", -})); - -// Mock DuckDB provider -vi.mock("@/components/providers/DuckDBProvider", () => ({ - useDuckDB: () => ({ - connection: mockConnection, - isInitialized: true, - }), })); ``` ### Test File Organization -``` -packages/types/src/ - encoding-helpers.ts - encoding-helpers.test.ts # Colocated with source - -apps/web/lib/ - visualizations/ - suggest-charts.ts - suggest-charts.test.ts # Unit tests - chart-suggestions.snapshot.test.ts # Snapshot tests - -apps/web/hooks/ - useCreateInsight.tsx - useCreateInsight.test.tsx # Hook tests with .tsx extension - -e2e/web/ - features/ - workflows/ - csv_to_chart.feature # Gherkin scenarios - steps/ - common.steps.ts # Reusable step definitions - data-sources.steps.ts - fixtures/ - sales_data.csv # Test data files -``` +- **Unit tests**: Colocated with source (`*.test.ts` or `*.test.tsx`) +- **E2E tests**: `e2e/web/tests/*.spec.ts` +- **Fixtures**: Custom Playwright fixtures in `e2e/web/lib/test-fixtures.ts` +- **Test data**: `e2e/web/fixtures/` (CSV, JSON files) ### Running Tests in CI -Tests run automatically in GitHub Actions on: - -- Pull requests (all tests + coverage) -- Main branch commits (all tests + coverage) -- Coverage reports uploaded to coverage service - -**Local pre-commit checks**: +- **Unit tests**: Run on all PRs and main commits with coverage +- **E2E**: Chromium only, single worker, 2 retries, artifacts for 7 days -```bash -bun check # Lint + typecheck + format -bun test:coverage # Run tests with coverage -``` +**Local pre-commit**: `bun check && bun run test:coverage` ### Debugging Tests ```bash -# Run single test file -bun test suggest-charts.test.ts - -# Run tests matching pattern -bun test --grep "bar chart" - -# Run with UI (vitest UI) -bun test --ui - -# Debug with breakpoints -bun test --inspect-brk - -# E2E debugging -cd e2e/web -bun test:debug # Opens Playwright inspector -bun test:ui # Opens Playwright UI mode +bun run test suggest-charts.test.ts # Single file +bun run test --grep "bar chart" # Pattern match +bun run test --ui # Vitest UI +cd e2e/web && bun run test:debug # Playwright inspector ``` ### Writing New Tests -When adding new functionality: - -1. **Start with unit tests**: Test pure functions and utilities first -2. **Add integration tests**: Test hooks and state interactions -3. **Add E2E tests**: For user-facing workflows (if critical path) -4. **Add snapshots**: For complex data transformations -5. **Run coverage**: Ensure you meet 80% threshold -6. **Update this doc**: If you introduce new patterns - -**Example checklist**: - -- [ ] Unit tests for all public functions -- [ ] Edge cases covered (null, undefined, empty arrays) -- [ ] Error handling tested -- [ ] Mock factories created for reusable test data -- [ ] No console.log statements -- [ ] Coverage threshold met (80%) -- [ ] Tests pass in CI +1. Start with unit tests for pure functions +2. Add hook/integration tests +3. Add E2E for critical user paths +4. Run coverage to meet 80% threshold diff --git a/apps/web/app/insights/[insightId]/_components/InsightPageContent.tsx b/apps/web/app/insights/[insightId]/_components/InsightPageContent.tsx new file mode 100644 index 00000000..83aef7b2 --- /dev/null +++ b/apps/web/app/insights/[insightId]/_components/InsightPageContent.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useInsight } from "@dashframe/core"; +import { InsightView } from "./InsightView"; +import { LoadingView } from "./LoadingView"; +import { NotFoundView } from "./NotFoundView"; + +interface InsightPageContentProps { + insightId: string; +} + +/** + * Insight page content - handles data fetching and rendering. + * + * This component is dynamically imported with ssr: false to ensure + * IndexedDB operations only happen in the browser. + */ +export default function InsightPageContent({ + insightId, +}: InsightPageContentProps) { + const { data: insight, isLoading } = useInsight(insightId); + + if (isLoading) { + return ; + } + + if (!insight) { + return ; + } + + return ; +} diff --git a/apps/web/app/insights/[insightId]/_components/InsightView.tsx b/apps/web/app/insights/[insightId]/_components/InsightView.tsx index a8c028a7..fb73dd42 100644 --- a/apps/web/app/insights/[insightId]/_components/InsightView.tsx +++ b/apps/web/app/insights/[insightId]/_components/InsightView.tsx @@ -54,13 +54,16 @@ function remapAnalysisColumnNames( // Build reverse lookup: original column name → field UUID alias const columnNameToAlias = new Map(); for (const field of fields) { + const alias = fieldIdToColumnAlias(field.id); if (field.columnName) { - columnNameToAlias.set(field.columnName, fieldIdToColumnAlias(field.id)); + columnNameToAlias.set(field.columnName.trim().toLowerCase(), alias); } + // Also map by field name as fallback (user-facing name often matches column name initially) + columnNameToAlias.set(field.name.trim().toLowerCase(), alias); } return analysis.map((col) => { - const alias = columnNameToAlias.get(col.columnName); + const alias = columnNameToAlias.get(col.columnName.toLowerCase()); if (alias) { return { ...col, columnName: alias }; } @@ -111,12 +114,6 @@ async function analyzeAndCacheDataFrame( }; await ctx.updateAnalysis(dataFrameId, analysis); - console.log( - "[InsightView] DataFrame cached", - dataFrameId, - remappedResults.length, - "columns", - ); return analysis; } @@ -140,6 +137,65 @@ interface JoinSpec { rightTableId: UUID; } +/** + * Check if all joined DataFrames are loaded (for joined insights). + */ +function areJoinedDataFramesLoaded( + joins: JoinSpec[], + allDataTables: DataTableEntry[], + allDataFrameEntries: DataFrameEntry[], +): boolean { + for (const join of joins) { + const joinedTable = allDataTables.find((t) => t.id === join.rightTableId); + if (!joinedTable?.dataFrameId) return false; + const joinedDf = allDataFrameEntries.find( + (df) => df.id === joinedTable.dataFrameId, + ); + if (!joinedDf) return false; + } + return true; +} + +/** + * Get cached columns for single DataFrame (non-joined). + */ +function getCachedSingleAnalysis( + baseDataFrameEntry: DataFrameEntry, + fieldHash: string, +): ColumnAnalysis[] | null { + if (!baseDataFrameEntry.analysis) return null; + const cached = baseDataFrameEntry.analysis; + if (cached.fieldHash !== fieldHash) return null; + if (cached.columns.length === 0) return null; + return cached.columns; +} + +/** + * Get merged cached columns for joined insights. + */ +function getCachedJoinedAnalysis( + baseDataFrameEntry: DataFrameEntry, + joins: JoinSpec[], + allDataTables: DataTableEntry[], + allDataFrameEntries: DataFrameEntry[], + baseFields: Field[], +): ColumnAnalysis[] | null { + if (!baseDataFrameEntry.analysis) return null; + + const analysesToMerge: DataFrameAnalysis[] = [baseDataFrameEntry.analysis]; + for (const join of joins) { + const joinedTable = allDataTables.find((t) => t.id === join.rightTableId); + if (!joinedTable?.dataFrameId) return null; + const joinedDf = allDataFrameEntries.find( + (df) => df.id === joinedTable.dataFrameId, + ); + if (!joinedDf?.analysis) return null; + analysesToMerge.push(joinedDf.analysis); + } + const merged = mergeAnalyses(analysesToMerge); + return remapAnalysisColumnNames(merged, baseFields); +} + /** * Analyze joined DataFrames and merge results. * Returns merged column analysis from all DataFrames. @@ -581,170 +637,76 @@ export function InsightView({ insight }: InsightViewProps) { // Column analysis effect - uses cached analysis from DataFrame if available // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis useEffect(() => { + // Early returns for loading/unready states if (isDuckDBLoading || !duckDBConnection || !isDuckDBReady) return; if (!chartTableName || !isChartViewReady) { setColumnAnalysis([]); return; } - // Wait for DataFrame entity to load before checking cache - // This prevents triggering analysis before we can check for cached results if (!baseDataFrameEntry) return; + if (isAnalyzing) return; - const hasJoins = (insight.joins?.length ?? 0) > 0; + const joins = insight.joins ?? []; + const hasJoins = joins.length > 0; const fieldHash = computeFieldHash(); - - // Helper: Check if all joined DataFrames are loaded (for joined insights) - const areJoinedDataFramesLoaded = (): boolean => { - for (const join of insight.joins ?? []) { - const joinedTable = allDataTables.find( - (t) => t.id === join.rightTableId, - ); - if (!joinedTable?.dataFrameId) return false; - const joinedDf = allDataFrameEntries.find( - (df) => df.id === joinedTable.dataFrameId, - ); - if (!joinedDf) return false; - } - return true; - }; + const baseFields = dataTable?.fields ?? []; // For joined insights, wait for all DataFrames to load - if (hasJoins && !areJoinedDataFramesLoaded()) return; - - // Helper: Get cached columns for single DataFrame (non-joined) - const getCachedSingleAnalysis = (): ColumnAnalysis[] | null => { - if (!baseDataFrameEntry.analysis) { - console.log("[InsightView] No cached analysis on DataFrame"); - return null; - } - const cached = baseDataFrameEntry.analysis; - if (cached.fieldHash !== fieldHash) { - console.log("[InsightView] Cache miss: fieldHash mismatch", { - cached: cached.fieldHash, - current: fieldHash, - }); - return null; - } - if (cached.columns.length === 0) { - console.log("[InsightView] Cache miss: empty columns"); - return null; - } - console.log( - "[InsightView] Cache HIT - using cached analysis", - cached.columns.length, - "columns", - ); - return cached.columns; - }; - - // Helper: Get merged cached columns for joined insights - const getCachedJoinedAnalysis = (): ColumnAnalysis[] | null => { - if (!baseDataFrameEntry.analysis) { - console.log( - "[InsightView] Joined: No cached analysis on base DataFrame", - ); - return null; - } - const analysesToMerge: DataFrameAnalysis[] = [ - baseDataFrameEntry.analysis, - ]; - for (const join of insight.joins ?? []) { - const joinedTable = allDataTables.find( - (t) => t.id === join.rightTableId, - ); - if (!joinedTable?.dataFrameId) { - console.log( - "[InsightView] Joined: Missing dataFrameId for join table", - ); - return null; - } - const joinedDf = allDataFrameEntries.find( - (df) => df.id === joinedTable.dataFrameId, - ); - if (!joinedDf?.analysis) { - console.log( - "[InsightView] Joined: No cached analysis on joined DataFrame", - joinedTable.dataFrameId, - ); - return null; - } - analysesToMerge.push(joinedDf.analysis); - } - const merged = mergeAnalyses(analysesToMerge); - console.log( - "[InsightView] Cache HIT - using merged cached analyses", - merged.length, - "columns", - ); - return merged; - }; + if ( + hasJoins && + !areJoinedDataFramesLoaded(joins, allDataTables, allDataFrameEntries) + ) { + return; + } // Try cached analysis first const cachedColumns = hasJoins - ? getCachedJoinedAnalysis() - : getCachedSingleAnalysis(); + ? getCachedJoinedAnalysis( + baseDataFrameEntry, + joins, + allDataTables, + allDataFrameEntries, + baseFields, + ) + : getCachedSingleAnalysis(baseDataFrameEntry, fieldHash); if (cachedColumns) { - console.log("[InsightView] Using cached analysis, skipping DuckDB"); - setColumnAnalysis(cachedColumns); + // For non-joined, we need to remap; for joined, already remapped + const result = hasJoins + ? cachedColumns + : remapAnalysisColumnNames(cachedColumns, baseFields); + setColumnAnalysis(result); return; } // No valid cache - run analysis and cache results - // Skip if already analyzing (prevents duplicate runs from effect re-triggering) - if (isAnalyzing) { - console.log("[InsightView] Analysis already in progress, skipping..."); - return; - } - - console.log("[InsightView] Cache miss - running DuckDB analysis..."); const runAnalysis = async () => { setIsAnalyzing(true); const ctx: AnalysisContext = { duckDBConnection, updateAnalysis }; try { if (!hasJoins) { - // Single DataFrame - analyze and cache - console.log("[InsightView] Analyzing single DataFrame..."); const results = await analyzeView(duckDBConnection, chartTableName); - console.log( - "[InsightView] Analysis complete", - results.length, - "columns", - ); - setColumnAnalysis(results); + const remappedResults = remapAnalysisColumnNames(results, baseFields); + setColumnAnalysis(remappedResults); // Cache for this DataFrame const analysisToCache: DataFrameAnalysis = { - columns: results, + columns: remappedResults, rowCount, analyzedAt: Date.now(), fieldHash, }; await updateAnalysis(baseDataFrameEntry.id, analysisToCache); - console.log( - "[InsightView] Analysis cached for DataFrame", - baseDataFrameEntry.id, - ); } else { - // Joined insight - analyze each DataFrame individually and cache - console.log( - "[InsightView] Analyzing joined DataFrames individually...", - ); - const baseFields = dataTable?.fields ?? []; const merged = await analyzeJoinedDataFrames( baseDataFrameEntry, baseFields, - insight.joins ?? [], + joins, allDataTables, allDataFrameEntries, ctx, ); - console.log( - "[InsightView] Merged analysis complete", - merged.length, - "columns", - ); setColumnAnalysis(merged); } } catch (e) { diff --git a/apps/web/app/insights/[insightId]/page.tsx b/apps/web/app/insights/[insightId]/page.tsx index 004d3f57..01a9aa4b 100644 --- a/apps/web/app/insights/[insightId]/page.tsx +++ b/apps/web/app/insights/[insightId]/page.tsx @@ -1,30 +1,36 @@ "use client"; -import { useInsight } from "@dashframe/core"; -import { use } from "react"; -import { InsightView, LoadingView, NotFoundView } from "./_components"; +import dynamic from "next/dynamic"; +import { useParams } from "next/navigation"; +// Import directly to avoid loading InsightView which imports @dashframe/core +import { LoadingView } from "./_components/LoadingView"; -interface PageProps { - params: Promise<{ insightId: string }>; -} +/** + * Dynamically import the insight content component with SSR disabled. + * This prevents IndexedDB access during static site generation. + */ +const InsightPageContent = dynamic( + () => import("./_components/InsightPageContent"), + { + ssr: false, + loading: () => , + }, +); /** * Insight Page * - * Minimal page component that only handles routing. - * Data fetching is handled by InsightView itself. + * Uses dynamic import with ssr: false to ensure IndexedDB operations + * only happen in the browser, not during static site generation. + * Uses useParams instead of server params to avoid server-side execution. */ -export default function InsightPage({ params }: PageProps) { - const { insightId } = use(params); - const { data: insight, isLoading } = useInsight(insightId); +export default function InsightPage() { + const params = useParams<{ insightId: string }>(); + const insightId = params?.insightId; - if (isLoading) { + if (!insightId) { return ; } - if (!insight) { - return ; - } - - return ; + return ; } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 224de7f6..17798004 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,12 +1,10 @@ import { ConfirmDialog } from "@/components/confirm-dialog"; import { Navigation } from "@/components/navigation"; -import { PassphraseGuard } from "@/components/PassphraseGuard"; import { DuckDBProvider } from "@/components/providers/DuckDBProvider"; import { PostHogPageView } from "@/components/providers/PostHogPageView"; import { PostHogProvider } from "@/components/providers/PostHogProvider"; import { VisualizationSetup } from "@/components/providers/VisualizationSetup"; import { ThemeProvider } from "@/components/theme-provider"; -import { EncryptionProvider } from "@/lib/contexts/encryption-context"; import { TRPCProvider } from "@/lib/trpc/Provider"; import { DatabaseProvider } from "@dashframe/core"; import { GeistMono, GeistSans, TooltipProvider } from "@dashframe/ui"; @@ -41,51 +39,47 @@ export default function RootLayout({ - - - - -
-
-
-
-
-
-
-
+ + +
+
+
+
+
+
+
+
-
- +
+ -
-
- {children} -
-
+
+
+ {children}
- - - - - - +
+
+ + + + diff --git a/apps/web/components/PassphraseGuard.tsx b/apps/web/components/PassphraseGuard.tsx deleted file mode 100644 index b2713beb..00000000 --- a/apps/web/components/PassphraseGuard.tsx +++ /dev/null @@ -1,55 +0,0 @@ -"use client"; - -import { PassphraseModal } from "@/components/PassphraseModal"; -import { useEncryption } from "@/lib/contexts/encryption-context"; -import { usePathname } from "next/navigation"; - -interface PassphraseGuardProps { - children: React.ReactNode; -} - -/** - * PassphraseGuard component - * - * Guards protected routes by showing PassphraseModal when encryption is locked. - * Protected routes that require encryption to be unlocked: - * - /data-sources (contains sensitive API keys) - * - * Non-protected routes (accessible without unlocking): - * - / (home/dashboards) - * - /visualizations - * - /insights - * - * @example - * ```tsx - * - * {children} - * - * ``` - */ -export function PassphraseGuard({ children }: PassphraseGuardProps) { - const { isUnlocked } = useEncryption(); - const pathname = usePathname(); - - // Protected routes that require encryption to be unlocked - const protectedRoutes = ["/data-sources"]; - - // Check if current route is protected - const isProtectedRoute = protectedRoutes.some((route) => - pathname.startsWith(route), - ); - - // Show modal if user is on a protected route AND encryption is not unlocked - // This covers both cases: - // - First time setup (not initialized) - // - Subsequent sessions (initialized but locked) - // Derived state - no need for useState/useEffect - const showModal = isProtectedRoute && !isUnlocked; - - return ( - <> - {children} - - - ); -} diff --git a/apps/web/components/PassphraseModal.tsx b/apps/web/components/PassphraseModal.tsx deleted file mode 100644 index 5fb2f3ec..00000000 --- a/apps/web/components/PassphraseModal.tsx +++ /dev/null @@ -1,193 +0,0 @@ -"use client"; - -import { useEncryption } from "@/lib/contexts/encryption-context"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - Input, - Label, -} from "@dashframe/ui"; -import { useState } from "react"; - -interface PassphraseModalProps { - /** - * Whether the modal is open - */ - isOpen: boolean; -} - -/** - * PassphraseModal component - * - * Modal for setting up or unlocking encryption with a passphrase. - * Shows different views based on encryption initialization state: - * - Setup view: Create new passphrase with confirmation (first-time) - * - Unlock view: Enter existing passphrase - * - * @example - * ```tsx - * - * ``` - */ -export function PassphraseModal({ isOpen }: PassphraseModalProps) { - const { isInitialized, initialize, unlock, error, isLoading } = - useEncryption(); - - const [passphrase, setPassphrase] = useState(""); - const [confirmPassphrase, setConfirmPassphrase] = useState(""); - const [validationError, setValidationError] = useState(null); - - /** - * Handle form submission for both setup and unlock - */ - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setValidationError(null); - - // Validate passphrase length - if (!passphrase || passphrase.length < 8) { - setValidationError("Passphrase must be at least 8 characters"); - return; - } - - try { - if (isInitialized) { - // Unlock with existing passphrase - await unlock(passphrase); - // Reset form on success (modal closes automatically via derived state) - setPassphrase(""); - } else { - // Setup new passphrase - validate confirmation - if (passphrase !== confirmPassphrase) { - setValidationError("Passphrases do not match"); - return; - } - - await initialize(passphrase); - // Reset form on success (modal closes automatically via derived state) - setPassphrase(""); - setConfirmPassphrase(""); - } - } catch (err) { - // Error state is managed by EncryptionContext and displayed via the error prop - // Log for debugging purposes - console.error("Passphrase operation failed:", err); - } - }; - - const handleOpenChange = (open: boolean) => { - // Prevent closing the modal - user must unlock/initialize - // Modal closes automatically via derived state when unlocked - if (!open) { - return; - } - }; - - // Display error from context or local validation error - const displayError = validationError || error; - - return ( - - -
- - - {isInitialized ? "Unlock Encryption" : "Setup Encryption"} - - - {isInitialized - ? "Enter your passphrase to access encrypted data" - : "Create a passphrase to encrypt sensitive data. You'll need this passphrase to access your data in future sessions."} - - - -
- {/* Passphrase field */} -
- - setPassphrase(e.target.value)} - placeholder="Enter passphrase (min 8 characters)" - autoFocus - disabled={isLoading} - required - /> -
- - {/* Confirmation field - only for setup */} - {!isInitialized && ( -
- - setConfirmPassphrase(e.target.value)} - placeholder="Re-enter passphrase" - disabled={isLoading} - required - /> -
- )} - - {/* Error display */} - {displayError && ( -
- {displayError} -
- )} - - {/* Warning for setup */} - {!isInitialized && ( -
- Important: There is no way to recover your data - if you forget this passphrase. Please store it securely. -
- )} -
- - - - -
-
-
- ); -} diff --git a/apps/web/components/navigation.tsx b/apps/web/components/navigation.tsx index 4d9e4680..7c2770ab 100644 --- a/apps/web/components/navigation.tsx +++ b/apps/web/components/navigation.tsx @@ -1,7 +1,6 @@ "use client"; import { ThemeToggle } from "@/components/theme-toggle"; -import { useEncryption } from "@/lib/contexts/encryption-context"; import { useToastStore } from "@/lib/stores"; import { useDashboardMutations, @@ -34,7 +33,6 @@ import { DropdownMenuTrigger, GithubIcon, GridIcon, - LockIcon, MenuIcon, SettingsIcon, SparklesIcon, @@ -90,7 +88,6 @@ function SidebarContent({ onClearData, }: SidebarContentProps) { const pathname = usePathname(); - const { isUnlocked, lock } = useEncryption(); return (
@@ -127,22 +124,7 @@ function SidebarContent({ isCollapsed && "w-full justify-center", )} > - {!isCollapsed && ( - <> - - {isUnlocked && ( -