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..a5bec203 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,21 @@ 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` | +| 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 +139,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 +196,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 +287,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 +299,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 +333,190 @@ 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 - -- **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. - -### E2E Testing Patterns - -DashFrame uses **Playwright** with **playwright-bdd** for behavior-driven E2E tests. - -#### Feature Files (Gherkin) +**Available fixtures:** -Located in `e2e/web/features/workflows/*.feature`: +- `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 -```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 +#### Running Tests - @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 +```bash +cd e2e/web +bun run test:e2e ``` -#### Step Definitions +- Builds to isolated `.next-e2e` directory +- Auto-finds available port (3100-3120) +- Local: parallel workers with separate servers for IndexedDB isolation +- CI: single worker for reliability -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 +- **Unit tests**: Run on all PRs and main commits with coverage +- **E2E**: Chromium only, single worker, 2 retries, artifacts for 7 days -**Local pre-commit checks**: - -```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/dashboards/[dashboardId]/_components/DashboardDetailContent.tsx b/apps/web/app/dashboards/[dashboardId]/_components/DashboardDetailContent.tsx new file mode 100644 index 00000000..6a590662 --- /dev/null +++ b/apps/web/app/dashboards/[dashboardId]/_components/DashboardDetailContent.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { DashboardGrid } from "@/components/dashboards/DashboardGrid"; +import { + useDashboardMutations, + useDashboards, + useVisualizations, +} from "@dashframe/core"; +import type { DashboardItemType } from "@dashframe/types"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@dashframe/ui"; +import { + ArrowLeftIcon, + ChartIcon, + CheckIcon, + EditIcon, + FileIcon, + PlusIcon, +} from "@dashframe/ui/icons"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +interface DashboardDetailContentProps { + dashboardId: string; +} + +export default function DashboardDetailContent({ + dashboardId, +}: DashboardDetailContentProps) { + const router = useRouter(); + + // Dexie hooks + const { data: dashboards = [], isLoading } = useDashboards(); + const { data: visualizations = [] } = useVisualizations(); + const { addItem } = useDashboardMutations(); + + // Find the dashboard + const dashboard = useMemo( + () => dashboards.find((d) => d.id === dashboardId), + [dashboards, dashboardId], + ); + + // Local state + const [isEditable, setIsEditable] = useState(false); + const [isAddOpen, setIsAddOpen] = useState(false); + const [addType, setAddType] = useState("visualization"); + const [selectedVizId, setSelectedVizId] = useState(""); + + // Redirect if not found after loading completes + useEffect(() => { + if (!isLoading && !dashboard) { + router.push("/dashboards"); + } + }, [isLoading, dashboard, router]); + + // Show loading state until we have the dashboard + if (isLoading || !dashboard) { + return ( +
+

Loading dashboard...

+
+ ); + } + + const handleAddItem = async () => { + await addItem(dashboardId, { + type: addType, + position: { + x: 0, + y: Infinity, // Put at bottom + width: addType === "visualization" ? 6 : 4, + height: addType === "visualization" ? 6 : 4, + }, + visualizationId: addType === "visualization" ? selectedVizId : undefined, + content: + addType === "markdown" + ? "## New Text Widget\n\nEdit this text..." + : undefined, + }); + + setIsAddOpen(false); + setAddType("visualization"); + setSelectedVizId(""); + }; + + return ( +
+ {/* Header */} +
+
+
+
+ {isEditable ? ( +
+
+ + {/* Grid Content */} +
+ +
+ + {/* Add Widget Dialog */} + + + + Add Widget + +
+
+ +
+
setAddType("visualization")} + > +
+ + Visualization +
+

+ Add an existing chart or table +

+
+
setAddType("markdown")} + > +
+ + Text / Markdown +
+

+ Add rich text, notes, or headers +

+
+
+
+ + {addType === "visualization" && ( +
+ + +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/dashboards/[dashboardId]/page.tsx b/apps/web/app/dashboards/[dashboardId]/page.tsx index 8f9082dc..8bac02c7 100644 --- a/apps/web/app/dashboards/[dashboardId]/page.tsx +++ b/apps/web/app/dashboards/[dashboardId]/page.tsx @@ -1,227 +1,17 @@ -"use client"; +import DashboardDetailContent from "./_components/DashboardDetailContent"; -import { DashboardGrid } from "@/components/dashboards/DashboardGrid"; -import { - useDashboardMutations, - useDashboards, - useVisualizations, -} from "@dashframe/core"; -import type { DashboardItemType } from "@dashframe/types"; -import { - Button, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@dashframe/ui"; -import { - ArrowLeftIcon, - ChartIcon, - CheckIcon, - EditIcon, - FileIcon, - PlusIcon, -} from "@dashframe/ui/icons"; -import { useRouter } from "next/navigation"; -import { use, useEffect, useMemo, useState } from "react"; +/** + * Force static generation - no serverless function. + * Data lives in IndexedDB (browser), so server rendering is meaningless. + */ +export const dynamic = "force-static"; +export const dynamicParams = true; -export default function DashboardDetailPage({ - params, -}: { +interface PageProps { params: Promise<{ dashboardId: string }>; -}) { - const { dashboardId } = use(params); - const router = useRouter(); - - // Dexie hooks - const { data: dashboards = [], isLoading } = useDashboards(); - const { data: visualizations = [] } = useVisualizations(); - const { addItem } = useDashboardMutations(); - - // Find the dashboard - const dashboard = useMemo( - () => dashboards.find((d) => d.id === dashboardId), - [dashboards, dashboardId], - ); - - // Local state - const [isEditable, setIsEditable] = useState(false); - const [isAddOpen, setIsAddOpen] = useState(false); - const [addType, setAddType] = useState("visualization"); - const [selectedVizId, setSelectedVizId] = useState(""); - - // Redirect if not found after loading completes - useEffect(() => { - if (!isLoading && !dashboard) { - router.push("/dashboards"); - } - }, [isLoading, dashboard, router]); - - // Show loading state until we have the dashboard - if (isLoading || !dashboard) { - return ( -
-

Loading dashboard...

-
- ); - } - - const handleAddItem = async () => { - await addItem(dashboardId, { - type: addType, - position: { - x: 0, - y: Infinity, // Put at bottom - width: addType === "visualization" ? 6 : 4, - height: addType === "visualization" ? 6 : 4, - }, - visualizationId: addType === "visualization" ? selectedVizId : undefined, - content: - addType === "markdown" - ? "## New Text Widget\n\nEdit this text..." - : undefined, - }); - - setIsAddOpen(false); - setAddType("visualization"); - setSelectedVizId(""); - }; - - return ( -
- {/* Header */} -
-
-
-
- {isEditable ? ( -
-
- - {/* Grid Content */} -
- -
- - {/* Add Widget Dialog */} - - - - Add Widget - -
-
- -
-
setAddType("visualization")} - > -
- - Visualization -
-

- Add an existing chart or table -

-
-
setAddType("markdown")} - > -
- - Text / Markdown -
-

- Add rich text, notes, or headers -

-
-
-
+} - {addType === "visualization" && ( -
- - -
- )} -
-
-
-
-
-
- ); +export default async function DashboardDetailPage({ params }: PageProps) { + const { dashboardId } = await params; + return ; } diff --git a/apps/web/app/data-sources/[sourceId]/_components/DataSourcePageContent.tsx b/apps/web/app/data-sources/[sourceId]/_components/DataSourcePageContent.tsx new file mode 100644 index 00000000..b1eca309 --- /dev/null +++ b/apps/web/app/data-sources/[sourceId]/_components/DataSourcePageContent.tsx @@ -0,0 +1,473 @@ +"use client"; + +import { AppLayout } from "@/components/layouts/AppLayout"; +import { useDataFrameData } from "@/hooks/useDataFrameData"; +import { + useDataFrames, + useDataSourceMutations, + useDataSources, + useDataTableMutations, + useDataTables, +} from "@dashframe/core"; +import type { UUID } from "@dashframe/types"; +import { + Badge, + Breadcrumb, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DatabaseIcon, + DeleteIcon, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + Input, + ItemCard, + ChevronLeftIcon as LuArrowLeft, + CloudIcon as LuCloud, + FileIcon as LuFileSpreadsheet, + MoreIcon as LuMoreHorizontal, + PlusIcon, + TableIcon, + VirtualTable, +} from "@dashframe/ui"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState } from "react"; + +interface DataSourcePageContentProps { + sourceId: string; +} + +// Get icon for data source type +function getSourceTypeIcon(type: string) { + switch (type) { + case "notion": + return ; + case "local": + return ; + case "postgresql": + return ; + default: + return ; + } +} + +/** + * Data Source Detail Page + * + * Shows a single data source with: + * - Source name and type + * - List of tables within the source + * - Selected table details (fields, metrics, preview) + * - Actions to create insights from tables + */ +export default function DataSourcePageContent({ + sourceId, +}: DataSourcePageContentProps) { + const router = useRouter(); + + // Dexie hooks + const { data: allDataSources = [] } = useDataSources(); + const { update: updateDataSource } = useDataSourceMutations(); + const { remove: removeDataTable } = useDataTableMutations(); + const { data: allDataFrames = [] } = useDataFrames(); + + // Find the data source + const dataSource = allDataSources.find((s) => s.id === sourceId); + const isLoading = false; // DataSources hook handles loading + + // Get tables for this data source (flat in Dexie) + const { data: dataTables = [] } = useDataTables(sourceId); + + // Local state for selected table - use null to indicate "not yet selected by user" + const [selectedTableId, setSelectedTableId] = useState(null); + + // Effective selected table ID - either user selection or first table as default + const effectiveSelectedTableId = selectedTableId ?? dataTables[0]?.id ?? null; + + // Get selected table details + const tableDetails = useMemo(() => { + if (!effectiveSelectedTableId) return null; + const table = dataTables.find((t) => t.id === effectiveSelectedTableId); + return table + ? { dataTable: table, fields: table.fields, metrics: table.metrics } + : null; + }, [effectiveSelectedTableId, dataTables]); + + // Use source name directly - mutations update database which triggers re-render + const sourceName = dataSource?.name ?? ""; + + // Local state for delete confirmation + const [deleteConfirmState, setDeleteConfirmState] = useState<{ + isOpen: boolean; + tableId: UUID | null; + tableName: string | null; + }>({ isOpen: false, tableId: null, tableName: null }); + + // Get DataFrame entry for metadata (row/column counts) + const dataFrameEntry = useMemo(() => { + const dataFrameId = tableDetails?.dataTable?.dataFrameId; + if (!dataFrameId) return null; + return allDataFrames.find((e) => e.id === dataFrameId); + }, [tableDetails, allDataFrames]); + + // Load actual data for preview (async from IndexedDB) + const { data: previewData, isLoading: isLoadingPreview } = useDataFrameData( + tableDetails?.dataTable?.dataFrameId, + { limit: 50 }, + ); + + // Handle name change - directly update database, triggers re-render via hook + const handleNameChange = async (newName: string) => { + await updateDataSource(sourceId, { name: newName }); + }; + + // Handle create insight from table + const handleCreateInsight = (tableId: UUID) => { + // Navigate to insights page with pre-selected table + // The actual insight creation will happen there + router.push(`/insights?newInsight=true&tableId=${tableId}`); + }; + + // Handle delete table + const handleDeleteTable = () => { + if (!selectedTableId || !tableDetails?.dataTable) return; + + setDeleteConfirmState({ + isOpen: true, + tableId: selectedTableId, + tableName: tableDetails.dataTable.name, + }); + }; + + // Handle confirm delete + const handleConfirmDelete = async () => { + if (!deleteConfirmState.tableId) return; + + try { + // Delete from local store + await removeDataTable(deleteConfirmState.tableId); + + // Clear selection and close dialog + setSelectedTableId(null); + setDeleteConfirmState({ isOpen: false, tableId: null, tableName: null }); + } catch (error) { + console.error("Failed to delete table:", error); + } + }; + + if (isLoading && !dataSource) { + return ( +
+
+

Loading data source…

+
+
+ ); + } + + // Not found state + if (!dataSource) { + return ( +
+
+

Data source not found

+

+ The data source you're looking for doesn't exist. +

+
+
+ ); + } + + return ( + <> + +
+ + + Back + + ), + href: "/data-sources", + }, + { label: "Data Sources", href: "/data-sources" }, + { label: sourceName || "Untitled Source" }, + ]} + /> +
+ + } + leftPanel={ +
+
+ handleNameChange(e.target.value)} + placeholder="Data source name" + className="w-full" + /> +
+
+

+ {getSourceTypeIcon(dataSource.type)} + Tables +

