├── .github └── FUNDING.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── app ├── assets │ └── fonts │ │ ├── CalSans-SemiBold.ttf │ │ ├── CalSans-SemiBold.woff │ │ └── CalSans-SemiBold.woff2 ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── AnalyticsDataInfo.tsx ├── AnalyticsDataLogo.tsx ├── FieldSelection.tsx ├── FileManager.tsx ├── FilterDialog.tsx ├── InitWasm.tsx ├── Main.tsx ├── PivotFields.tsx ├── RelationalStructure.tsx ├── pivotfields │ ├── Aggregation.tsx │ ├── PivotColumns.tsx │ ├── PivotFilters.tsx │ ├── PivotRows.tsx │ └── SelectAggregation.tsx └── ui │ ├── accordion.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── table.tsx │ ├── toast.tsx │ └── toaster.tsx ├── eslint.config.mjs ├── hooks └── use-toast.ts ├── lib ├── constants.ts └── utils.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public └── adpivot_snapshot.png ├── stores ├── useDuckDBStore.ts ├── useExcelStore.ts ├── useFileStore.ts ├── useLanguageStore.ts ├── usePivotStore.ts ├── usePyodideStore.ts ├── useRelationalStore.ts └── useTableStore.ts ├── tailwind.config.ts └── tsconfig.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: danilo-css # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN npm install --force 8 | 9 | COPY . . 10 | 11 | # Build the Next.js app 12 | RUN npm run build 13 | 14 | # Use a different port (e.g., 3002) 15 | ENV PORT=3002 16 | EXPOSE 3002 17 | 18 | RUN apk add --no-cache curl 19 | 20 | HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ 21 | CMD curl -f http://localhost:3002 || exit 1 22 | 23 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2025 analyticsdata.pro 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Analytics Data Pivot (ADPivot) 2 | 3 | Analytics Data Pivot (ADPivot) is a no code tool that helps build pivot tables from databases of any size with a few clicks. You can access a live version of the app through the link: [https://datapivot.analyticsdata.pro/](https://datapivot.analyticsdata.pro/). 4 | 5 | The databases have to be in the Apache Parquet (.parquet) format. ADPivot also supports uploading Excel spreadsheets (.xlsx), selecting a sheet and converting it to a Parquet file that can be used in the application. Support for CSV files is being considered for future versions. 6 | 7 | ![ADPivot](public/adpivot_snapshot.png) 8 | 9 | ## ADPivot advantages 10 | 11 | - **Client-side**. The application is run entirely on the client (on the user's device) without the need to send any data to a server, allowing fast interactivity and increased security. 12 | - **Table focused**. Unlike similar solutions, this tool is completely focused on creating pivot tables. No overhead for trying to handle charts. 13 | - **Relational**. Multiple databases can be added to allow relational database analytics. 14 | - **MultiIndex**. The resulting pivot table then can be visualized or downloaded preserving the MultiIndex (hierarchical index) structure defined in the application. 15 | - **Fast**. Under the hood, ADPivot uses [WebAssembly](https://webassembly.org/) [DuckDB](https://github.com/duckdb/duckdb-wasm) and [Pyodide](https://github.com/pyodide/pyodide) for maximum querying performance. On the front-end side, [React](https://github.com/facebook/react) and [Next.js](https://github.com/vercel/next.js) provide the best UI performance. 16 | - **Open-source and free:** ADPivot is completely open-source licensed under the MIT license. 17 | 18 | See the [launch article](https://analyticsdata.pro/blog/Analytics%20Data%20Pivot%20(ADPivot)%20launch) for further details. 19 | 20 | ## How to use it 21 | 22 | You can use the ADPivot app that's currently hosted on my server in the link: https://datapivot.analyticsdata.pro/ 23 | 24 | Or you can install [Node.js](https://nodejs.org/en/download), clone the repository, and deploy locally running these commands sequentially in the project folder (the application will run in http://localhost:3000/): 25 | 26 | ```bash 27 | npm install --force 28 | ``` 29 | 30 | ```bash 31 | npm run build 32 | ``` 33 | 34 | ```bash 35 | npm start 36 | ``` 37 | 38 | You can also deploy ADPivot to your own server. This requires knowledge of [Docker](https://www.docker.com/get-started/) and cloud infrastructure in general. A Dockerfile is provided in the main folder of the repository to help users with this task. 39 | 40 | ## Contribute 41 | 42 | If ADPivot is useful to you, you can help me keep the app up on my server and develop it further by making a donation through [GitHub sponsor program](https://github.com/sponsors/danilo-css?frequency=one-time&sponsor=danilo-css) or through [PayPal](https://www.paypal.com/donate/?business=VM8L5KP6R5FQY&no_recurring=0&item_name=Thank+you+for+donating.+Your+money+goes+toward+covering+server+expenses+and+developing+the+app+further.¤cy_code=USD). I'm also open to freelancing gigs using the tech stack of this application. You can reach out to me through the e-mail [contact@analyticsdata.pro](mailto:contact@analyticsdata.pro). 43 | 44 | If you're a user with no coding experience you can open an [issue](https://github.com/danilo-css/analytics-data-pivot/issues) and try to explain bugs or suggestions you might have to enhance the application. 45 | 46 | If you're a developer and want to contribute with code, I currently don't have much time to review [pull requests](https://github.com/danilo-css/analytics-data-pivot/pulls), but your pull request will be considered if you can explain clearly what the purpose is, how it is done and it clearly solves a bug or improves features of the application using React, Next.js, TypeScript and the other core dependencies that were used to build the app. 47 | 48 | ## Sponsors 49 | 50 | Permanently add your company, website, or GitHub profile link and description here in this README section by [becoming a one-time sponsor](https://github.com/sponsors/danilo-css?frequency=one-time&sponsor=danilo-css) of this open-source project. 51 | 52 | These are our current sponsors, from latest to earliest: 53 | 54 | - [AnalyticsData.Pro](https://analyticsdata.pro/) - Building and deploying data analytics tools that run on the browser. 55 | -------------------------------------------------------------------------------- /app/assets/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danilo-css/analytics-data-pivot/b7b34911b3fc1c79854764ba910b28d4cb0b1cd8/app/assets/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /app/assets/fonts/CalSans-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danilo-css/analytics-data-pivot/b7b34911b3fc1c79854764ba910b28d4cb0b1cd8/app/assets/fonts/CalSans-SemiBold.woff -------------------------------------------------------------------------------- /app/assets/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danilo-css/analytics-data-pivot/b7b34911b3fc1c79854764ba910b28d4cb0b1cd8/app/assets/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danilo-css/analytics-data-pivot/b7b34911b3fc1c79854764ba910b28d4cb0b1cd8/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 222.2 84% 4.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 222.2 84% 4.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 222.2 84% 4.9%; 17 | --primary: 221.2 83.2% 43.3%; 18 | --primary-foreground: 210 40% 98%; 19 | --secondary: 210 40% 86.1%; 20 | --secondary-foreground: 222.2 47.4% 11.2%; 21 | --muted: 210 40% 86.1%; 22 | --muted-foreground: 215.4 16.3% 46.9%; 23 | --accent: 210 40% 86.1%; 24 | --accent-foreground: 222.2 47.4% 11.2%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 210 40% 98%; 27 | --border: 214.3 31.8% 81.4%; 28 | --input: 214.3 31.8% 81.4%; 29 | --ring: 221.2 83.2% 43.3%; 30 | --radius: 0.5rem; 31 | --chart-1: 12 76% 61%; 32 | --chart-2: 173 58% 39%; 33 | --chart-3: 197 37% 24%; 34 | --chart-4: 43 74% 66%; 35 | --chart-5: 27 87% 67%; 36 | } 37 | 38 | .dark { 39 | --background: 222.2 84% 4.9%; 40 | --foreground: 210 40% 98%; 41 | --card: 222.2 84% 4.9%; 42 | --card-foreground: 210 40% 98%; 43 | --popover: 222.2 84% 4.9%; 44 | --popover-foreground: 210 40% 98%; 45 | --primary: 217.2 91.2% 49.8%; 46 | --primary-foreground: 222.2 47.4% 11.2%; 47 | --secondary: 217.2 32.6% 12.5%; 48 | --secondary-foreground: 210 40% 98%; 49 | --muted: 217.2 32.6% 12.5%; 50 | --muted-foreground: 215 20.2% 65.1%; 51 | --accent: 217.2 32.6% 12.5%; 52 | --accent-foreground: 210 40% 98%; 53 | --destructive: 0 62.8% 30.6%; 54 | --destructive-foreground: 210 40% 98%; 55 | --border: 217.2 32.6% 12.5%; 56 | --input: 217.2 32.6% 12.5%; 57 | --ring: 224.3 76.3% 38%; 58 | --chart-1: 220 70% 40%; 59 | --chart-2: 160 60% 35%; 60 | --chart-3: 30 80% 45%; 61 | --chart-4: 280 65% 50%; 62 | --chart-5: 340 75% 45%; 63 | } 64 | } 65 | 66 | @layer base { 67 | * { 68 | @apply border-border; 69 | } 70 | body { 71 | @apply bg-background text-foreground; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist as FontSans } from "next/font/google"; 3 | import localFont from "next/font/local"; 4 | import "./globals.css"; 5 | import { cn } from "@/lib/utils"; 6 | import { Toaster } from "@/components/ui/toaster"; 7 | 8 | const fontSans = FontSans({ 9 | subsets: ["latin"], 10 | variable: "--font-sans", 11 | }); 12 | 13 | const fontHeading = localFont({ 14 | src: "./assets/fonts/CalSans-SemiBold.woff2", 15 | variable: "--font-heading", 16 | }); 17 | 18 | export const metadata: Metadata = { 19 | title: "Analytics Data Pivot (ADPivot) | AnalyticsData.Pro", 20 | description: 21 | "Analytics Data Pivot (ADPivot) is a powerful Data Analytics tool, allowing users to build pivot tables from Excel or Parquet files, powered by DuckDB-Wasm and Pyodide.", 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: Readonly<{ 27 | children: React.ReactNode; 28 | }>) { 29 | return ( 30 | 31 | 38 | {children} 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next"; 2 | import Main from "../components/Main"; 3 | 4 | export const metadata: Metadata = { 5 | title: "Analytics Data Pivot (ADPivot) | AnalyticsData.Pro", 6 | description: 7 | "Analytics Data Pivot (ADPivot) is a powerful Data Analytics tool, allowing users to build pivot tables from Excel or Parquet files, powered by DuckDB-Wasm and Pyodide.", 8 | }; 9 | 10 | export default function Home() { 11 | return
; 12 | } 13 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/AnalyticsDataInfo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useDuckDBStore } from "@/stores/useDuckDBStore"; 3 | import { usePyodideStore } from "@/stores/usePyodideStore"; 4 | import { SiGithubsponsors } from "react-icons/si"; 5 | import { FaGithub } from "react-icons/fa"; 6 | 7 | export default function AnalyticsDataInfo() { 8 | const { pyodide } = usePyodideStore(); 9 | const { db } = useDuckDBStore(); 10 | return ( 11 | <> 12 | {pyodide && db && ( 13 |
14 |
15 |
16 |

Contact us:

17 | 21 | contact@analyticsdata.pro 22 | 23 |
24 | 30 | 31 | 32 |
33 |
34 |

Support this app:

35 | 40 |
41 | 42 |
43 |

44 | SPONSOR 45 |

46 |

47 | (GitHub) 48 |

49 |
50 |
51 |
52 | 57 |
58 | 59 |
60 |

61 | DONATE 62 |

63 |

64 | (PayPal) 65 |

66 |
67 |
68 |
69 |
70 |
71 | )} 72 | 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /components/AnalyticsDataLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { FiShield } from "react-icons/fi"; 3 | import { GiEagleEmblem } from "react-icons/gi"; 4 | 5 | export default function Logo() { 6 | return ( 7 |
8 | 14 |
15 | 16 |
17 | 18 |
19 |
20 |
21 | DATA PIVOT 22 |
23 | ANALYTICSDATA.PRO 24 |
25 |
26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/FieldSelection.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTableStore } from "@/stores/useTableStore"; 4 | import React from "react"; 5 | import { 6 | Accordion, 7 | AccordionContent, 8 | AccordionItem, 9 | AccordionTrigger, 10 | } from "@/components/ui/accordion"; 11 | import { Badge } from "./ui/badge"; 12 | import { TbNumber123 } from "react-icons/tb"; 13 | import { PiTextAaFill } from "react-icons/pi"; 14 | import { Clock, Columns3, Database, Rows3, SquareSigma } from "lucide-react"; 15 | import { useFileStore } from "@/stores/useFileStore"; 16 | import { usePivotStore } from "@/stores/usePivotStore"; 17 | import FilterDialog from "./FilterDialog"; 18 | import { 19 | Dialog, 20 | DialogContent, 21 | DialogDescription, 22 | DialogHeader, 23 | DialogTitle, 24 | DialogTrigger, 25 | } from "./ui/dialog"; 26 | 27 | function DateOptionsDialog({ table, field }: { table: string; field: string }) { 28 | const { addRow, addColumn } = usePivotStore(); 29 | 30 | const options = [ 31 | { label: "Year", extract: "YEAR" }, 32 | { label: "Month", extract: "MONTH" }, 33 | { label: "Quarter", extract: "QUARTER" }, 34 | ] as const; 35 | 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | Date parsing 44 | 45 | {`Date parse "${field}" from "${table}". Do notice the field has to be in the format YYYY-MM-DD.`} 46 | 47 | 48 |
49 |

Date Field Options

50 |
51 | {options.map((opt) => ( 52 |
56 | {opt.label} 57 |
58 | addRow(table, field, opt.extract)} 62 | /> 63 | addColumn(table, field, opt.extract)} 67 | /> 68 | 73 |
74 |
75 | ))} 76 |
77 |
78 |
79 |
80 | ); 81 | } 82 | 83 | export default function FieldSelection() { 84 | const { queryFields, setQueryFields, isLoadingFields } = useTableStore(); 85 | const { files } = useFileStore(); 86 | const { addRow, addColumn, setAggregation } = usePivotStore(); 87 | 88 | const handleTypeChange = (parentKey: string, itemIndex: number) => { 89 | const updatedFields = [...queryFields[parentKey]]; 90 | updatedFields[itemIndex].type = 91 | updatedFields[itemIndex].type === "Utf8" ? "Float" : "Utf8"; 92 | setQueryFields(parentKey, updatedFields); 93 | }; 94 | 95 | return ( 96 | 97 | {files?.map((parentKey: File, index: number) => ( 98 | 103 | 104 |
105 | 106 | 107 | {index} 108 | 109 |
110 |
111 | {parentKey.name} 112 |
113 |
114 | 115 | {isLoadingFields ? ( 116 |
117 |
118 |
119 | ) : ( 120 | queryFields[parentKey.name]?.map((item, index) => ( 121 | 125 |
129 | {item.name} 130 |
131 |
132 |
133 | {item.type === "Utf8" ? ( 134 | <> 135 | 138 | handleTypeChange(parentKey.name, index) 139 | } 140 | className="cursor-pointer hover:text-black" 141 | title="Current format: Text. Click to change to number or date." 142 | /> 143 | addRow(parentKey.name, item.name)} 147 | /> 148 | addColumn(parentKey.name, item.name)} 152 | /> 153 | 157 | 158 | ) : ( 159 | <> 160 | 163 | handleTypeChange(parentKey.name, index) 164 | } 165 | title="Current format: Number. Click to change to text." 166 | className="cursor-pointer hover:text-black" 167 | /> 168 | 172 | 176 | setAggregation(parentKey.name, item.name, "SUM") 177 | } 178 | /> 179 | 180 | )} 181 |
182 |
183 |
184 | )) 185 | )} 186 |
187 |
188 | ))} 189 |
190 | ); 191 | } 192 | -------------------------------------------------------------------------------- /components/FileManager.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useRef, useState } from "react"; 4 | import { useFileStore } from "@/stores/useFileStore"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Input } from "@/components/ui/input"; 7 | import { useTableStore } from "@/stores/useTableStore"; 8 | import { usePivotStore } from "@/stores/usePivotStore"; 9 | import { usePyodideStore } from "@/stores/usePyodideStore"; 10 | import { 11 | Dialog, 12 | DialogContent, 13 | DialogHeader, 14 | DialogTitle, 15 | } from "@/components/ui/dialog"; 16 | import { 17 | Select, 18 | SelectContent, 19 | SelectItem, 20 | SelectTrigger, 21 | SelectValue, 22 | } from "@/components/ui/select"; 23 | 24 | const Spinner = () => ( 25 |
26 | ); 27 | 28 | export default function FileManager() { 29 | const { files, addFile, removeFile } = useFileStore(); 30 | const { clearQueryFields } = useTableStore(); 31 | const { pyodide } = usePyodideStore(); 32 | const { 33 | clearFileRows, 34 | clearFileColumns, 35 | clearFileAggregation, 36 | clearFileFilters, 37 | } = usePivotStore(); 38 | const fileInputRef = useRef(null); 39 | const [isDialogOpen, setIsDialogOpen] = useState(false); 40 | const [sheets, setSheets] = useState([]); 41 | const [selectedSheet, setSelectedSheet] = useState(""); 42 | const [currentXlsxFile, setCurrentXlsxFile] = useState(null); 43 | const [isLoading, setIsLoading] = useState(false); 44 | const [isConverting, setIsConverting] = useState(false); 45 | 46 | const handleFileChange = async ( 47 | event: React.ChangeEvent 48 | ) => { 49 | setIsLoading(true); 50 | const selectedFile = event.target.files?.[0]; 51 | if (selectedFile) { 52 | if (selectedFile.name.endsWith(".xlsx")) { 53 | setCurrentXlsxFile(selectedFile); 54 | const sheetNames = await getSheetNames(selectedFile); 55 | setSheets(sheetNames); 56 | setIsDialogOpen(true); 57 | } else if (selectedFile.name.endsWith(".parquet")) { 58 | addFile(selectedFile); 59 | } 60 | event.target.value = ""; 61 | } 62 | setIsLoading(false); 63 | }; 64 | 65 | const getSheetNames = async (file: File): Promise => { 66 | const arrayBuffer = await file.arrayBuffer(); 67 | const uint8Array = new Uint8Array(arrayBuffer); 68 | 69 | // Write to virtual filesystem instead of passing data through JSON 70 | pyodide?.FS.writeFile("/tmp/excel_file.xlsx", uint8Array); 71 | 72 | const result = await pyodide?.runPythonAsync(` 73 | import pandas as pd 74 | 75 | xl = pd.ExcelFile('/tmp/excel_file.xlsx') 76 | sheet_names = xl.sheet_names 77 | sheet_names 78 | `); 79 | 80 | // Cleanup 81 | pyodide?.FS.unlink("/tmp/excel_file.xlsx"); 82 | 83 | return result.toJs(); 84 | }; 85 | 86 | const convertToParquet = async (sheet: string) => { 87 | if (!currentXlsxFile) return; 88 | setIsConverting(true); 89 | 90 | const arrayBuffer = await currentXlsxFile.arrayBuffer(); 91 | const uint8Array = new Uint8Array(arrayBuffer); 92 | 93 | // Write to virtual filesystem 94 | pyodide?.FS.writeFile("/tmp/excel_file.xlsx", uint8Array); 95 | 96 | const result = await pyodide?.runPythonAsync(` 97 | import pandas as pd 98 | import pyarrow as pa 99 | import pyarrow.parquet as pq 100 | import io 101 | 102 | df = pd.read_excel('/tmp/excel_file.xlsx', sheet_name='${sheet}', dtype=str) 103 | table = pa.Table.from_pandas(df) 104 | 105 | output_buffer = io.BytesIO() 106 | pq.write_table(table, output_buffer) 107 | output_buffer.getvalue() 108 | `); 109 | 110 | // Cleanup 111 | pyodide?.FS.unlink("/tmp/excel_file.xlsx"); 112 | 113 | const uint8ArrayResult = new Uint8Array(result.toJs()); 114 | const blob = new Blob([uint8ArrayResult], { 115 | type: "application/octet-stream", 116 | }); 117 | const parquetFileName = `${currentXlsxFile.name.replace( 118 | ".xlsx", 119 | "" 120 | )}-${sheet}.parquet`; 121 | 122 | // Create a link element, set its href to the blob URL, and click it to trigger the download 123 | const link = document.createElement("a"); 124 | link.href = URL.createObjectURL(blob); 125 | link.download = parquetFileName; 126 | document.body.appendChild(link); 127 | link.click(); 128 | document.body.removeChild(link); 129 | 130 | setIsConverting(false); 131 | setIsDialogOpen(false); 132 | setCurrentXlsxFile(null); 133 | setSelectedSheet(""); 134 | }; 135 | 136 | const handleButtonClick = () => { 137 | fileInputRef.current?.click(); 138 | }; 139 | 140 | return ( 141 |
142 | {(isLoading || isConverting) && ( 143 |
144 | 145 |
146 | )} 147 |
148 | 156 | 159 |

.parquet, .xlsx

160 |
161 | 162 | 163 | 164 | 165 | Select Sheet 166 | 167 | 179 | 185 | 186 | 187 | 188 | {files.length > 0 && ( 189 |
    190 | {files.map((file) => ( 191 |
  • 195 | 199 | {file.name} 200 | 201 | 213 |
  • 214 | ))} 215 |
216 | )} 217 |
218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /components/FilterDialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Dialog, 3 | DialogContent, 4 | DialogDescription, 5 | DialogHeader, 6 | DialogTitle, 7 | DialogTrigger, 8 | } from "@/components/ui/dialog"; 9 | import { useDuckDBStore } from "@/stores/useDuckDBStore"; 10 | import { 11 | ChevronLeft, 12 | ChevronRight, 13 | Filter, 14 | Loader2, 15 | ChevronsLeft, 16 | ChevronsRight, 17 | } from "lucide-react"; 18 | import React, { useState, useEffect } from "react"; 19 | import { Button } from "./ui/button"; 20 | import { 21 | Table, 22 | TableBody, 23 | TableCell, 24 | TableHeader, 25 | TableRow, 26 | } from "@/components/ui/table"; 27 | import { Input } from "@/components/ui/input"; 28 | import { Checkbox } from "@/components/ui/checkbox"; 29 | import { usePivotStore } from "@/stores/usePivotStore"; 30 | import { 31 | Collapsible, 32 | CollapsibleContent, 33 | CollapsibleTrigger, 34 | } from "@/components/ui/collapsible"; 35 | import { ChevronDown, ChevronUp } from "lucide-react"; 36 | import { Separator } from "./ui/separator"; 37 | import { useToast } from "@/hooks/use-toast"; 38 | 39 | type FilterDialogProps = { 40 | table: string; 41 | field: string; 42 | dateExtract?: "YEAR" | "MONTH" | "QUARTER"; 43 | }; 44 | 45 | export default function FilterDialog({ 46 | table, 47 | field, 48 | dateExtract, 49 | }: FilterDialogProps) { 50 | const { toast } = useToast(); 51 | const { db, runQuery } = useDuckDBStore(); 52 | const { filters, addFilter } = usePivotStore(); 53 | const [loading, setLoading] = useState(false); 54 | const [values, setValues] = useState([]); 55 | const [selectedValues, setSelectedValues] = useState( 56 | filters.find((filter) => filter.table === table && filter.field === field) 57 | ?.values || [] 58 | ); 59 | const [searchQuery, setSearchQuery] = useState(""); 60 | const [currentPage, setCurrentPage] = useState(1); 61 | const [open, setOpen] = useState(false); 62 | const [isOpen, setIsOpen] = useState(false); 63 | const itemsPerPage = 10; 64 | 65 | const fetchData = async () => { 66 | if (!db) return; 67 | setLoading(true); 68 | 69 | try { 70 | let query; 71 | // Extract the original field name if it's already a date-extracted field 72 | const originalField = field.match(/^(YEAR|MONTH|QUARTER)\((.*?)\)$/); 73 | const actualField = originalField ? originalField[2] : field; 74 | const actualExtract = originalField ? originalField[1] : dateExtract; 75 | 76 | if (actualExtract) { 77 | if (actualExtract === "MONTH") { 78 | query = `SELECT DISTINCT LPAD(CAST(EXTRACT(${actualExtract} FROM CAST("${actualField}" AS DATE)) AS VARCHAR), 2, '0') as value FROM '${table}' WHERE "${actualField}" IS NOT NULL ORDER BY value`; 79 | } else if (actualExtract === "QUARTER") { 80 | query = `SELECT DISTINCT 'Q' || CAST(EXTRACT(${actualExtract} FROM CAST("${actualField}" AS DATE)) AS VARCHAR) as value FROM '${table}' WHERE "${actualField}" IS NOT NULL ORDER BY value`; 81 | } else { 82 | query = `SELECT DISTINCT CAST(EXTRACT(${actualExtract} FROM CAST("${actualField}" AS DATE)) AS VARCHAR) as value FROM '${table}' WHERE "${actualField}" IS NOT NULL ORDER BY value`; 83 | } 84 | } else { 85 | query = `SELECT DISTINCT REPLACE("${actualField}", '"', '') as value FROM '${table}' WHERE "${actualField}" IS NOT NULL ORDER BY value`; 86 | } 87 | const result = await runQuery(db, query); 88 | 89 | const values = result 90 | .toArray() 91 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 92 | .map((row: { value: { toString: () => any } }) => row.value.toString()); 93 | setValues(values); 94 | } catch (error) { 95 | console.error("Error fetching filter options:", error); 96 | toast({ 97 | title: "Error", 98 | description: 99 | "Failed to fetch filter values. Please make sure this is a proper date or text field.", 100 | variant: "destructive", 101 | }); 102 | } finally { 103 | setLoading(false); 104 | } 105 | }; 106 | 107 | // Remove the automatic fetching from useEffect since we now have a manual fetch button 108 | useEffect(() => { 109 | // Reset selected values when dialog opens 110 | if (open) { 111 | // Find the filter checking both regular and date-extracted field names 112 | const existingFilter = filters.find( 113 | (f) => 114 | f.table === table && 115 | (f.field === field || f.field === `${dateExtract}(${field})`) 116 | ); 117 | setSelectedValues(existingFilter?.values || []); 118 | } 119 | }, [open, table, field, dateExtract, filters]); 120 | 121 | const filteredValues = values.filter((value) => 122 | value.toString().toLowerCase().includes(searchQuery.toLowerCase()) 123 | ); 124 | 125 | const totalPages = Math.ceil(filteredValues.length / itemsPerPage); 126 | const startIndex = (currentPage - 1) * itemsPerPage; 127 | const paginatedValues = filteredValues.slice( 128 | startIndex, 129 | startIndex + itemsPerPage 130 | ); 131 | 132 | const toggleSelection = (value: string) => { 133 | setSelectedValues((prev) => 134 | prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value] 135 | ); 136 | }; 137 | 138 | const handleSubmit = () => { 139 | // Extract the original field name if it's already a date-extracted field 140 | const originalField = field.match(/^(YEAR|MONTH|QUARTER)\((.*?)\)$/); 141 | const actualField = originalField ? originalField[2] : field; 142 | const actualExtract = originalField 143 | ? (originalField[1] as "YEAR" | "MONTH" | "QUARTER") 144 | : dateExtract; 145 | 146 | addFilter(table, actualField, selectedValues, actualExtract); 147 | toast({ 148 | title: "Filter Applied", 149 | description: `Successfully applied filter for ${actualField}`, 150 | }); 151 | }; 152 | 153 | const clearAll = () => { 154 | setSelectedValues([]); 155 | setSearchQuery(""); 156 | setCurrentPage(1); 157 | }; 158 | 159 | const handleOpenChange = (isOpen: boolean) => { 160 | setOpen(isOpen); 161 | if (!isOpen) { 162 | setCurrentPage(1); 163 | setSearchQuery(""); 164 | } 165 | }; 166 | 167 | return ( 168 | 169 | 170 | 171 | 172 | 173 | 174 | Add filter 175 | 176 | {dateExtract 177 | ? `Filter by ${dateExtract.toLowerCase()}s from "${field}" in "${table}"` 178 | : `Add filter for "${field}" from "${table}"`} 179 | 180 | 181 |
182 |
183 | 193 | 194 |
195 | { 199 | setSearchQuery(e.target.value); 200 | setCurrentPage(1); 201 | }} 202 | className="bg-gray-600 text-white" 203 | /> 204 |
205 | 206 | 207 | 208 | 209 | 210 | {loading ? ( 211 | 212 | 213 | 214 | 215 | 216 | ) : ( 217 | paginatedValues.map((value, index) => ( 218 | toggleSelection(value)} 222 | > 223 | 224 | 225 | 226 | 230 | {value} 231 | 232 | 233 | )) 234 | )} 235 | 236 |
237 |
238 |
239 | 245 | 251 | 252 | Page {currentPage} of {totalPages} 253 | 254 | 262 | 268 |
269 | 272 | 273 | 274 | Selected values ({selectedValues.length}) 275 | {isOpen ? : } 276 | 277 | 278 |
    279 | {selectedValues.map((value, index) => ( 280 |
    281 | 282 |
  • 286 | {value} 287 |
  • 288 |
    289 | ))} 290 |
291 |
292 |
293 |
294 |
295 |
296 | ); 297 | } 298 | -------------------------------------------------------------------------------- /components/InitWasm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useDuckDBStore } from "@/stores/useDuckDBStore"; 4 | import { usePyodideStore } from "@/stores/usePyodideStore"; 5 | import { CircleCheck, CircleX, Loader2 } from "lucide-react"; 6 | import React, { useEffect } from "react"; 7 | 8 | export default function InitWasm() { 9 | const { pyodide, loadingpyodide, errorpyodide, initializePyodide } = 10 | usePyodideStore(); 11 | const { db, loadingduckdb, errorduckdb, initializeDuckDB } = useDuckDBStore(); 12 | 13 | useEffect(() => { 14 | initializePyodide(); 15 | initializeDuckDB(); 16 | }, []); 17 | 18 | return ( 19 |
20 | {loadingduckdb && ( 21 |
22 | 23 |

Loading DuckDB...

24 |
25 | )} 26 | {db && !(db && pyodide) && ( 27 |
28 | 29 |

DuckDB initialized

30 |
31 | )} 32 | {errorduckdb && ( 33 |
34 | 35 |

Error initializing DuckDB: {errorduckdb.message}

36 |
37 | )} 38 | {loadingpyodide && ( 39 |
40 | 41 |

Loading Pyodide...

42 |
43 | )} 44 | {pyodide && !(db && pyodide) && ( 45 |
46 | 47 |

Pyodide initialized

48 |
49 | )} 50 | {errorpyodide && ( 51 |
52 | 53 |

Error initializing Pyodide: {errorpyodide.message}

54 |
55 | )} 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /components/Main.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import React, { useEffect, useMemo, useState, useRef } from "react"; 5 | import FileManager from "./FileManager"; 6 | import { useFileStore } from "@/stores/useFileStore"; 7 | import { useDuckDBStore } from "@/stores/useDuckDBStore"; 8 | import InitWasm from "./InitWasm"; 9 | import { usePyodideStore } from "@/stores/usePyodideStore"; 10 | import { useTableStore } from "@/stores/useTableStore"; 11 | import FieldSelection from "./FieldSelection"; 12 | import PivotFields from "./PivotFields"; 13 | import { Copy, Play, Loader2 } from "lucide-react"; 14 | import { usePivotStore } from "@/stores/usePivotStore"; 15 | import { getTypeForColumn } from "@/lib/utils"; 16 | import { Button } from "./ui/button"; 17 | import { useExcelStore } from "@/stores/useExcelStore"; 18 | import { PiMicrosoftExcelLogoFill } from "react-icons/pi"; 19 | import { useRelationalStore } from "@/stores/useRelationalStore"; 20 | import RelationalStructure from "./RelationalStructure"; 21 | import { FaLanguage } from "react-icons/fa6"; 22 | import { Table as Arrow } from "apache-arrow"; 23 | import AnalyticsDataLogo from "./AnalyticsDataLogo"; 24 | import AnalyticsDataInfo from "./AnalyticsDataInfo"; 25 | import { useToast } from "@/hooks/use-toast"; 26 | import { format } from "sql-formatter"; 27 | 28 | export default function Main() { 29 | const { toast } = useToast(); 30 | const { db, runQuery } = useDuckDBStore(); 31 | const { pyodide } = usePyodideStore(); 32 | const { files } = useFileStore(); 33 | const { queryFields, setQueryFieldsFromFiles } = useTableStore(); 34 | const { rows, columns, aggregation, filters } = usePivotStore(); 35 | const { relationships } = useRelationalStore(); 36 | const { result, setResult } = useExcelStore(); 37 | const [loading, setLoading] = useState(false); 38 | const [error, setError] = useState(null); 39 | const [isQueryRunning, setIsQueryRunning] = useState(false); 40 | const [useFormat, setUseFormat] = useState(true); 41 | const resultContainerRef = useRef(null); 42 | const [preview, setPreview] = useState(false); 43 | const PREVIEW_ROW_OPTIONS = [10, 50, 100, 1000] as const; 44 | const [previewRows, setPreviewRows] = 45 | useState<(typeof PREVIEW_ROW_OPTIONS)[number]>(10); 46 | const [selectedPreviewFile, setSelectedPreviewFile] = useState(""); 47 | 48 | const handlePreviewRowsChange = (value: string) => { 49 | setPreviewRows(Number(value) as (typeof PREVIEW_ROW_OPTIONS)[number]); 50 | }; 51 | 52 | useEffect(() => { 53 | if (!files || !db) return; 54 | 55 | setLoading(true); 56 | setError(null); 57 | 58 | try { 59 | setQueryFieldsFromFiles(files, db, runQuery); 60 | } catch (err) { 61 | console.error("Error processing file:", err); 62 | setError( 63 | "Error processing file. Please check the console for more details." 64 | ); 65 | } finally { 66 | setLoading(false); 67 | } 68 | }, [files, db, runQuery, setQueryFieldsFromFiles]); 69 | 70 | useEffect(() => { 71 | if (files.length > 0) { 72 | // If current selection is no longer in files list, select first available file 73 | if (!files.some((file) => file.name === selectedPreviewFile)) { 74 | setSelectedPreviewFile(files[0].name); 75 | } 76 | } else { 77 | // Clear selection when no files remain 78 | setSelectedPreviewFile(""); 79 | } 80 | }, [files, selectedPreviewFile]); 81 | 82 | useEffect(() => { 83 | const shouldShowPreview = !( 84 | files.length >= 1 && 85 | aggregation.name && 86 | (rows.length > 0 || columns.length > 0) 87 | ); 88 | setPreview(shouldShowPreview); 89 | }, [files.length, aggregation.name, rows.length, columns.length]); 90 | 91 | const sqlQuery = (() => { 92 | const generateQuery = () => { 93 | if (files.length === 0) { 94 | return null; 95 | } else if ( 96 | files.length === 1 && 97 | aggregation.name && 98 | (rows.length > 0 || columns.length > 0) 99 | ) { 100 | const generateFieldExpression = ( 101 | field: (typeof rows)[0] | (typeof columns)[0] 102 | ) => { 103 | if (field.dateExtract) { 104 | const originalField = field.name 105 | .replace(`${field.dateExtract}(`, "") 106 | .replace(")", ""); 107 | const extractExpr = `EXTRACT(${field.dateExtract} FROM CAST("${originalField}" AS DATE))`; 108 | return field.dateExtract === "MONTH" 109 | ? `LPAD(CAST(${extractExpr} AS VARCHAR), 2, '0') AS "${field.name}"` 110 | : field.dateExtract === "QUARTER" 111 | ? `CONCAT('Q', CAST(${extractExpr} AS VARCHAR)) AS "${field.name}"` 112 | : `CAST(${extractExpr} AS VARCHAR) AS "${field.name}"`; 113 | } 114 | return `CAST("${field.name}" AS ${ 115 | getTypeForColumn(queryFields, field.table, field.name) === "Utf8" 116 | ? "VARCHAR" 117 | : "DOUBLE" 118 | }) AS "${field.name}"`; 119 | }; 120 | 121 | const generateGroupByExpression = ( 122 | field: (typeof rows)[0] | (typeof columns)[0] 123 | ) => { 124 | if (field.dateExtract) { 125 | const originalField = field.name 126 | .replace(`${field.dateExtract}(`, "") 127 | .replace(")", ""); 128 | return `EXTRACT(${field.dateExtract} FROM CAST("${originalField}" AS DATE))`; 129 | } 130 | return `CAST("${field.name}" AS ${ 131 | getTypeForColumn(queryFields, field.table, field.name) === "Utf8" 132 | ? "VARCHAR" 133 | : "DOUBLE" 134 | })`; 135 | }; 136 | 137 | const all_fields = [...rows, ...columns]; 138 | const all_fields_string = all_fields 139 | .map((field) => generateFieldExpression(field)) 140 | .join(", "); 141 | 142 | const all_fields_string_groupby = all_fields 143 | .map((field) => generateGroupByExpression(field)) 144 | .join(", "); 145 | 146 | return ` 147 | SELECT ${all_fields_string}, ${aggregation.type}(CAST("${ 148 | aggregation.name 149 | }" AS ${ 150 | getTypeForColumn(queryFields, files[0].name, aggregation.name) === 151 | "Utf8" 152 | ? "VARCHAR" 153 | : "DOUBLE" 154 | })) AS "${aggregation.name}" 155 | FROM '${files[0].name}' 156 | ${ 157 | filters.length > 0 158 | ? `WHERE ${filters 159 | .map((filter) => { 160 | if (filter.dateExtract) { 161 | const originalField = filter.field 162 | .replace(`${filter.dateExtract}(`, "") 163 | .replace(")", ""); 164 | const extractExpr = `EXTRACT(${filter.dateExtract} FROM CAST("${originalField}" AS DATE))`; 165 | const filterExpr = 166 | filter.dateExtract === "MONTH" 167 | ? `LPAD(CAST(${extractExpr} AS VARCHAR), 2, '0')` 168 | : filter.dateExtract === "QUARTER" 169 | ? `CONCAT('Q', CAST(${extractExpr} AS VARCHAR))` 170 | : `CAST(${extractExpr} AS VARCHAR)`; 171 | return `${filterExpr} IN (${filter.values 172 | .map((value) => `'${value}'`) 173 | .join(", ")})`; 174 | } 175 | return `"${filter.field}" IN (${filter.values 176 | .map((value) => `'${value}'`) 177 | .join(", ")})`; 178 | }) 179 | .join(" AND ")}` 180 | : "" 181 | } 182 | GROUP BY ${all_fields_string_groupby} 183 | `; 184 | } else if ( 185 | files.length > 1 && 186 | aggregation.name && 187 | (rows.length > 0 || columns.length > 0) 188 | ) { 189 | const generateFieldExpression = ( 190 | field: (typeof rows)[0] | (typeof columns)[0] 191 | ) => { 192 | if (field.dateExtract) { 193 | const originalField = field.name 194 | .replace(`${field.dateExtract}(`, "") 195 | .replace(")", ""); 196 | const tablePrefix = `TABLE${files.findIndex( 197 | (file) => file.name === field.table 198 | )}`; 199 | const extractExpr = `EXTRACT(${field.dateExtract} FROM CAST(${tablePrefix}."${originalField}" AS DATE))`; 200 | return field.dateExtract === "MONTH" 201 | ? `LPAD(CAST(${extractExpr} AS VARCHAR), 2, '0') AS "${field.name}"` 202 | : field.dateExtract === "QUARTER" 203 | ? `CONCAT('Q', CAST(${extractExpr} AS VARCHAR)) AS "${field.name}"` 204 | : `CAST(${extractExpr} AS VARCHAR) AS "${field.name}"`; 205 | } 206 | return `CAST(TABLE${files.findIndex( 207 | (file) => file.name === field.table 208 | )}."${field.name}" AS ${ 209 | getTypeForColumn(queryFields, field.table, field.name) === "Utf8" 210 | ? "VARCHAR" 211 | : "DOUBLE" 212 | }) AS "${field.name}"`; 213 | }; 214 | 215 | const generateGroupByExpression = ( 216 | field: (typeof rows)[0] | (typeof columns)[0] 217 | ) => { 218 | if (field.dateExtract) { 219 | const originalField = field.name 220 | .replace(`${field.dateExtract}(`, "") 221 | .replace(")", ""); 222 | return `EXTRACT(${ 223 | field.dateExtract 224 | } FROM CAST(TABLE${files.findIndex( 225 | (file) => file.name === field.table 226 | )}."${originalField}" AS DATE))`; 227 | } 228 | return `CAST(TABLE${files.findIndex( 229 | (file) => file.name === field.table 230 | )}."${field.name}" AS ${ 231 | getTypeForColumn(queryFields, field.table, field.name) === "Utf8" 232 | ? "VARCHAR" 233 | : "DOUBLE" 234 | })`; 235 | }; 236 | 237 | const fields = [...rows, ...columns]; 238 | const uniqueFields = [ 239 | ...new Set(fields.map((field) => JSON.stringify(field))), 240 | ].map((str) => JSON.parse(str)); 241 | 242 | const all_fields_string = uniqueFields.map(generateFieldExpression); 243 | const all_fields_string_groupby = uniqueFields 244 | .map(generateGroupByExpression) 245 | .join(", "); 246 | 247 | const relationship_list = relationships.map((relationship) => { 248 | const isPrimaryFloat = 249 | getTypeForColumn( 250 | queryFields, 251 | relationship.primary_table, 252 | relationship.primary_key 253 | ) !== "Utf8"; 254 | const isForeignFloat = 255 | getTypeForColumn( 256 | queryFields, 257 | relationship.foreign_table, 258 | relationship.foreign_key 259 | ) !== "Utf8"; 260 | const castType = 261 | isPrimaryFloat || isForeignFloat ? "DOUBLE" : "VARCHAR"; 262 | 263 | return `CAST(TABLE${files.findIndex( 264 | (file) => file.name === relationship.primary_table 265 | )}."${ 266 | relationship.primary_key 267 | }" AS ${castType}) = CAST(TABLE${files.findIndex( 268 | (file) => file.name === relationship.foreign_table 269 | )}."${relationship.foreign_key}" AS ${castType})`; 270 | }); 271 | 272 | return ` 273 | SELECT ${all_fields_string}, ${aggregation.type}(CAST("${ 274 | aggregation.name 275 | }" AS ${ 276 | getTypeForColumn( 277 | queryFields, 278 | files[files.findIndex((file) => file.name === aggregation.table)] 279 | .name, 280 | aggregation.name 281 | ) === "Utf8" 282 | ? "VARCHAR" 283 | : "DOUBLE" 284 | })) AS "${aggregation.name}" 285 | FROM '${files[0].name}' AS TABLE0 286 | JOIN ${files 287 | .slice(1) 288 | .map( 289 | (file) => 290 | `'${file.name}' AS TABLE${files.findIndex( 291 | (innerFile) => file.name === innerFile.name 292 | )} ON ${relationship_list 293 | .filter((relationship) => 294 | relationship.includes( 295 | `TABLE${files.findIndex( 296 | (innerFile2) => file.name === innerFile2.name 297 | )}` 298 | ) 299 | ) 300 | .join(" AND ")}` 301 | ) 302 | .join(" JOIN ")} 303 | ${ 304 | filters.length > 0 305 | ? `WHERE ${filters 306 | .map((filter) => { 307 | const tablePrefix = `TABLE${files.findIndex( 308 | (file) => file.name === filter.table 309 | )}`; 310 | if (filter.dateExtract) { 311 | // Extract original field name from the date extract expression 312 | const originalField = filter.field 313 | .replace(`${filter.dateExtract}(`, "") 314 | .replace(")", ""); 315 | const extractExpr = `EXTRACT(${filter.dateExtract} FROM CAST(${tablePrefix}."${originalField}" AS DATE))`; 316 | const filterExpr = 317 | filter.dateExtract === "MONTH" 318 | ? `LPAD(CAST(${extractExpr} AS VARCHAR), 2, '0')` 319 | : filter.dateExtract === "QUARTER" 320 | ? `CONCAT('Q', CAST(${extractExpr} AS VARCHAR))` 321 | : `CAST(${extractExpr} AS VARCHAR)`; 322 | return `${filterExpr} IN (${filter.values 323 | .map((value) => `'${value}'`) 324 | .join(", ")})`; 325 | } 326 | // For non-date fields, use the original field name 327 | return `${tablePrefix}."${ 328 | filter.field 329 | }" IN (${filter.values 330 | .map((value) => `'${value}'`) 331 | .join(", ")})`; 332 | }) 333 | .join(" AND ")}` 334 | : "" 335 | } 336 | GROUP BY ${all_fields_string_groupby} 337 | `; 338 | } else { 339 | return `SELECT * FROM '${selectedPreviewFile}' LIMIT ${previewRows}`; 340 | } 341 | }; 342 | 343 | const rawQuery = generateQuery(); 344 | return rawQuery ? format(rawQuery, { language: "duckdb" }) : null; 345 | })(); 346 | 347 | const handleRunQuery = async () => { 348 | if (!sqlQuery || !db || isQueryRunning) return; 349 | 350 | try { 351 | setIsQueryRunning(true); 352 | const result: Arrow = await runQuery(db, sqlQuery); 353 | 354 | // Convert Arrow table to array and clean string values 355 | const cleanedData = result.toArray().map((row) => { 356 | const cleanedRow: any = {}; 357 | for (const [key, value] of Object.entries(row)) { 358 | cleanedRow[key] = 359 | typeof value === "string" ? value.replace(/"/g, "") : value; 360 | } 361 | return cleanedRow; 362 | }); 363 | handleRunPyodide(cleanedData); 364 | } catch (error) { 365 | toast({ 366 | title: "Query Execution Error", 367 | description: 368 | error instanceof Error 369 | ? error.message 370 | : "An unknown error occurred while running the query", 371 | variant: "destructive", 372 | }); 373 | } finally { 374 | setIsQueryRunning(false); 375 | } 376 | }; 377 | 378 | const handleDownload = () => { 379 | if (!pyodide) { 380 | console.error("Pyodide is not initialized"); 381 | return; 382 | } 383 | 384 | try { 385 | const excelBytes = pyodide.FS.readFile("/excel_output.xlsx"); 386 | const blob = new Blob([Buffer.from(excelBytes)], { 387 | type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 388 | }); 389 | const url = URL.createObjectURL(blob); 390 | const a = document.createElement("a"); 391 | a.href = url; 392 | a.download = "pivot_table.xlsx"; 393 | document.body.appendChild(a); 394 | a.click(); 395 | document.body.removeChild(a); 396 | URL.revokeObjectURL(url); 397 | } catch (error) { 398 | console.error("Error downloading file:", error); 399 | } 400 | }; 401 | const handleRunPyodide = async (queryData: any) => { 402 | if (!pyodide || !resultContainerRef.current) return; 403 | 404 | try { 405 | pyodide.globals.set("js_data", queryData); 406 | pyodide.globals.set("js_filters", filters); 407 | pyodide.globals.set("use_format", useFormat); 408 | pyodide.globals.set("preview", preview); 409 | 410 | const pythonCode = ` 411 | import io 412 | from openpyxl.styles import numbers 413 | import openpyxl 414 | df = pd.json_normalize(js_data.to_py()) 415 | 416 | if "__index_level_0__" in df.columns: 417 | df = df.drop(columns=["__index_level_0__"]) 418 | 419 | if not preview: 420 | df = ${getPivotCode()} 421 | 422 | def format_excel_sheet(writer, df, sheet_name='Pivot Table'): 423 | df.to_excel(writer, sheet_name=sheet_name) 424 | worksheet = writer.sheets[sheet_name] 425 | 426 | # Get the dimensions of the data 427 | all_rows = list(worksheet.rows) 428 | if not all_rows: 429 | return 430 | 431 | for col_idx, col in enumerate(worksheet.iter_cols(min_col=1, max_col=len(all_rows[0]), min_row=1), 1): 432 | max_length = 0 433 | column_letter = None 434 | 435 | for cell in col: 436 | if cell.value: 437 | try: 438 | max_length = max(max_length, len(str(cell.value))) 439 | if isinstance(cell.value, (int, float)): 440 | cell.number_format = '#,##0' 441 | # Enable text wrapping and center alignment for column headers 442 | if cell.row <= df.columns.nlevels + 1: 443 | cell.alignment = openpyxl.styles.Alignment(wrap_text=True, horizontal='center', vertical='center') 444 | except: 445 | pass 446 | # Get column letter from first non-merged cell 447 | if not column_letter and hasattr(cell, 'column_letter'): 448 | column_letter = cell.column_letter 449 | 450 | # Set column width with a minimum of 8 and maximum of 50 451 | if column_letter: 452 | adjusted_width = min(max(max_length + 2, 8), 50) 453 | worksheet.column_dimensions[column_letter].width = adjusted_width 454 | 455 | # Save Excel with formatting 456 | with pd.ExcelWriter('/excel_output.xlsx', engine='openpyxl') as writer: 457 | try: 458 | format_excel_sheet(writer, df) 459 | except Exception as e: 460 | print(f"Error formatting main sheet: {str(e)}") 461 | df.to_excel(writer, sheet_name='Pivot Table') 462 | 463 | if not preview and len(js_filters.to_py()) > 0: 464 | try: 465 | filters_data = js_filters.to_py() 466 | filters_df = pd.DataFrame([(f['table'], f['field'], ', '.join(f['values'])) 467 | for f in filters_data], 468 | columns=['Table', 'Field', 'Values']) 469 | format_excel_sheet(writer, filters_df, 'Filters') 470 | except Exception as e: 471 | print(f"Error formatting filters sheet: {str(e)}") 472 | 473 | # Generate HTML 474 | if use_format: 475 | df_styled = df.style.format(formatter=lambda x: '{:,.0f}'.format(float(x)).replace(',', '.') if pd.notnull(x) and isinstance(x, (int, float)) else x, na_rep='') 476 | else: 477 | df_styled = df.style.format(formatter=lambda x: '{:,.0f}'.format(float(x)) if pd.notnull(x) and isinstance(x, (int, float)) else x, na_rep='') 478 | 479 | html_content = df_styled.to_html() 480 | del df_styled 481 | 482 | html_content 483 | `; 484 | 485 | const htmlResult = await pyodide.runPythonAsync(pythonCode); 486 | resultContainerRef.current.innerHTML = htmlResult; 487 | setResult(true); // Just set a flag that we have data available 488 | } catch (err) { 489 | console.error("Error running Pandas operation: " + err); 490 | setResult(false); 491 | } 492 | }; 493 | 494 | const getPivotCode = () => { 495 | if ( 496 | !preview && 497 | aggregation.name && 498 | (rows.length > 0 || columns.length > 0) 499 | ) { 500 | return `df.pivot_table(index=[${rows 501 | .map((row) => `'${row.name}'`) 502 | .toString()}], 503 | columns=[${columns 504 | .map((column) => `'${column.name}'`) 505 | .toString()}], 506 | values='${aggregation.name}', 507 | aggfunc='${ 508 | aggregation.type?.toLowerCase() === "avg" 509 | ? "mean" 510 | : aggregation.type?.toLowerCase() 511 | }')`; 512 | } 513 | return null; 514 | }; 515 | 516 | const hasRelationships = useMemo( 517 | () => 518 | files.every((file) => 519 | relationships.some( 520 | (rel) => 521 | rel.primary_table === file.name || rel.foreign_table === file.name 522 | ) 523 | ), 524 | [files, relationships] 525 | ); 526 | 527 | return ( 528 |
529 |
530 | 531 |
532 | 533 | {pyodide && db && } 534 | {loading &&
Loading...
} 535 | {error && ( 536 |
537 | {error} 538 |
539 | )} 540 |
541 | 542 | {files.length > 0 && db && } 543 | 544 |
545 | {pyodide && db && ( 546 |
547 |
548 | {(files.length <= 1 || hasRelationships) && ( 549 | <> 550 | 551 |
552 |
553 | 567 | {result && ( 568 | 575 | )} 576 | {sqlQuery && ( 577 | <> 578 | 592 | {!preview && getPivotCode() && ( 593 | 607 | )} 608 | 609 | )} 610 | 631 |
632 | {preview && files.length > 0 && ( 633 |
634 | 635 | Not enough pivot parameters selected. Click "Run 636 | query" to preview top 637 | 638 | 655 | rows of 656 | 671 |
672 | )} 673 |
674 |
675 |
679 |
680 | 681 | )} 682 | {files.length > 1 && } 683 |
684 |
685 | )} 686 |
687 | ); 688 | } 689 | -------------------------------------------------------------------------------- /components/PivotFields.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Aggregation from "./pivotfields/Aggregation"; 5 | import PivotColumns from "./pivotfields/PivotColumns"; 6 | import PivotRows from "./pivotfields/PivotRows"; 7 | import PivotFilters from "./pivotfields/PivotFilters"; 8 | 9 | export default function PivotFields() { 10 | return ( 11 |
12 |
13 | 14 |
15 |
16 | 17 | 18 | 19 |
20 |
21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /components/RelationalStructure.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useFileStore } from "@/stores/useFileStore"; 4 | import { useRelationalStore } from "@/stores/useRelationalStore"; 5 | import { useTableStore } from "@/stores/useTableStore"; 6 | import React, { useState } from "react"; 7 | import { Button } from "./ui/button"; 8 | import { Trash2 } from "lucide-react"; 9 | import { 10 | Accordion, 11 | AccordionContent, 12 | AccordionItem, 13 | AccordionTrigger, 14 | } from "./ui/accordion"; 15 | 16 | export default function RelationalStructure() { 17 | const { files } = useFileStore(); 18 | const { 19 | relationships, 20 | addRelationship, 21 | removeRelationship, 22 | clearRelationships, 23 | } = useRelationalStore(); 24 | const { queryFields } = useTableStore(); 25 | 26 | const [selectedPrimaryTable, setSelectedPrimaryTable] = useState(""); 27 | const [selectedForeignTable, setSelectedForeignTable] = useState(""); 28 | const [selectedPrimaryKey, setSelectedPrimaryKey] = useState(""); 29 | const [selectedForeignKey, setSelectedForeignKey] = useState(""); 30 | 31 | const handleApplyRelationship = () => { 32 | if ( 33 | selectedPrimaryTable && 34 | selectedForeignTable && 35 | selectedPrimaryKey && 36 | selectedForeignKey 37 | ) { 38 | addRelationship({ 39 | primary_table: selectedPrimaryTable, 40 | primary_key: selectedPrimaryKey, 41 | foreign_table: selectedForeignTable, 42 | foreign_key: selectedForeignKey, 43 | }); 44 | // Reset selections 45 | setSelectedPrimaryKey(""); 46 | setSelectedForeignKey(""); 47 | } 48 | }; 49 | 50 | return ( 51 |
52 | 58 | 59 | 60 | Table Relationships 61 | 62 | 63 |
64 | 76 | 77 | {selectedPrimaryTable && ( 78 | <> 79 | 91 | 92 | 106 | 107 | {selectedForeignTable && ( 108 | 120 | )} 121 | 122 | )} 123 | 124 |
125 | 128 | 131 |
132 |
133 | 134 |
135 |

136 | Current Relationships 137 |

138 | {relationships.map((rel, index) => ( 139 |
143 |
144 | {rel.primary_table}🔑{rel.primary_key} ➜ {rel.foreign_table} 145 | 🔑 146 | {rel.foreign_key} 147 |
148 | 155 |
156 | ))} 157 |
158 |
159 |
160 |
161 |
162 | ); 163 | } 164 | -------------------------------------------------------------------------------- /components/pivotfields/Aggregation.tsx: -------------------------------------------------------------------------------- 1 | import { usePivotStore } from "@/stores/usePivotStore"; 2 | import React from "react"; 3 | import { Badge } from "../ui/badge"; 4 | import { Database, SquareSigma, Trash2 } from "lucide-react"; 5 | import { useFileStore } from "@/stores/useFileStore"; 6 | import { SelectAggregation } from "./SelectAggregation"; 7 | 8 | export default function Aggregation() { 9 | const { aggregation, clearAggregation } = usePivotStore(); 10 | const { files } = useFileStore(); 11 | return ( 12 |
13 |
14 | 15 |

Aggregation

16 |
17 | 18 | {aggregation.name && aggregation.table && ( 19 | 20 |
21 | 22 | 23 | {files.findIndex((file) => file.name === aggregation.table)} 24 | 25 |
26 |
{aggregation.name}
27 | clearAggregation()} 31 | /> 32 |
33 | )} 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/pivotfields/PivotColumns.tsx: -------------------------------------------------------------------------------- 1 | import { usePivotStore } from "@/stores/usePivotStore"; 2 | import React from "react"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { 5 | Columns3, 6 | Database, 7 | Trash2, 8 | ArrowLeft, 9 | ArrowRight, 10 | } from "lucide-react"; 11 | import { useFileStore } from "@/stores/useFileStore"; 12 | 13 | export default function PivotColumns() { 14 | const { columns, clearColumn, clearColumns } = usePivotStore(); 15 | const { files } = useFileStore(); 16 | 17 | const moveColumn = (fromIndex: number, toIndex: number) => { 18 | const newColumns = [...columns]; 19 | const [movedColumn] = newColumns.splice(fromIndex, 1); 20 | newColumns.splice(toIndex, 0, movedColumn); 21 | // Update the store with new column order 22 | // You'll need to add this function to your store 23 | usePivotStore.setState({ columns: newColumns }); 24 | }; 25 | 26 | return ( 27 |
28 |
29 | 30 |

Columns

31 |
32 |
33 | {columns.map((column, index) => ( 34 |
35 | 36 |
37 | 38 | 39 | {files.findIndex((file) => file.name === column.table)} 40 | 41 |
42 |
{column.name}
43 |
44 | {index > 0 && ( 45 | moveColumn(index, index - 1)} 49 | /> 50 | )} 51 | {index < columns.length - 1 && ( 52 | moveColumn(index, index + 1)} 56 | /> 57 | )} 58 | clearColumn(column.table, column.name)} 62 | /> 63 |
64 |
65 |
66 | ))} 67 |
68 | 73 |
74 | ); 75 | } 76 | -------------------------------------------------------------------------------- /components/pivotfields/PivotFilters.tsx: -------------------------------------------------------------------------------- 1 | import { usePivotStore } from "@/stores/usePivotStore"; 2 | import React from "react"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { Database, Filter, Trash2 } from "lucide-react"; 5 | import { useFileStore } from "@/stores/useFileStore"; 6 | import FilterDialog from "../FilterDialog"; 7 | 8 | export default function PivotFilters() { 9 | const { filters, clearFilter, clearFilters } = usePivotStore(); 10 | const { files } = useFileStore(); 11 | 12 | return ( 13 |
14 |
15 | 16 |

Filters

17 |
18 |
19 | {filters.map((filter) => ( 20 |
24 | 25 |
26 | 27 | 28 | {files.findIndex((file) => file.name === filter.table)} 29 | 30 |
31 |
{filter.field}
32 | 37 | clearFilter(filter.table, filter.field)} 41 | /> 42 |
43 |
44 | ))} 45 |
46 | 51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /components/pivotfields/PivotRows.tsx: -------------------------------------------------------------------------------- 1 | import { usePivotStore } from "@/stores/usePivotStore"; 2 | import React from "react"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { Database, Rows3, Trash2, ArrowLeft, ArrowRight } from "lucide-react"; 5 | import { useFileStore } from "@/stores/useFileStore"; 6 | 7 | export default function PivotRows() { 8 | const { rows, clearRow, clearRows } = usePivotStore(); 9 | const { files } = useFileStore(); 10 | 11 | const moveRows = (fromIndex: number, toIndex: number) => { 12 | const newRows = [...rows]; 13 | const [movedColumn] = newRows.splice(fromIndex, 1); 14 | newRows.splice(toIndex, 0, movedColumn); 15 | // Update the store with new column order 16 | // You'll need to add this function to your store 17 | usePivotStore.setState({ rows: newRows }); 18 | }; 19 | 20 | return ( 21 |
22 |
23 | 24 |

Rows

25 |
26 |
27 | {rows.map((row, index) => ( 28 |
29 | 30 |
31 | 32 | 33 | {files.findIndex((file) => file.name === row.table)} 34 | 35 |
36 |
{row.name}
37 |
38 | {index > 0 && ( 39 | moveRows(index, index - 1)} 43 | /> 44 | )} 45 | {index < rows.length - 1 && ( 46 | moveRows(index, index + 1)} 50 | /> 51 | )} 52 | clearRow(row.table, row.name)} 56 | /> 57 |
58 |
59 |
60 | ))} 61 |
62 | 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /components/pivotfields/SelectAggregation.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { usePivotStore } from "@/stores/usePivotStore"; 3 | 4 | export function SelectAggregation() { 5 | const { aggregation, setAggregation } = usePivotStore(); 6 | return ( 7 | <> 8 | {aggregation.type && aggregation.table && aggregation.name && ( 9 | 35 | )} 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 52 |
{children}
53 |
54 | )) 55 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 56 | 57 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 58 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLDivElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |
53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |
61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { Check } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 4 | 5 | const Collapsible = CollapsiblePrimitive.Root 6 | 7 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 8 | 9 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 10 | 11 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 12 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SelectPrimitive from "@radix-ui/react-select" 5 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Select = SelectPrimitive.Root 10 | 11 | const SelectGroup = SelectPrimitive.Group 12 | 13 | const SelectValue = SelectPrimitive.Value 14 | 15 | const SelectTrigger = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, children, ...props }, ref) => ( 19 | span]:line-clamp-1", 23 | className 24 | )} 25 | {...props} 26 | > 27 | {children} 28 | 29 | 30 | 31 | 32 | )) 33 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 34 | 35 | const SelectScrollUpButton = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | 48 | 49 | )) 50 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 51 | 52 | const SelectScrollDownButton = React.forwardRef< 53 | React.ElementRef, 54 | React.ComponentPropsWithoutRef 55 | >(({ className, ...props }, ref) => ( 56 | 64 | 65 | 66 | )) 67 | SelectScrollDownButton.displayName = 68 | SelectPrimitive.ScrollDownButton.displayName 69 | 70 | const SelectContent = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >(({ className, children, position = "popper", ...props }, ref) => ( 74 | 75 | 86 | 87 | 94 | {children} 95 | 96 | 97 | 98 | 99 | )) 100 | SelectContent.displayName = SelectPrimitive.Content.displayName 101 | 102 | const SelectLabel = React.forwardRef< 103 | React.ElementRef, 104 | React.ComponentPropsWithoutRef 105 | >(({ className, ...props }, ref) => ( 106 | 111 | )) 112 | SelectLabel.displayName = SelectPrimitive.Label.displayName 113 | 114 | const SelectItem = React.forwardRef< 115 | React.ElementRef, 116 | React.ComponentPropsWithoutRef 117 | >(({ className, children, ...props }, ref) => ( 118 | 126 | 127 | 128 | 129 | 130 | 131 | {children} 132 | 133 | )) 134 | SelectItem.displayName = SelectPrimitive.Item.displayName 135 | 136 | const SelectSeparator = React.forwardRef< 137 | React.ElementRef, 138 | React.ComponentPropsWithoutRef 139 | >(({ className, ...props }, ref) => ( 140 | 145 | )) 146 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 147 | 148 | export { 149 | Select, 150 | SelectGroup, 151 | SelectValue, 152 | SelectTrigger, 153 | SelectContent, 154 | SelectLabel, 155 | SelectItem, 156 | SelectSeparator, 157 | SelectScrollUpButton, 158 | SelectScrollDownButton, 159 | } 160 | -------------------------------------------------------------------------------- /components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as SeparatorPrimitive from "@radix-ui/react-separator" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Separator = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >( 12 | ( 13 | { className, orientation = "horizontal", decorative = true, ...props }, 14 | ref 15 | ) => ( 16 | 27 | ) 28 | ) 29 | Separator.displayName = SeparatorPrimitive.Root.displayName 30 | 31 | export { Separator } 32 | -------------------------------------------------------------------------------- /components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as ToastPrimitives from "@radix-ui/react-toast" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | import { X } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const PYODIDE_VERSION = "0.27.4"; 2 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function getTypeForColumn( 9 | schema: Record, 10 | fileName: string, 11 | columnName: string 12 | ): string | undefined { 13 | const fileSchema = schema[fileName]; 14 | const column = fileSchema?.find((col) => col.name === columnName); 15 | return column?.type; 16 | } 17 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | /* config options here */ 5 | }; 6 | 7 | export default nextConfig; 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "analytics-data-wrangler", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@duckdb/duckdb-wasm": "^1.29.1-dev132.0", 13 | "@radix-ui/react-accordion": "^1.2.2", 14 | "@radix-ui/react-checkbox": "^1.1.3", 15 | "@radix-ui/react-collapsible": "^1.1.3", 16 | "@radix-ui/react-dialog": "^1.1.4", 17 | "@radix-ui/react-label": "^2.1.1", 18 | "@radix-ui/react-select": "^2.1.3", 19 | "@radix-ui/react-separator": "^1.1.2", 20 | "@radix-ui/react-slot": "^1.1.1", 21 | "@radix-ui/react-toast": "^1.2.6", 22 | "apache-arrow": "^18.1.0", 23 | "class-variance-authority": "^0.7.1", 24 | "clsx": "^2.1.1", 25 | "duckdb-wasm-kit": "^0.1.39", 26 | "lucide-react": "^0.468.0", 27 | "next": "^15.3.2", 28 | "pyodide": "^0.27.6", 29 | "react": "^19.1.0", 30 | "react-dom": "^19.1.0", 31 | "react-icons": "^5.5.0", 32 | "sql-formatter": "^15.6.2", 33 | "tailwind-merge": "^2.5.5", 34 | "tailwindcss-animate": "^1.0.7", 35 | "zustand": "^5.0.5" 36 | }, 37 | "devDependencies": { 38 | "@eslint/eslintrc": "^3", 39 | "@types/node": "^20", 40 | "@types/react": "^19", 41 | "@types/react-dom": "^19", 42 | "eslint": "^9", 43 | "eslint-config-next": "^15.2.4", 44 | "postcss": "^8", 45 | "tailwindcss": "^3.4.1", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/adpivot_snapshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/danilo-css/analytics-data-pivot/b7b34911b3fc1c79854764ba910b28d4cb0b1cd8/public/adpivot_snapshot.png -------------------------------------------------------------------------------- /stores/useDuckDBStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import { AsyncDuckDB, initializeDuckDb } from "duckdb-wasm-kit"; 3 | import { DuckDBConfig } from "@duckdb/duckdb-wasm"; 4 | 5 | type DuckDBStore = { 6 | db: AsyncDuckDB | null; 7 | loadingduckdb: boolean; 8 | errorduckdb: Error | null; 9 | initializeDuckDB: () => Promise; 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | runQuery: (db: AsyncDuckDB, sql: string) => Promise; 12 | }; 13 | 14 | export const useDuckDBStore = create((set) => ({ 15 | db: null, 16 | loadingduckdb: false, 17 | errorduckdb: null, 18 | initializeDuckDB: async () => { 19 | try { 20 | set({ loadingduckdb: true, errorduckdb: null }); 21 | 22 | const config: DuckDBConfig = { 23 | query: { 24 | castBigIntToDouble: true, 25 | }, 26 | }; 27 | 28 | try { 29 | const duckDBInstance = await initializeDuckDb({ config, debug: false }); 30 | set({ db: duckDBInstance, loadingduckdb: false }); 31 | } catch (errorduckdb) { 32 | set({ errorduckdb: errorduckdb as Error, loadingduckdb: false }); 33 | } 34 | } catch (errorduckdb) { 35 | set({ errorduckdb: errorduckdb as Error, loadingduckdb: false }); 36 | } 37 | }, 38 | runQuery: async (db, sql) => { 39 | const conn = await db.connect(); 40 | const arrow = await conn.query(sql); 41 | await conn.close(); 42 | return arrow; 43 | }, 44 | })); 45 | -------------------------------------------------------------------------------- /stores/useExcelStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface ExcelStore { 4 | result: boolean; 5 | setResult: (result: boolean) => void; 6 | } 7 | 8 | export const useExcelStore = create((set) => ({ 9 | result: false, 10 | setResult: (result) => set({ result }), 11 | })); 12 | -------------------------------------------------------------------------------- /stores/useFileStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type FileStoreType = { 4 | files: File[]; 5 | addFile: (file: File) => void; 6 | removeFile: (fileName: string) => void; 7 | }; 8 | 9 | export const useFileStore = create((set) => ({ 10 | files: [], 11 | addFile: (file: File) => 12 | set((state) => ({ 13 | files: state.files.some((f) => f.name === file.name) 14 | ? state.files 15 | : [...state.files, file], 16 | })), 17 | removeFile: (fileName: string) => 18 | set((state) => ({ 19 | files: state.files.filter((file) => file.name !== fileName), 20 | })), 21 | })); 22 | -------------------------------------------------------------------------------- /stores/useLanguageStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | interface LanguageState { 4 | language: string; 5 | toggleLanguage: () => void; 6 | } 7 | 8 | export const useLanguageStore = create((set) => ({ 9 | language: "English", 10 | toggleLanguage: () => 11 | set((state) => ({ 12 | language: state.language === "English" ? "Portuguese" : "English", 13 | })), 14 | })); 15 | -------------------------------------------------------------------------------- /stores/usePivotStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type rowType = { 4 | name: string; 5 | table: string; 6 | dateExtract?: "YEAR" | "MONTH" | "QUARTER"; 7 | }; 8 | 9 | type columnType = { 10 | name: string; 11 | table: string; 12 | dateExtract?: "YEAR" | "MONTH" | "QUARTER"; 13 | }; 14 | 15 | type aggregationType = { 16 | name?: string; 17 | table?: string; 18 | type?: "SUM" | "AVG" | "MIN" | "MAX"; 19 | }; 20 | 21 | export type filterType = { 22 | table: string; 23 | field: string; 24 | values: string[]; 25 | dateExtract?: "YEAR" | "MONTH" | "QUARTER"; 26 | }; 27 | 28 | export type PivotState = { 29 | rows: rowType[]; 30 | setRows: (table: string, rows: string[]) => void; 31 | addRow: ( 32 | table: string, 33 | row: string, 34 | dateExtract?: "YEAR" | "MONTH" | "QUARTER" 35 | ) => void; 36 | clearRow: (table: string, row: string) => void; 37 | clearRows: () => void; 38 | clearFileRows: (table?: string) => void; 39 | columns: columnType[]; 40 | setColumns: (table: string, columns: string[]) => void; 41 | addColumn: ( 42 | table: string, 43 | column: string, 44 | dateExtract?: "YEAR" | "MONTH" | "QUARTER" 45 | ) => void; 46 | clearColumn: (table: string, column: string) => void; 47 | clearColumns: () => void; 48 | clearFileColumns: (table?: string) => void; 49 | aggregation: aggregationType; 50 | setAggregation: ( 51 | table: string, 52 | aggregation: string, 53 | type: "SUM" | "AVG" | "MIN" | "MAX" 54 | ) => void; 55 | clearAggregation: () => void; 56 | clearFileAggregation: (table?: string) => void; 57 | filters: filterType[]; 58 | addFilter: ( 59 | table: string, 60 | field: string, 61 | values: string[], 62 | dateExtract?: "YEAR" | "MONTH" | "QUARTER" 63 | ) => void; 64 | clearFilter: (table: string, field: string) => void; 65 | clearFilters: () => void; 66 | clearFileFilters: (table?: string) => void; 67 | }; 68 | 69 | export const usePivotStore = create((set) => ({ 70 | rows: [], 71 | setRows: (table, rows) => 72 | set((state) => ({ 73 | rows: [ 74 | ...state.rows, 75 | ...rows 76 | .filter( 77 | (row) => 78 | !state.rows.some((r) => r.table === table && r.name === row) 79 | ) 80 | .map((row) => ({ name: row, table })), 81 | ], 82 | })), 83 | addRow: (table, row, dateExtract) => { 84 | set((state) => { 85 | const fieldId = dateExtract ? `${dateExtract}(${row})` : row; 86 | if (state.rows.some((r) => r.table === table && r.name === fieldId)) { 87 | return { rows: state.rows }; 88 | } 89 | return { 90 | rows: [...state.rows, { name: fieldId, table, dateExtract }], 91 | }; 92 | }); 93 | }, 94 | clearRow: (table, row) => { 95 | set((state) => ({ 96 | rows: state.rows.filter((r) => !(r.table === table && r.name === row)), 97 | })); 98 | }, 99 | clearRows: () => set({ rows: [] }), 100 | clearFileRows: (table) => 101 | set((state) => ({ 102 | rows: table ? state.rows.filter((r) => r.table !== table) : [], 103 | })), 104 | columns: [], 105 | setColumns: (table, columns) => 106 | set((state) => ({ 107 | columns: [ 108 | ...state.columns, 109 | ...columns 110 | .filter( 111 | (column) => 112 | !state.columns.some((c) => c.table === table && c.name === column) 113 | ) 114 | .map((column) => ({ name: column, table })), 115 | ], 116 | })), 117 | addColumn: (table, column, dateExtract) => { 118 | set((state) => { 119 | const fieldId = dateExtract ? `${dateExtract}(${column})` : column; 120 | if (state.columns.some((c) => c.table === table && c.name === fieldId)) { 121 | return { columns: state.columns }; 122 | } 123 | return { 124 | columns: [...state.columns, { name: fieldId, table, dateExtract }], 125 | }; 126 | }); 127 | }, 128 | clearColumn: (table, column) => { 129 | set((state) => ({ 130 | columns: state.columns.filter( 131 | (c) => !(c.table === table && c.name === column) 132 | ), 133 | })); 134 | }, 135 | clearColumns: () => set({ columns: [] }), 136 | clearFileColumns: (table) => 137 | set((state) => ({ 138 | columns: table ? state.columns.filter((c) => c.table !== table) : [], 139 | })), 140 | aggregation: {}, 141 | setAggregation: (table, name, type) => { 142 | set(() => ({ 143 | aggregation: { 144 | table: table, 145 | name: name, 146 | type: type, 147 | }, 148 | })); 149 | }, 150 | clearAggregation: () => set({ aggregation: {} }), 151 | clearFileAggregation: (table) => 152 | set((state) => ({ 153 | aggregation: 154 | table && state.aggregation.table === table ? {} : state.aggregation, 155 | })), 156 | filters: [], 157 | addFilter: (table, field, values, dateExtract) => { 158 | set((state) => { 159 | const fieldId = dateExtract ? `${dateExtract}(${field})` : field; 160 | const filteredFilters = state.filters.filter( 161 | (f) => !(f.table === table && f.field === fieldId) 162 | ); 163 | return { 164 | filters: [ 165 | ...filteredFilters, 166 | { table, field: fieldId, values, dateExtract }, 167 | ], 168 | }; 169 | }); 170 | }, 171 | clearFilter: (table, field) => { 172 | set((state) => ({ 173 | filters: state.filters.filter( 174 | (f) => !(f.table === table && f.field === field) 175 | ), 176 | })); 177 | }, 178 | clearFilters: () => set({ filters: [] }), 179 | clearFileFilters: (table) => 180 | set((state) => ({ 181 | filters: table ? state.filters.filter((f) => f.table !== table) : [], 182 | })), 183 | })); 184 | -------------------------------------------------------------------------------- /stores/usePyodideStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | import type { PyodideInterface } from "pyodide"; 3 | import { PYODIDE_VERSION } from "@/lib/constants"; 4 | 5 | type PyodideStore = { 6 | pyodide: PyodideInterface | null; 7 | loadingpyodide: boolean; 8 | errorpyodide: Error | null; 9 | initializePyodide: () => Promise; 10 | }; 11 | 12 | export const usePyodideStore = create((set) => ({ 13 | pyodide: null, 14 | loadingpyodide: false, 15 | errorpyodide: null, 16 | initializePyodide: async () => { 17 | try { 18 | set({ loadingpyodide: true, errorpyodide: null }); 19 | 20 | // Load Pyodide script 21 | const script = document.createElement("script"); 22 | script.src = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/pyodide.js`; 23 | script.async = true; 24 | document.body.appendChild(script); 25 | 26 | await new Promise((resolve) => { 27 | script.onload = async () => { 28 | try { 29 | // Initialize Pyodide 30 | const pyodideInstance = await ( 31 | window as Window & 32 | typeof globalThis & { 33 | loadPyodide: (options: { 34 | indexURL: string; 35 | }) => Promise; 36 | } 37 | ).loadPyodide({ 38 | indexURL: `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`, 39 | }); 40 | 41 | // Install and import pandas 42 | await pyodideInstance.loadPackage("pandas"); 43 | await pyodideInstance.loadPackage("Jinja2"); 44 | await pyodideInstance.loadPackage("micropip"); 45 | await pyodideInstance.runPythonAsync(` 46 | import pandas as pd 47 | import js 48 | import micropip 49 | await micropip.install('openpyxl') 50 | await micropip.install('pyarrow') 51 | `); 52 | 53 | set({ pyodide: pyodideInstance, loadingpyodide: false }); 54 | resolve(); 55 | } catch (errorpyodide) { 56 | set({ errorpyodide: errorpyodide as Error, loadingpyodide: false }); 57 | } 58 | }; 59 | }); 60 | } catch (errorpyodide) { 61 | set({ errorpyodide: errorpyodide as Error, loadingpyodide: false }); 62 | } 63 | }, 64 | })); 65 | -------------------------------------------------------------------------------- /stores/useRelationalStore.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type RelationshipType = { 4 | primary_table: string; 5 | primary_key: string; 6 | foreign_table: string; 7 | foreign_key: string; 8 | }; 9 | 10 | interface RelationalState { 11 | relationships: RelationshipType[]; 12 | addRelationship: (relationship: RelationshipType) => void; 13 | removeRelationship: (index: number) => void; 14 | clearRelationships: () => void; 15 | } 16 | 17 | export const useRelationalStore = create((set) => ({ 18 | relationships: [], 19 | 20 | addRelationship: (relationship: RelationshipType) => 21 | set((state) => ({ 22 | relationships: [...state.relationships, relationship], 23 | })), 24 | 25 | removeRelationship: (index: number) => 26 | set((state) => ({ 27 | relationships: state.relationships.filter((_, i) => i !== index), 28 | })), 29 | 30 | clearRelationships: () => 31 | set(() => ({ 32 | relationships: [], 33 | })), 34 | })); 35 | -------------------------------------------------------------------------------- /stores/useTableStore.ts: -------------------------------------------------------------------------------- 1 | import { AsyncDuckDB } from "duckdb-wasm-kit"; 2 | import { create } from "zustand"; 3 | import * as duckdb from "@duckdb/duckdb-wasm"; 4 | 5 | export type FieldsType = { 6 | name: string; 7 | type: string; 8 | }; 9 | 10 | type TableState = { 11 | queryResults: Record; 12 | setQueryResults: (tableName: string, results: unknown[]) => void; 13 | clearResults: (tableName: string) => void; 14 | queryFields: Record; 15 | setQueryFields: (tableName: string, fields: FieldsType[]) => void; 16 | clearQueryFields: (tableName: string) => void; 17 | isLoadingFields: boolean; 18 | setQueryFieldsFromFiles: ( 19 | files: File[], 20 | db: AsyncDuckDB, 21 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 22 | runQuery: any 23 | ) => Promise; 24 | }; 25 | 26 | export const useTableStore = create((set) => ({ 27 | queryResults: {}, 28 | setQueryResults: (tableName, results) => 29 | set((state) => ({ 30 | queryResults: { ...state.queryResults, [tableName]: results }, 31 | })), 32 | clearResults: (tableName) => 33 | set((state) => { 34 | const newResults = { ...state.queryResults }; 35 | delete newResults[tableName]; 36 | return { queryResults: newResults }; 37 | }), 38 | queryFields: {}, 39 | isLoadingFields: false, 40 | setQueryFields: (tableName, fields) => { 41 | set((state) => ({ 42 | queryFields: { ...state.queryFields, [tableName]: fields }, 43 | })); 44 | }, 45 | setQueryFieldsFromFiles: async (files, db, runQuery) => { 46 | const store = useTableStore.getState(); 47 | set({ isLoadingFields: true }); 48 | try { 49 | for (const file of Object.values(files)) { 50 | await db.registerFileHandle( 51 | file.name, 52 | file, 53 | duckdb.DuckDBDataProtocol.BROWSER_FILEREADER, 54 | true 55 | ); 56 | const query = `SELECT * FROM '${file.name}' LIMIT 1`; 57 | const result = await runQuery(db, query); 58 | 59 | const fields: FieldsType[] = result.schema.fields 60 | .filter( 61 | (field: { name: string; type: string }) => 62 | field.name !== "__index_level_0__" 63 | ) 64 | .map((field: { name: string; type: string }) => ({ 65 | name: field.name, 66 | type: field.type.toString(), 67 | })); 68 | 69 | store.setQueryFields(file.name, fields); 70 | } 71 | } finally { 72 | set({ isLoadingFields: false }); 73 | } 74 | }, 75 | clearQueryFields: (tableName) => 76 | set((state) => { 77 | const newFields = { ...state.queryFields }; 78 | delete newFields[tableName]; 79 | return { queryFields: newFields }; 80 | }), 81 | })); 82 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | import { fontFamily } from "tailwindcss/defaultTheme"; 3 | import tailwindcssAnimate from "tailwindcss-animate"; 4 | 5 | export default { 6 | darkMode: ["class"], 7 | content: [ 8 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 9 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 10 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 11 | ], 12 | theme: { 13 | extend: { 14 | colors: { 15 | background: "hsl(var(--background))", 16 | foreground: "hsl(var(--foreground))", 17 | card: { 18 | DEFAULT: "hsl(var(--card))", 19 | foreground: "hsl(var(--card-foreground))", 20 | }, 21 | popover: { 22 | DEFAULT: "hsl(var(--popover))", 23 | foreground: "hsl(var(--popover-foreground))", 24 | }, 25 | primary: { 26 | DEFAULT: "hsl(var(--primary))", 27 | foreground: "hsl(var(--primary-foreground))", 28 | }, 29 | secondary: { 30 | DEFAULT: "hsl(var(--secondary))", 31 | foreground: "hsl(var(--secondary-foreground))", 32 | }, 33 | muted: { 34 | DEFAULT: "hsl(var(--muted))", 35 | foreground: "hsl(var(--muted-foreground))", 36 | }, 37 | accent: { 38 | DEFAULT: "hsl(var(--accent))", 39 | foreground: "hsl(var(--accent-foreground))", 40 | }, 41 | destructive: { 42 | DEFAULT: "hsl(var(--destructive))", 43 | foreground: "hsl(var(--destructive-foreground))", 44 | }, 45 | border: "hsl(var(--border))", 46 | input: "hsl(var(--input))", 47 | ring: "hsl(var(--ring))", 48 | chart: { 49 | "1": "hsl(var(--chart-1))", 50 | "2": "hsl(var(--chart-2))", 51 | "3": "hsl(var(--chart-3))", 52 | "4": "hsl(var(--chart-4))", 53 | "5": "hsl(var(--chart-5))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { 64 | height: "0", 65 | }, 66 | to: { 67 | height: "var(--radix-accordion-content-height)", 68 | }, 69 | }, 70 | "accordion-up": { 71 | from: { 72 | height: "var(--radix-accordion-content-height)", 73 | }, 74 | to: { 75 | height: "0", 76 | }, 77 | }, 78 | }, 79 | animation: { 80 | "accordion-down": "accordion-down 0.2s ease-out", 81 | "accordion-up": "accordion-up 0.2s ease-out", 82 | }, 83 | fontFamily: { 84 | sans: ["var(--font-sans)", ...fontFamily.sans], 85 | heading: ["var(--font-heading)", ...fontFamily.sans], 86 | }, 87 | }, 88 | }, 89 | plugins: [tailwindcssAnimate], 90 | } satisfies Config; 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | --------------------------------------------------------------------------------