+ + {dataTables.length === 0 ? ( +
+ +

No tables yet

+
+ ) : ( +
+ {dataTables.map((table) => { + const fieldCount = table.fields.length; + + return ( + } + title={table.name} + subtitle={`${fieldCount} fields`} + onClick={() => setSelectedTableId(table.id)} + active={selectedTableId === table.id} + /> + ); + })} +
+ )} +
+
+ } + > + {selectedTableId && tableDetails ? ( +
+ {/* Table header */} +
+
+

+ {tableDetails.dataTable?.name} +

+

+ {tableDetails.fields.length} fields •{" "} + {tableDetails.metrics.length} metrics +

+
+
+
+
+ + {/* Fields */} + + + Fields + + + {tableDetails.fields.length === 0 ? ( +

+ No fields defined +

+ ) : ( +
+ {tableDetails.fields.map((field) => ( +
+ + {field.name} + + + {field.type} + +
+ ))} +
+ )} +
+
+ + {/* Metrics */} + + + Metrics + + + {tableDetails.metrics.length === 0 ? ( +

+ No metrics defined +

+ ) : ( +
+ {tableDetails.metrics.map((metric) => ( +
+ + {metric.name} + + + {metric.aggregation} + {metric.columnName && `(${metric.columnName})`} + +
+ ))} +
+ )} +
+
+ + {/* Data preview */} + {dataFrameEntry && ( + + + Data Preview + + +
+ {(() => { + if (isLoadingPreview) { + return ( +
+
+
+

+ Loading data... +

+
+
+ ); + } + + if (previewData) { + return ( + + ); + } + + return ( +
+

+ No data available +

+
+ ); + })()} +
+ + + )} +
+ ) : ( +
+
+ +

Select a table

+

+ Choose a table from the sidebar to view its details +

+
+
+ )} + + + {/* Delete Confirmation Dialog */} + + !open && + setDeleteConfirmState({ + isOpen: false, + tableId: null, + tableName: null, + }) + } + > + + + Delete Table + + Are you sure you want to delete " + {deleteConfirmState.tableName}"? This action cannot be + undone. + + + + + + ); +} diff --git a/apps/web/app/data-sources/[sourceId]/page.tsx b/apps/web/app/data-sources/[sourceId]/page.tsx index 98b59e3e..09283cc6 100644 --- a/apps/web/app/data-sources/[sourceId]/page.tsx +++ b/apps/web/app/data-sources/[sourceId]/page.tsx @@ -1,472 +1,17 @@ -"use client"; +import DataSourcePageContent from "./_components/DataSourcePageContent"; -import { AppLayout } from "@/components/layouts/AppLayout"; -import { useDataFrameData } from "@/hooks/useDataFrameData"; -import { - useDataFrames, - useDataSourceMutations, - useDataSources, - useDataTableMutations, - useDataTables, -} from "@dashframe/core"; -import type { UUID } from "@dashframe/types"; -import { - Badge, - Breadcrumb, - Button, - Card, - CardContent, - CardHeader, - CardTitle, - DatabaseIcon, - DeleteIcon, - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - Input, - ItemCard, - ChevronLeftIcon as LuArrowLeft, - CloudIcon as LuCloud, - FileIcon as LuFileSpreadsheet, - MoreIcon as LuMoreHorizontal, - PlusIcon, - TableIcon, - VirtualTable, -} from "@dashframe/ui"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { use, useMemo, useState } from "react"; +/** + * Force static generation - no serverless function. + * Data lives in IndexedDB (browser), so server rendering is meaningless. + */ +export const dynamic = "force-static"; +export const dynamicParams = true; interface PageProps { params: Promise<{ sourceId: string }>; } -// Get icon for data source type -function getSourceTypeIcon(type: string) { - switch (type) { - case "notion": - return ; - case "local": - return ; - case "postgresql": - return ; - default: - return ; - } -} - -/** - * Data Source Detail Page - * - * Shows a single data source with: - * - Source name and type - * - List of tables within the source - * - Selected table details (fields, metrics, preview) - * - Actions to create insights from tables - */ -export default function DataSourcePage({ params }: PageProps) { - const { sourceId } = use(params); - const router = useRouter(); - - // Dexie hooks - const { data: allDataSources = [] } = useDataSources(); - const { update: updateDataSource } = useDataSourceMutations(); - const { remove: removeDataTable } = useDataTableMutations(); - const { data: allDataFrames = [] } = useDataFrames(); - - // Find the data source - const dataSource = allDataSources.find((s) => s.id === sourceId); - const isLoading = false; // DataSources hook handles loading - - // Get tables for this data source (flat in Dexie) - const { data: dataTables = [] } = useDataTables(sourceId); - - // Local state for selected table - use null to indicate "not yet selected by user" - const [selectedTableId, setSelectedTableId] = useState(null); - - // Effective selected table ID - either user selection or first table as default - const effectiveSelectedTableId = selectedTableId ?? dataTables[0]?.id ?? null; - - // Get selected table details - const tableDetails = useMemo(() => { - if (!effectiveSelectedTableId) return null; - const table = dataTables.find((t) => t.id === effectiveSelectedTableId); - return table - ? { dataTable: table, fields: table.fields, metrics: table.metrics } - : null; - }, [effectiveSelectedTableId, dataTables]); - - // Use source name directly - mutations update database which triggers re-render - const sourceName = dataSource?.name ?? ""; - - // Local state for delete confirmation - const [deleteConfirmState, setDeleteConfirmState] = useState<{ - isOpen: boolean; - tableId: UUID | null; - tableName: string | null; - }>({ isOpen: false, tableId: null, tableName: null }); - - // Get DataFrame entry for metadata (row/column counts) - const dataFrameEntry = useMemo(() => { - const dataFrameId = tableDetails?.dataTable?.dataFrameId; - if (!dataFrameId) return null; - return allDataFrames.find((e) => e.id === dataFrameId); - }, [tableDetails, allDataFrames]); - - // Load actual data for preview (async from IndexedDB) - const { data: previewData, isLoading: isLoadingPreview } = useDataFrameData( - tableDetails?.dataTable?.dataFrameId, - { limit: 50 }, - ); - - // Handle name change - directly update database, triggers re-render via hook - const handleNameChange = async (newName: string) => { - await updateDataSource(sourceId, { name: newName }); - }; - - // Handle create insight from table - const handleCreateInsight = (tableId: UUID) => { - // Navigate to insights page with pre-selected table - // The actual insight creation will happen there - router.push(`/insights?newInsight=true&tableId=${tableId}`); - }; - - // Handle delete table - const handleDeleteTable = () => { - if (!selectedTableId || !tableDetails?.dataTable) return; - - setDeleteConfirmState({ - isOpen: true, - tableId: selectedTableId, - tableName: tableDetails.dataTable.name, - }); - }; - - // Handle confirm delete - const handleConfirmDelete = async () => { - if (!deleteConfirmState.tableId) return; - - try { - // Delete from local store - await removeDataTable(deleteConfirmState.tableId); - - // Clear selection and close dialog - setSelectedTableId(null); - setDeleteConfirmState({ isOpen: false, tableId: null, tableName: null }); - } catch (error) { - console.error("Failed to delete table:", error); - } - }; - - if (isLoading && !dataSource) { - return ( -
-
-

Loading data source…

-
-
- ); - } - - // Not found state - if (!dataSource) { - return ( -
-
-

Data source not found

-

- The data source you're looking for doesn't exist. -

-
-
- ); - } - - return ( - <> - -
- - - Back - - ), - href: "/data-sources", - }, - { label: "Data Sources", href: "/data-sources" }, - { label: sourceName || "Untitled Source" }, - ]} - /> -
-
- } - leftPanel={ -
-
- handleNameChange(e.target.value)} - placeholder="Data source name" - className="w-full" - /> -
-
-

- {getSourceTypeIcon(dataSource.type)} - Tables -

- - {dataTables.length === 0 ? ( -
- -

No tables yet

-
- ) : ( -
- {dataTables.map((table) => { - const fieldCount = table.fields.length; - - return ( - } - title={table.name} - subtitle={`${fieldCount} fields`} - onClick={() => setSelectedTableId(table.id)} - active={selectedTableId === table.id} - /> - ); - })} -
- )} -
-
- } - > - {selectedTableId && tableDetails ? ( -
- {/* Table header */} -
-
-

- {tableDetails.dataTable?.name} -

-

- {tableDetails.fields.length} fields •{" "} - {tableDetails.metrics.length} metrics -

-
-
-
-
- - {/* Fields */} - - - Fields - - - {tableDetails.fields.length === 0 ? ( -

- No fields defined -

- ) : ( -
- {tableDetails.fields.map((field) => ( -
- - {field.name} - - - {field.type} - -
- ))} -
- )} -
-
- - {/* Metrics */} - - - Metrics - - - {tableDetails.metrics.length === 0 ? ( -

- No metrics defined -

- ) : ( -
- {tableDetails.metrics.map((metric) => ( -
- - {metric.name} - - - {metric.aggregation} - {metric.columnName && `(${metric.columnName})`} - -
- ))} -
- )} -
-
- - {/* Data preview */} - {dataFrameEntry && ( - - - Data Preview - - -
- {(() => { - if (isLoadingPreview) { - return ( -
-
-
-

- Loading data... -

-
-
- ); - } - - if (previewData) { - return ( - - ); - } - - return ( -
-

- No data available -

-
- ); - })()} -
- - - )} -
- ) : ( -
-
- -

Select a table

-

- Choose a table from the sidebar to view its details -

-
-
- )} - - - {/* Delete Confirmation Dialog */} - - !open && - setDeleteConfirmState({ - isOpen: false, - tableId: null, - tableName: null, - }) - } - > - - - Delete Table - - Are you sure you want to delete " - {deleteConfirmState.tableName}"? This action cannot be - undone. - - - - - - ); +export default async function DataSourcePage({ params }: PageProps) { + const { sourceId } = await params; + return ; } 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..efb54b55 --- /dev/null +++ b/apps/web/app/insights/[insightId]/_components/InsightPageContent.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useInsight } from "@dashframe/core"; +import { Spinner } from "@dashframe/ui"; +import { InsightView } from "./InsightView"; +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 ( +
+
+ +

Loading insight...

+
+
+ ); + } + + 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]/_components/LoadingView.tsx b/apps/web/app/insights/[insightId]/_components/LoadingView.tsx deleted file mode 100644 index 6bc7a39c..00000000 --- a/apps/web/app/insights/[insightId]/_components/LoadingView.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import { Spinner } from "@dashframe/ui"; - -/** - * LoadingView - Centered loading spinner for insight page - */ -export function LoadingView() { - return ( -
-
- -

Loading insight...

-
-
- ); -} diff --git a/apps/web/app/insights/[insightId]/_components/index.ts b/apps/web/app/insights/[insightId]/_components/index.ts index 7bacb484..0092738b 100644 --- a/apps/web/app/insights/[insightId]/_components/index.ts +++ b/apps/web/app/insights/[insightId]/_components/index.ts @@ -1,3 +1,2 @@ export { InsightView } from "./InsightView"; -export { LoadingView } from "./LoadingView"; export { NotFoundView } from "./NotFoundView"; diff --git a/apps/web/app/insights/[insightId]/join/[tableId]/_components/JoinConfigureContent.tsx b/apps/web/app/insights/[insightId]/join/[tableId]/_components/JoinConfigureContent.tsx new file mode 100644 index 00000000..e02fa317 --- /dev/null +++ b/apps/web/app/insights/[insightId]/join/[tableId]/_components/JoinConfigureContent.tsx @@ -0,0 +1,1256 @@ +"use client"; + +/* eslint-disable sonarjs/cognitive-complexity */ + +import { useDuckDB } from "@/components/providers/DuckDBProvider"; +import { useDataFramePagination } from "@/hooks/useDataFramePagination"; +import { + getDataFrame, + useDataTables, + useInsightMutations, + useInsights, +} from "@dashframe/core"; +import { shortenAutoGeneratedName } from "@dashframe/engine-browser"; +import type { + DataFrameRow, + DataTable, + Field, + InsightJoinConfig, +} from "@dashframe/types"; +import { + Alert, + AlertCircleIcon, + AlertDescription, + ArrowLeftIcon, + Button, + Label, + MergeIcon, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + Surface, + VirtualTable, + type VirtualTableColumn, + type VirtualTableColumnConfig, +} from "@dashframe/ui"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +interface JoinConfigureContentProps { + insightId: string; + tableId: string; +} + +/** Local type for join preview result (static data for VirtualTable) */ +interface JoinPreviewData { + columns: VirtualTableColumn[]; + rows: DataFrameRow[]; +} + +const PREVIEW_ROW_LIMIT = 50; + +/** + * Join Configuration Page + * + * Provides a full-page experience for configuring joins between two tables: + * - Side-by-side table previews (responsive: stacked on narrow screens) + * - Column selection for join keys + * - Join type selection + * - Live preview of join result + */ +export default function JoinConfigureContent({ + insightId, + tableId: joinTableId, +}: JoinConfigureContentProps) { + const router = useRouter(); + + // Dexie hooks for data access + const { data: allInsights, isLoading: isInsightsLoading } = useInsights(); + const { data: allDataTables, isLoading: isTablesLoading } = useDataTables(); + const { update: updateInsight } = useInsightMutations(); + + const isLoading = isInsightsLoading || isTablesLoading; + + // Find the current insight + const insight = useMemo( + () => allInsights?.find((i) => i.id === insightId), + [allInsights, insightId], + ); + + // Join configuration state + const [leftFieldId, setLeftFieldId] = useState(null); + const [rightFieldId, setRightFieldId] = useState(null); + const [joinType, setJoinType] = useState< + "inner" | "left" | "right" | "outer" + >("inner"); + const intersectionFill = useMemo(() => { + if (joinType === "inner") { + return "rgba(251, 191, 36, 0.5)"; + } + + if (joinType === "left" || joinType === "right") { + return "rgba(251, 191, 36, 0.3)"; + } + + return "rgba(251, 191, 36, 0.2)"; + }, [joinType]); + const [error, setError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [previewResult, setPreviewResult] = useState( + null, + ); + const [isComputingPreview, setIsComputingPreview] = useState(false); + + // Resolve base table (from insight's baseTableId) + const baseTable = useMemo(() => { + if (!insight || !allDataTables) return null; + return allDataTables.find((t) => t.id === insight.baseTableId) ?? null; + }, [insight, allDataTables]); + + // Resolve join table (from tableId param) + const joinTable = useMemo(() => { + if (!allDataTables) return null; + return allDataTables.find((t) => t.id === joinTableId) ?? null; + }, [allDataTables, joinTableId]); + + // DuckDB connection for join preview (initialized by DuckDBProvider during idle time) + const { + connection, + isInitialized: isDuckDBReady, + isLoading: isDuckDBLoading, + } = useDuckDB(); + + // Pagination hooks for async VirtualTable (full dataset browsing) + const { + fetchData: fetchBaseData, + totalCount: baseTotalCount, + isReady: isBaseReady, + } = useDataFramePagination(baseTable?.dataFrameId); + + const { + fetchData: fetchJoinData, + totalCount: joinTotalCount, + isReady: isJoinReady, + } = useDataFramePagination(joinTable?.dataFrameId); + + // Filter out internal fields (those starting with _) + const baseFields = useMemo( + () => baseTable?.fields?.filter((f) => !f.name.startsWith("_")) ?? [], + [baseTable], + ); + + const joinFields = useMemo( + () => joinTable?.fields?.filter((f) => !f.name.startsWith("_")) ?? [], + [joinTable], + ); + + // Column configs for highlighting selected columns in source tables + const baseColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { + const leftField = baseFields.find((f) => f.id === leftFieldId); + if (!leftField) return []; + return [{ id: leftField.columnName ?? leftField.name, highlight: true }]; + }, [baseFields, leftFieldId]); + + const joinColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { + const rightField = joinFields.find((f) => f.id === rightFieldId); + if (!rightField) return []; + return [{ id: rightField.columnName ?? rightField.name, highlight: true }]; + }, [joinFields, rightFieldId]); + + // Column configs for the preview result - highlight base vs join columns + const previewColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { + if (!previewResult?.columns || !baseTable || !joinTable) return []; + + // Get column names from each source table + const baseColumnNames = new Set( + baseFields.map((f) => f.columnName ?? f.name), + ); + const joinColumnNames = new Set( + joinFields.map((f) => f.columnName ?? f.name), + ); + + // Get table display names for prefix detection (must match SQL generation) + const baseDisplayName = shortenAutoGeneratedName(baseTable.name); + const joinDisplayName = shortenAutoGeneratedName(joinTable.name); + + return previewResult.columns + .filter((col) => !col.name.startsWith("_")) + .map((col) => { + // Check if this column came from base or join table + // Handle table name prefix format: "tableName.columnName" + const hasBasePrefix = col.name.startsWith(`${baseDisplayName}.`); + const hasJoinPrefix = col.name.startsWith(`${joinDisplayName}.`); + + // Determine highlight variant + // If column has a prefix, it's definitively from ONE table only + // (the prefix was added to disambiguate duplicate column names) + let highlight: "base" | "join" | "both" | undefined; + if (hasBasePrefix) { + highlight = "base"; + } else if (hasJoinPrefix) { + highlight = "join"; + } else { + // No prefix - check which table(s) this column belongs to + const isFromBase = baseColumnNames.has(col.name); + const isFromJoin = joinColumnNames.has(col.name); + + if (isFromBase && isFromJoin) { + // Column name exists in both original tables, but check if the other + // table's version was prefixed (meaning this unprefixed one is from just one table) + const hasBasePrefixedVersion = previewResult.columns.some( + (c) => c.name === `${baseDisplayName}.${col.name}`, + ); + const hasJoinPrefixedVersion = previewResult.columns.some( + (c) => c.name === `${joinDisplayName}.${col.name}`, + ); + + if (hasJoinPrefixedVersion) { + // Join table's version was prefixed, so unprefixed is from base only + highlight = "base"; + } else if (hasBasePrefixedVersion) { + // Base table's version was prefixed, so unprefixed is from join only + highlight = "join"; + } else { + // Neither was prefixed - genuinely in both (rare, e.g., join key) + highlight = "both"; + } + } else if (isFromBase) { + highlight = "base"; + } else if (isFromJoin) { + highlight = "join"; + } + } + + return { + id: col.name, + highlight, + }; + }) + .filter( + (config) => config.highlight !== undefined, + ) as VirtualTableColumnConfig[]; + }, [previewResult, baseTable, joinTable, baseFields, joinFields]); + + // Join analysis results for Venn diagram visualization + const [joinAnalysis, setJoinAnalysis] = useState<{ + baseUniqueCount: number; + joinUniqueCount: number; + matchingCount: number; // values that exist in both + estimatedResultRows: number; // approximate rows after join + } | null>(null); + + // Find columns with matching names (for suggestions) with analysis + const [columnSuggestions, setColumnSuggestions] = useState< + Array<{ + leftField: (typeof baseFields)[0]; + rightField: (typeof joinFields)[0]; + columnName: string; + matchingValues: number; // how many values match between tables + baseUniqueValues: number; + }> + >([]); + + // Analyze matching columns for suggestions + // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis + useEffect(() => { + if (isDuckDBLoading || !connection || !isDuckDBReady) return; + if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) return; + // Wait for pagination hooks to be ready (they load the data into DuckDB) + if (!isBaseReady || !isJoinReady) return; + + const baseColMap = new Map< + string, + { field: (typeof baseFields)[0]; name: string } + >(); + for (const field of baseFields) { + const colName = field.columnName ?? field.name; + baseColMap.set(colName.toLowerCase(), { field, name: colName }); + } + + const pairs: Array<{ + leftField: (typeof baseFields)[0]; + rightField: (typeof joinFields)[0]; + columnName: string; + }> = []; + for (const joinField of joinFields) { + const joinColName = joinField.columnName ?? joinField.name; + const baseMatch = baseColMap.get(joinColName.toLowerCase()); + if (baseMatch) { + pairs.push({ + leftField: baseMatch.field, + rightField: joinField, + columnName: joinColName, + }); + } + } + + if (pairs.length === 0) { + setColumnSuggestions([]); + return; + } + + const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; + const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; + + const analyze = async () => { + try { + // Tables are already loaded by the pagination hooks + + const results: typeof columnSuggestions = []; + + for (const pair of pairs) { + const leftColName = pair.leftField.columnName ?? pair.leftField.name; + const rightColName = + pair.rightField.columnName ?? pair.rightField.name; + + const sql = ` + WITH base_vals AS (SELECT DISTINCT "${leftColName}" as val FROM ${baseTableName}), + join_vals AS (SELECT DISTINCT "${rightColName}" as val FROM ${joinTableName}) + SELECT + (SELECT COUNT(*) FROM base_vals) as base_unique, + (SELECT COUNT(*) FROM base_vals b WHERE EXISTS (SELECT 1 FROM join_vals j WHERE j.val = b.val)) as matching + `; + + const result = await connection.query(sql); + const row = result.toArray()[0] as { + base_unique: number; + matching: number; + }; + + results.push({ + ...pair, + matchingValues: Number(row?.matching ?? 0), + baseUniqueValues: Number(row?.base_unique ?? 0), + }); + } + + // Sort by matching values (higher = better) + results.sort((a, b) => b.matchingValues - a.matchingValues); + setColumnSuggestions(results); + } catch (err) { + console.error("Failed to analyze column suggestions:", err); + // Fallback without analysis + setColumnSuggestions( + pairs.map((p) => ({ ...p, matchingValues: 0, baseUniqueValues: 0 })), + ); + } + }; + + analyze(); + }, [ + connection, + isDuckDBReady, + isDuckDBLoading, + baseTable, + joinTable, + baseFields, + joinFields, + isBaseReady, + isJoinReady, + ]); + + // Apply a suggestion + const applySuggestion = useCallback((pair: (typeof columnSuggestions)[0]) => { + setLeftFieldId(pair.leftField.id); + setRightFieldId(pair.rightField.id); + }, []); + + // Analyze selected columns for Venn diagram + // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis + useEffect(() => { + if (!leftFieldId || !rightFieldId) { + setJoinAnalysis(null); + return; + } + if (isDuckDBLoading || !connection || !isDuckDBReady) return; + if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) return; + + const leftField = baseFields.find((f) => f.id === leftFieldId); + const rightField = joinFields.find((f) => f.id === rightFieldId); + if (!leftField || !rightField) return; + + const leftColumnName = leftField.columnName ?? leftField.name; + const rightColumnName = rightField.columnName ?? rightField.name; + + const analyze = async () => { + try { + // Get DataFrames from Dexie (async) + const baseDataFrame = await getDataFrame(baseTable.dataFrameId!); + const joinDataFrame = await getDataFrame(joinTable.dataFrameId!); + if (!baseDataFrame || !joinDataFrame) return; + + await baseDataFrame.load(connection); + await joinDataFrame.load(connection); + + const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; + const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; + + // Get Venn diagram stats + const vennSQL = ` + WITH base_values AS ( + SELECT DISTINCT "${leftColumnName}" as val FROM ${baseTableName} + ), + join_values AS ( + SELECT DISTINCT "${rightColumnName}" as val FROM ${joinTableName} + ), + matching AS ( + SELECT b.val FROM base_values b + INNER JOIN join_values j ON b.val = j.val + ) + SELECT + (SELECT COUNT(*) FROM base_values) as base_unique, + (SELECT COUNT(*) FROM join_values) as join_unique, + (SELECT COUNT(*) FROM matching) as matching_count + `; + + const vennResult = await connection.query(vennSQL); + const vennRow = vennResult.toArray()[0] as { + base_unique: number; + join_unique: number; + matching_count: number; + }; + + // Estimate result rows (for inner join) + const countSQL = ` + SELECT COUNT(*) as cnt + FROM ${baseTableName} b + INNER JOIN ${joinTableName} j ON b."${leftColumnName}" = j."${rightColumnName}" + `; + const countResult = await connection.query(countSQL); + const countRow = countResult.toArray()[0] as { cnt: number }; + + setJoinAnalysis({ + baseUniqueCount: Number(vennRow?.base_unique ?? 0), + joinUniqueCount: Number(vennRow?.join_unique ?? 0), + matchingCount: Number(vennRow?.matching_count ?? 0), + estimatedResultRows: Number(countRow?.cnt ?? 0), + }); + } catch (err) { + console.error("Failed to analyze join:", err); + setJoinAnalysis(null); + } + }; + + analyze(); + }, [ + leftFieldId, + rightFieldId, + connection, + isDuckDBReady, + isDuckDBLoading, + baseTable, + joinTable, + baseFields, + joinFields, + ]); + + // Compute join preview using DuckDB (handles full dataset efficiently) + // DuckDB is lazy-loaded, so we check isDuckDBLoading before computing preview + useEffect(() => { + if (!leftFieldId || !rightFieldId) { + setPreviewResult(null); + return; + } + + if (isDuckDBLoading || !connection || !isDuckDBReady) { + return; + } + + if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) { + return; + } + + const leftField = baseFields.find((f) => f.id === leftFieldId); + const rightField = joinFields.find((f) => f.id === rightFieldId); + + if (!leftField || !rightField) { + setPreviewResult(null); + return; + } + + const leftColumnName = leftField.columnName ?? leftField.name; + const rightColumnName = rightField.columnName ?? rightField.name; + + setIsComputingPreview(true); + setError(null); + + const computeJoin = async () => { + try { + // Get DataFrames from Dexie (async) + const baseDataFrame = await getDataFrame(baseTable.dataFrameId!); + const joinDataFrame = await getDataFrame(joinTable.dataFrameId!); + + if (!baseDataFrame || !joinDataFrame) { + setError("Could not load DataFrames"); + return; + } + + // Load both tables into DuckDB (side effect ensures tables exist) + await baseDataFrame.load(connection); + await joinDataFrame.load(connection); + + const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; + const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; + + // Build the JOIN SQL + const joinTypeSQL = joinType.toUpperCase(); + + // Get column lists, handling duplicates with table name prefixes + const baseColNames = baseFields.map((f) => f.columnName ?? f.name); + const joinColNames = joinFields.map((f) => f.columnName ?? f.name); + + // Get display names for table prefixes (cleaned of UUIDs) + const baseDisplayName = shortenAutoGeneratedName(baseTable.name); + const joinDisplayName = shortenAutoGeneratedName(joinTable.name); + + // Build SELECT clause with table name prefixes for duplicate columns + const selectParts: string[] = []; + + for (const col of baseColNames) { + if (col.startsWith("_")) continue; // Skip internal columns + const isDuplicate = + joinColNames.includes(col) && col !== leftColumnName; + if (isDuplicate) { + // Use table name prefix: "accounts.acctid" instead of "acctid_base" + selectParts.push(`base."${col}" AS "${baseDisplayName}.${col}"`); + } else { + selectParts.push(`base."${col}"`); + } + } + + // Build lowercase set of base column names for case-insensitive duplicate detection + const baseColNamesLower = new Set( + baseColNames.map((c) => c.toLowerCase()), + ); + + for (const col of joinColNames) { + if (col.startsWith("_")) continue; // Skip internal columns + // For INNER joins, skip the join key column - it's redundant with base table's + // join key (they have the same values for all matched rows). + // For LEFT/RIGHT/OUTER joins, keep both since NULLs may differ. + if (joinType === "inner" && col === rightColumnName) continue; + // Check for duplicate using case-insensitive comparison + // DuckDB treats column names case-insensitively, so "acctId" and "acctid" conflict + const isDuplicate = baseColNamesLower.has(col.toLowerCase()); + if (isDuplicate) { + // Use table name prefix: "opportunities.acctid" instead of "acctid_1" + selectParts.push(`j."${col}" AS "${joinDisplayName}.${col}"`); + } else { + selectParts.push(`j."${col}"`); + } + } + + const joinSQL = ` + SELECT ${selectParts.join(", ")} + FROM ${baseTableName} AS base + ${joinTypeSQL} JOIN ${joinTableName} AS j + ON base."${leftColumnName}" = j."${rightColumnName}" + LIMIT ${PREVIEW_ROW_LIMIT} + `; + + console.log("[JoinPreview] Executing DuckDB join:", joinSQL); + + const result = await connection.query(joinSQL); + const rows = result.toArray() as DataFrameRow[]; + + // Get total count for the join + const countSQL = ` + SELECT COUNT(*) as count + FROM ${baseTableName} AS base + ${joinTypeSQL} JOIN ${joinTableName} AS j + ON base."${leftColumnName}" = j."${rightColumnName}" + `; + const countResult = await connection.query(countSQL); + const totalJoinCount = Number(countResult.toArray()[0]?.count ?? 0); + + // Build columns from the result + const columns: VirtualTableColumn[] = + rows.length > 0 + ? Object.keys(rows[0]) + .filter((key) => !key.startsWith("_")) + .map((name) => ({ name })) + : []; + + console.log("[JoinPreview] DuckDB result:", { + previewRows: rows.length, + totalJoinCount, + columns: columns.length, + }); + + setPreviewResult({ columns, rows }); + // Store total count for display + setPreviewTotalCount(totalJoinCount); + } catch (err) { + console.error("DuckDB join preview failed:", err); + setPreviewResult(null); + const errorMessage = + err instanceof Error ? err.message : "Unknown error"; + setError(`Join preview failed: ${errorMessage}`); + } finally { + setIsComputingPreview(false); + } + }; + + computeJoin(); + }, [ + leftFieldId, + rightFieldId, + joinType, + connection, + isDuckDBReady, + isDuckDBLoading, + baseTable, + joinTable, + baseFields, + joinFields, + ]); + + // Track total join count for display + const [previewTotalCount, setPreviewTotalCount] = useState(0); + + // Execute full join and add to existing insight + // Note: We only store the join configuration here. The actual join is computed + // on-demand when displaying the preview in InsightConfigureTab. + const handleExecuteJoin = useCallback(async () => { + if (!leftFieldId || !rightFieldId) { + setError("Select both join columns."); + return; + } + + if (!baseTable || !joinTable || !insight) { + setError("Unable to load table data."); + return; + } + + const leftField = baseFields.find((f) => f.id === leftFieldId); + const rightField = joinFields.find((f) => f.id === rightFieldId); + + if (!leftField || !rightField) { + setError("Selected columns are no longer available."); + return; + } + + setError(null); + setIsSubmitting(true); + + // Validate the join works by testing it (using preview result) + // The preview is already computed, so we just check if it succeeded + if (!previewResult || previewResult.rows.length === 0) { + // Still allow the join even with 0 rows - user may want to keep the config + // Just warn them in the UI (handled by the existing Alert component) + } + + // Create join config using the Core schema + // Uses column names (strings) as join keys, not field UUIDs + const joinConfig: InsightJoinConfig = { + type: joinType === "outer" ? "full" : joinType, // "outer" → "full" for Core type + rightTableId: joinTable!.id, + leftKey: leftField.columnName ?? leftField.name, + rightKey: rightField.columnName ?? rightField.name, + }; + + // Add join to existing insight (append to existing joins if any) + const existingJoins = insight.joins ?? []; + await updateInsight(insightId, { + joins: [...existingJoins, joinConfig], + }); + + // Note: We intentionally do NOT store a pre-computed joined DataFrame here. + // The join preview in InsightConfigureTab computes the join on-demand, + // which ensures we always show raw joined data (not aggregated data). + + setIsSubmitting(false); + // Navigate back to the same insight + router.push(`/insights/${insightId}`); + }, [ + leftFieldId, + rightFieldId, + joinType, + baseTable, + joinTable, + baseFields, + joinFields, + insight, + insightId, + updateInsight, + previewResult, + router, + ]); + + // Loading state - wait for all stores to hydrate before rendering + if (isLoading) { + return ( +
+
+ +

+ Loading join configuration... +

+
+
+ ); + } + + // Error states + if (!insight) { + return ( +
+ + +

Insight not found

+

+ The insight you're looking for doesn't exist. +

+
+ ); + } + + if (!baseTable) { + return ( +
+ + +

Base table not found

+

+ The data table for this insight no longer exists. +

+
+ ); + } + + if (!joinTable) { + return ( +
+ + +

Join table not found

+

+ The table you're trying to join with doesn't exist. +

+
+ ); + } + + const canJoin = leftFieldId && rightFieldId; + + return ( +
+ {/* Header */} +
+
+
+
+
+
+
+
+ + {/* Main Content */} +
+
+ {/* Dual Table Previews */} +
+ {/* Base Table Preview */} + { + const field = baseFields.find( + (f) => (f.columnName ?? f.name) === colName, + ); + if (field) setLeftFieldId(field.id); + }} + /> + + {/* Join Table Preview */} + { + const field = joinFields.find( + (f) => (f.columnName ?? f.name) === colName, + ); + if (field) setRightFieldId(field.id); + }} + /> +
+ + {/* Join Configuration */} + +

Join Configuration

+ + {/* Matching column suggestions */} + {columnSuggestions.length > 0 && ( +
+
+ + Matching columns found + + + – click to select + +
+
+ {columnSuggestions.map((pair) => ( + + ))} +
+
+ )} + +
+ {/* Column Selection */} +
+
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ + {/* Venn Diagram Visualization */} + {joinAnalysis && ( +
+ {/* SVG Venn Diagram */} + + {/* Base table circle (left) */} + + {/* Join table circle (right) */} + + {/* Intersection highlight based on join type */} + + + + + + + {/* Intersection area */} + + {/* Labels */} + + {( + joinAnalysis.baseUniqueCount - + joinAnalysis.matchingCount + ).toLocaleString()} + + + {joinAnalysis.matchingCount.toLocaleString()} + + + matching + + + {( + joinAnalysis.joinUniqueCount - + joinAnalysis.matchingCount + ).toLocaleString()} + + {/* Circle labels */} + + Base + + + Join + + + + {/* Result summary */} +
+

+ {joinType === "inner" && ( + <> + Result:{" "} + + {joinAnalysis.estimatedResultRows.toLocaleString()} + {" "} + rows + + )} + {joinType === "left" && ( + <>Result: all base rows + matched join data + )} + {joinType === "right" && ( + <>Result: matched base data + all join rows + )} + {joinType === "outer" && ( + <>Result: all rows from both tables + )} +

+

+ {joinAnalysis.matchingCount.toLocaleString()} of{" "} + {joinAnalysis.baseUniqueCount.toLocaleString()} base + values have matches +

+
+
+ )} +
+
+ + {/* Error Display */} + {error && ( + + {error} + + )} + + {/* Preview Result */} + {canJoin && ( + +
+

Preview Result

+ {isComputingPreview && ( +
+ + Computing preview... +
+ )} + {!isComputingPreview && previewResult && ( +

+ {previewTotalCount.toLocaleString()} total rows + {previewTotalCount > PREVIEW_ROW_LIMIT && + ` (showing first ${PREVIEW_ROW_LIMIT})`} + {" · "} + {previewResult.columns?.length ?? 0} columns +

+ )} +
+ + {/* Preview result or placeholder */} + {!isComputingPreview && previewResult && ( + <> + {/* Legend for column colors */} +
+
+
+ + From {baseTable.name ?? "base table"} + +
+
+
+ + From {joinTable.name ?? "join table"} + +
+
+
+ + In both tables + +
+
+
+ +
+ + )} + {!isComputingPreview && !previewResult && ( +
+ {error + ? "Unable to generate preview" + : "Select join columns to see preview"} +
+ )} + + {!isComputingPreview && + previewResult && + previewResult.rows.length === 0 && ( + + + This join produces 0 rows. Consider using a different join + type or checking that the columns have matching values. + + + )} + + )} +
+
+
+ ); +} + +// ============================================================================ +// Table Preview Section Component +// ============================================================================ + +interface TablePreviewSectionProps { + title: string; + table: DataTable; + totalCount: number; + isReady: boolean; + onFetchData: ( + params: import("@dashframe/ui").FetchDataParams, + ) => Promise; + fields: Field[]; + columnConfigs?: VirtualTableColumnConfig[]; + onHeaderClick?: (columnName: string) => void; +} + +function TablePreviewSection({ + title, + table, + totalCount, + isReady, + onFetchData, + fields, + columnConfigs, + onHeaderClick, +}: TablePreviewSectionProps) { + const colCount = fields.filter((f) => !f.name.startsWith("_")).length; + + return ( + +
+
+
+

+ {title} +

+

{table.name}

+
+

+ {totalCount.toLocaleString()} rows · {colCount} columns +

+
+

+ Click a column header to select it for joining +

+
+
+ {!isReady ? ( +
+
+ + Loading data... +
+
+ ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/app/insights/[insightId]/join/[tableId]/page.tsx b/apps/web/app/insights/[insightId]/join/[tableId]/page.tsx index 76da8526..3539b959 100644 --- a/apps/web/app/insights/[insightId]/join/[tableId]/page.tsx +++ b/apps/web/app/insights/[insightId]/join/[tableId]/page.tsx @@ -1,1253 +1,17 @@ -"use client"; - -/* eslint-disable sonarjs/cognitive-complexity */ - -import { useDuckDB } from "@/components/providers/DuckDBProvider"; -import { useDataFramePagination } from "@/hooks/useDataFramePagination"; -import { - getDataFrame, - useDataTables, - useInsightMutations, - useInsights, -} from "@dashframe/core"; -import { shortenAutoGeneratedName } from "@dashframe/engine-browser"; -import type { - DataFrameRow, - DataTable, - Field, - InsightJoinConfig, -} from "@dashframe/types"; -import { - Alert, - AlertCircleIcon, - AlertDescription, - ArrowLeftIcon, - Button, - Label, - MergeIcon, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, - Spinner, - Surface, - VirtualTable, - type VirtualTableColumn, - type VirtualTableColumnConfig, -} from "@dashframe/ui"; -import { useRouter } from "next/navigation"; -import { use, useCallback, useEffect, useMemo, useState } from "react"; - -interface PageProps { - params: Promise<{ insightId: string; tableId: string }>; -} - -/** Local type for join preview result (static data for VirtualTable) */ -interface JoinPreviewData { - columns: VirtualTableColumn[]; - rows: DataFrameRow[]; -} - -const PREVIEW_ROW_LIMIT = 50; +import JoinConfigureContent from "./_components/JoinConfigureContent"; /** - * Join Configuration Page - * - * Provides a full-page experience for configuring joins between two tables: - * - Side-by-side table previews (responsive: stacked on narrow screens) - * - Column selection for join keys - * - Join type selection - * - Live preview of join result + * Force static generation - no serverless function. + * Data lives in IndexedDB (browser), so server rendering is meaningless. */ -export default function JoinConfigurePage({ params }: PageProps) { - const { insightId, tableId: joinTableId } = use(params); - const router = useRouter(); - - // Dexie hooks for data access - const { data: allInsights, isLoading: isInsightsLoading } = useInsights(); - const { data: allDataTables, isLoading: isTablesLoading } = useDataTables(); - const { update: updateInsight } = useInsightMutations(); - - const isLoading = isInsightsLoading || isTablesLoading; - - // Find the current insight - const insight = useMemo( - () => allInsights?.find((i) => i.id === insightId), - [allInsights, insightId], - ); - - // Join configuration state - const [leftFieldId, setLeftFieldId] = useState(null); - const [rightFieldId, setRightFieldId] = useState(null); - const [joinType, setJoinType] = useState< - "inner" | "left" | "right" | "outer" - >("inner"); - const intersectionFill = useMemo(() => { - if (joinType === "inner") { - return "rgba(251, 191, 36, 0.5)"; - } - - if (joinType === "left" || joinType === "right") { - return "rgba(251, 191, 36, 0.3)"; - } - - return "rgba(251, 191, 36, 0.2)"; - }, [joinType]); - const [error, setError] = useState(null); - const [isSubmitting, setIsSubmitting] = useState(false); - const [previewResult, setPreviewResult] = useState( - null, - ); - const [isComputingPreview, setIsComputingPreview] = useState(false); - - // Resolve base table (from insight's baseTableId) - const baseTable = useMemo(() => { - if (!insight || !allDataTables) return null; - return allDataTables.find((t) => t.id === insight.baseTableId) ?? null; - }, [insight, allDataTables]); - - // Resolve join table (from tableId param) - const joinTable = useMemo(() => { - if (!allDataTables) return null; - return allDataTables.find((t) => t.id === joinTableId) ?? null; - }, [allDataTables, joinTableId]); - - // DuckDB connection for join preview (initialized by DuckDBProvider during idle time) - const { - connection, - isInitialized: isDuckDBReady, - isLoading: isDuckDBLoading, - } = useDuckDB(); - - // Pagination hooks for async VirtualTable (full dataset browsing) - const { - fetchData: fetchBaseData, - totalCount: baseTotalCount, - isReady: isBaseReady, - } = useDataFramePagination(baseTable?.dataFrameId); - - const { - fetchData: fetchJoinData, - totalCount: joinTotalCount, - isReady: isJoinReady, - } = useDataFramePagination(joinTable?.dataFrameId); - - // Filter out internal fields (those starting with _) - const baseFields = useMemo( - () => baseTable?.fields?.filter((f) => !f.name.startsWith("_")) ?? [], - [baseTable], - ); - - const joinFields = useMemo( - () => joinTable?.fields?.filter((f) => !f.name.startsWith("_")) ?? [], - [joinTable], - ); - - // Column configs for highlighting selected columns in source tables - const baseColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { - const leftField = baseFields.find((f) => f.id === leftFieldId); - if (!leftField) return []; - return [{ id: leftField.columnName ?? leftField.name, highlight: true }]; - }, [baseFields, leftFieldId]); - - const joinColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { - const rightField = joinFields.find((f) => f.id === rightFieldId); - if (!rightField) return []; - return [{ id: rightField.columnName ?? rightField.name, highlight: true }]; - }, [joinFields, rightFieldId]); - - // Column configs for the preview result - highlight base vs join columns - const previewColumnConfigs = useMemo((): VirtualTableColumnConfig[] => { - if (!previewResult?.columns || !baseTable || !joinTable) return []; - - // Get column names from each source table - const baseColumnNames = new Set( - baseFields.map((f) => f.columnName ?? f.name), - ); - const joinColumnNames = new Set( - joinFields.map((f) => f.columnName ?? f.name), - ); - - // Get table display names for prefix detection (must match SQL generation) - const baseDisplayName = shortenAutoGeneratedName(baseTable.name); - const joinDisplayName = shortenAutoGeneratedName(joinTable.name); - - return previewResult.columns - .filter((col) => !col.name.startsWith("_")) - .map((col) => { - // Check if this column came from base or join table - // Handle table name prefix format: "tableName.columnName" - const hasBasePrefix = col.name.startsWith(`${baseDisplayName}.`); - const hasJoinPrefix = col.name.startsWith(`${joinDisplayName}.`); - - // Determine highlight variant - // If column has a prefix, it's definitively from ONE table only - // (the prefix was added to disambiguate duplicate column names) - let highlight: "base" | "join" | "both" | undefined; - if (hasBasePrefix) { - highlight = "base"; - } else if (hasJoinPrefix) { - highlight = "join"; - } else { - // No prefix - check which table(s) this column belongs to - const isFromBase = baseColumnNames.has(col.name); - const isFromJoin = joinColumnNames.has(col.name); - - if (isFromBase && isFromJoin) { - // Column name exists in both original tables, but check if the other - // table's version was prefixed (meaning this unprefixed one is from just one table) - const hasBasePrefixedVersion = previewResult.columns.some( - (c) => c.name === `${baseDisplayName}.${col.name}`, - ); - const hasJoinPrefixedVersion = previewResult.columns.some( - (c) => c.name === `${joinDisplayName}.${col.name}`, - ); - - if (hasJoinPrefixedVersion) { - // Join table's version was prefixed, so unprefixed is from base only - highlight = "base"; - } else if (hasBasePrefixedVersion) { - // Base table's version was prefixed, so unprefixed is from join only - highlight = "join"; - } else { - // Neither was prefixed - genuinely in both (rare, e.g., join key) - highlight = "both"; - } - } else if (isFromBase) { - highlight = "base"; - } else if (isFromJoin) { - highlight = "join"; - } - } - - return { - id: col.name, - highlight, - }; - }) - .filter( - (config) => config.highlight !== undefined, - ) as VirtualTableColumnConfig[]; - }, [previewResult, baseTable, joinTable, baseFields, joinFields]); - - // Join analysis results for Venn diagram visualization - const [joinAnalysis, setJoinAnalysis] = useState<{ - baseUniqueCount: number; - joinUniqueCount: number; - matchingCount: number; // values that exist in both - estimatedResultRows: number; // approximate rows after join - } | null>(null); - - // Find columns with matching names (for suggestions) with analysis - const [columnSuggestions, setColumnSuggestions] = useState< - Array<{ - leftField: (typeof baseFields)[0]; - rightField: (typeof joinFields)[0]; - columnName: string; - matchingValues: number; // how many values match between tables - baseUniqueValues: number; - }> - >([]); - - // Analyze matching columns for suggestions - // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis - useEffect(() => { - if (isDuckDBLoading || !connection || !isDuckDBReady) return; - if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) return; - // Wait for pagination hooks to be ready (they load the data into DuckDB) - if (!isBaseReady || !isJoinReady) return; - - const baseColMap = new Map< - string, - { field: (typeof baseFields)[0]; name: string } - >(); - for (const field of baseFields) { - const colName = field.columnName ?? field.name; - baseColMap.set(colName.toLowerCase(), { field, name: colName }); - } - - const pairs: Array<{ - leftField: (typeof baseFields)[0]; - rightField: (typeof joinFields)[0]; - columnName: string; - }> = []; - for (const joinField of joinFields) { - const joinColName = joinField.columnName ?? joinField.name; - const baseMatch = baseColMap.get(joinColName.toLowerCase()); - if (baseMatch) { - pairs.push({ - leftField: baseMatch.field, - rightField: joinField, - columnName: joinColName, - }); - } - } - - if (pairs.length === 0) { - setColumnSuggestions([]); - return; - } - - const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; - const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; - - const analyze = async () => { - try { - // Tables are already loaded by the pagination hooks - - const results: typeof columnSuggestions = []; - - for (const pair of pairs) { - const leftColName = pair.leftField.columnName ?? pair.leftField.name; - const rightColName = - pair.rightField.columnName ?? pair.rightField.name; - - const sql = ` - WITH base_vals AS (SELECT DISTINCT "${leftColName}" as val FROM ${baseTableName}), - join_vals AS (SELECT DISTINCT "${rightColName}" as val FROM ${joinTableName}) - SELECT - (SELECT COUNT(*) FROM base_vals) as base_unique, - (SELECT COUNT(*) FROM base_vals b WHERE EXISTS (SELECT 1 FROM join_vals j WHERE j.val = b.val)) as matching - `; - - const result = await connection.query(sql); - const row = result.toArray()[0] as { - base_unique: number; - matching: number; - }; - - results.push({ - ...pair, - matchingValues: Number(row?.matching ?? 0), - baseUniqueValues: Number(row?.base_unique ?? 0), - }); - } - - // Sort by matching values (higher = better) - results.sort((a, b) => b.matchingValues - a.matchingValues); - setColumnSuggestions(results); - } catch (err) { - console.error("Failed to analyze column suggestions:", err); - // Fallback without analysis - setColumnSuggestions( - pairs.map((p) => ({ ...p, matchingValues: 0, baseUniqueValues: 0 })), - ); - } - }; - - analyze(); - }, [ - connection, - isDuckDBReady, - isDuckDBLoading, - baseTable, - joinTable, - baseFields, - joinFields, - isBaseReady, - isJoinReady, - ]); - - // Apply a suggestion - const applySuggestion = useCallback((pair: (typeof columnSuggestions)[0]) => { - setLeftFieldId(pair.leftField.id); - setRightFieldId(pair.rightField.id); - }, []); - - // Analyze selected columns for Venn diagram - // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis - useEffect(() => { - if (!leftFieldId || !rightFieldId) { - setJoinAnalysis(null); - return; - } - if (isDuckDBLoading || !connection || !isDuckDBReady) return; - if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) return; - - const leftField = baseFields.find((f) => f.id === leftFieldId); - const rightField = joinFields.find((f) => f.id === rightFieldId); - if (!leftField || !rightField) return; - - const leftColumnName = leftField.columnName ?? leftField.name; - const rightColumnName = rightField.columnName ?? rightField.name; - - const analyze = async () => { - try { - // Get DataFrames from Dexie (async) - const baseDataFrame = await getDataFrame(baseTable.dataFrameId!); - const joinDataFrame = await getDataFrame(joinTable.dataFrameId!); - if (!baseDataFrame || !joinDataFrame) return; - - await baseDataFrame.load(connection); - await joinDataFrame.load(connection); - - const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; - const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; - - // Get Venn diagram stats - const vennSQL = ` - WITH base_values AS ( - SELECT DISTINCT "${leftColumnName}" as val FROM ${baseTableName} - ), - join_values AS ( - SELECT DISTINCT "${rightColumnName}" as val FROM ${joinTableName} - ), - matching AS ( - SELECT b.val FROM base_values b - INNER JOIN join_values j ON b.val = j.val - ) - SELECT - (SELECT COUNT(*) FROM base_values) as base_unique, - (SELECT COUNT(*) FROM join_values) as join_unique, - (SELECT COUNT(*) FROM matching) as matching_count - `; - - const vennResult = await connection.query(vennSQL); - const vennRow = vennResult.toArray()[0] as { - base_unique: number; - join_unique: number; - matching_count: number; - }; - - // Estimate result rows (for inner join) - const countSQL = ` - SELECT COUNT(*) as cnt - FROM ${baseTableName} b - INNER JOIN ${joinTableName} j ON b."${leftColumnName}" = j."${rightColumnName}" - `; - const countResult = await connection.query(countSQL); - const countRow = countResult.toArray()[0] as { cnt: number }; - - setJoinAnalysis({ - baseUniqueCount: Number(vennRow?.base_unique ?? 0), - joinUniqueCount: Number(vennRow?.join_unique ?? 0), - matchingCount: Number(vennRow?.matching_count ?? 0), - estimatedResultRows: Number(countRow?.cnt ?? 0), - }); - } catch (err) { - console.error("Failed to analyze join:", err); - setJoinAnalysis(null); - } - }; - - analyze(); - }, [ - leftFieldId, - rightFieldId, - connection, - isDuckDBReady, - isDuckDBLoading, - baseTable, - joinTable, - baseFields, - joinFields, - ]); - - // Compute join preview using DuckDB (handles full dataset efficiently) - // DuckDB is lazy-loaded, so we check isDuckDBLoading before computing preview - useEffect(() => { - if (!leftFieldId || !rightFieldId) { - setPreviewResult(null); - return; - } +export const dynamic = "force-static"; +export const dynamicParams = true; - if (isDuckDBLoading || !connection || !isDuckDBReady) { - return; - } - - if (!baseTable?.dataFrameId || !joinTable?.dataFrameId) { - return; - } - - const leftField = baseFields.find((f) => f.id === leftFieldId); - const rightField = joinFields.find((f) => f.id === rightFieldId); - - if (!leftField || !rightField) { - setPreviewResult(null); - return; - } - - const leftColumnName = leftField.columnName ?? leftField.name; - const rightColumnName = rightField.columnName ?? rightField.name; - - setIsComputingPreview(true); - setError(null); - - const computeJoin = async () => { - try { - // Get DataFrames from Dexie (async) - const baseDataFrame = await getDataFrame(baseTable.dataFrameId!); - const joinDataFrame = await getDataFrame(joinTable.dataFrameId!); - - if (!baseDataFrame || !joinDataFrame) { - setError("Could not load DataFrames"); - return; - } - - // Load both tables into DuckDB (side effect ensures tables exist) - await baseDataFrame.load(connection); - await joinDataFrame.load(connection); - - const baseTableName = `df_${baseTable.dataFrameId!.replace(/-/g, "_")}`; - const joinTableName = `df_${joinTable.dataFrameId!.replace(/-/g, "_")}`; - - // Build the JOIN SQL - const joinTypeSQL = joinType.toUpperCase(); - - // Get column lists, handling duplicates with table name prefixes - const baseColNames = baseFields.map((f) => f.columnName ?? f.name); - const joinColNames = joinFields.map((f) => f.columnName ?? f.name); - - // Get display names for table prefixes (cleaned of UUIDs) - const baseDisplayName = shortenAutoGeneratedName(baseTable.name); - const joinDisplayName = shortenAutoGeneratedName(joinTable.name); - - // Build SELECT clause with table name prefixes for duplicate columns - const selectParts: string[] = []; - - for (const col of baseColNames) { - if (col.startsWith("_")) continue; // Skip internal columns - const isDuplicate = - joinColNames.includes(col) && col !== leftColumnName; - if (isDuplicate) { - // Use table name prefix: "accounts.acctid" instead of "acctid_base" - selectParts.push(`base."${col}" AS "${baseDisplayName}.${col}"`); - } else { - selectParts.push(`base."${col}"`); - } - } - - // Build lowercase set of base column names for case-insensitive duplicate detection - const baseColNamesLower = new Set( - baseColNames.map((c) => c.toLowerCase()), - ); - - for (const col of joinColNames) { - if (col.startsWith("_")) continue; // Skip internal columns - // For INNER joins, skip the join key column - it's redundant with base table's - // join key (they have the same values for all matched rows). - // For LEFT/RIGHT/OUTER joins, keep both since NULLs may differ. - if (joinType === "inner" && col === rightColumnName) continue; - // Check for duplicate using case-insensitive comparison - // DuckDB treats column names case-insensitively, so "acctId" and "acctid" conflict - const isDuplicate = baseColNamesLower.has(col.toLowerCase()); - if (isDuplicate) { - // Use table name prefix: "opportunities.acctid" instead of "acctid_1" - selectParts.push(`j."${col}" AS "${joinDisplayName}.${col}"`); - } else { - selectParts.push(`j."${col}"`); - } - } - - const joinSQL = ` - SELECT ${selectParts.join(", ")} - FROM ${baseTableName} AS base - ${joinTypeSQL} JOIN ${joinTableName} AS j - ON base."${leftColumnName}" = j."${rightColumnName}" - LIMIT ${PREVIEW_ROW_LIMIT} - `; - - console.log("[JoinPreview] Executing DuckDB join:", joinSQL); - - const result = await connection.query(joinSQL); - const rows = result.toArray() as DataFrameRow[]; - - // Get total count for the join - const countSQL = ` - SELECT COUNT(*) as count - FROM ${baseTableName} AS base - ${joinTypeSQL} JOIN ${joinTableName} AS j - ON base."${leftColumnName}" = j."${rightColumnName}" - `; - const countResult = await connection.query(countSQL); - const totalJoinCount = Number(countResult.toArray()[0]?.count ?? 0); - - // Build columns from the result - const columns: VirtualTableColumn[] = - rows.length > 0 - ? Object.keys(rows[0]) - .filter((key) => !key.startsWith("_")) - .map((name) => ({ name })) - : []; - - console.log("[JoinPreview] DuckDB result:", { - previewRows: rows.length, - totalJoinCount, - columns: columns.length, - }); - - setPreviewResult({ columns, rows }); - // Store total count for display - setPreviewTotalCount(totalJoinCount); - } catch (err) { - console.error("DuckDB join preview failed:", err); - setPreviewResult(null); - const errorMessage = - err instanceof Error ? err.message : "Unknown error"; - setError(`Join preview failed: ${errorMessage}`); - } finally { - setIsComputingPreview(false); - } - }; - - computeJoin(); - }, [ - leftFieldId, - rightFieldId, - joinType, - connection, - isDuckDBReady, - isDuckDBLoading, - baseTable, - joinTable, - baseFields, - joinFields, - ]); - - // Track total join count for display - const [previewTotalCount, setPreviewTotalCount] = useState(0); - - // Execute full join and add to existing insight - // Note: We only store the join configuration here. The actual join is computed - // on-demand when displaying the preview in InsightConfigureTab. - const handleExecuteJoin = useCallback(async () => { - if (!leftFieldId || !rightFieldId) { - setError("Select both join columns."); - return; - } - - if (!baseTable || !joinTable || !insight) { - setError("Unable to load table data."); - return; - } - - const leftField = baseFields.find((f) => f.id === leftFieldId); - const rightField = joinFields.find((f) => f.id === rightFieldId); - - if (!leftField || !rightField) { - setError("Selected columns are no longer available."); - return; - } - - setError(null); - setIsSubmitting(true); - - // Validate the join works by testing it (using preview result) - // The preview is already computed, so we just check if it succeeded - if (!previewResult || previewResult.rows.length === 0) { - // Still allow the join even with 0 rows - user may want to keep the config - // Just warn them in the UI (handled by the existing Alert component) - } - - // Create join config using the Core schema - // Uses column names (strings) as join keys, not field UUIDs - const joinConfig: InsightJoinConfig = { - type: joinType === "outer" ? "full" : joinType, // "outer" → "full" for Core type - rightTableId: joinTable!.id, - leftKey: leftField.columnName ?? leftField.name, - rightKey: rightField.columnName ?? rightField.name, - }; - - // Add join to existing insight (append to existing joins if any) - const existingJoins = insight.joins ?? []; - await updateInsight(insightId, { - joins: [...existingJoins, joinConfig], - }); - - // Note: We intentionally do NOT store a pre-computed joined DataFrame here. - // The join preview in InsightConfigureTab computes the join on-demand, - // which ensures we always show raw joined data (not aggregated data). - - setIsSubmitting(false); - // Navigate back to the same insight - router.push(`/insights/${insightId}`); - }, [ - leftFieldId, - rightFieldId, - joinType, - baseTable, - joinTable, - baseFields, - joinFields, - insight, - insightId, - updateInsight, - previewResult, - router, - ]); - - // Loading state - wait for all stores to hydrate before rendering - if (isLoading) { - return ( -
-
- -

- Loading join configuration... -

-
-
- ); - } - - // Error states - if (!insight) { - return ( -
- - -

Insight not found

-

- The insight you're looking for doesn't exist. -

-
- ); - } - - if (!baseTable) { - return ( -
- - -

Base table not found

-

- The data table for this insight no longer exists. -

-
- ); - } - - if (!joinTable) { - return ( -
- - -

Join table not found

-

- The table you're trying to join with doesn't exist. -

-
- ); - } - - const canJoin = leftFieldId && rightFieldId; - - return ( -
- {/* Header */} -
-
-
-
-
-
-
-
- - {/* Main Content */} -
-
- {/* Dual Table Previews */} -
- {/* Base Table Preview */} - { - const field = baseFields.find( - (f) => (f.columnName ?? f.name) === colName, - ); - if (field) setLeftFieldId(field.id); - }} - /> - - {/* Join Table Preview */} - { - const field = joinFields.find( - (f) => (f.columnName ?? f.name) === colName, - ); - if (field) setRightFieldId(field.id); - }} - /> -
- - {/* Join Configuration */} - -

Join Configuration

- - {/* Matching column suggestions */} - {columnSuggestions.length > 0 && ( -
-
- - Matching columns found - - - – click to select - -
-
- {columnSuggestions.map((pair) => ( - - ))} -
-
- )} - -
- {/* Column Selection */} -
-
-
- - -
- -
- - -
-
- -
- - -
-
- - {/* Venn Diagram Visualization */} - {joinAnalysis && ( -
- {/* SVG Venn Diagram */} - - {/* Base table circle (left) */} - - {/* Join table circle (right) */} - - {/* Intersection highlight based on join type */} - - - - - - - {/* Intersection area */} - - {/* Labels */} - - {( - joinAnalysis.baseUniqueCount - - joinAnalysis.matchingCount - ).toLocaleString()} - - - {joinAnalysis.matchingCount.toLocaleString()} - - - matching - - - {( - joinAnalysis.joinUniqueCount - - joinAnalysis.matchingCount - ).toLocaleString()} - - {/* Circle labels */} - - Base - - - Join - - - - {/* Result summary */} -
-

- {joinType === "inner" && ( - <> - Result:{" "} - - {joinAnalysis.estimatedResultRows.toLocaleString()} - {" "} - rows - - )} - {joinType === "left" && ( - <>Result: all base rows + matched join data - )} - {joinType === "right" && ( - <>Result: matched base data + all join rows - )} - {joinType === "outer" && ( - <>Result: all rows from both tables - )} -

-

- {joinAnalysis.matchingCount.toLocaleString()} of{" "} - {joinAnalysis.baseUniqueCount.toLocaleString()} base - values have matches -

-
-
- )} -
-
- - {/* Error Display */} - {error && ( - - {error} - - )} - - {/* Preview Result */} - {canJoin && ( - -
-

Preview Result

- {isComputingPreview && ( -
- - Computing preview... -
- )} - {!isComputingPreview && previewResult && ( -

- {previewTotalCount.toLocaleString()} total rows - {previewTotalCount > PREVIEW_ROW_LIMIT && - ` (showing first ${PREVIEW_ROW_LIMIT})`} - {" · "} - {previewResult.columns?.length ?? 0} columns -

- )} -
- - {/* Preview result or placeholder */} - {!isComputingPreview && previewResult && ( - <> - {/* Legend for column colors */} -
-
-
- - From {baseTable.name ?? "base table"} - -
-
-
- - From {joinTable.name ?? "join table"} - -
-
-
- - In both tables - -
-
-
- -
- - )} - {!isComputingPreview && !previewResult && ( -
- {error - ? "Unable to generate preview" - : "Select join columns to see preview"} -
- )} - - {!isComputingPreview && - previewResult && - previewResult.rows.length === 0 && ( - - - This join produces 0 rows. Consider using a different join - type or checking that the columns have matching values. - - - )} - - )} -
-
-
- ); -} - -// ============================================================================ -// Table Preview Section Component -// ============================================================================ - -interface TablePreviewSectionProps { - title: string; - table: DataTable; - totalCount: number; - isReady: boolean; - onFetchData: ( - params: import("@dashframe/ui").FetchDataParams, - ) => Promise; - fields: Field[]; - columnConfigs?: VirtualTableColumnConfig[]; - onHeaderClick?: (columnName: string) => void; +interface PageProps { + params: Promise<{ insightId: string; tableId: string }>; } -function TablePreviewSection({ - title, - table, - totalCount, - isReady, - onFetchData, - fields, - columnConfigs, - onHeaderClick, -}: TablePreviewSectionProps) { - const colCount = fields.filter((f) => !f.name.startsWith("_")).length; - - return ( - -
-
-
-

- {title} -

-

{table.name}

-
-

- {totalCount.toLocaleString()} rows · {colCount} columns -

-
-

- Click a column header to select it for joining -

-
-
- {!isReady ? ( -
-
- - Loading data... -
-
- ) : ( - - )} -
-
- ); +export default async function JoinConfigurePage({ params }: PageProps) { + const { insightId, tableId } = await params; + return ; } diff --git a/apps/web/app/insights/[insightId]/page.tsx b/apps/web/app/insights/[insightId]/page.tsx index 004d3f57..1745973d 100644 --- a/apps/web/app/insights/[insightId]/page.tsx +++ b/apps/web/app/insights/[insightId]/page.tsx @@ -1,30 +1,17 @@ -"use client"; +import InsightPageContent from "./_components/InsightPageContent"; -import { useInsight } from "@dashframe/core"; -import { use } from "react"; -import { InsightView, LoadingView, NotFoundView } from "./_components"; +/** + * Force static generation - no serverless function. + * Data lives in IndexedDB (browser), so server rendering is meaningless. + */ +export const dynamic = "force-static"; +export const dynamicParams = true; interface PageProps { params: Promise<{ insightId: string }>; } -/** - * Insight Page - * - * Minimal page component that only handles routing. - * Data fetching is handled by InsightView itself. - */ -export default function InsightPage({ params }: PageProps) { - const { insightId } = use(params); - const { data: insight, isLoading } = useInsight(insightId); - - if (isLoading) { - return ; - } - - if (!insight) { - return ; - } - - return ; +export default async function InsightPage({ params }: PageProps) { + const { insightId } = await params; + 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/app/visualizations/[visualizationId]/_components/VisualizationPageContent.tsx b/apps/web/app/visualizations/[visualizationId]/_components/VisualizationPageContent.tsx new file mode 100644 index 00000000..bb51d76b --- /dev/null +++ b/apps/web/app/visualizations/[visualizationId]/_components/VisualizationPageContent.tsx @@ -0,0 +1,931 @@ +"use client"; + +import { AppLayout } from "@/components/layouts/AppLayout"; +import { useDuckDB } from "@/components/providers/DuckDBProvider"; +import { AxisSelectField } from "@/components/visualizations/AxisSelectField"; +import { VisualizationDisplay } from "@/components/visualizations/VisualizationDisplay"; +import { useDataFrameData } from "@/hooks/useDataFrameData"; +import { useInsightView } from "@/hooks/useInsightView"; +import { + computeInsightPreview, + type PreviewResult, +} from "@/lib/insights/compute-preview"; +import { getColumnIcon } from "@/lib/utils/field-icons"; +import { + getSwappedChartType, + isSwapAllowed, + validateEncoding, +} from "@/lib/visualizations/encoding-enforcer"; +import { getAlternativeChartTypes } from "@/lib/visualizations/suggest-charts"; +import { + getDataFrame as getDexieDataFrame, + useCompiledInsight, + useDataTables, + useInsights, + useVisualizationMutations, + useVisualizations, +} from "@dashframe/core"; +import { fieldIdToColumnAlias, metricIdToColumnAlias } from "@dashframe/engine"; +import { analyzeView, type ColumnAnalysis } from "@dashframe/engine-browser"; +import type { + DataFrameColumn, + DataFrameRow, + Insight as InsightType, + UUID, + VisualizationEncoding, + VisualizationType, +} from "@dashframe/types"; +import { CHART_TYPE_METADATA, parseEncoding } from "@dashframe/types"; +import { + Badge, + Button, + Card, + CardContent, + ChartIcon, + DeleteIcon, + Input, + SelectField, + Spinner, +} from "@dashframe/ui"; +import { + AlertCircleIcon, + ArrowLeftIcon, + ArrowUpDownIcon, + DataPointIcon, +} from "@dashframe/ui/icons"; +import { useRouter } from "next/navigation"; +import { useEffect, useMemo, useState } from "react"; + +interface VisualizationPageContentProps { + visualizationId: string; +} + +// Get icon for visualization type +function getVizIcon(type: string) { + switch (type) { + case "barY": + case "barX": + return ; + case "line": + case "areaY": + return ; + case "dot": + case "hexbin": + case "heatmap": + case "raster": + return ; + default: + return ; + } +} + +/** + * Visualization Detail Page + * + * Shows a single visualization with: + * - Chart/table display with view mode toggle + * - Encoding controls for axis configuration + * - Link back to source insight if applicable + * - Delete functionality + */ +export default function VisualizationPageContent({ + visualizationId, +}: VisualizationPageContentProps) { + const router = useRouter(); + + // Dexie hooks for data + const { data: visualizations = [], isLoading: isVizLoading } = + useVisualizations(); + const { data: insights = [] } = useInsights(); + const { data: dataTables = [] } = useDataTables(); + const { + update: updateVisualization, + updateEncoding, + remove: removeVisualization, + } = useVisualizationMutations(); + + // Find the visualization + const visualization = useMemo( + () => visualizations.find((v) => v.id === visualizationId), + [visualizations, visualizationId], + ); + + // Find the insight + const insight = useMemo( + () => + visualization?.insightId + ? insights.find((i) => i.id === visualization.insightId) + : undefined, + [insights, visualization?.insightId], + ); + + // Get compiled insight with resolved dimensions (for AxisSelectField) + const { data: compiledInsight } = useCompiledInsight( + visualization?.insightId, + ); + + // Find the data table + const dataTable = useMemo( + () => + insight?.baseTableId + ? dataTables.find((t) => t.id === insight.baseTableId) + : undefined, + [dataTables, insight?.baseTableId], + ); + + // Get the dataFrameId from the dataTable + const dataFrameId = dataTable?.dataFrameId; + + // Load source DataFrame data async + const { + data: sourceDataFrame, + isLoading: isDataLoading, + entry: dataFrameEntry, + } = useDataFrameData(dataFrameId); + + // DuckDB connection for join computation (initialized by DuckDBProvider during idle time) + const { + connection: duckDBConnection, + isInitialized: isDuckDBReady, + isLoading: isDuckDBLoading, + } = useDuckDB(); + + // Build Insight object for useInsightView (needs baseTableId and joins) + const insightForView: InsightType | null = useMemo(() => { + if (!insight) return null; + return { + id: insight.id, + name: insight.name, + baseTableId: insight.baseTableId, + joins: insight.joins, + } as InsightType; + }, [insight]); + + // Get DuckDB view name for analysis (uses UUID-based column names) + const { viewName: analysisViewName, isReady: isAnalysisViewReady } = + useInsightView(insightForView); + + // State for DuckDB-computed joined data (when insight has joins) + const [joinedData, setJoinedData] = useState<{ + rows: DataFrameRow[]; + columns: DataFrameColumn[]; + } | null>(null); + const [isLoadingJoinedData, setIsLoadingJoinedData] = useState(false); + + // Compute joined data using DuckDB when insight has joins + useEffect(() => { + // Skip if no joins configured + if (!insight?.joins?.length) { + setJoinedData(null); + return; + } + + // Wait for DuckDB to be ready + if (isDuckDBLoading || !duckDBConnection || !isDuckDBReady) { + return; + } + + // Need base dataTable for field info + if (!dataTable?.dataFrameId) { + return; + } + + const computeJoinedData = async () => { + setIsLoadingJoinedData(true); + + try { + // Get the base DataFrame + const baseDataFrame = await getDexieDataFrame(dataTable.dataFrameId!); + if (!baseDataFrame) { + throw new Error("Base DataFrame not found"); + } + + // Load base table into DuckDB + const baseQueryBuilder = await baseDataFrame.load(duckDBConnection); + await baseQueryBuilder.sql(); // Triggers table creation + + // Load join tables into DuckDB + for (const join of insight.joins ?? []) { + const joinTable = dataTables.find((t) => t.id === join.rightTableId); + if (joinTable?.dataFrameId) { + const joinDataFrame = await getDexieDataFrame( + joinTable.dataFrameId, + ); + if (joinDataFrame) { + const joinQueryBuilder = + await joinDataFrame.load(duckDBConnection); + await joinQueryBuilder.sql(); // Triggers table creation + } + } + } + + // Build and execute join SQL + // [Future] Generate proper SQL from insight joins configuration + // For now, just use the base table data + const sql = await baseQueryBuilder.sql(); + const result = await duckDBConnection.query(sql); + const rows = result.toArray() as DataFrameRow[]; + + // Build columns from result + const columns: DataFrameColumn[] = + rows.length > 0 + ? Object.keys(rows[0]) + .filter((key) => !key.startsWith("_")) + .map((name) => ({ + name, + type: + typeof rows[0][name] === "number" + ? ("number" as const) + : ("string" as const), + })) + : []; + + setJoinedData({ rows, columns }); + } catch (err) { + console.error( + "[VisualizationPage] Failed to compute joined data:", + err, + ); + setJoinedData(null); + } finally { + setIsLoadingJoinedData(false); + } + }; + + computeJoinedData(); + }, [ + insight?.joins, + insight?.id, + duckDBConnection, + isDuckDBReady, + isDuckDBLoading, + dataTable, + dataTables, + ]); + + // Compute aggregated data if we have an insight with metrics/dimensions (non-join case) + const aggregatedPreview = useMemo(() => { + // If we have joins, use joinedData instead + if (insight?.joins?.length) return null; + + if (!sourceDataFrame || !insight || !dataTable) return null; + + // Check if insight has dimensions or metrics configured + const selectedFields = insight.selectedFields ?? []; + const metrics = insight.metrics ?? []; + + // If no aggregation config, return null (use raw data) + if (selectedFields.length === 0 && metrics.length === 0) return null; + + // Use source data directly (DataFrameData format) + const sourceDataFrameData = { + columns: sourceDataFrame.columns, + rows: sourceDataFrame.rows, + }; + + // Build insight object for computation + const insightForCompute = { + id: insight.id, + name: insight.name, + baseTableId: insight.baseTableId, + selectedFields: selectedFields, + metrics: metrics, + createdAt: insight.createdAt, + updatedAt: insight.updatedAt, + }; + + return computeInsightPreview( + insightForCompute, + dataTable, + sourceDataFrameData, + 1000, // Allow more rows for visualization + ); + }, [sourceDataFrame, insight, dataTable]); + + // Use joined data if available, then aggregated data, then source data + const dataFrame = useMemo(() => { + // Priority 1: DuckDB-computed joined data (when insight has joins) + if (joinedData) { + return joinedData; + } + // Priority 2: Aggregated preview (non-join case with metrics/dimensions) + if (aggregatedPreview) { + return { + rows: aggregatedPreview.dataFrame.rows, + columns: aggregatedPreview.dataFrame.columns ?? [], + }; + } + // Priority 3: Raw source data + return sourceDataFrame; + }, [joinedData, aggregatedPreview, sourceDataFrame]); + + // Include dataFrame check to prevent "Data not available" flash + const isLoading = + isVizLoading || + isDataLoading || + isLoadingJoinedData || + (visualization && !dataFrame); + + // Local state + const [vizName, setVizName] = useState(""); + + // Sync visualization name when data loads + useEffect(() => { + if (visualization?.name) { + setVizName(visualization.name); + } + }, [visualization?.name]); + + // State for DuckDB-computed column analysis + const [columnAnalysis, setColumnAnalysis] = useState([]); + + // Run DuckDB analysis on the insight view (has UUID-based column names) + // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis + useEffect(() => { + if (isDuckDBLoading || !duckDBConnection || !isDuckDBReady) return; + if (!analysisViewName || !isAnalysisViewReady) { + setColumnAnalysis([]); + return; + } + + const runAnalysis = async () => { + try { + const results = await analyzeView(duckDBConnection, analysisViewName); + setColumnAnalysis(results); + } catch (e) { + console.error("[VisualizationPage] Analysis failed:", e); + setColumnAnalysis([]); + } + }; + runAnalysis(); + }, [ + duckDBConnection, + isDuckDBReady, + isDuckDBLoading, + analysisViewName, + isAnalysisViewReady, + ]); + + // Get column options for Color/Size selects (derived from compiledInsight) + // Uses storage encoding format (field:, metric:) for values + // Includes icons to show column types + const columnOptions = useMemo(() => { + if (!compiledInsight) return []; + + // Build set of metric SQL aliases for icon lookup + const metricAliases = new Set( + compiledInsight.metrics.map((m) => metricIdToColumnAlias(m.id)), + ); + const options: Array<{ + label: string; + value: string; + icon: React.ComponentType<{ className?: string }>; + }> = []; + + // Add dimensions (resolved Field objects from compiledInsight) + // Use field: encoding format for value + compiledInsight.dimensions.forEach((field) => { + const sqlAlias = fieldIdToColumnAlias(field.id); + options.push({ + label: field.name, + value: `field:${field.id}`, + icon: getColumnIcon(sqlAlias, columnAnalysis, metricAliases), + }); + }); + + // Add metrics using metric: encoding format + compiledInsight.metrics.forEach((metric) => { + const sqlAlias = metricIdToColumnAlias(metric.id); + options.push({ + label: metric.name, + value: `metric:${metric.id}`, + icon: getColumnIcon(sqlAlias, columnAnalysis, metricAliases), + }); + }); + + return options; + }, [compiledInsight, columnAnalysis]); + + // Validate encoding configuration - returns errors for X/Y if invalid + const encodingErrors = useMemo(() => { + if (!visualization || columnAnalysis.length === 0) return {}; + return validateEncoding( + visualization.encoding ?? {}, + visualization.visualizationType, + columnAnalysis, + compiledInsight ?? undefined, + ); + }, [visualization, columnAnalysis, compiledInsight]); + + // Check if there are any encoding errors + const hasEncodingErrors = !!(encodingErrors.x || encodingErrors.y); + + // Handle name change + const handleNameChange = async (newName: string) => { + setVizName(newName); + await updateVisualization(visualizationId as UUID, { name: newName }); + }; + + // Infer axis type from column analysis semantic type + const inferAxisType = ( + semantic: string, + ): "quantitative" | "nominal" | "ordinal" | "temporal" => { + if (semantic === "numerical") return "quantitative"; + if (semantic === "temporal") return "temporal"; + return "nominal"; + }; + + // Handle encoding change + // Value comes in as storage encoding format (field:, metric:) + const handleEncodingChange = async ( + field: "x" | "y" | "color" | "size", + value: string, + ) => { + if (!visualization) return; + + const newEncoding: VisualizationEncoding = { + ...visualization.encoding, + [field]: value, + }; + + // Auto-detect type if changing x or y + if (field === "x" || field === "y") { + // Convert storage encoding to SQL alias to find in columnAnalysis + const parsed = parseEncoding(value); + let sqlAlias: string | undefined; + if (parsed) { + sqlAlias = + parsed.type === "field" + ? fieldIdToColumnAlias(parsed.id) + : metricIdToColumnAlias(parsed.id); + } + + const colAnalysis = sqlAlias + ? columnAnalysis.find((c) => c.columnName === sqlAlias) + : undefined; + + if (colAnalysis) { + const typeField = field === "x" ? "xType" : "yType"; + newEncoding[typeField] = inferAxisType(colAnalysis.semantic); + } + } + + await updateEncoding(visualizationId as UUID, newEncoding); + }; + + // Handle visualization type change + // Auto-swaps axes when switching between barY and barX + const handleTypeChange = async (type: string) => { + const newType = type as VisualizationType; + const currentType = visualization?.visualizationType; + + // Check if switching between bar orientations - auto-swap axes + const isBarSwitch = + (currentType === "barY" && newType === "barX") || + (currentType === "barX" && newType === "barY"); + + if (isBarSwitch && visualization?.encoding) { + // Swap X and Y when changing bar orientation + const currentEncoding = visualization.encoding; + const newEncoding = { + ...currentEncoding, + x: currentEncoding.y, + y: currentEncoding.x, + xType: currentEncoding.yType, + yType: currentEncoding.xType, + }; + + // Update both type and encoding together + await updateVisualization(visualizationId as UUID, { + visualizationType: newType, + }); + await updateEncoding(visualizationId as UUID, newEncoding); + } else { + // Just update the type + await updateVisualization(visualizationId as UUID, { + visualizationType: newType, + }); + } + }; + + // Handle delete + const handleDelete = async () => { + if (confirm(`Are you sure you want to delete "${visualization?.name}"?`)) { + await removeVisualization(visualizationId as UUID); + router.push("/insights"); + } + }; + + // Loading state + if (isLoading) { + return ( +
+
+ +

+ Loading visualization... +

+
+
+ ); + } + + // Not found state + if (!visualization) { + return ( +
+
+

Visualization not found

+

+ The visualization you're looking for doesn't exist. +

+
+
+ ); + } + + // No DataFrame state + if (!dataFrame) { + return ( + +
+
+
+ } + > +
+ + +
+ {getVizIcon(visualization.visualizationType)} +
+

Data not available

+

+ The data for this visualization is not available. Please refresh + from the source insight. +

+ {visualization.insightId && ( +
+ + ); + } + + // Visualization type options - condensed (bar orientations combined, scatter as umbrella) + const hasNumericColumns = dataFrame?.columns?.some( + (col) => col.type === "number", + ); + const vizTypeOptions = hasNumericColumns + ? [ + { label: "Bar", value: "barY" }, + { label: "Line", value: "line" }, + { label: "Scatter", value: "dot" }, + { label: "Area", value: "areaY" }, + ] + : []; + + // Check if current chart is a scatter-type (dot, hexbin, heatmap, raster) + const isScatterType = ["dot", "hexbin", "heatmap", "raster"].includes( + visualization?.visualizationType ?? "", + ); + + // Scatter render mode options - disable Dots for large datasets + const rowCount = dataFrameEntry?.rowCount ?? 0; + const isLargeDataset = rowCount > 10000; + const scatterRenderModeOptions = [ + { + label: "Dots", + value: "dot", + description: isLargeDataset + ? `Disabled for large datasets (${rowCount.toLocaleString()} rows)` + : "Raw dots - best for small datasets", + disabled: isLargeDataset, + }, + { + label: "Hexbin", + value: "hexbin", + description: "Hexagonal binning - shows density patterns", + }, + { + label: "Heatmap", + value: "heatmap", + description: "Smooth density visualization", + }, + { + label: "Raster", + value: "raster", + description: "Pixel aggregation - fastest for huge datasets", + }, + ]; + + // Get the display chart type (maps scatter variants back to "dot" for UI) + const displayChartType = isScatterType + ? "dot" + : (visualization?.visualizationType ?? "barY"); + + // Handle chart type change with scatter umbrella logic + const handleDisplayTypeChange = async (type: string) => { + if (type === "dot" && !isScatterType) { + // Switching to scatter - default to appropriate render mode based on data size + const newType = isLargeDataset ? "hexbin" : "dot"; + await handleTypeChange(newType); + } else if (type !== "dot") { + await handleTypeChange(type); + } + // If already scatter-type and selecting dot, keep current render mode + }; + + // Handle swap button click - swaps X/Y axes and toggles bar orientation + const handleSwapAxes = async () => { + if (!visualization) return; + + const currentEncoding = visualization.encoding || {}; + const newEncoding = { + ...currentEncoding, + x: currentEncoding.y, + y: currentEncoding.x, + xType: currentEncoding.yType, + yType: currentEncoding.xType, + }; + + // For bar charts, also toggle the chart type + const newChartType = getSwappedChartType(visualization.visualizationType); + + if (newChartType !== visualization.visualizationType) { + // Update both encoding and chart type + await updateVisualization(visualizationId as UUID, { + visualizationType: newChartType, + encoding: newEncoding, + }); + } else { + // Just update encoding + await updateEncoding(visualizationId as UUID, newEncoding); + } + }; + + // Check if swap is allowed for current chart type + const canSwap = isSwapAllowed(visualization.visualizationType); + + return ( + +
+
+ + {/* Metadata row */} +
+ + {dataFrameEntry?.rowCount?.toLocaleString() ?? "?"} rows •{" "} + {dataFrameEntry?.columnCount ?? "?"} columns + + {visualization.insightId && ( + <> + + + + )} +
+ + {/* Delete button */} +
+
+
+ } + rightPanel={ +
+
+

Encodings

+ +
+ {compiledInsight && ( + handleEncodingChange("x", value)} + placeholder="Select column..." + axis="x" + chartType={visualization.visualizationType} + columnAnalysis={columnAnalysis} + compiledInsight={compiledInsight} + otherAxisColumn={visualization.encoding?.y} + onSwapAxes={canSwap ? handleSwapAxes : undefined} + /> + )} + + {/* Swap button - swaps axes and toggles bar orientation */} + {canSwap && ( +
+
+ )} + + {compiledInsight && ( + handleEncodingChange("y", value)} + placeholder="Select column..." + axis="y" + chartType={visualization.visualizationType} + columnAnalysis={columnAnalysis} + compiledInsight={compiledInsight} + otherAxisColumn={visualization.encoding?.x} + onSwapAxes={canSwap ? handleSwapAxes : undefined} + /> + )} + + handleEncodingChange("color", value)} + onClear={() => handleEncodingChange("color", "")} + options={columnOptions} + placeholder="None" + /> + + {visualization.visualizationType === "dot" && ( + handleEncodingChange("size", value)} + onClear={() => handleEncodingChange("size", "")} + options={columnOptions} + placeholder="None" + /> + )} +
+
+ +
+

Chart Type

+ + + {/* Render mode selector for scatter-type charts */} + {isScatterType && ( +
+ +
+ )} + + {/* Alternative chart types - show related charts from same tags */} + {(() => { + const alternatives = getAlternativeChartTypes( + visualization.visualizationType, + ); + if (alternatives.length === 0) return null; + + return ( +
+

+ Similar charts +

+
+ {alternatives.map((altType) => { + const meta = CHART_TYPE_METADATA[altType]; + return ( +
+
+ ); + })()} +
+ + {/* Source insight link */} + {visualization.insightId && ( +
+

Source

+ + router.push(`/insights/${visualization.insightId}`) + } + > + +

Source Insight

+

+ Click to view insight details +

+
+
+
+ )} +
+ } + > +
+ {hasEncodingErrors ? ( +
+
+
+ +
+

+ Invalid encoding configuration +

+
+ {encodingErrors.x && ( +

+ X Axis: {encodingErrors.x} +

+ )} + {encodingErrors.y && ( +

+ Y Axis: {encodingErrors.y} +

+ )} +
+

+ Please update the axis configuration in the panel on the right. +

+
+
+ ) : ( + + )} +
+ + ); +} diff --git a/apps/web/app/visualizations/[visualizationId]/page.tsx b/apps/web/app/visualizations/[visualizationId]/page.tsx index e32b7e6b..9452864f 100644 --- a/apps/web/app/visualizations/[visualizationId]/page.tsx +++ b/apps/web/app/visualizations/[visualizationId]/page.tsx @@ -1,930 +1,17 @@ -"use client"; +import VisualizationPageContent from "./_components/VisualizationPageContent"; -import { AppLayout } from "@/components/layouts/AppLayout"; -import { useDuckDB } from "@/components/providers/DuckDBProvider"; -import { AxisSelectField } from "@/components/visualizations/AxisSelectField"; -import { VisualizationDisplay } from "@/components/visualizations/VisualizationDisplay"; -import { useDataFrameData } from "@/hooks/useDataFrameData"; -import { useInsightView } from "@/hooks/useInsightView"; -import { - computeInsightPreview, - type PreviewResult, -} from "@/lib/insights/compute-preview"; -import { getColumnIcon } from "@/lib/utils/field-icons"; -import { - getSwappedChartType, - isSwapAllowed, - validateEncoding, -} from "@/lib/visualizations/encoding-enforcer"; -import { getAlternativeChartTypes } from "@/lib/visualizations/suggest-charts"; -import { - getDataFrame as getDexieDataFrame, - useCompiledInsight, - useDataTables, - useInsights, - useVisualizationMutations, - useVisualizations, -} from "@dashframe/core"; -import { fieldIdToColumnAlias, metricIdToColumnAlias } from "@dashframe/engine"; -import { analyzeView, type ColumnAnalysis } from "@dashframe/engine-browser"; -import type { - DataFrameColumn, - DataFrameRow, - Insight as InsightType, - UUID, - VisualizationEncoding, - VisualizationType, -} from "@dashframe/types"; -import { CHART_TYPE_METADATA, parseEncoding } from "@dashframe/types"; -import { - Badge, - Button, - Card, - CardContent, - ChartIcon, - DeleteIcon, - Input, - SelectField, - Spinner, -} from "@dashframe/ui"; -import { - AlertCircleIcon, - ArrowLeftIcon, - ArrowUpDownIcon, - DataPointIcon, -} from "@dashframe/ui/icons"; -import { useRouter } from "next/navigation"; -import { use, useEffect, useMemo, useState } from "react"; +/** + * Force static generation - no serverless function. + * Data lives in IndexedDB (browser), so server rendering is meaningless. + */ +export const dynamic = "force-static"; +export const dynamicParams = true; interface PageProps { params: Promise<{ visualizationId: string }>; } -// Get icon for visualization type -function getVizIcon(type: string) { - switch (type) { - case "barY": - case "barX": - return ; - case "line": - case "areaY": - return ; - case "dot": - case "hexbin": - case "heatmap": - case "raster": - return ; - default: - return ; - } -} - -/** - * Visualization Detail Page - * - * Shows a single visualization with: - * - Chart/table display with view mode toggle - * - Encoding controls for axis configuration - * - Link back to source insight if applicable - * - Delete functionality - */ -export default function VisualizationPage({ params }: PageProps) { - const { visualizationId } = use(params); - const router = useRouter(); - - // Dexie hooks for data - const { data: visualizations = [], isLoading: isVizLoading } = - useVisualizations(); - const { data: insights = [] } = useInsights(); - const { data: dataTables = [] } = useDataTables(); - const { - update: updateVisualization, - updateEncoding, - remove: removeVisualization, - } = useVisualizationMutations(); - - // Find the visualization - const visualization = useMemo( - () => visualizations.find((v) => v.id === visualizationId), - [visualizations, visualizationId], - ); - - // Find the insight - const insight = useMemo( - () => - visualization?.insightId - ? insights.find((i) => i.id === visualization.insightId) - : undefined, - [insights, visualization?.insightId], - ); - - // Get compiled insight with resolved dimensions (for AxisSelectField) - const { data: compiledInsight } = useCompiledInsight( - visualization?.insightId, - ); - - // Find the data table - const dataTable = useMemo( - () => - insight?.baseTableId - ? dataTables.find((t) => t.id === insight.baseTableId) - : undefined, - [dataTables, insight?.baseTableId], - ); - - // Get the dataFrameId from the dataTable - const dataFrameId = dataTable?.dataFrameId; - - // Load source DataFrame data async - const { - data: sourceDataFrame, - isLoading: isDataLoading, - entry: dataFrameEntry, - } = useDataFrameData(dataFrameId); - - // DuckDB connection for join computation (initialized by DuckDBProvider during idle time) - const { - connection: duckDBConnection, - isInitialized: isDuckDBReady, - isLoading: isDuckDBLoading, - } = useDuckDB(); - - // Build Insight object for useInsightView (needs baseTableId and joins) - const insightForView: InsightType | null = useMemo(() => { - if (!insight) return null; - return { - id: insight.id, - name: insight.name, - baseTableId: insight.baseTableId, - joins: insight.joins, - } as InsightType; - }, [insight]); - - // Get DuckDB view name for analysis (uses UUID-based column names) - const { viewName: analysisViewName, isReady: isAnalysisViewReady } = - useInsightView(insightForView); - - // State for DuckDB-computed joined data (when insight has joins) - const [joinedData, setJoinedData] = useState<{ - rows: DataFrameRow[]; - columns: DataFrameColumn[]; - } | null>(null); - const [isLoadingJoinedData, setIsLoadingJoinedData] = useState(false); - - // Compute joined data using DuckDB when insight has joins - useEffect(() => { - // Skip if no joins configured - if (!insight?.joins?.length) { - setJoinedData(null); - return; - } - - // Wait for DuckDB to be ready - if (isDuckDBLoading || !duckDBConnection || !isDuckDBReady) { - return; - } - - // Need base dataTable for field info - if (!dataTable?.dataFrameId) { - return; - } - - const computeJoinedData = async () => { - setIsLoadingJoinedData(true); - - try { - // Get the base DataFrame - const baseDataFrame = await getDexieDataFrame(dataTable.dataFrameId!); - if (!baseDataFrame) { - throw new Error("Base DataFrame not found"); - } - - // Load base table into DuckDB - const baseQueryBuilder = await baseDataFrame.load(duckDBConnection); - await baseQueryBuilder.sql(); // Triggers table creation - - // Load join tables into DuckDB - for (const join of insight.joins ?? []) { - const joinTable = dataTables.find((t) => t.id === join.rightTableId); - if (joinTable?.dataFrameId) { - const joinDataFrame = await getDexieDataFrame( - joinTable.dataFrameId, - ); - if (joinDataFrame) { - const joinQueryBuilder = - await joinDataFrame.load(duckDBConnection); - await joinQueryBuilder.sql(); // Triggers table creation - } - } - } - - // Build and execute join SQL - // [Future] Generate proper SQL from insight joins configuration - // For now, just use the base table data - const sql = await baseQueryBuilder.sql(); - const result = await duckDBConnection.query(sql); - const rows = result.toArray() as DataFrameRow[]; - - // Build columns from result - const columns: DataFrameColumn[] = - rows.length > 0 - ? Object.keys(rows[0]) - .filter((key) => !key.startsWith("_")) - .map((name) => ({ - name, - type: - typeof rows[0][name] === "number" - ? ("number" as const) - : ("string" as const), - })) - : []; - - setJoinedData({ rows, columns }); - } catch (err) { - console.error( - "[VisualizationPage] Failed to compute joined data:", - err, - ); - setJoinedData(null); - } finally { - setIsLoadingJoinedData(false); - } - }; - - computeJoinedData(); - }, [ - insight?.joins, - insight?.id, - duckDBConnection, - isDuckDBReady, - isDuckDBLoading, - dataTable, - dataTables, - ]); - - // Compute aggregated data if we have an insight with metrics/dimensions (non-join case) - const aggregatedPreview = useMemo(() => { - // If we have joins, use joinedData instead - if (insight?.joins?.length) return null; - - if (!sourceDataFrame || !insight || !dataTable) return null; - - // Check if insight has dimensions or metrics configured - const selectedFields = insight.selectedFields ?? []; - const metrics = insight.metrics ?? []; - - // If no aggregation config, return null (use raw data) - if (selectedFields.length === 0 && metrics.length === 0) return null; - - // Use source data directly (DataFrameData format) - const sourceDataFrameData = { - columns: sourceDataFrame.columns, - rows: sourceDataFrame.rows, - }; - - // Build insight object for computation - const insightForCompute = { - id: insight.id, - name: insight.name, - baseTableId: insight.baseTableId, - selectedFields: selectedFields, - metrics: metrics, - createdAt: insight.createdAt, - updatedAt: insight.updatedAt, - }; - - return computeInsightPreview( - insightForCompute, - dataTable, - sourceDataFrameData, - 1000, // Allow more rows for visualization - ); - }, [sourceDataFrame, insight, dataTable]); - - // Use joined data if available, then aggregated data, then source data - const dataFrame = useMemo(() => { - // Priority 1: DuckDB-computed joined data (when insight has joins) - if (joinedData) { - return joinedData; - } - // Priority 2: Aggregated preview (non-join case with metrics/dimensions) - if (aggregatedPreview) { - return { - rows: aggregatedPreview.dataFrame.rows, - columns: aggregatedPreview.dataFrame.columns ?? [], - }; - } - // Priority 3: Raw source data - return sourceDataFrame; - }, [joinedData, aggregatedPreview, sourceDataFrame]); - - // Include dataFrame check to prevent "Data not available" flash - const isLoading = - isVizLoading || - isDataLoading || - isLoadingJoinedData || - (visualization && !dataFrame); - - // Local state - const [vizName, setVizName] = useState(""); - - // Sync visualization name when data loads - useEffect(() => { - if (visualization?.name) { - setVizName(visualization.name); - } - }, [visualization?.name]); - - // State for DuckDB-computed column analysis - const [columnAnalysis, setColumnAnalysis] = useState([]); - - // Run DuckDB analysis on the insight view (has UUID-based column names) - // DuckDB is lazy-loaded, so we check isDuckDBLoading before running analysis - useEffect(() => { - if (isDuckDBLoading || !duckDBConnection || !isDuckDBReady) return; - if (!analysisViewName || !isAnalysisViewReady) { - setColumnAnalysis([]); - return; - } - - const runAnalysis = async () => { - try { - const results = await analyzeView(duckDBConnection, analysisViewName); - setColumnAnalysis(results); - } catch (e) { - console.error("[VisualizationPage] Analysis failed:", e); - setColumnAnalysis([]); - } - }; - runAnalysis(); - }, [ - duckDBConnection, - isDuckDBReady, - isDuckDBLoading, - analysisViewName, - isAnalysisViewReady, - ]); - - // Get column options for Color/Size selects (derived from compiledInsight) - // Uses storage encoding format (field:, metric:) for values - // Includes icons to show column types - const columnOptions = useMemo(() => { - if (!compiledInsight) return []; - - // Build set of metric SQL aliases for icon lookup - const metricAliases = new Set( - compiledInsight.metrics.map((m) => metricIdToColumnAlias(m.id)), - ); - const options: Array<{ - label: string; - value: string; - icon: React.ComponentType<{ className?: string }>; - }> = []; - - // Add dimensions (resolved Field objects from compiledInsight) - // Use field: encoding format for value - compiledInsight.dimensions.forEach((field) => { - const sqlAlias = fieldIdToColumnAlias(field.id); - options.push({ - label: field.name, - value: `field:${field.id}`, - icon: getColumnIcon(sqlAlias, columnAnalysis, metricAliases), - }); - }); - - // Add metrics using metric: encoding format - compiledInsight.metrics.forEach((metric) => { - const sqlAlias = metricIdToColumnAlias(metric.id); - options.push({ - label: metric.name, - value: `metric:${metric.id}`, - icon: getColumnIcon(sqlAlias, columnAnalysis, metricAliases), - }); - }); - - return options; - }, [compiledInsight, columnAnalysis]); - - // Validate encoding configuration - returns errors for X/Y if invalid - const encodingErrors = useMemo(() => { - if (!visualization || columnAnalysis.length === 0) return {}; - return validateEncoding( - visualization.encoding ?? {}, - visualization.visualizationType, - columnAnalysis, - compiledInsight ?? undefined, - ); - }, [visualization, columnAnalysis, compiledInsight]); - - // Check if there are any encoding errors - const hasEncodingErrors = !!(encodingErrors.x || encodingErrors.y); - - // Handle name change - const handleNameChange = async (newName: string) => { - setVizName(newName); - await updateVisualization(visualizationId as UUID, { name: newName }); - }; - - // Infer axis type from column analysis semantic type - const inferAxisType = ( - semantic: string, - ): "quantitative" | "nominal" | "ordinal" | "temporal" => { - if (semantic === "numerical") return "quantitative"; - if (semantic === "temporal") return "temporal"; - return "nominal"; - }; - - // Handle encoding change - // Value comes in as storage encoding format (field:, metric:) - const handleEncodingChange = async ( - field: "x" | "y" | "color" | "size", - value: string, - ) => { - if (!visualization) return; - - const newEncoding: VisualizationEncoding = { - ...visualization.encoding, - [field]: value, - }; - - // Auto-detect type if changing x or y - if (field === "x" || field === "y") { - // Convert storage encoding to SQL alias to find in columnAnalysis - const parsed = parseEncoding(value); - let sqlAlias: string | undefined; - if (parsed) { - sqlAlias = - parsed.type === "field" - ? fieldIdToColumnAlias(parsed.id) - : metricIdToColumnAlias(parsed.id); - } - - const colAnalysis = sqlAlias - ? columnAnalysis.find((c) => c.columnName === sqlAlias) - : undefined; - - if (colAnalysis) { - const typeField = field === "x" ? "xType" : "yType"; - newEncoding[typeField] = inferAxisType(colAnalysis.semantic); - } - } - - await updateEncoding(visualizationId as UUID, newEncoding); - }; - - // Handle visualization type change - // Auto-swaps axes when switching between barY and barX - const handleTypeChange = async (type: string) => { - const newType = type as VisualizationType; - const currentType = visualization?.visualizationType; - - // Check if switching between bar orientations - auto-swap axes - const isBarSwitch = - (currentType === "barY" && newType === "barX") || - (currentType === "barX" && newType === "barY"); - - if (isBarSwitch && visualization?.encoding) { - // Swap X and Y when changing bar orientation - const currentEncoding = visualization.encoding; - const newEncoding = { - ...currentEncoding, - x: currentEncoding.y, - y: currentEncoding.x, - xType: currentEncoding.yType, - yType: currentEncoding.xType, - }; - - // Update both type and encoding together - await updateVisualization(visualizationId as UUID, { - visualizationType: newType, - }); - await updateEncoding(visualizationId as UUID, newEncoding); - } else { - // Just update the type - await updateVisualization(visualizationId as UUID, { - visualizationType: newType, - }); - } - }; - - // Handle delete - const handleDelete = async () => { - if (confirm(`Are you sure you want to delete "${visualization?.name}"?`)) { - await removeVisualization(visualizationId as UUID); - router.push("/insights"); - } - }; - - // Loading state - if (isLoading) { - return ( -
-
- -

- Loading visualization... -

-
-
- ); - } - - // Not found state - if (!visualization) { - return ( -
-
-

Visualization not found

-

- The visualization you're looking for doesn't exist. -

-
-
- ); - } - - // No DataFrame state - if (!dataFrame) { - return ( - -
-
-
- } - > -
- - -
- {getVizIcon(visualization.visualizationType)} -
-

Data not available

-

- The data for this visualization is not available. Please refresh - from the source insight. -

- {visualization.insightId && ( -
- - ); - } - - // Visualization type options - condensed (bar orientations combined, scatter as umbrella) - const hasNumericColumns = dataFrame?.columns?.some( - (col) => col.type === "number", - ); - const vizTypeOptions = hasNumericColumns - ? [ - { label: "Bar", value: "barY" }, - { label: "Line", value: "line" }, - { label: "Scatter", value: "dot" }, - { label: "Area", value: "areaY" }, - ] - : []; - - // Check if current chart is a scatter-type (dot, hexbin, heatmap, raster) - const isScatterType = ["dot", "hexbin", "heatmap", "raster"].includes( - visualization?.visualizationType ?? "", - ); - - // Scatter render mode options - disable Dots for large datasets - const rowCount = dataFrameEntry?.rowCount ?? 0; - const isLargeDataset = rowCount > 10000; - const scatterRenderModeOptions = [ - { - label: "Dots", - value: "dot", - description: isLargeDataset - ? `Disabled for large datasets (${rowCount.toLocaleString()} rows)` - : "Raw dots - best for small datasets", - disabled: isLargeDataset, - }, - { - label: "Hexbin", - value: "hexbin", - description: "Hexagonal binning - shows density patterns", - }, - { - label: "Heatmap", - value: "heatmap", - description: "Smooth density visualization", - }, - { - label: "Raster", - value: "raster", - description: "Pixel aggregation - fastest for huge datasets", - }, - ]; - - // Get the display chart type (maps scatter variants back to "dot" for UI) - const displayChartType = isScatterType - ? "dot" - : (visualization?.visualizationType ?? "barY"); - - // Handle chart type change with scatter umbrella logic - const handleDisplayTypeChange = async (type: string) => { - if (type === "dot" && !isScatterType) { - // Switching to scatter - default to appropriate render mode based on data size - const newType = isLargeDataset ? "hexbin" : "dot"; - await handleTypeChange(newType); - } else if (type !== "dot") { - await handleTypeChange(type); - } - // If already scatter-type and selecting dot, keep current render mode - }; - - // Handle swap button click - swaps X/Y axes and toggles bar orientation - const handleSwapAxes = async () => { - if (!visualization) return; - - const currentEncoding = visualization.encoding || {}; - const newEncoding = { - ...currentEncoding, - x: currentEncoding.y, - y: currentEncoding.x, - xType: currentEncoding.yType, - yType: currentEncoding.xType, - }; - - // For bar charts, also toggle the chart type - const newChartType = getSwappedChartType(visualization.visualizationType); - - if (newChartType !== visualization.visualizationType) { - // Update both encoding and chart type - await updateVisualization(visualizationId as UUID, { - visualizationType: newChartType, - encoding: newEncoding, - }); - } else { - // Just update encoding - await updateEncoding(visualizationId as UUID, newEncoding); - } - }; - - // Check if swap is allowed for current chart type - const canSwap = isSwapAllowed(visualization.visualizationType); - - return ( - -
-
- - {/* Metadata row */} -
- - {dataFrameEntry?.rowCount?.toLocaleString() ?? "?"} rows •{" "} - {dataFrameEntry?.columnCount ?? "?"} columns - - {visualization.insightId && ( - <> - - - - )} -
- - {/* Delete button */} -
-
-
- } - rightPanel={ -
-
-

Encodings

- -
- {compiledInsight && ( - handleEncodingChange("x", value)} - placeholder="Select column..." - axis="x" - chartType={visualization.visualizationType} - columnAnalysis={columnAnalysis} - compiledInsight={compiledInsight} - otherAxisColumn={visualization.encoding?.y} - onSwapAxes={canSwap ? handleSwapAxes : undefined} - /> - )} - - {/* Swap button - swaps axes and toggles bar orientation */} - {canSwap && ( -
-
- )} - - {compiledInsight && ( - handleEncodingChange("y", value)} - placeholder="Select column..." - axis="y" - chartType={visualization.visualizationType} - columnAnalysis={columnAnalysis} - compiledInsight={compiledInsight} - otherAxisColumn={visualization.encoding?.x} - onSwapAxes={canSwap ? handleSwapAxes : undefined} - /> - )} - - handleEncodingChange("color", value)} - onClear={() => handleEncodingChange("color", "")} - options={columnOptions} - placeholder="None" - /> - - {visualization.visualizationType === "dot" && ( - handleEncodingChange("size", value)} - onClear={() => handleEncodingChange("size", "")} - options={columnOptions} - placeholder="None" - /> - )} -
-
- -
-

Chart Type

- - - {/* Render mode selector for scatter-type charts */} - {isScatterType && ( -
- -
- )} - - {/* Alternative chart types - show related charts from same tags */} - {(() => { - const alternatives = getAlternativeChartTypes( - visualization.visualizationType, - ); - if (alternatives.length === 0) return null; - - return ( -
-

- Similar charts -

-
- {alternatives.map((altType) => { - const meta = CHART_TYPE_METADATA[altType]; - return ( -
-
- ); - })()} -
- - {/* Source insight link */} - {visualization.insightId && ( -
-

Source

- - router.push(`/insights/${visualization.insightId}`) - } - > - -

Source Insight

-

- Click to view insight details -

-
-
-
- )} -
- } - > -
- {hasEncodingErrors ? ( -
-
-
- -
-

- Invalid encoding configuration -

-
- {encodingErrors.x && ( -

- X Axis: {encodingErrors.x} -

- )} - {encodingErrors.y && ( -

- Y Axis: {encodingErrors.y} -

- )} -
-

- Please update the axis configuration in the panel on the right. -

-
-
- ) : ( - - )} -
- - ); +export default async function VisualizationPage({ params }: PageProps) { + const { visualizationId } = await params; + return ; } 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 && ( -