diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..009bf0b
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,34 @@
+name: CI
+
+on:
+ pull_request:
+ branches: [main]
+ push:
+ branches: [main]
+
+jobs:
+ lint-and-test:
+ name: Lint & Test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: latest
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Run linter
+ run: bun run lint
+
+ - name: Run tests
+ run: bun run test
+
+ # Note: Production build is handled by Vercel deployment
+ # Build step requires valid Clerk keys and database connection
+
diff --git a/biome.json b/biome.json
index 2db32aa..81a44fb 100644
--- a/biome.json
+++ b/biome.json
@@ -1,40 +1,57 @@
{
- "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
- "vcs": {
- "enabled": true,
- "clientKind": "git",
- "useIgnoreFile": true
- },
- "files": {
- "ignoreUnknown": false
- },
- "formatter": {
- "enabled": true,
- "indentStyle": "space"
- },
- "linter": {
- "enabled": true,
- "rules": {
- "recommended": true,
- "suspicious": {
- "noExplicitAny": "off"
- },
- "correctness": {
- "noUnusedVariables": "warn"
- }
- }
- },
- "javascript": {
- "formatter": {
- "quoteStyle": "double"
- }
- },
- "assist": {
- "enabled": true,
- "actions": {
- "source": {
- "organizeImports": "on"
- }
- }
- }
+ "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json",
+ "vcs": {
+ "enabled": true,
+ "clientKind": "git",
+ "useIgnoreFile": true
+ },
+ "files": {
+ "ignoreUnknown": false,
+ "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"]
+ },
+ "formatter": {
+ "enabled": true,
+ "indentStyle": "space"
+ },
+ "linter": {
+ "enabled": true,
+ "rules": {
+ "recommended": true,
+ "suspicious": {
+ "noExplicitAny": "off",
+ "noTemplateCurlyInString": "off",
+ "noArrayIndexKey": "off"
+ },
+ "correctness": {
+ "noUnusedVariables": "warn",
+ "noUnusedImports": "warn",
+ "useExhaustiveDependencies": "warn"
+ },
+ "style": {
+ "noNonNullAssertion": "off",
+ "useTemplate": "off"
+ },
+ "a11y": {
+ "noSvgWithoutTitle": "off",
+ "noStaticElementInteractions": "off",
+ "useKeyWithClickEvents": "off"
+ },
+ "complexity": {
+ "noUselessFragments": "warn"
+ }
+ }
+ },
+ "javascript": {
+ "formatter": {
+ "quoteStyle": "double"
+ }
+ },
+ "assist": {
+ "enabled": true,
+ "actions": {
+ "source": {
+ "organizeImports": "on"
+ }
+ }
+ }
}
diff --git a/bun.lock b/bun.lock
index b1f8561..7af0320 100644
--- a/bun.lock
+++ b/bun.lock
@@ -64,6 +64,7 @@
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
"typescript": "^5.6.3",
+ "vitest": "^4.0.16",
},
},
},
@@ -376,6 +377,50 @@
"@resvg/resvg-js-win32-x64-msvc": ["@resvg/resvg-js-win32-x64-msvc@2.6.2", "", { "os": "win32", "cpu": "x64" }, "sha512-ZXtYhtUr5SSaBrUDq7DiyjOFJqBVL/dOBN7N/qmi/pO0IgiWW/f/ue3nbvu9joWE5aAKDoIzy/CxsY0suwGosQ=="],
+ "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
+
+ "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
+
+ "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.54.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw=="],
+
+ "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.54.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A=="],
+
+ "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.54.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA=="],
+
+ "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.54.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ=="],
+
+ "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ=="],
+
+ "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.54.0", "", { "os": "linux", "cpu": "arm" }, "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA=="],
+
+ "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng=="],
+
+ "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.54.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg=="],
+
+ "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw=="],
+
+ "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.54.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA=="],
+
+ "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ=="],
+
+ "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.54.0", "", { "os": "linux", "cpu": "none" }, "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A=="],
+
+ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.54.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ=="],
+
+ "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ=="],
+
+ "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.54.0", "", { "os": "linux", "cpu": "x64" }, "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw=="],
+
+ "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.54.0", "", { "os": "none", "cpu": "arm64" }, "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg=="],
+
+ "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.54.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw=="],
+
+ "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.54.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ=="],
+
+ "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ=="],
+
+ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.54.0", "", { "os": "win32", "cpu": "x64" }, "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg=="],
+
"@shuding/opentype.js": ["@shuding/opentype.js@1.4.0-beta.0", "", { "dependencies": { "fflate": "^0.7.3", "string.prototype.codepointat": "^0.2.1" }, "bin": { "ot": "bin/ot" } }, "sha512-3NgmNyH3l/Hv6EvsWJbsvpcpUba6R8IREQ83nH83cyakCw7uM1arZKNfHwv1Wz6jgqrF/j4x5ELvR6PnK9nTcA=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
@@ -392,8 +437,12 @@
"@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="],
+ "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
@@ -422,6 +471,20 @@
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
+ "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
+
+ "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
+
+ "@vitest/pretty-format": ["@vitest/pretty-format@4.0.16", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-eNCYNsSty9xJKi/UdVD8Ou16alu7AYiS2fCPRs0b1OdhJiV89buAXQLpTbe+X8V9L6qrs9CqyvU7OaAopJYPsA=="],
+
+ "@vitest/runner": ["@vitest/runner@4.0.16", "", { "dependencies": { "@vitest/utils": "4.0.16", "pathe": "^2.0.3" } }, "sha512-VWEDm5Wv9xEo80ctjORcTQRJ539EGPB3Pb9ApvVRAY1U/WkHXmmYISqU5E79uCwcW7xYUV38gwZD+RV755fu3Q=="],
+
+ "@vitest/snapshot": ["@vitest/snapshot@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-sf6NcrYhYBsSYefxnry+DR8n3UV4xWZwWxYbCJUt2YdvtqzSPR7VfGrY0zsv090DAbjFZsi7ZaMi1KnSRyK1XA=="],
+
+ "@vitest/spy": ["@vitest/spy@4.0.16", "", {}, "sha512-4jIOWjKP0ZUaEmJm00E0cOBLU+5WE0BpeNr3XN6TEF05ltro6NJqHWxXD0kA8/Zc8Nh23AT8WQxwNG+WeROupw=="],
+
+ "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
+
"ai": ["ai@6.0.3", "", { "dependencies": { "@ai-sdk/gateway": "3.0.2", "@ai-sdk/provider": "3.0.0", "@ai-sdk/provider-utils": "4.0.1", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
@@ -434,6 +497,8 @@
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
+
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -460,6 +525,8 @@
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
+ "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
+
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
@@ -530,6 +597,8 @@
"env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="],
+ "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
+
"esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
@@ -540,8 +609,12 @@
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
+
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
+ "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
+
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
@@ -628,6 +701,8 @@
"lucide-react": ["lucide-react@0.453.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-kL+RGZCcJi9BvJtzg2kshO192Ddy9hv3ij+cPrVPWSRzgCWCVazoQJxOjAwgK53NomL07HB7GPHW120FimjNhQ=="],
+ "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+
"marked": ["marked@14.0.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA=="],
@@ -724,6 +799,8 @@
"obuf": ["obuf@1.1.2", "", {}, "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg=="],
+ "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
+
"pako": ["pako@0.2.9", "", {}, "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA=="],
"parse-css-color": ["parse-css-color@0.2.1", "", { "dependencies": { "color-name": "^1.1.4", "hex-rgb": "^4.1.0" } }, "sha512-bwS/GGIFV3b6KS4uwpzCFj4w297Yl3uqnSgIPsoQkx7GMLROXfMnWvxfNkL0oh8HVhZA4hvJoEoEIqonfJ3BWg=="],
@@ -732,6 +809,8 @@
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+ "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
+
"pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="],
"pg-numeric": ["pg-numeric@1.0.2", "", {}, "sha512-BM/Thnrw5jm2kKLE5uJkXqqExRUY/toLHda65XgFTBTFYZyopbKjBe29Ii3RbkvlsMoFwD+tHeGaCjjv0gHlyw=="],
@@ -742,7 +821,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
- "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
@@ -808,6 +887,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+ "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
+
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"satori": ["satori@0.18.3", "", { "dependencies": { "@shuding/opentype.js": "1.4.0-beta.0", "css-background-parser": "^0.1.0", "css-box-shadow": "1.0.0-3", "css-gradient-parser": "^0.0.17", "css-to-react-native": "^3.0.0", "emoji-regex-xs": "^2.0.1", "escape-html": "^1.0.3", "linebreak": "^1.1.0", "parse-css-color": "^0.2.1", "postcss-value-parser": "^4.2.0", "yoga-layout": "^3.2.1" } }, "sha512-T3DzWNmnrfVmk2gCIlAxLRLbGkfp3K7TyRva+Byyojqu83BNvnMeqVeYRdmUw4TKCsyH4RiQ/KuF/I4yEzgR5A=="],
@@ -824,6 +905,8 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+ "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
+
"sonner": ["sonner@1.7.4", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -836,6 +919,8 @@
"sql-formatter": ["sql-formatter@15.6.12", "", { "dependencies": { "argparse": "^2.0.1", "nearley": "^2.20.1" }, "bin": { "sql-formatter": "bin/sql-formatter-cli.cjs" } }, "sha512-mkpF+RG402P66VMsnQkWewTRzDBWfu9iLbOfxaW/nAKOS/2A9MheQmcU5cmX0D0At9azrorZwpvcBRNNBozACQ=="],
+ "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
+
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"state-local": ["state-local@1.0.7", "", {}, "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w=="],
@@ -874,8 +959,14 @@
"tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="],
+ "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
+
+ "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
+
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
+
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
@@ -920,8 +1011,14 @@
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
+ "vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
+
+ "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
+
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
+ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
+
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -952,12 +1049,16 @@
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
+ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"jsonp/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
+ "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
+
"nearley/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
@@ -966,12 +1067,14 @@
"postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
- "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
+ "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
- "tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "tailwindcss/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+ "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
+
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
@@ -1063,5 +1166,51 @@
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
+
+ "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
+
+ "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
+
+ "vite/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="],
+
+ "vite/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="],
+
+ "vite/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="],
+
+ "vite/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="],
+
+ "vite/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="],
+
+ "vite/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="],
+
+ "vite/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="],
+
+ "vite/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="],
+
+ "vite/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="],
+
+ "vite/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="],
+
+ "vite/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="],
+
+ "vite/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="],
+
+ "vite/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="],
+
+ "vite/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="],
+
+ "vite/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="],
+
+ "vite/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="],
+
+ "vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="],
+
+ "vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="],
+
+ "vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="],
+
+ "vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="],
+
+ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
}
}
diff --git a/drizzle.config.ts b/drizzle.config.ts
index b10f1f5..3ea1b44 100644
--- a/drizzle.config.ts
+++ b/drizzle.config.ts
@@ -1,15 +1,14 @@
-import { defineConfig } from 'drizzle-kit'
-import 'dotenv/config'
-import { config } from 'dotenv'
+import { defineConfig } from "drizzle-kit";
+import "dotenv/config";
+import { config } from "dotenv";
-config({ path: '.env.local' })
+config({ path: ".env.local" });
export default defineConfig({
- schema: './src/lib/db/schema.ts',
- out: './drizzle',
- dialect: 'postgresql',
+ schema: "./src/lib/db/schema.ts",
+ out: "./drizzle",
+ dialect: "postgresql",
dbCredentials: {
- url: process.env.DATABASE_URL!,
+ url: process.env.DATABASE_URL ?? "",
},
-})
-
+});
diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json
index 08c14a9..f9ced15 100644
--- a/drizzle/meta/0000_snapshot.json
+++ b/drizzle/meta/0000_snapshot.json
@@ -162,12 +162,8 @@
"name": "submissions_exercise_id_exercises_id_fk",
"tableFrom": "submissions",
"tableTo": "exercises",
- "columnsFrom": [
- "exercise_id"
- ],
- "columnsTo": [
- "id"
- ],
+ "columnsFrom": ["exercise_id"],
+ "columnsTo": ["id"],
"onDelete": "cascade",
"onUpdate": "no action"
}
@@ -190,4 +186,4 @@
"schemas": {},
"tables": {}
}
-}
\ No newline at end of file
+}
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 58bae5e..b28ada5 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -24,4 +24,4 @@
"breakpoints": true
}
]
-}
\ No newline at end of file
+}
diff --git a/next.config.ts b/next.config.ts
index 3eaaba5..8fdb586 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,32 +3,32 @@ const nextConfig = {
images: {
remotePatterns: [
{
- protocol: 'https',
- hostname: 'www.google.com',
- pathname: '/**',
+ protocol: "https",
+ hostname: "www.google.com",
+ pathname: "/**",
},
{
- protocol: 'https',
- hostname: 'lh3.googleusercontent.com',
- pathname: '/**',
+ protocol: "https",
+ hostname: "lh3.googleusercontent.com",
+ pathname: "/**",
},
{
- protocol: 'https',
- hostname: 'pglite.dev',
- pathname: '/**',
+ protocol: "https",
+ hostname: "pglite.dev",
+ pathname: "/**",
},
{
- protocol: 'https',
- hostname: 'img.clerk.com',
- pathname: '/**',
+ protocol: "https",
+ hostname: "img.clerk.com",
+ pathname: "/**",
},
{
- protocol: 'https',
- hostname: '*.clerk.com',
- pathname: '/**',
+ protocol: "https",
+ hostname: "*.clerk.com",
+ pathname: "/**",
},
],
},
-}
+};
-module.exports = nextConfig
+module.exports = nextConfig;
diff --git a/package.json b/package.json
index c0139c7..b28da70 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,8 @@
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write .",
+ "test": "vitest run",
+ "test:watch": "vitest",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
@@ -77,7 +79,8 @@
"satori": "^0.18.3",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
- "typescript": "^5.6.3"
+ "typescript": "^5.6.3",
+ "vitest": "^4.0.16"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
diff --git a/scripts/debug-user-submissions.ts b/scripts/debug-user-submissions.ts
index 8dbba42..967d8f5 100644
--- a/scripts/debug-user-submissions.ts
+++ b/scripts/debug-user-submissions.ts
@@ -1,29 +1,30 @@
-import { config } from 'dotenv'
-config({ path: '.env.local' })
+import { config } from "dotenv";
-import { neon } from '@neondatabase/serverless'
-import { drizzle } from 'drizzle-orm/neon-http'
-import { submissions } from '../src/lib/db/schema'
-import { eq, desc, gte, and } from 'drizzle-orm'
+config({ path: ".env.local" });
-const USER_ID = process.argv[2] || 'user_37PFmZhlxMbK0JtsR2qPHCMnFJK'
+import { neon } from "@neondatabase/serverless";
+import { and, desc, eq, gte } from "drizzle-orm";
+import { drizzle } from "drizzle-orm/neon-http";
+import { submissions } from "../src/lib/db/schema";
+
+const USER_ID = process.argv[2] || "user_37PFmZhlxMbK0JtsR2qPHCMnFJK";
async function debugUserSubmissions() {
- const databaseUrl = process.env.DATABASE_URL
+ const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
- console.error('DATABASE_URL environment variable is not set')
- process.exit(1)
+ console.error("DATABASE_URL environment variable is not set");
+ process.exit(1);
}
- console.log('='.repeat(60))
- console.log('DEBUG: User Submissions')
- console.log('='.repeat(60))
- console.log('User ID:', USER_ID)
- console.log('')
+ console.log("=".repeat(60));
+ console.log("DEBUG: User Submissions");
+ console.log("=".repeat(60));
+ console.log("User ID:", USER_ID);
+ console.log("");
- const sql = neon(databaseUrl)
- const db = drizzle(sql)
+ const sql = neon(databaseUrl);
+ const db = drizzle(sql);
try {
// Get all submissions for the user
@@ -31,100 +32,110 @@ async function debugUserSubmissions() {
.select()
.from(submissions)
.where(eq(submissions.userId, USER_ID))
- .orderBy(desc(submissions.createdAt))
+ .orderBy(desc(submissions.createdAt));
- console.log(`Total submissions found: ${allSubmissions.length}`)
- console.log('')
+ console.log(`Total submissions found: ${allSubmissions.length}`);
+ console.log("");
if (allSubmissions.length === 0) {
- console.log('No submissions found for this user.')
- return
+ console.log("No submissions found for this user.");
+ return;
}
// Show last 10 submissions
- console.log('Last 10 submissions:')
- console.log('-'.repeat(60))
-
- const recent = allSubmissions.slice(0, 10)
+ console.log("Last 10 submissions:");
+ console.log("-".repeat(60));
+
+ const recent = allSubmissions.slice(0, 10);
for (const sub of recent) {
- console.log(` ID: ${sub.id}`)
- console.log(` Created At: ${sub.createdAt.toISOString()}`)
- console.log(` Local Time: ${sub.createdAt.toLocaleString()}`)
- console.log(` Exercise ID: ${sub.exerciseId}`)
- console.log(` Score: ${sub.score}`)
- console.log('-'.repeat(60))
+ console.log(` ID: ${sub.id}`);
+ console.log(` Created At: ${sub.createdAt.toISOString()}`);
+ console.log(` Local Time: ${sub.createdAt.toLocaleString()}`);
+ console.log(` Exercise ID: ${sub.exerciseId}`);
+ console.log(` Score: ${sub.score}`);
+ console.log("-".repeat(60));
}
// Check submissions for current year (heatmap logic)
- const currentYear = new Date().getFullYear()
- const startOfYear = new Date(currentYear, 0, 1)
-
- console.log('')
- console.log('Heatmap Query Check:')
- console.log(` Current Year: ${currentYear}`)
- console.log(` Start of Year: ${startOfYear.toISOString()}`)
-
+ const currentYear = new Date().getFullYear();
+ const startOfYear = new Date(currentYear, 0, 1);
+
+ console.log("");
+ console.log("Heatmap Query Check:");
+ console.log(` Current Year: ${currentYear}`);
+ console.log(` Start of Year: ${startOfYear.toISOString()}`);
+
const heatmapSubmissions = await db
.select({ createdAt: submissions.createdAt })
.from(submissions)
.where(
and(
eq(submissions.userId, USER_ID),
- gte(submissions.createdAt, startOfYear)
- )
- )
+ gte(submissions.createdAt, startOfYear),
+ ),
+ );
- console.log(` Submissions in ${currentYear}: ${heatmapSubmissions.length}`)
- console.log('')
+ console.log(
+ ` Submissions in ${currentYear}: ${heatmapSubmissions.length}`,
+ );
+ console.log("");
// Check today's submissions
- const today = new Date()
- const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
- const endOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1)
-
- console.log('Today Check:')
- console.log(` Today (UTC): ${today.toISOString()}`)
- console.log(` Start of Today: ${startOfToday.toISOString()}`)
- console.log(` End of Today: ${endOfToday.toISOString()}`)
-
- const todaySubmissions = allSubmissions.filter(sub => {
- const subDate = new Date(sub.createdAt)
- return subDate >= startOfToday && subDate < endOfToday
- })
-
- console.log(` Submissions today: ${todaySubmissions.length}`)
-
+ const today = new Date();
+ const startOfToday = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate(),
+ );
+ const endOfToday = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate() + 1,
+ );
+
+ console.log("Today Check:");
+ console.log(` Today (UTC): ${today.toISOString()}`);
+ console.log(` Start of Today: ${startOfToday.toISOString()}`);
+ console.log(` End of Today: ${endOfToday.toISOString()}`);
+
+ const todaySubmissions = allSubmissions.filter((sub) => {
+ const subDate = new Date(sub.createdAt);
+ return subDate >= startOfToday && subDate < endOfToday;
+ });
+
+ console.log(` Submissions today: ${todaySubmissions.length}`);
+
if (todaySubmissions.length > 0) {
- console.log('')
- console.log('Today\'s submissions:')
+ console.log("");
+ console.log("Today's submissions:");
for (const sub of todaySubmissions) {
- console.log(` - ${sub.createdAt.toISOString()} | Exercise: ${sub.exerciseId} | Score: ${sub.score}`)
+ console.log(
+ ` - ${sub.createdAt.toISOString()} | Exercise: ${sub.exerciseId} | Score: ${sub.score}`,
+ );
}
}
// Group by date for heatmap visualization
- console.log('')
- console.log('Submissions by date (last 7 days):')
- const dateCounts: Record
SQL (Structured Query Language) es el lenguaje estándar para - manipular y consultar bases de datos relacionales. En SQL4All aprenderás SQL usando PostgreSQL, uno de los sistemas de bases de datos más populares y potentes. Te permite: + manipular y consultar bases de datos relacionales. En SQL4All + aprenderás SQL usando{" "} + PostgreSQL, uno de los + sistemas de bases de datos más populares y potentes. Te permite:
- 4. Presiona Ctrl + Enter para ejecutar + 4. Presiona{" "} + Ctrl +{" "} + Enter{" "} + para ejecutar
- Mantén tu racha completando ejercicios cada día. Gana puntos - por cada ejercicio completado y sube de nivel. + Mantén tu racha completando ejercicios cada día. Gana puntos por + cada ejercicio completado y sube de nivel.
- Visita tu perfil para ver estadísticas detalladas y tu - historial de ejercicios completados. + Visita tu perfil para ver estadísticas detalladas y tu historial + de ejercicios completados.
{user.primaryEmailAddress?.emailAddress} @@ -115,7 +115,7 @@ export default function ProfilePage() { {isLoading ? (
| Columna | -Tipo | -Descripción | ++ Columna + | ++ Tipo + | ++ Descripción + |
|---|---|---|---|---|---|
|
- {column.name}
+
+ {column.name}
+
{column.isPrimary && (
|
@@ -225,7 +293,7 @@ export function SchemaViewer() {
)}
|
No se encontraron resultados
++ No se encontraron resultados +
No hay comandos que coincidan con "{searchQuery}"
Referencia rápida de comandos SQL más utilizados
@@ -204,7 +246,7 @@ export function SqlReference({ searchQuery = '' }: SqlReferenceProps) {
{example.code}
@@ -260,10 +311,9 @@ export function SqlReference({ searchQuery = '' }: SqlReferenceProps) {
- )
+ );
})}
{table.name}
+
+ {table.name}
+
El esquema está vacío
Ejecuta tu consulta DDL para ver los cambios
- ) + ); } - const tableValidity = validationResult?.schemaValidation?.tableFound + const tableValidity = validationResult?.schemaValidation?.tableFound; return (- © {new Date().getFullYear()} sql4All. Aprende SQL de forma interactiva. + © {new Date().getFullYear()} sql4All. Aprende SQL de forma + interactiva.
- {displayedQuery.split('\n').map((line, i) => (
+ {displayedQuery.split("\n").map((line, i) => (
- {line.split(' ').map((word, j) => {
- const keywords = ['SELECT', 'FROM', 'WHERE', 'LIMIT', 'AND', 'OR', 'true', 'false']
- const isKeyword = keywords.includes(word.replace(/[,;]/g, ''))
+ {line.split(" ").map((word, j) => {
+ const keywords = [
+ "SELECT",
+ "FROM",
+ "WHERE",
+ "LIMIT",
+ "AND",
+ "OR",
+ "true",
+ "false",
+ ];
+ const isKeyword = keywords.includes(
+ word.replace(/[,;]/g, ""),
+ );
return (
- {word}{' '}
+ {word}{" "}
- )
+ );
})}
))}
{(isTyping || isWaiting) && (
)}
@@ -206,12 +241,12 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
animate={isExecuting ? { scale: [1, 0.95, 1] } : {}}
transition={{ duration: 0.2 }}
className={cn(
- 'group flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all',
+ "group flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all",
isExecuting
- ? 'bg-primary/80 text-primary-foreground cursor-wait'
+ ? "bg-primary/80 text-primary-foreground cursor-wait"
: isWaiting
- ? 'bg-primary text-primary-foreground cursor-pointer hover:bg-primary/90 shadow-lg shadow-primary/25'
- : 'bg-primary/50 text-primary-foreground/70 cursor-not-allowed'
+ ? "bg-primary text-primary-foreground cursor-pointer hover:bg-primary/90 shadow-lg shadow-primary/25"
+ : "bg-primary/50 text-primary-foreground/70 cursor-not-allowed",
)}
disabled={!canExecute}
>
@@ -220,11 +255,15 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
) : (
)}
- {isExecuting ? 'Ejecutando...' : 'Comenzar ahora'}
+ {isExecuting ? "Ejecutando..." : "Comenzar ahora"}
{isWaiting && (
@@ -245,24 +284,32 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
Presiona
-
- {isMac ? '⌘' : 'Ctrl'}
+
+ {isMac ? "⌘" : "Ctrl"}
+
-
+
Enter
@@ -275,9 +322,13 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
exit={{ opacity: 0 }}
className="hidden sm:flex items-center gap-1.5 text-sm text-muted-foreground"
>
- {isMac ? '⌘' : 'Ctrl'}
+
+ {isMac ? "⌘" : "Ctrl"}
+
+
- Enter
+
+ Enter
+
para ejecutar
)}
@@ -290,14 +341,16 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
{showResults && (
- Resultados
+
+ Resultados
+
{DEMO_RESULTS.length} filas
@@ -321,8 +374,12 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
- nombre
- email
+
+ nombre
+
+
+ email
+
@@ -334,8 +391,12 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
transition={{ delay: i * 0.05 }}
className="border-t border-primary/5 hover:bg-primary/5 transition-colors"
>
- {row.nombre}
- {row.email}
+
+ {row.nombre}
+
+
+ {row.email}
+
))}
@@ -351,7 +412,7 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
{showRedirect && (
@@ -379,6 +440,5 @@ export function SqlDemoAnimation({ className }: SqlDemoAnimationProps) {
- )
+ );
}
-
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
index 2cb47a7..6d351a3 100644
--- a/src/components/layout/header.tsx
+++ b/src/components/layout/header.tsx
@@ -1,9 +1,17 @@
-'use client'
+"use client";
-import { useState, useEffect } from 'react'
-import { BookOpen, Menu, User, Star, Database, Trophy } from 'lucide-react'
-import Link from 'next/link'
-import { useUser } from '@clerk/nextjs'
+import { useUser } from "@clerk/nextjs";
+import { BookOpen, Database, Menu, Star, Trophy, User } from "lucide-react";
+import Link from "next/link";
+import { useEffect, useState } from "react";
+import { UserProfile } from "@/components/auth/user-profile";
+import { CrafterStationLogo } from "@/components/logos/crafter-station";
+import { GithubLogo } from "@/components/logos/github";
+import { KeboLogo } from "@/components/logos/kebo";
+import { MoralejaDesignLogo } from "@/components/logos/moraleja-design";
+import { ScoreBadge } from "@/components/shared/score-badge";
+import { StreakBadge } from "@/components/shared/streak-badge";
+import { ThemeToggle } from "@/components/shared/theme-toggle";
import {
Button,
Separator,
@@ -12,49 +20,41 @@ import {
SheetHeader,
SheetTitle,
SheetTrigger,
-} from '@/components/ui'
-import { UserProfile } from '@/components/auth/user-profile'
-import { useProfileSync } from '@/hooks/use-profile-sync'
-import { ScoreBadge } from '@/components/shared/score-badge'
-import { StreakBadge } from '@/components/shared/streak-badge'
-import { ThemeToggle } from '@/components/shared/theme-toggle'
-import { CrafterStationLogo } from '@/components/logos/crafter-station'
-import { GithubLogo } from '@/components/logos/github'
-import { MoralejaDesignLogo } from '@/components/logos/moraleja-design'
-import { KeboLogo } from '@/components/logos/kebo'
+} from "@/components/ui";
+import { useProfileSync } from "@/hooks/use-profile-sync";
export function Header() {
- const { user, isLoaded: isClerkLoaded } = useUser()
- const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
- const [githubStars, setGithubStars] = useState(null)
- const [isMounted, setIsMounted] = useState(false)
+ const { user, isLoaded: isClerkLoaded } = useUser();
+ const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
+ const [githubStars, setGithubStars] = useState(null);
+ const [isMounted, setIsMounted] = useState(false);
// Sync profile to database on login
- useProfileSync()
+ useProfileSync();
useEffect(() => {
- setIsMounted(true)
- }, [])
-
+ setIsMounted(true);
+ }, []);
+
// Only show user-specific UI after Clerk has loaded
- const showUserUI = isClerkLoaded && !!user
+ const showUserUI = isClerkLoaded && !!user;
useEffect(() => {
const fetchGithubStars = async () => {
try {
const response = await fetch(
- 'https://api.github.com/repos/camilocbarrera/sql4all'
- )
+ "https://api.github.com/repos/camilocbarrera/sql4all",
+ );
if (response.ok) {
- const data = await response.json()
- setGithubStars(data.stargazers_count)
+ const data = await response.json();
+ setGithubStars(data.stargazers_count);
}
} catch (error) {
- console.warn('Failed to fetch GitHub stars:', error)
+ console.warn("Failed to fetch GitHub stars:", error);
}
- }
- fetchGithubStars()
- }, [])
+ };
+ fetchGithubStars();
+ }, []);
return (
@@ -186,7 +186,7 @@ export function Header() {
)}
-
+
@@ -213,7 +213,7 @@ export function Header() {
)}
-
+
- )
+ );
}
-
diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts
index 55ad2a7..924cc88 100644
--- a/src/components/layout/index.ts
+++ b/src/components/layout/index.ts
@@ -1,4 +1 @@
-export { Header } from './header'
-
-
-
+export { Header } from "./header";
diff --git a/src/components/leaderboard/index.ts b/src/components/leaderboard/index.ts
index 0ec5b73..0162b6a 100644
--- a/src/components/leaderboard/index.ts
+++ b/src/components/leaderboard/index.ts
@@ -1,2 +1 @@
-export { LeaderboardTable } from './leaderboard-table'
-
+export { LeaderboardTable } from "./leaderboard-table";
diff --git a/src/components/leaderboard/leaderboard-table.tsx b/src/components/leaderboard/leaderboard-table.tsx
index 8e05ce8..17627a9 100644
--- a/src/components/leaderboard/leaderboard-table.tsx
+++ b/src/components/leaderboard/leaderboard-table.tsx
@@ -1,66 +1,67 @@
-'use client'
+"use client";
-import Image from 'next/image'
-import { motion } from 'framer-motion'
-import { Trophy, Medal, Award, TrendingUp, User } from 'lucide-react'
-import { cn } from '@/lib/utils'
+import { motion } from "framer-motion";
+import { Award, Medal, TrendingUp, Trophy, User } from "lucide-react";
+import Image from "next/image";
+import { cn } from "@/lib/utils";
interface LeaderboardEntry {
- userId: string
- displayName: string
- imageUrl: string | null
- countryCode: string | null
- totalScore: number
- exercisesSolved: number
- rank: number
+ userId: string;
+ displayName: string;
+ imageUrl: string | null;
+ countryCode: string | null;
+ totalScore: number;
+ exercisesSolved: number;
+ rank: number;
}
interface LeaderboardTableProps {
- entries: LeaderboardEntry[]
- currentUserId?: string | null
+ entries: LeaderboardEntry[];
+ currentUserId?: string | null;
}
function getRankIcon(rank: number) {
- if (rank === 1) return
- if (rank === 2) return
- if (rank === 3) return
- return null
+ if (rank === 1) return ;
+ if (rank === 2) return ;
+ if (rank === 3) return ;
+ return null;
}
function getRankStyle(rank: number) {
- if (rank === 1) return 'bg-primary/5 border-primary/20'
- if (rank === 2) return 'bg-muted/40 border-border/50'
- if (rank === 3) return 'bg-muted/30 border-border/40'
- return 'border-border/30 hover:bg-muted/20'
+ if (rank === 1) return "bg-primary/5 border-primary/20";
+ if (rank === 2) return "bg-muted/40 border-border/50";
+ if (rank === 3) return "bg-muted/30 border-border/40";
+ return "border-border/30 hover:bg-muted/20";
}
function countryCodeToFlag(code: string | null): string | null {
- if (!code || code.length !== 2) return null
+ if (!code || code.length !== 2) return null;
const codePoints = code
.toUpperCase()
- .split('')
- .map(char => 127397 + char.charCodeAt(0))
- return String.fromCodePoint(...codePoints)
+ .split("")
+ .map((char) => 127397 + char.charCodeAt(0));
+ return String.fromCodePoint(...codePoints);
}
-export function LeaderboardTable({ entries, currentUserId }: LeaderboardTableProps) {
+export function LeaderboardTable({
+ entries,
+ currentUserId,
+}: LeaderboardTableProps) {
if (entries.length === 0) {
return (
-
- Sin datos aún
-
+ Sin datos aún
- )
+ );
}
return (
{entries.map((entry, index) => {
- const isCurrentUser = currentUserId === entry.userId
- const rankIcon = getRankIcon(entry.rank)
- const rankStyle = getRankStyle(entry.rank)
+ const isCurrentUser = currentUserId === entry.userId;
+ const rankIcon = getRankIcon(entry.rank);
+ const rankStyle = getRankStyle(entry.rank);
return (
{/* Rank */}
@@ -109,10 +110,12 @@ export function LeaderboardTable({ entries, currentUserId }: LeaderboardTablePro
{countryCodeToFlag(entry.countryCode)}
)}
-
+
{entry.displayName}
{isCurrentUser && (
@@ -124,21 +127,22 @@ export function LeaderboardTable({ entries, currentUserId }: LeaderboardTablePro
{/* Score */}
- 3 && 'text-muted-foreground'
- )}>
+ 3 && "text-muted-foreground",
+ )}
+ >
{entry.totalScore.toLocaleString()}
pts
- )
+ );
})}
- )
+ );
}
-
diff --git a/src/components/logos/kebo.tsx b/src/components/logos/kebo.tsx
index 71924b4..498dde4 100644
--- a/src/components/logos/kebo.tsx
+++ b/src/components/logos/kebo.tsx
@@ -1,33 +1,32 @@
interface KeboLogoProps extends React.SVGProps {}
export function KeboLogo({ ...props }: KeboLogoProps) {
- return (
-
- );
+ return (
+
+ );
}
-
diff --git a/src/components/logos/moraleja-design.tsx b/src/components/logos/moraleja-design.tsx
index 7ef1107..eaca702 100644
--- a/src/components/logos/moraleja-design.tsx
+++ b/src/components/logos/moraleja-design.tsx
@@ -1,24 +1,23 @@
interface MoralejaDesignLogoProps extends React.SVGProps {}
export function MoralejaDesignLogo({ ...props }: MoralejaDesignLogoProps) {
- return (
-
- );
+ return (
+
+ );
}
-
diff --git a/src/components/profile/activity-heatmap.tsx b/src/components/profile/activity-heatmap.tsx
index 5c7cbbb..226b7df 100644
--- a/src/components/profile/activity-heatmap.tsx
+++ b/src/components/profile/activity-heatmap.tsx
@@ -1,30 +1,27 @@
-'use client'
+"use client";
-import { useMemo } from 'react'
-import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
-} from '@/components/ui'
-import { cn } from '@/lib/utils'
+import { useMemo } from "react";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
+import { cn } from "@/lib/utils";
interface WeekDay {
- day: string
- completed: boolean
+ day: string;
+ completed: boolean;
}
interface ActivityHeatmapProps {
- weekProgress: WeekDay[]
- streak: number
+ weekProgress: WeekDay[];
+ streak: number;
}
-export function ActivityHeatmap({ weekProgress, streak }: ActivityHeatmapProps) {
+export function ActivityHeatmap({
+ weekProgress,
+ streak,
+}: ActivityHeatmapProps) {
const completedDays = useMemo(
() => weekProgress.filter((d) => d.completed).length,
- [weekProgress]
- )
+ [weekProgress],
+ );
return (
@@ -39,22 +36,19 @@ export function ActivityHeatmap({ weekProgress, streak }: ActivityHeatmapProps)
{weekProgress.map((day) => (
-
+
{day.day}
- {day.completed ? '✓' : '·'}
+ {day.completed ? "✓" : "·"}
))}
@@ -68,6 +62,5 @@ export function ActivityHeatmap({ weekProgress, streak }: ActivityHeatmapProps)
)}
- )
+ );
}
-
diff --git a/src/components/profile/exercise-history.tsx b/src/components/profile/exercise-history.tsx
index 75a97d2..e58b3d3 100644
--- a/src/components/profile/exercise-history.tsx
+++ b/src/components/profile/exercise-history.tsx
@@ -1,30 +1,36 @@
-'use client'
+"use client";
-import Link from 'next/link'
-import { CheckCircle2, Clock, ArrowRight, History } from 'lucide-react'
-import { formatDistanceToNow } from '@/lib/utils'
-import { Badge, Button } from '@/components/ui'
+import { ArrowRight, CheckCircle2, Clock, History } from "lucide-react";
+import Link from "next/link";
+import { Badge, Button } from "@/components/ui";
+import { formatDistanceToNow } from "@/lib/utils";
interface HistoryItem {
- exerciseId: string
- exerciseTitle: string
- difficulty: string
- solvedAt: Date
- score: number
+ exerciseId: string;
+ exerciseTitle: string;
+ difficulty: string;
+ solvedAt: Date;
+ score: number;
}
interface ExerciseHistoryProps {
- history: HistoryItem[]
- hasSolvedExercises?: boolean
+ history: HistoryItem[];
+ hasSolvedExercises?: boolean;
}
const difficultyColors: Record = {
- Principiante: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20 dark:text-emerald-400',
- Intermedio: 'bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400',
- Avanzado: 'bg-rose-500/10 text-rose-600 border-rose-500/20 dark:text-rose-400',
-}
+ Principiante:
+ "bg-emerald-500/10 text-emerald-600 border-emerald-500/20 dark:text-emerald-400",
+ Intermedio:
+ "bg-amber-500/10 text-amber-600 border-amber-500/20 dark:text-amber-400",
+ Avanzado:
+ "bg-rose-500/10 text-rose-600 border-rose-500/20 dark:text-rose-400",
+};
-export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistoryProps) {
+export function ExerciseHistory({
+ history,
+ hasSolvedExercises,
+}: ExerciseHistoryProps) {
if (!history || history.length === 0) {
if (hasSolvedExercises) {
return (
@@ -37,10 +43,12 @@ export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistory
- Cargando historial...
+
+ Cargando historial...
+
- )
+ );
}
return (
@@ -63,7 +71,7 @@ export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistory
- )
+ );
}
return (
@@ -75,7 +83,7 @@ export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistory
Historial
- {history.length} completado{history.length !== 1 ? 's' : ''}
+ {history.length} completado{history.length !== 1 ? "s" : ""}
@@ -99,7 +107,7 @@ export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistory
{item.difficulty.slice(0, 3)}
@@ -118,6 +126,5 @@ export function ExerciseHistory({ history, hasSolvedExercises }: ExerciseHistory
))}
- )
+ );
}
-
diff --git a/src/components/profile/github-heatmap.tsx b/src/components/profile/github-heatmap.tsx
index ec521d9..32fabed 100644
--- a/src/components/profile/github-heatmap.tsx
+++ b/src/components/profile/github-heatmap.tsx
@@ -1,161 +1,185 @@
-'use client'
+"use client";
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
+import { cn } from "@/lib/utils";
// ============================================================================
// Types
// ============================================================================
export interface HeatmapData {
- date: string | Date
- count?: number
+ date: string | Date;
+ count?: number;
}
export interface HeatmapProps extends React.HTMLAttributes {
/** Array of dates (strings or Date objects) or objects with date and count */
- data?: (string | Date | HeatmapData)[]
+ data?: (string | Date | HeatmapData)[];
/** Year to display (defaults to current year) */
- year?: number
+ year?: number;
/** Cell shape variant */
- shape?: 'circle' | 'square'
+ shape?: "circle" | "square";
/** Cell size in pixels */
- cellSize?: number
+ cellSize?: number;
/** Gap between cells in pixels */
- gap?: number
+ gap?: number;
/** Show month labels */
- showMonthLabels?: boolean
+ showMonthLabels?: boolean;
/** Show day labels (M, W, F) */
- showDayLabels?: boolean
+ showDayLabels?: boolean;
/** Show legend */
- showLegend?: boolean
+ showLegend?: boolean;
/** Show tooltip on hover */
- showTooltip?: boolean
+ showTooltip?: boolean;
/** Show year in header */
- showYear?: boolean
+ showYear?: boolean;
/** Locale for date formatting */
- locale?: string
+ locale?: string;
/** Custom month labels (12 items) */
- monthLabels?: string[]
+ monthLabels?: string[];
/** Custom day labels (7 items, starting from Sunday) */
- dayLabels?: string[]
+ dayLabels?: string[];
/** Tooltip formatter */
- tooltipFormatter?: (date: Date, count: number) => string
+ tooltipFormatter?: (date: Date, count: number) => string;
/** Callback when a cell is clicked */
- onCellClick?: (date: Date, count: number) => void
+ onCellClick?: (date: Date, count: number) => void;
/** Custom color getter for levels 0-4 */
- getLevelColor?: (level: number) => React.CSSProperties
+ getLevelColor?: (level: number) => React.CSSProperties;
}
// ============================================================================
// Constants
// ============================================================================
-const DEFAULT_MONTH_LABELS = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic']
-const DEFAULT_DAY_LABELS = ['D', 'L', 'M', 'M', 'J', 'V', 'S']
+const DEFAULT_MONTH_LABELS = [
+ "Ene",
+ "Feb",
+ "Mar",
+ "Abr",
+ "May",
+ "Jun",
+ "Jul",
+ "Ago",
+ "Sep",
+ "Oct",
+ "Nov",
+ "Dic",
+];
+const DEFAULT_DAY_LABELS = ["D", "L", "M", "M", "J", "V", "S"];
// ============================================================================
// Helpers
// ============================================================================
function generateYearData(year: number) {
- const weeks: { date: Date; dayOfWeek: number }[][] = []
- const monthPositions: { month: number; weekIndex: number }[] = []
+ const weeks: { date: Date; dayOfWeek: number }[][] = [];
+ const monthPositions: { month: number; weekIndex: number }[] = [];
- const startDate = new Date(year, 0, 1)
- const endDate = new Date(year, 11, 31)
+ const startDate = new Date(year, 0, 1);
+ const endDate = new Date(year, 11, 31);
- const firstSunday = new Date(startDate)
- firstSunday.setDate(firstSunday.getDate() - firstSunday.getDay())
+ const firstSunday = new Date(startDate);
+ firstSunday.setDate(firstSunday.getDate() - firstSunday.getDay());
- let currentDate = new Date(firstSunday)
- let currentWeek: { date: Date; dayOfWeek: number }[] = []
- let lastTrackedMonth = -1
+ const currentDate = new Date(firstSunday);
+ let currentWeek: { date: Date; dayOfWeek: number }[] = [];
+ let lastTrackedMonth = -1;
while (currentDate <= endDate || currentWeek.length > 0) {
- const dayOfWeek = currentDate.getDay()
+ const dayOfWeek = currentDate.getDay();
if (dayOfWeek === 0 && currentWeek.length > 0) {
- weeks.push(currentWeek)
- currentWeek = []
+ weeks.push(currentWeek);
+ currentWeek = [];
}
if (currentDate.getFullYear() === year) {
- const month = currentDate.getMonth()
+ const month = currentDate.getMonth();
if (month !== lastTrackedMonth && dayOfWeek === 0) {
- monthPositions.push({ month, weekIndex: weeks.length })
- lastTrackedMonth = month
- } else if (month !== lastTrackedMonth && weeks.length === 0 && currentWeek.length === 0) {
- monthPositions.push({ month, weekIndex: 0 })
- lastTrackedMonth = month
+ monthPositions.push({ month, weekIndex: weeks.length });
+ lastTrackedMonth = month;
+ } else if (
+ month !== lastTrackedMonth &&
+ weeks.length === 0 &&
+ currentWeek.length === 0
+ ) {
+ monthPositions.push({ month, weekIndex: 0 });
+ lastTrackedMonth = month;
}
}
- currentWeek.push({ date: new Date(currentDate), dayOfWeek })
- currentDate.setDate(currentDate.getDate() + 1)
+ currentWeek.push({ date: new Date(currentDate), dayOfWeek });
+ currentDate.setDate(currentDate.getDate() + 1);
if (currentDate > endDate && dayOfWeek === 6) {
if (currentWeek.length > 0) {
- weeks.push(currentWeek)
+ weeks.push(currentWeek);
}
- break
+ break;
}
}
- return { weeks, monthPositions }
+ return { weeks, monthPositions };
}
function getContributionLevel(count: number): number {
- if (count === 0) return 0
- if (count === 1) return 1
- if (count <= 3) return 2
- if (count <= 5) return 3
- return 4
+ if (count === 0) return 0;
+ if (count === 1) return 1;
+ if (count <= 3) return 2;
+ if (count <= 5) return 3;
+ return 4;
}
function formatDateKey(date: Date): string {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
- return `${year}-${month}-${day}`
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${year}-${month}-${day}`;
}
-function parseDataToMap(data: (string | Date | HeatmapData)[]): Record {
- const counts: Record = {}
-
+function parseDataToMap(
+ data: (string | Date | HeatmapData)[],
+): Record {
+ const counts: Record = {};
+
data.forEach((item) => {
- let dateStr: string
- let count = 1
-
- if (typeof item === 'string') {
+ let dateStr: string;
+ let count = 1;
+
+ if (typeof item === "string") {
// Parse ISO string to Date and use local time for consistency with grid
- const date = new Date(item)
- dateStr = formatDateKey(date)
+ const date = new Date(item);
+ dateStr = formatDateKey(date);
} else if (item instanceof Date) {
- dateStr = formatDateKey(item)
+ dateStr = formatDateKey(item);
} else {
- const date = typeof item.date === 'string'
- ? new Date(item.date)
- : item.date
- dateStr = formatDateKey(date)
- count = item.count ?? 1
+ const date =
+ typeof item.date === "string" ? new Date(item.date) : item.date;
+ dateStr = formatDateKey(date);
+ count = item.count ?? 1;
}
-
- counts[dateStr] = (counts[dateStr] || 0) + count
- })
-
- return counts
+
+ counts[dateStr] = (counts[dateStr] || 0) + count;
+ });
+
+ return counts;
}
function defaultLevelStyle(level: number): React.CSSProperties {
const styles: React.CSSProperties[] = [
- { backgroundColor: 'var(--muted)', opacity: 0.5 },
- { backgroundColor: 'oklch(from var(--primary) calc(l + 0.25) calc(c * 0.4) h)' },
- { backgroundColor: 'oklch(from var(--primary) calc(l + 0.12) calc(c * 0.7) h)' },
- { backgroundColor: 'oklch(from var(--primary) l calc(c * 0.9) h)' },
- { backgroundColor: 'var(--primary)' },
- ]
- return styles[level] || styles[0]
+ { backgroundColor: "var(--muted)", opacity: 0.5 },
+ {
+ backgroundColor:
+ "oklch(from var(--primary) calc(l + 0.25) calc(c * 0.4) h)",
+ },
+ {
+ backgroundColor:
+ "oklch(from var(--primary) calc(l + 0.12) calc(c * 0.7) h)",
+ },
+ { backgroundColor: "oklch(from var(--primary) l calc(c * 0.9) h)" },
+ { backgroundColor: "var(--primary)" },
+ ];
+ return styles[level] || styles[0];
}
// ============================================================================
@@ -167,7 +191,7 @@ const Heatmap = React.forwardRef(
{
data = [],
year = new Date().getFullYear(),
- shape = 'circle',
+ shape = "circle",
cellSize = 11,
gap = 3,
showMonthLabels = true,
@@ -175,7 +199,7 @@ const Heatmap = React.forwardRef(
showLegend = true,
showTooltip = true,
showYear = true,
- locale = 'es-ES',
+ locale = "es-ES",
monthLabels = DEFAULT_MONTH_LABELS,
dayLabels = DEFAULT_DAY_LABELS,
tooltipFormatter,
@@ -184,43 +208,40 @@ const Heatmap = React.forwardRef(
className,
...props
},
- ref
+ ref,
) => {
- const today = new Date()
+ const today = new Date();
const [tooltip, setTooltip] = React.useState<{
- x: number
- y: number
- date: string
- count: number
- } | null>(null)
+ x: number;
+ y: number;
+ date: string;
+ count: number;
+ } | null>(null);
const { weeks, monthPositions } = React.useMemo(
() => generateYearData(year),
- [year]
- )
+ [year],
+ );
- const dateCounts = React.useMemo(
- () => parseDataToMap(data),
- [data]
- )
+ const dateCounts = React.useMemo(() => parseDataToMap(data), [data]);
const formatTooltip = React.useCallback(
(dateStr: string, count: number) => {
- const date = new Date(dateStr + 'T12:00:00')
+ const date = new Date(dateStr + "T12:00:00");
if (tooltipFormatter) {
- return tooltipFormatter(date, count)
+ return tooltipFormatter(date, count);
}
- return `${date.toLocaleDateString(locale, { day: 'numeric', month: 'short' })} · ${count}`
+ return `${date.toLocaleDateString(locale, { day: "numeric", month: "short" })} · ${count}`;
},
- [locale, tooltipFormatter]
- )
+ [locale, tooltipFormatter],
+ );
- const shapeClass = shape === 'circle' ? 'rounded-full' : 'rounded-[2px]'
- const labelWidth = showDayLabels ? 24 : 0
- const cellTotalSize = cellSize + gap
+ const shapeClass = shape === "circle" ? "rounded-full" : "rounded-[2px]";
+ const labelWidth = showDayLabels ? 24 : 0;
+ const cellTotalSize = cellSize + gap;
return (
-
+
{/* Header */}
{(showYear || showLegend) && (
@@ -233,7 +254,10 @@ const Heatmap = React.forwardRef(
{[0, 1, 2, 3, 4].map((level) => (
(
style={{
left: tooltip.x,
top: tooltip.y - 28,
- transform: 'translateX(-50%)',
+ transform: "translateX(-50%)",
}}
>
@@ -274,17 +298,20 @@ const Heatmap = React.forwardRef(
>
{weeks.map((_, weekIndex) => {
const monthPos = monthPositions.find(
- (m) => m.weekIndex === weekIndex
- )
+ (m) => m.weekIndex === weekIndex,
+ );
return (
- {monthPos ? monthLabels[monthPos.month] : ''}
+ {monthPos ? monthLabels[monthPos.month] : ""}
- )
+ );
})}
)}
@@ -306,7 +333,7 @@ const Heatmap = React.forwardRef(
lineHeight: `${cellTotalSize}px`,
}}
>
- {dayIndex % 2 === 1 ? dayLabels[dayIndex] : ''}
+ {dayIndex % 2 === 1 ? dayLabels[dayIndex] : ""}
))}
@@ -321,34 +348,42 @@ const Heatmap = React.forwardRef(
style={{ gap: `${gap}px` }}
>
{[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => {
- const dayData = week.find((d) => d.dayOfWeek === dayIndex)
+ const dayData = week.find(
+ (d) => d.dayOfWeek === dayIndex,
+ );
if (!dayData) {
return (
- )
+ );
}
- const { date } = dayData
- const dateStr = formatDateKey(date)
- const count = dateCounts[dateStr] || 0
- const level = getContributionLevel(count)
- const isFuture = date > today
- const isCurrentYear = date.getFullYear() === year
- const isValid = !isFuture && isCurrentYear
+ const { date } = dayData;
+ const dateStr = formatDateKey(date);
+ const count = dateCounts[dateStr] || 0;
+ const level = getContributionLevel(count);
+ const isFuture = date > today;
+ const isCurrentYear = date.getFullYear() === year;
+ const isValid = !isFuture && isCurrentYear;
return (
(
}}
onClick={() => {
if (isValid && onCellClick) {
- onCellClick(date, count)
+ onCellClick(date, count);
}
}}
onMouseEnter={(e) => {
if (isValid && showTooltip) {
- const rect = e.currentTarget.getBoundingClientRect()
+ const rect =
+ e.currentTarget.getBoundingClientRect();
const parentRect = e.currentTarget
- .closest('.overflow-x-auto')
- ?.getBoundingClientRect()
+ .closest(".overflow-x-auto")
+ ?.getBoundingClientRect();
setTooltip({
- x: rect.left - (parentRect?.left || 0) + rect.width / 2,
+ x:
+ rect.left -
+ (parentRect?.left || 0) +
+ rect.width / 2,
y: rect.top - (parentRect?.top || 0),
date: dateStr,
count,
- })
+ });
}
}}
onMouseLeave={() => setTooltip(null)}
/>
- )
+ );
})}
))}
@@ -386,14 +425,18 @@ const Heatmap = React.forwardRef(
- )
- }
-)
-Heatmap.displayName = 'Heatmap'
+ );
+ },
+);
+Heatmap.displayName = "Heatmap";
// Legacy export for backwards compatibility
-export function GitHubHeatmap({ submissionDates = [] }: { submissionDates?: string[] }) {
- return
+export function GitHubHeatmap({
+ submissionDates = [],
+}: {
+ submissionDates?: string[];
+}) {
+ return ;
}
-export { Heatmap }
+export { Heatmap };
diff --git a/src/components/profile/index.ts b/src/components/profile/index.ts
index 6a11229..6d8bdcf 100644
--- a/src/components/profile/index.ts
+++ b/src/components/profile/index.ts
@@ -1,7 +1,6 @@
-export { UserAvatar, LevelBadge, getLevelTier } from './user-avatar'
-export { StatsGrid } from './stats-grid'
-export { ExerciseHistory } from './exercise-history'
-export { ActivityHeatmap } from './activity-heatmap'
-export { GitHubHeatmap, Heatmap } from './github-heatmap'
-export type { HeatmapProps, HeatmapData } from './github-heatmap'
-
+export { ActivityHeatmap } from "./activity-heatmap";
+export { ExerciseHistory } from "./exercise-history";
+export type { HeatmapData, HeatmapProps } from "./github-heatmap";
+export { GitHubHeatmap, Heatmap } from "./github-heatmap";
+export { StatsGrid } from "./stats-grid";
+export { getLevelTier, LevelBadge, UserAvatar } from "./user-avatar";
diff --git a/src/components/profile/stats-grid.tsx b/src/components/profile/stats-grid.tsx
index 38a0932..27a807d 100644
--- a/src/components/profile/stats-grid.tsx
+++ b/src/components/profile/stats-grid.tsx
@@ -1,29 +1,35 @@
-'use client'
+"use client";
-import { Trophy, Flame, Target, CheckCircle2 } from 'lucide-react'
-import { Card, CardContent } from '@/components/ui'
+import { CheckCircle2, Flame, Target, Trophy } from "lucide-react";
+import { Card, CardContent } from "@/components/ui";
interface StatsGridProps {
- score: number
- streak: number
- totalSolved: number
- totalExercises: number
+ score: number;
+ streak: number;
+ totalSolved: number;
+ totalExercises: number;
}
-export function StatsGrid({ score, streak, totalSolved, totalExercises }: StatsGridProps) {
- const completionRate = totalExercises > 0 ? Math.round((totalSolved / totalExercises) * 100) : 0
+export function StatsGrid({
+ score,
+ streak,
+ totalSolved,
+ totalExercises,
+}: StatsGridProps) {
+ const completionRate =
+ totalExercises > 0 ? Math.round((totalSolved / totalExercises) * 100) : 0;
const stats = [
- { label: 'Puntos', value: score, icon: Trophy },
- { label: 'Racha', value: `${streak}d`, icon: Flame },
- { label: 'Resueltos', value: totalSolved, icon: CheckCircle2 },
- { label: 'Progreso', value: `${completionRate}%`, icon: Target },
- ]
+ { label: "Puntos", value: score, icon: Trophy },
+ { label: "Racha", value: `${streak}d`, icon: Flame },
+ { label: "Resueltos", value: totalSolved, icon: CheckCircle2 },
+ { label: "Progreso", value: `${completionRate}%`, icon: Target },
+ ];
return (
{stats.map((stat) => {
- const Icon = stat.icon
+ const Icon = stat.icon;
return (
@@ -32,9 +38,8 @@ export function StatsGrid({ score, streak, totalSolved, totalExercises }: StatsG
{stat.label}
- )
+ );
})}
- )
+ );
}
-
diff --git a/src/components/profile/user-avatar.tsx b/src/components/profile/user-avatar.tsx
index bd75428..e36abbe 100644
--- a/src/components/profile/user-avatar.tsx
+++ b/src/components/profile/user-avatar.tsx
@@ -1,63 +1,62 @@
-'use client'
+"use client";
-import { useMemo } from 'react'
-import { cn } from '@/lib/utils'
+import { useMemo } from "react";
+import { cn } from "@/lib/utils";
interface UserAvatarProps {
- name?: string | null
- score: number
- size?: 'sm' | 'md' | 'lg' | 'xl'
- className?: string
+ name?: string | null;
+ score: number;
+ size?: "sm" | "md" | "lg" | "xl";
+ className?: string;
}
// Level tiers based on score
const getLevelTier = (score: number) => {
- if (score >= 100) return { tier: 'Maestro', level: 4 }
- if (score >= 50) return { tier: 'Experto', level: 3 }
- if (score >= 20) return { tier: 'Intermedio', level: 2 }
- return { tier: 'Principiante', level: 1 }
-}
+ if (score >= 100) return { tier: "Maestro", level: 4 };
+ if (score >= 50) return { tier: "Experto", level: 3 };
+ if (score >= 20) return { tier: "Intermedio", level: 2 };
+ return { tier: "Principiante", level: 1 };
+};
const getInitials = (name?: string | null) => {
- if (!name) return '??'
- const parts = name.trim().split(' ')
- if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase()
- return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
-}
+ if (!name) return "??";
+ const parts = name.trim().split(" ");
+ if (parts.length === 1) return parts[0].substring(0, 2).toUpperCase();
+ return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
+};
const sizeClasses = {
- sm: 'h-8 w-8 text-xs',
- md: 'h-10 w-10 text-sm',
- lg: 'h-14 w-14 text-base',
- xl: 'h-16 w-16 text-lg',
-}
+ sm: "h-8 w-8 text-xs",
+ md: "h-10 w-10 text-sm",
+ lg: "h-14 w-14 text-base",
+ xl: "h-16 w-16 text-lg",
+};
-export function UserAvatar({ name, size = 'md', className }: UserAvatarProps) {
- const initials = useMemo(() => getInitials(name), [name])
+export function UserAvatar({ name, size = "md", className }: UserAvatarProps) {
+ const initials = useMemo(() => getInitials(name), [name]);
return (
{initials}
- )
+ );
}
export function LevelBadge({ score }: { score: number }) {
- const { tier, level } = useMemo(() => getLevelTier(score), [score])
+ const { tier, level } = useMemo(() => getLevelTier(score), [score]);
return (
Nv.{level} · {tier}
- )
+ );
}
-export { getLevelTier }
-
+export { getLevelTier };
diff --git a/src/components/shared/ai-hint.tsx b/src/components/shared/ai-hint.tsx
index 021b160..32d2de5 100644
--- a/src/components/shared/ai-hint.tsx
+++ b/src/components/shared/ai-hint.tsx
@@ -1,87 +1,96 @@
-'use client'
+"use client";
-import { useState, useCallback, useRef } from 'react'
-import { motion, AnimatePresence } from 'framer-motion'
-import { Sparkles, X, Loader2, RefreshCw } from 'lucide-react'
-import Markdown from 'react-markdown'
-import { Button } from '@/components/ui'
+import { AnimatePresence, motion } from "framer-motion";
+import { Loader2, RefreshCw, Sparkles, X } from "lucide-react";
+import { useCallback, useRef, useState } from "react";
+import Markdown from "react-markdown";
+import { Button } from "@/components/ui";
interface ExerciseContext {
- title: string
- description: string
- details: string
- hint: string
- type?: 'dml' | 'ddl'
+ title: string;
+ description: string;
+ details: string;
+ hint: string;
+ type?: "dml" | "ddl";
}
interface AIHintProps {
- exercise: ExerciseContext
- userQuery: string
- error: string
- schema?: string
+ exercise: ExerciseContext;
+ userQuery: string;
+ error: string;
+ schema?: string;
}
export function AIHint({ exercise, userQuery, error, schema }: AIHintProps) {
- const [isVisible, setIsVisible] = useState(false)
- const [completion, setCompletion] = useState('')
- const [isLoading, setIsLoading] = useState(false)
- const [hintCount, setHintCount] = useState(0)
- const abortControllerRef = useRef(null)
-
- const fetchHint = useCallback(async (previousHint?: string) => {
- setIsLoading(true)
- setCompletion('')
-
- abortControllerRef.current = new AbortController()
-
- try {
- const response = await fetch('/api/ai/hint', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ exercise, userQuery, error, schema, previousHint }),
- signal: abortControllerRef.current.signal,
- })
-
- if (!response.ok) throw new Error('Failed to fetch hint')
- if (!response.body) throw new Error('No response body')
-
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
-
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- const chunk = decoder.decode(value, { stream: true })
- setCompletion(prev => prev + chunk)
- }
- setHintCount(prev => prev + 1)
- } catch (err) {
- if ((err as Error).name !== 'AbortError') {
- console.error('AI hint error:', err)
- setCompletion('Error al generar la pista. Intenta de nuevo.')
+ const [isVisible, setIsVisible] = useState(false);
+ const [completion, setCompletion] = useState("");
+ const [isLoading, setIsLoading] = useState(false);
+ const [hintCount, setHintCount] = useState(0);
+ const abortControllerRef = useRef(null);
+
+ const fetchHint = useCallback(
+ async (previousHint?: string) => {
+ setIsLoading(true);
+ setCompletion("");
+
+ abortControllerRef.current = new AbortController();
+
+ try {
+ const response = await fetch("/api/ai/hint", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ exercise,
+ userQuery,
+ error,
+ schema,
+ previousHint,
+ }),
+ signal: abortControllerRef.current.signal,
+ });
+
+ if (!response.ok) throw new Error("Failed to fetch hint");
+ if (!response.body) throw new Error("No response body");
+
+ const reader = response.body.getReader();
+ const decoder = new TextDecoder();
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ const chunk = decoder.decode(value, { stream: true });
+ setCompletion((prev) => prev + chunk);
+ }
+ setHintCount((prev) => prev + 1);
+ } catch (err) {
+ if ((err as Error).name !== "AbortError") {
+ console.error("AI hint error:", err);
+ setCompletion("Error al generar la pista. Intenta de nuevo.");
+ }
+ } finally {
+ setIsLoading(false);
}
- } finally {
- setIsLoading(false)
- }
- }, [exercise, userQuery, error, schema])
+ },
+ [exercise, userQuery, error, schema],
+ );
const requestHint = useCallback(async () => {
if (completion && !isLoading) {
- setIsVisible(true)
- return
+ setIsVisible(true);
+ return;
}
- setIsVisible(true)
- await fetchHint()
- }, [completion, isLoading, fetchHint])
+ setIsVisible(true);
+ await fetchHint();
+ }, [completion, isLoading, fetchHint]);
const requestBetterHint = useCallback(async () => {
- if (isLoading) return
- await fetchHint(completion)
- }, [isLoading, completion, fetchHint])
+ if (isLoading) return;
+ await fetchHint(completion);
+ }, [isLoading, completion, fetchHint]);
const dismiss = useCallback(() => {
- setIsVisible(false)
- }, [])
+ setIsVisible(false);
+ }, []);
if (!isVisible) {
return (
@@ -95,14 +104,14 @@ export function AIHint({ exercise, userQuery, error, schema }: AIHintProps) {
Obtener pista con IA
- )
+ );
}
return (
- {completion || 'Generando pista...'}
+ {completion || "Generando pista..."}
{completion && !isLoading && hintCount < 3 && (
- )
+ );
}
-
diff --git a/src/components/shared/celebration.tsx b/src/components/shared/celebration.tsx
index b8f62a9..415c65c 100644
--- a/src/components/shared/celebration.tsx
+++ b/src/components/shared/celebration.tsx
@@ -1,45 +1,42 @@
-'use client'
+"use client";
-import { useEffect } from 'react'
-import confetti from 'canvas-confetti'
+import confetti from "canvas-confetti";
+import { useEffect } from "react";
export function Celebration() {
useEffect(() => {
- const duration = 3 * 1000
- const animationEnd = Date.now() + duration
- const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }
+ const duration = 3 * 1000;
+ const animationEnd = Date.now() + duration;
+ const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min: number, max: number) {
- return Math.random() * (max - min) + min
+ return Math.random() * (max - min) + min;
}
const interval = setInterval(() => {
- const timeLeft = animationEnd - Date.now()
+ const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
- clearInterval(interval)
- return
+ clearInterval(interval);
+ return;
}
- const particleCount = 50 * (timeLeft / duration)
+ const particleCount = 50 * (timeLeft / duration);
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
- })
+ });
confetti({
...defaults,
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
- })
- }, 250)
+ });
+ }, 250);
- return () => clearInterval(interval)
- }, [])
+ return () => clearInterval(interval);
+ }, []);
- return null
+ return null;
}
-
-
-
diff --git a/src/components/shared/error-boundary.tsx b/src/components/shared/error-boundary.tsx
index 1922c63..e236684 100644
--- a/src/components/shared/error-boundary.tsx
+++ b/src/components/shared/error-boundary.tsx
@@ -1,37 +1,43 @@
-'use client'
+"use client";
-import { Component, type ReactNode } from 'react'
-import { Button, Card, CardContent, CardHeader, CardTitle } from '@/components/ui'
-import { AlertTriangle, RefreshCw } from 'lucide-react'
+import { AlertTriangle, RefreshCw } from "lucide-react";
+import { Component, type ReactNode } from "react";
+import {
+ Button,
+ Card,
+ CardContent,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui";
interface Props {
- children: ReactNode
- fallback?: ReactNode
+ children: ReactNode;
+ fallback?: ReactNode;
}
interface State {
- hasError: boolean
- error?: Error
+ hasError: boolean;
+ error?: Error;
}
export class ErrorBoundary extends Component {
constructor(props: Props) {
- super(props)
- this.state = { hasError: false }
+ super(props);
+ this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
- return { hasError: true, error }
+ return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- console.error('ErrorBoundary caught an error:', error, errorInfo)
+ console.error("ErrorBoundary caught an error:", error, errorInfo);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
- return this.props.fallback
+ return this.props.fallback;
}
return (
@@ -56,12 +62,9 @@ export class ErrorBoundary extends Component {
- )
+ );
}
- return this.props.children
+ return this.props.children;
}
}
-
-
-
diff --git a/src/components/shared/error-message.tsx b/src/components/shared/error-message.tsx
index 2660998..7ddf8f7 100644
--- a/src/components/shared/error-message.tsx
+++ b/src/components/shared/error-message.tsx
@@ -1,27 +1,33 @@
-'use client'
+"use client";
-import { motion, AnimatePresence } from 'framer-motion'
-import { AlertCircle, Lightbulb } from 'lucide-react'
-import { Card, CardContent } from '@/components/ui'
-import { AIHint } from './ai-hint'
+import { AnimatePresence, motion } from "framer-motion";
+import { AlertCircle, Lightbulb } from "lucide-react";
+import { Card, CardContent } from "@/components/ui";
+import { AIHint } from "./ai-hint";
interface ExerciseContext {
- title: string
- description: string
- details: string
- hint: string
- type?: 'dml' | 'ddl'
+ title: string;
+ description: string;
+ details: string;
+ hint: string;
+ type?: "dml" | "ddl";
}
interface ErrorMessageProps {
- message: string
- example?: string | null
- timestamp?: number
- exercise?: ExerciseContext
- userQuery?: string
+ message: string;
+ example?: string | null;
+ timestamp?: number;
+ exercise?: ExerciseContext;
+ userQuery?: string;
}
-export function ErrorMessage({ message, example, timestamp, exercise, userQuery }: ErrorMessageProps) {
+export function ErrorMessage({
+ message,
+ example,
+ timestamp,
+ exercise,
+ userQuery,
+}: ErrorMessageProps) {
return (
- {message}
+
+ {message}
+
{example && (
@@ -63,8 +71,5 @@ export function ErrorMessage({ message, example, timestamp, exercise, userQuery
- )
+ );
}
-
-
-
diff --git a/src/components/shared/github-badge.tsx b/src/components/shared/github-badge.tsx
index 0217a92..4e7ddf5 100644
--- a/src/components/shared/github-badge.tsx
+++ b/src/components/shared/github-badge.tsx
@@ -1,30 +1,30 @@
-'use client'
+"use client";
-import { useState, useEffect, useRef } from 'react'
-import { motion } from 'framer-motion'
+import { motion } from "framer-motion";
+import { useEffect, useRef, useState } from "react";
export function GithubBadge() {
- const [githubStars, setGithubStars] = useState(null)
- const [shouldAnimate, setShouldAnimate] = useState(false)
- const starRef = useRef(null)
+ const [githubStars, setGithubStars] = useState(null);
+ const [shouldAnimate, setShouldAnimate] = useState(false);
+ const starRef = useRef(null);
useEffect(() => {
const fetchGithubStars = async () => {
try {
const response = await fetch(
- 'https://api.github.com/repos/camilocbarrera/sql4all'
- )
+ "https://api.github.com/repos/camilocbarrera/sql4all",
+ );
if (response.ok) {
- const data = await response.json()
- setGithubStars(data.stargazers_count)
- setTimeout(() => setShouldAnimate(true), 100)
+ const data = await response.json();
+ setGithubStars(data.stargazers_count);
+ setTimeout(() => setShouldAnimate(true), 100);
}
} catch (error) {
- console.warn('Failed to fetch GitHub stars:', error)
+ console.warn("Failed to fetch GitHub stars:", error);
}
- }
- fetchGithubStars()
- }, [])
+ };
+ fetchGithubStars();
+ }, []);
return (
<>
@@ -33,11 +33,13 @@ export function GithubBadge() {
target="_blank"
rel="noopener noreferrer"
className="github-badge fixed top-[80px] right-4 md:top-[72px] md:right-6 z-50 flex items-center gap-2.5 px-4 py-2 md:px-5 md:py-2.5 bg-black/40 dark:bg-white/10 backdrop-blur-sm border border-white/10 dark:border-white/20 rounded-md opacity-60 hover:opacity-100 transition-opacity duration-300 group"
- style={{ pointerEvents: 'auto' }}
+ style={{ pointerEvents: "auto" }}
initial={{ x: 0, opacity: 0.6 }}
- animate={shouldAnimate ? { x: -20, opacity: 0.6 } : { x: 0, opacity: 0.6 }}
+ animate={
+ shouldAnimate ? { x: -20, opacity: 0.6 } : { x: 0, opacity: 0.6 }
+ }
transition={{
- type: 'spring',
+ type: "spring",
stiffness: 100,
damping: 15,
}}
@@ -65,7 +67,7 @@ export function GithubBadge() {
initial={{ x: 20, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{
- type: 'spring',
+ type: "spring",
stiffness: 120,
damping: 15,
delay: 0.2,
@@ -85,11 +87,17 @@ export function GithubBadge() {
strokeLinejoin="round"
className="star-icon text-yellow-400 relative"
style={{
- filter: 'drop-shadow(0 0 0.5px rgba(251, 191, 36, 0.2))',
+ filter: "drop-shadow(0 0 0.5px rgba(251, 191, 36, 0.2))",
}}
>
-
+
@@ -106,6 +114,5 @@ export function GithubBadge() {
)}
>
- )
+ );
}
-
diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts
index 55fcd27..16c08f0 100644
--- a/src/components/shared/index.ts
+++ b/src/components/shared/index.ts
@@ -1,12 +1,11 @@
-export { AIHint } from './ai-hint'
-export { Celebration } from './celebration'
-export { ErrorBoundary } from './error-boundary'
-export { ErrorMessage } from './error-message'
-export { GithubBadge } from './github-badge'
-export { ResultsTable } from './results-table'
-export { ScoreBadge } from './score-badge'
-export { SignupPromptModal } from './signup-prompt-modal'
-export { StreakBadge } from './streak-badge'
-export { SuccessModal } from './success-modal'
-export { ThemeToggle } from './theme-toggle'
-
+export { AIHint } from "./ai-hint";
+export { Celebration } from "./celebration";
+export { ErrorBoundary } from "./error-boundary";
+export { ErrorMessage } from "./error-message";
+export { GithubBadge } from "./github-badge";
+export { ResultsTable } from "./results-table";
+export { ScoreBadge } from "./score-badge";
+export { SignupPromptModal } from "./signup-prompt-modal";
+export { StreakBadge } from "./streak-badge";
+export { SuccessModal } from "./success-modal";
+export { ThemeToggle } from "./theme-toggle";
diff --git a/src/components/shared/results-table.tsx b/src/components/shared/results-table.tsx
index 27eedad..0a69459 100644
--- a/src/components/shared/results-table.tsx
+++ b/src/components/shared/results-table.tsx
@@ -1,20 +1,20 @@
-'use client'
+"use client";
-import { Badge } from '@/components/ui'
import {
+ Badge,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
-} from '@/components/ui'
+} from "@/components/ui";
interface ResultsTableProps {
results: {
- rows: Record[]
- fields: { name: string }[]
- }
+ rows: Record[];
+ fields: { name: string }[];
+ };
}
export function ResultsTable({ results }: ResultsTableProps) {
@@ -23,29 +23,29 @@ export function ResultsTable({ results }: ResultsTableProps) {
No hay resultados para mostrar
- )
+ );
}
return (
- {results.rows.length} fila{results.rows.length !== 1 ? 's' : ''}
+ {results.rows.length} fila{results.rows.length !== 1 ? "s" : ""}
← Desliza para ver más →
-
+
{/* Scrollable table container */}
{results.fields.map((field, idx) => (
-
{field.name}
@@ -56,10 +56,10 @@ export function ResultsTable({ results }: ResultsTableProps) {
{results.rows.map((row, rowIndex) => (
{results.fields.map((field, idx) => (
-
{formatCellValue(row[field.name])}
@@ -71,14 +71,13 @@ export function ResultsTable({ results }: ResultsTableProps) {
- )
+ );
}
function formatCellValue(value: unknown): string {
- if (value === null || value === undefined) return 'NULL'
- if (typeof value === 'boolean') return value ? 'true' : 'false'
- if (value instanceof Date) return value.toLocaleDateString()
- if (typeof value === 'object') return JSON.stringify(value)
- return String(value)
+ if (value === null || value === undefined) return "NULL";
+ if (typeof value === "boolean") return value ? "true" : "false";
+ if (value instanceof Date) return value.toLocaleDateString();
+ if (typeof value === "object") return JSON.stringify(value);
+ return String(value);
}
-
diff --git a/src/components/shared/score-badge.tsx b/src/components/shared/score-badge.tsx
index 6287a16..f639873 100644
--- a/src/components/shared/score-badge.tsx
+++ b/src/components/shared/score-badge.tsx
@@ -1,34 +1,40 @@
-'use client'
+"use client";
-import Link from 'next/link'
-import { motion, AnimatePresence } from 'framer-motion'
-import { Trophy } from 'lucide-react'
-import { useUser } from '@clerk/nextjs'
-import { useUserScore } from '@/hooks/use-submissions'
-import { Badge, Skeleton } from '@/components/ui'
+import { useUser } from "@clerk/nextjs";
+import { AnimatePresence, motion } from "framer-motion";
+import { Trophy } from "lucide-react";
+import Link from "next/link";
+import { Badge, Skeleton } from "@/components/ui";
+import { useUserScore } from "@/hooks/use-submissions";
export function ScoreBadge() {
- const { user, isLoaded: isClerkLoaded } = useUser()
- const { data: score, isLoading, isFetched } = useUserScore()
+ const { user, isLoaded: isClerkLoaded } = useUser();
+ const { data: score, isLoading, isFetched } = useUserScore();
- console.log('[ScoreBadge] State:', { isClerkLoaded, hasUser: !!user, score, isLoading, isFetched })
+ console.log("[ScoreBadge] State:", {
+ isClerkLoaded,
+ hasUser: !!user,
+ score,
+ isLoading,
+ isFetched,
+ });
// Wait for Clerk to load before deciding to hide
if (!isClerkLoaded) {
- return
+ return ;
}
- if (!user) return null
+ if (!user) return null;
if (isLoading || score === undefined) {
- return
+ return ;
}
return (
-
@@ -46,6 +52,5 @@ export function ScoreBadge() {
- )
+ );
}
-
diff --git a/src/components/shared/signup-prompt-modal.tsx b/src/components/shared/signup-prompt-modal.tsx
index 6054cf9..ae5040c 100644
--- a/src/components/shared/signup-prompt-modal.tsx
+++ b/src/components/shared/signup-prompt-modal.tsx
@@ -1,21 +1,21 @@
-'use client'
+"use client";
-import { SignUpButton, SignInButton } from '@clerk/nextjs'
-import { Save, ArrowRight } from 'lucide-react'
+import { SignInButton, SignUpButton } from "@clerk/nextjs";
+import { ArrowRight, Save } from "lucide-react";
import {
+ Button,
Dialog,
DialogContent,
DialogDescription,
+ DialogFooter,
DialogHeader,
DialogTitle,
- DialogFooter,
- Button,
-} from '@/components/ui'
+} from "@/components/ui";
interface SignupPromptModalProps {
- isOpen: boolean
- onClose: () => void
- onSkip: () => void
+ isOpen: boolean;
+ onClose: () => void;
+ onSkip: () => void;
}
export function SignupPromptModal({
@@ -34,7 +34,8 @@ export function SignupPromptModal({
Guarda tu progreso
- Has completado tu primer ejercicio. Crea una cuenta para guardar tu progreso y continuar donde lo dejaste.
+ Has completado tu primer ejercicio. Crea una cuenta para guardar tu
+ progreso y continuar donde lo dejaste.
@@ -51,9 +52,9 @@ export function SignupPromptModal({
-
@@ -62,8 +63,5 @@ export function SignupPromptModal({
- )
+ );
}
-
-
-
diff --git a/src/components/shared/streak-badge.tsx b/src/components/shared/streak-badge.tsx
index 6aba21b..6e4b70e 100644
--- a/src/components/shared/streak-badge.tsx
+++ b/src/components/shared/streak-badge.tsx
@@ -1,34 +1,40 @@
-'use client'
+"use client";
-import Link from 'next/link'
-import { motion, AnimatePresence } from 'framer-motion'
-import { Flame } from 'lucide-react'
-import { useUser } from '@clerk/nextjs'
-import { useUserStreak } from '@/hooks/use-submissions'
-import { Badge, Skeleton } from '@/components/ui'
+import { useUser } from "@clerk/nextjs";
+import { AnimatePresence, motion } from "framer-motion";
+import { Flame } from "lucide-react";
+import Link from "next/link";
+import { Badge, Skeleton } from "@/components/ui";
+import { useUserStreak } from "@/hooks/use-submissions";
export function StreakBadge() {
- const { user, isLoaded: isClerkLoaded } = useUser()
- const { data: streak, isLoading: streakLoading, isFetched } = useUserStreak()
+ const { user, isLoaded: isClerkLoaded } = useUser();
+ const { data: streak, isLoading: streakLoading, isFetched } = useUserStreak();
- console.log('[StreakBadge] State:', { isClerkLoaded, hasUser: !!user, streak, isLoading: streakLoading, isFetched })
+ console.log("[StreakBadge] State:", {
+ isClerkLoaded,
+ hasUser: !!user,
+ streak,
+ isLoading: streakLoading,
+ isFetched,
+ });
// Wait for Clerk to load before deciding to hide
if (!isClerkLoaded) {
- return
+ return ;
}
- if (!user) return null
+ if (!user) return null;
if (streakLoading || streak === undefined) {
- return
+ return ;
}
return (
-
@@ -46,6 +52,5 @@ export function StreakBadge() {
- )
+ );
}
-
diff --git a/src/components/shared/success-modal.tsx b/src/components/shared/success-modal.tsx
index 11511bc..ad06a9c 100644
--- a/src/components/shared/success-modal.tsx
+++ b/src/components/shared/success-modal.tsx
@@ -1,21 +1,21 @@
-'use client'
+"use client";
-import { useRouter } from 'next/navigation'
-import { PartyPopper, ArrowRight, Home } from 'lucide-react'
+import { ArrowRight, Home, PartyPopper } from "lucide-react";
+import { useRouter } from "next/navigation";
import {
+ Button,
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
- Button,
-} from '@/components/ui'
+} from "@/components/ui";
interface SuccessModalProps {
- isOpen: boolean
- onClose: () => void
- exerciseTitle: string
- nextExerciseId?: string
+ isOpen: boolean;
+ onClose: () => void;
+ exerciseTitle: string;
+ nextExerciseId?: string;
}
export function SuccessModal({
@@ -24,19 +24,19 @@ export function SuccessModal({
exerciseTitle,
nextExerciseId,
}: SuccessModalProps) {
- const router = useRouter()
+ const router = useRouter();
const handleNextExercise = () => {
if (nextExerciseId) {
- router.push(`/exercises/${nextExerciseId}`)
- onClose()
+ router.push(`/exercises/${nextExerciseId}`);
+ onClose();
}
- }
+ };
const handleGoHome = () => {
- router.push('/')
- onClose()
- }
+ router.push("/");
+ onClose();
+ };
return (
- )
+ );
}
-
-
-
diff --git a/src/components/shared/theme-toggle.tsx b/src/components/shared/theme-toggle.tsx
index d5706f2..40fb84a 100644
--- a/src/components/shared/theme-toggle.tsx
+++ b/src/components/shared/theme-toggle.tsx
@@ -1,64 +1,67 @@
-'use client'
+"use client";
-import { useRef, useCallback } from 'react'
-import { useTheme } from 'next-themes'
-import { Moon, Sun } from 'lucide-react'
-import { Button } from '@/components/ui'
-import { useThemeStore } from '@/stores/theme-store'
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+import { useCallback, useRef } from "react";
+import { Button } from "@/components/ui";
+import { useThemeStore } from "@/stores/theme-store";
export function ThemeToggle() {
- const { theme, setTheme } = useTheme()
- const { startTransition, endTransition } = useThemeStore()
- const buttonRef = useRef(null)
+ const { theme, setTheme } = useTheme();
+ const { startTransition, endTransition } = useThemeStore();
+ const buttonRef = useRef(null);
- const handleToggle = useCallback(async (event: React.MouseEvent) => {
- const rect = buttonRef.current?.getBoundingClientRect()
- if (!rect) return
+ const handleToggle = useCallback(
+ async (event: React.MouseEvent) => {
+ const rect = buttonRef.current?.getBoundingClientRect();
+ if (!rect) return;
- const x = event.clientX
- const y = event.clientY
+ const x = event.clientX;
+ const y = event.clientY;
- const newTheme = theme === 'light' ? 'dark' : 'light'
+ const newTheme = theme === "light" ? "dark" : "light";
- // Check if View Transitions API is supported
- if (!document.startViewTransition) {
- setTheme(newTheme)
- return
- }
+ // Check if View Transitions API is supported
+ if (!document.startViewTransition) {
+ setTheme(newTheme);
+ return;
+ }
- startTransition(x, y)
+ startTransition(x, y);
- const transition = document.startViewTransition(() => {
- setTheme(newTheme)
- })
+ const transition = document.startViewTransition(() => {
+ setTheme(newTheme);
+ });
- try {
- await transition.ready
+ try {
+ await transition.ready;
- const maxRadius = Math.hypot(
- Math.max(x, window.innerWidth - x),
- Math.max(y, window.innerHeight - y)
- )
+ const maxRadius = Math.hypot(
+ Math.max(x, window.innerWidth - x),
+ Math.max(y, window.innerHeight - y),
+ );
- document.documentElement.animate(
- {
- clipPath: [
- `circle(0px at ${x}px ${y}px)`,
- `circle(${maxRadius}px at ${x}px ${y}px)`,
- ],
- },
- {
- duration: 500,
- easing: 'ease-out',
- pseudoElement: '::view-transition-new(root)',
- }
- )
+ document.documentElement.animate(
+ {
+ clipPath: [
+ `circle(0px at ${x}px ${y}px)`,
+ `circle(${maxRadius}px at ${x}px ${y}px)`,
+ ],
+ },
+ {
+ duration: 500,
+ easing: "ease-out",
+ pseudoElement: "::view-transition-new(root)",
+ },
+ );
- await transition.finished
- } finally {
- endTransition()
- }
- }, [theme, setTheme, startTransition, endTransition])
+ await transition.finished;
+ } finally {
+ endTransition();
+ }
+ },
+ [theme, setTheme, startTransition, endTransition],
+ );
return (
Toggle theme
- )
+ );
}
-
diff --git a/src/components/ui/accordion.tsx b/src/components/ui/accordion.tsx
index 8dcf9b6..57fe6ee 100644
--- a/src/components/ui/accordion.tsx
+++ b/src/components/ui/accordion.tsx
@@ -1,12 +1,12 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as AccordionPrimitive from "@radix-ui/react-accordion"
-import { ChevronDownIcon } from "@radix-ui/react-icons"
+import * as AccordionPrimitive from "@radix-ui/react-accordion";
+import { ChevronDownIcon } from "@radix-ui/react-icons";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const Accordion = AccordionPrimitive.Root
+const Accordion = AccordionPrimitive.Root;
const AccordionItem = React.forwardRef<
React.ElementRef,
@@ -17,8 +17,8 @@ const AccordionItem = React.forwardRef<
className={cn("border-b", className)}
{...props}
/>
-))
-AccordionItem.displayName = "AccordionItem"
+));
+AccordionItem.displayName = "AccordionItem";
const AccordionTrigger = React.forwardRef<
React.ElementRef,
@@ -29,7 +29,7 @@ const AccordionTrigger = React.forwardRef<
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
- className
+ className,
)}
{...props}
>
@@ -37,8 +37,8 @@ const AccordionTrigger = React.forwardRef<
-))
-AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
+));
+AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName;
const AccordionContent = React.forwardRef<
React.ElementRef,
@@ -51,7 +51,7 @@ const AccordionContent = React.forwardRef<
>
{children}
-))
-AccordionContent.displayName = AccordionPrimitive.Content.displayName
+));
+AccordionContent.displayName = AccordionPrimitive.Content.displayName;
-export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
index e370072..6c2e382 100644
--- a/src/components/ui/avatar.tsx
+++ b/src/components/ui/avatar.tsx
@@ -1,8 +1,8 @@
-'use client'
+"use client";
-import * as React from 'react'
-import * as AvatarPrimitive from '@radix-ui/react-avatar'
-import { cn } from '@/lib/utils'
+import * as AvatarPrimitive from "@radix-ui/react-avatar";
+import * as React from "react";
+import { cn } from "@/lib/utils";
const Avatar = React.forwardRef<
React.ComponentRef,
@@ -11,13 +11,13 @@ const Avatar = React.forwardRef<
-))
-Avatar.displayName = AvatarPrimitive.Root.displayName
+));
+Avatar.displayName = AvatarPrimitive.Root.displayName;
const AvatarImage = React.forwardRef<
React.ComponentRef,
@@ -25,11 +25,11 @@ const AvatarImage = React.forwardRef<
>(({ className, ...props }, ref) => (
-))
-AvatarImage.displayName = AvatarPrimitive.Image.displayName
+));
+AvatarImage.displayName = AvatarPrimitive.Image.displayName;
const AvatarFallback = React.forwardRef<
React.ComponentRef,
@@ -38,15 +38,12 @@ const AvatarFallback = React.forwardRef<
-))
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
-
-export { Avatar, AvatarImage, AvatarFallback }
-
-
+));
+AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
+export { Avatar, AvatarImage, AvatarFallback };
diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx
index 9a41b99..b6b0fcf 100644
--- a/src/components/ui/badge.tsx
+++ b/src/components/ui/badge.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
-import { cva, type VariantProps } from "class-variance-authority"
-import { cn } from "@/lib/utils"
+import { cva, type VariantProps } from "class-variance-authority";
+import type * as React from "react";
+import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-md 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",
@@ -19,8 +19,8 @@ const badgeVariants = cva(
defaultVariants: {
variant: "default",
},
- }
-)
+ },
+);
export interface BadgeProps
extends React.HTMLAttributes,
@@ -29,7 +29,7 @@ export interface BadgeProps
function Badge({ className, variant, ...props }: BadgeProps) {
return (
- )
+ );
}
-export { Badge, badgeVariants }
\ No newline at end of file
+export { Badge, badgeVariants };
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 65d4fcd..961fe42 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -1,8 +1,8 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const buttonVariants = cva(
"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",
@@ -31,27 +31,27 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
- }
-)
+ },
+);
export interface ButtonProps
extends React.ButtonHTMLAttributes,
VariantProps {
- asChild?: boolean
+ asChild?: boolean;
}
const Button = React.forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button"
+ const Comp = asChild ? Slot : "button";
return (
- )
- }
-)
-Button.displayName = "Button"
+ );
+ },
+);
+Button.displayName = "Button";
-export { Button, buttonVariants }
+export { Button, buttonVariants };
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
index 77e9fb7..75cc369 100644
--- a/src/components/ui/card.tsx
+++ b/src/components/ui/card.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
@@ -10,12 +10,12 @@ const Card = React.forwardRef<
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
- className
+ className,
)}
{...props}
/>
-))
-Card.displayName = "Card"
+));
+Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
@@ -26,8 +26,8 @@ const CardHeader = React.forwardRef<
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
-))
-CardHeader.displayName = "CardHeader"
+));
+CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
@@ -38,8 +38,8 @@ const CardTitle = React.forwardRef<
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
-))
-CardTitle.displayName = "CardTitle"
+));
+CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
@@ -50,16 +50,16 @@ const CardDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
-))
-CardDescription.displayName = "CardDescription"
+));
+CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-))
-CardContent.displayName = "CardContent"
+));
+CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
@@ -70,7 +70,14 @@ const CardFooter = React.forwardRef<
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
-))
-CardFooter.displayName = "CardFooter"
+));
+CardFooter.displayName = "CardFooter";
-export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardDescription,
+ CardContent,
+};
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 5af8d45..e3c991a 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -1,18 +1,18 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { X } from "lucide-react"
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { X } from "lucide-react";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const Dialog = DialogPrimitive.Root
+const Dialog = DialogPrimitive.Root;
-const DialogTrigger = DialogPrimitive.Trigger
+const DialogTrigger = DialogPrimitive.Trigger;
-const DialogPortal = DialogPrimitive.Portal
+const DialogPortal = DialogPrimitive.Portal;
-const DialogClose = DialogPrimitive.Close
+const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef,
@@ -22,12 +22,12 @@ const DialogOverlay = React.forwardRef<
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
- className
+ className,
)}
{...props}
/>
-))
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
+));
+DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ElementRef,
@@ -39,7 +39,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
- className
+ className,
)}
{...props}
>
@@ -50,8 +50,8 @@ const DialogContent = React.forwardRef<
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
+));
+DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({
className,
@@ -60,12 +60,12 @@ const DialogHeader = ({
-)
-DialogHeader.displayName = "DialogHeader"
+);
+DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
@@ -74,12 +74,12 @@ const DialogFooter = ({
-)
-DialogFooter.displayName = "DialogFooter"
+);
+DialogFooter.displayName = "DialogFooter";
const DialogTitle = React.forwardRef<
React.ElementRef,
@@ -89,12 +89,12 @@ const DialogTitle = React.forwardRef<
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
- className
+ className,
)}
{...props}
/>
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
+));
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef,
@@ -105,8 +105,8 @@ const DialogDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
-))
-DialogDescription.displayName = DialogPrimitive.Description.displayName
+));
+DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
@@ -119,4 +119,4 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
-}
+};
diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx
index f69a0d6..f0aaafa 100644
--- a/src/components/ui/dropdown-menu.tsx
+++ b/src/components/ui/dropdown-menu.tsx
@@ -1,27 +1,27 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
-import { Check, ChevronRight, Circle } from "lucide-react"
+import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
+import { Check, ChevronRight, Circle } from "lucide-react";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const DropdownMenu = DropdownMenuPrimitive.Root
+const DropdownMenu = DropdownMenuPrimitive.Root;
-const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
+const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
-const DropdownMenuGroup = DropdownMenuPrimitive.Group
+const DropdownMenuGroup = DropdownMenuPrimitive.Group;
-const DropdownMenuPortal = DropdownMenuPrimitive.Portal
+const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
-const DropdownMenuSub = DropdownMenuPrimitive.Sub
+const DropdownMenuSub = DropdownMenuPrimitive.Sub;
-const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
+const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & {
- inset?: boolean
+ inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
{children}
-))
+));
DropdownMenuSubTrigger.displayName =
- DropdownMenuPrimitive.SubTrigger.displayName
+ DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef,
@@ -48,13 +48,13 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
- className
+ className,
)}
{...props}
/>
-))
+));
DropdownMenuSubContent.displayName =
- DropdownMenuPrimitive.SubContent.displayName
+ DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef,
@@ -66,18 +66,18 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
- className
+ className,
)}
{...props}
/>
-))
-DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
+));
+DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & {
- inset?: boolean
+ inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
-))
-DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
+));
+DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef,
@@ -100,7 +100,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
+ className,
)}
checked={checked}
{...props}
@@ -112,9 +112,9 @@ const DropdownMenuCheckboxItem = React.forwardRef<
{children}
-))
+));
DropdownMenuCheckboxItem.displayName =
- DropdownMenuPrimitive.CheckboxItem.displayName
+ DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef,
@@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
+ className,
)}
{...props}
>
@@ -135,13 +135,13 @@ const DropdownMenuRadioItem = React.forwardRef<
{children}
-))
-DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
+));
+DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef & {
- inset?: boolean
+ inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
-))
-DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
+));
+DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef,
@@ -165,8 +165,8 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
-))
-DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
+));
+DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
@@ -177,9 +177,9 @@ const DropdownMenuShortcut = ({
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
- )
-}
-DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
+ );
+};
+DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
@@ -197,4 +197,4 @@ export {
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
-}
+};
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index cb144cb..23f8e38 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,16 +1,15 @@
-export * from './accordion'
-export * from './avatar'
-export * from './badge'
-export * from './button'
-export * from './card'
-export * from './dialog'
-export * from './dropdown-menu'
-export * from './input'
-export * from './label'
-export * from './separator'
-export * from './sheet'
-export * from './skeleton'
-export * from './table'
-export * from './tabs'
-export * from './tooltip'
-
+export * from "./accordion";
+export * from "./avatar";
+export * from "./badge";
+export * from "./button";
+export * from "./card";
+export * from "./dialog";
+export * from "./dropdown-menu";
+export * from "./input";
+export * from "./label";
+export * from "./separator";
+export * from "./sheet";
+export * from "./skeleton";
+export * from "./table";
+export * from "./tabs";
+export * from "./tooltip";
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
index 5d6d540..fcba82c 100644
--- a/src/components/ui/input.tsx
+++ b/src/components/ui/input.tsx
@@ -1,24 +1,21 @@
-import * as React from 'react'
-import { cn } from '@/lib/utils'
+import * as React from "react";
+import { cn } from "@/lib/utils";
-const Input = React.forwardRef>(
+const Input = React.forwardRef>(
({ className, type, ...props }, ref) => {
return (
- )
- }
-)
-Input.displayName = 'Input'
-
-export { Input }
-
-
+ );
+ },
+);
+Input.displayName = "Input";
+export { Input };
diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx
index 3c9879c..513efdd 100644
--- a/src/components/ui/label.tsx
+++ b/src/components/ui/label.tsx
@@ -1,13 +1,13 @@
-'use client'
+"use client";
-import * as React from 'react'
-import * as LabelPrimitive from '@radix-ui/react-label'
-import { cva, type VariantProps } from 'class-variance-authority'
-import { cn } from '@/lib/utils'
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+import * as React from "react";
+import { cn } from "@/lib/utils";
const labelVariants = cva(
- 'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
-)
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
+);
const Label = React.forwardRef<
React.ComponentRef,
@@ -19,10 +19,7 @@ const Label = React.forwardRef<
className={cn(labelVariants(), className)}
{...props}
/>
-))
-Label.displayName = LabelPrimitive.Root.displayName
-
-export { Label }
-
-
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+export { Label };
diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx
index 12d81c4..3cc446d 100644
--- a/src/components/ui/separator.tsx
+++ b/src/components/ui/separator.tsx
@@ -1,9 +1,9 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as SeparatorPrimitive from "@radix-ui/react-separator"
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef,
@@ -11,7 +11,7 @@ const Separator = React.forwardRef<
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
- ref
+ ref,
) => (
- )
-)
-Separator.displayName = SeparatorPrimitive.Root.displayName
+ ),
+);
+Separator.displayName = SeparatorPrimitive.Root.displayName;
-export { Separator }
+export { Separator };
diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx
index a37f17b..236010b 100644
--- a/src/components/ui/sheet.tsx
+++ b/src/components/ui/sheet.tsx
@@ -1,19 +1,19 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as SheetPrimitive from "@radix-ui/react-dialog"
-import { cva, type VariantProps } from "class-variance-authority"
-import { X } from "lucide-react"
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { cva, type VariantProps } from "class-variance-authority";
+import { X } from "lucide-react";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const Sheet = SheetPrimitive.Root
+const Sheet = SheetPrimitive.Root;
-const SheetTrigger = SheetPrimitive.Trigger
+const SheetTrigger = SheetPrimitive.Trigger;
-const SheetClose = SheetPrimitive.Close
+const SheetClose = SheetPrimitive.Close;
-const SheetPortal = SheetPrimitive.Portal
+const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef,
@@ -22,13 +22,13 @@ const SheetOverlay = React.forwardRef<
-))
-SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
@@ -46,8 +46,8 @@ const sheetVariants = cva(
defaultVariants: {
side: "right",
},
- }
-)
+ },
+);
interface SheetContentProps
extends React.ComponentPropsWithoutRef,
@@ -71,8 +71,8 @@ const SheetContent = React.forwardRef<
-))
-SheetContent.displayName = SheetPrimitive.Content.displayName
+));
+SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
@@ -81,12 +81,12 @@ const SheetHeader = ({
-)
-SheetHeader.displayName = "SheetHeader"
+);
+SheetHeader.displayName = "SheetHeader";
const SheetFooter = ({
className,
@@ -95,12 +95,12 @@ const SheetFooter = ({
-)
-SheetFooter.displayName = "SheetFooter"
+);
+SheetFooter.displayName = "SheetFooter";
const SheetTitle = React.forwardRef<
React.ElementRef,
@@ -111,8 +111,8 @@ const SheetTitle = React.forwardRef<
className={cn("text-lg font-semibold text-foreground", className)}
{...props}
/>
-))
-SheetTitle.displayName = SheetPrimitive.Title.displayName
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef,
@@ -123,8 +123,8 @@ const SheetDescription = React.forwardRef<
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
-))
-SheetDescription.displayName = SheetPrimitive.Description.displayName
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
@@ -137,4 +137,4 @@ export {
SheetFooter,
SheetTitle,
SheetDescription,
-}
+};
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
index b860580..2cdf440 100644
--- a/src/components/ui/skeleton.tsx
+++ b/src/components/ui/skeleton.tsx
@@ -1,4 +1,4 @@
-import { cn } from '@/lib/utils'
+import { cn } from "@/lib/utils";
function Skeleton({
className,
@@ -6,13 +6,10 @@ function Skeleton({
}: React.HTMLAttributes) {
return (
- )
+ );
}
-export { Skeleton }
-
-
-
+export { Skeleton };
diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx
index c0df655..d109c2a 100644
--- a/src/components/ui/table.tsx
+++ b/src/components/ui/table.tsx
@@ -1,6 +1,6 @@
-import * as React from "react"
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const Table = React.forwardRef<
HTMLTableElement,
@@ -13,16 +13,16 @@ const Table = React.forwardRef<
{...props}
/>
-))
-Table.displayName = "Table"
+));
+Table.displayName = "Table";
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes
>(({ className, ...props }, ref) => (
-))
-TableHeader.displayName = "TableHeader"
+));
+TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef<
HTMLTableSectionElement,
@@ -33,8 +33,8 @@ const TableBody = React.forwardRef<
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
-))
-TableBody.displayName = "TableBody"
+));
+TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
@@ -44,12 +44,12 @@ const TableFooter = React.forwardRef<
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
- className
+ className,
)}
{...props}
/>
-))
-TableFooter.displayName = "TableFooter"
+));
+TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef<
HTMLTableRowElement,
@@ -59,12 +59,12 @@ const TableRow = React.forwardRef<
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
- className
+ className,
)}
{...props}
/>
-))
-TableRow.displayName = "TableRow"
+));
+TableRow.displayName = "TableRow";
const TableHead = React.forwardRef<
HTMLTableCellElement,
@@ -74,12 +74,12 @@ const TableHead = React.forwardRef<
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
- className
+ className,
)}
{...props}
/>
-))
-TableHead.displayName = "TableHead"
+));
+TableHead.displayName = "TableHead";
const TableCell = React.forwardRef<
HTMLTableCellElement,
@@ -89,12 +89,12 @@ const TableCell = React.forwardRef<
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
- className
+ className,
)}
{...props}
/>
-))
-TableCell.displayName = "TableCell"
+));
+TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
@@ -105,8 +105,8 @@ const TableCaption = React.forwardRef<
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
-))
-TableCaption.displayName = "TableCaption"
+));
+TableCaption.displayName = "TableCaption";
export {
Table,
@@ -117,4 +117,4 @@ export {
TableRow,
TableCell,
TableCaption,
-}
+};
diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx
index 0f4caeb..f2bc984 100644
--- a/src/components/ui/tabs.tsx
+++ b/src/components/ui/tabs.tsx
@@ -1,11 +1,11 @@
-"use client"
+"use client";
-import * as React from "react"
-import * as TabsPrimitive from "@radix-ui/react-tabs"
+import * as TabsPrimitive from "@radix-ui/react-tabs";
+import * as React from "react";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
-const Tabs = TabsPrimitive.Root
+const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef,
@@ -15,12 +15,12 @@ const TabsList = React.forwardRef<
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
- className
+ className,
)}
{...props}
/>
-))
-TabsList.displayName = TabsPrimitive.List.displayName
+));
+TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef,
@@ -30,12 +30,12 @@ const TabsTrigger = React.forwardRef<
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
- className
+ className,
)}
{...props}
/>
-))
-TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
+));
+TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef,
@@ -45,11 +45,11 @@ const TabsContent = React.forwardRef<
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
- className
+ className,
)}
{...props}
/>
-))
-TabsContent.displayName = TabsPrimitive.Content.displayName
+));
+TabsContent.displayName = TabsPrimitive.Content.displayName;
-export { Tabs, TabsList, TabsTrigger, TabsContent }
+export { Tabs, TabsList, TabsTrigger, TabsContent };
diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx
index 91b129a..acb666a 100644
--- a/src/components/ui/tooltip.tsx
+++ b/src/components/ui/tooltip.tsx
@@ -1,12 +1,12 @@
-'use client'
+"use client";
-import * as React from 'react'
-import * as TooltipPrimitive from '@radix-ui/react-tooltip'
-import { cn } from '@/lib/utils'
+import * as TooltipPrimitive from "@radix-ui/react-tooltip";
+import * as React from "react";
+import { cn } from "@/lib/utils";
-const TooltipProvider = TooltipPrimitive.Provider
-const TooltipRoot = TooltipPrimitive.Root
-const TooltipTrigger = TooltipPrimitive.Trigger
+const TooltipProvider = TooltipPrimitive.Provider;
+const TooltipRoot = TooltipPrimitive.Root;
+const TooltipTrigger = TooltipPrimitive.Trigger;
const TooltipContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
@@ -15,17 +15,17 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
- 'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
- className
+ "z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
+ className,
)}
{...props}
/>
-))
-TooltipContent.displayName = TooltipPrimitive.Content.displayName
+));
+TooltipContent.displayName = TooltipPrimitive.Content.displayName;
export interface TooltipProps {
- content: string
- children: React.ReactNode
+ content: string;
+ children: React.ReactNode;
}
export function Tooltip({ content, children }: TooltipProps) {
@@ -36,5 +36,5 @@ export function Tooltip({ content, children }: TooltipProps) {
{content}
- )
+ );
}
diff --git a/src/data/ejercicios.ts b/src/data/ejercicios.ts
index 458c464..76a7a55 100644
--- a/src/data/ejercicios.ts
+++ b/src/data/ejercicios.ts
@@ -37,7 +37,7 @@
// titulo: "Filtrado con WHERE",
// dificultad: "Intermedio",
// descripcion: "Selecciona usuarios que se registraron después del '2023-05-10'.",
-// detalles: `Este ejercicio te enseñará a usar la cláusula WHERE para filtrar resultados.
+// detalles: `Este ejercicio te enseñará a usar la cláusula WHERE para filtrar resultados.
// Deberás seleccionar usuarios basándote en su fecha de registro.`,
// ejemplo: {
// entrada: "La tabla 'usuarios' con columnas: id, nombre, email, fecha_registro",
@@ -65,7 +65,7 @@
// titulo: "Joins Avanzados",
// dificultad: "Avanzado",
// descripcion: "Realiza un JOIN entre las tablas 'usuarios' y 'pedidos' para obtener el total de pedidos por usuario. Ordena los resultados por nombre de usuario.",
-// detalles: `En este ejercicio avanzado, practicarás cómo unir dos tablas y realizar cálculos agregados.
+// detalles: `En este ejercicio avanzado, practicarás cómo unir dos tablas y realizar cálculos agregados.
// Deberás combinar la información de usuarios con sus pedidos y calcular el total de pedidos para cada usuario.`,
// ejemplo: {
// entrada: `Tablas:
@@ -100,7 +100,7 @@
// // Verificar que cada fila coincida con los resultados esperados
// return result.rows.every(row => {
-// return expectedResults.some(expected =>
+// return expectedResults.some(expected =>
// expected.nombre === row.nombre &&
// expected.email === row.email &&
// Number(row.total_pedidos) === expected.total_pedidos
diff --git a/src/hooks/use-exercises.ts b/src/hooks/use-exercises.ts
index 4166bff..b728241 100644
--- a/src/hooks/use-exercises.ts
+++ b/src/hooks/use-exercises.ts
@@ -1,41 +1,41 @@
-'use client'
+"use client";
-import { useQuery } from '@tanstack/react-query'
-import { queryKeys } from '@/lib/query-client'
-import { exercisesResponseSchema, type Exercise } from '@/lib/validations'
+import { useQuery } from "@tanstack/react-query";
+import { queryKeys } from "@/lib/query-client";
+import { type Exercise, exercisesResponseSchema } from "@/lib/validations";
async function fetchExercises(): Promise {
- const res = await fetch('/api/exercises', {
- headers: { 'Content-Type': 'application/json' },
- })
-
+ const res = await fetch("/api/exercises", {
+ headers: { "Content-Type": "application/json" },
+ });
+
if (!res.ok) {
- throw new Error('Failed to fetch exercises')
+ throw new Error("Failed to fetch exercises");
}
-
- const data = await res.json()
- const parsed = exercisesResponseSchema.parse(data)
- return parsed.exercises
+
+ const data = await res.json();
+ const parsed = exercisesResponseSchema.parse(data);
+ return parsed.exercises;
}
async function fetchExercise(id: string): Promise {
const res = await fetch(`/api/exercises/${id}`, {
- headers: { 'Content-Type': 'application/json' },
- })
-
+ headers: { "Content-Type": "application/json" },
+ });
+
if (!res.ok) {
- throw new Error('Failed to fetch exercise')
+ throw new Error("Failed to fetch exercise");
}
-
- const data = await res.json()
- return data.exercise
+
+ const data = await res.json();
+ return data.exercise;
}
export function useExercises() {
return useQuery({
queryKey: queryKeys.exercises,
queryFn: fetchExercises,
- })
+ });
}
export function useExercise(id: string) {
@@ -43,8 +43,5 @@ export function useExercise(id: string) {
queryKey: queryKeys.exercise(id),
queryFn: () => fetchExercise(id),
enabled: !!id,
- })
+ });
}
-
-
-
diff --git a/src/hooks/use-leaderboard.ts b/src/hooks/use-leaderboard.ts
index cec124d..fb3616b 100644
--- a/src/hooks/use-leaderboard.ts
+++ b/src/hooks/use-leaderboard.ts
@@ -1,34 +1,33 @@
-'use client'
+"use client";
-import { useQuery } from '@tanstack/react-query'
+import { useQuery } from "@tanstack/react-query";
interface LeaderboardEntry {
- userId: string
- displayName: string
- imageUrl: string | null
- countryCode: string | null
- totalScore: number
- exercisesSolved: number
- rank: number
+ userId: string;
+ displayName: string;
+ imageUrl: string | null;
+ countryCode: string | null;
+ totalScore: number;
+ exercisesSolved: number;
+ rank: number;
}
interface LeaderboardResponse {
- leaderboard: LeaderboardEntry[]
- updatedAt: string
+ leaderboard: LeaderboardEntry[];
+ updatedAt: string;
}
async function fetchLeaderboard(): Promise {
- const res = await fetch('/api/leaderboard')
- if (!res.ok) throw new Error('Failed to fetch leaderboard')
- return res.json()
+ const res = await fetch("/api/leaderboard");
+ if (!res.ok) throw new Error("Failed to fetch leaderboard");
+ return res.json();
}
export function useLeaderboard() {
return useQuery({
- queryKey: ['leaderboard'],
+ queryKey: ["leaderboard"],
queryFn: fetchLeaderboard,
staleTime: 60 * 1000, // Consider data fresh for 1 minute
refetchInterval: 60 * 1000, // Refetch every minute
- })
+ });
}
-
diff --git a/src/hooks/use-local-query.ts b/src/hooks/use-local-query.ts
index 514a3a4..c004dc6 100644
--- a/src/hooks/use-local-query.ts
+++ b/src/hooks/use-local-query.ts
@@ -1,64 +1,69 @@
-'use client'
+"use client";
-import { useState, useEffect, useCallback, useRef } from 'react'
+import { useCallback, useEffect, useRef, useState } from "react";
-const STORAGE_PREFIX = 'sql4all_query_'
+const STORAGE_PREFIX = "sql4all_query_";
interface UseLocalQueryOptions {
- savedSolution?: string | null
+ savedSolution?: string | null;
}
-export function useLocalQuery(exerciseId: string, options?: UseLocalQueryOptions) {
- const storageKey = `${STORAGE_PREFIX}${exerciseId}`
- const savedSolution = options?.savedSolution
-
- const [query, setQueryState] = useState('')
- const [isHydrated, setIsHydrated] = useState(false)
- const initializedRef = useRef(false)
-
+export function useLocalQuery(
+ exerciseId: string,
+ options?: UseLocalQueryOptions,
+) {
+ const storageKey = `${STORAGE_PREFIX}${exerciseId}`;
+ const savedSolution = options?.savedSolution;
+
+ const [query, setQueryState] = useState("");
+ const [isHydrated, setIsHydrated] = useState(false);
+ const initializedRef = useRef(false);
+
// Load from localStorage or saved solution on mount
useEffect(() => {
- if (typeof window === 'undefined') return
- if (initializedRef.current) return
-
- const stored = localStorage.getItem(storageKey)
+ if (typeof window === "undefined") return;
+ if (initializedRef.current) return;
+
+ const stored = localStorage.getItem(storageKey);
if (stored) {
// Local draft takes priority
- setQueryState(stored)
+ setQueryState(stored);
} else if (savedSolution) {
// Fall back to saved solution from server
- setQueryState(savedSolution)
+ setQueryState(savedSolution);
}
-
- initializedRef.current = true
- setIsHydrated(true)
- }, [storageKey, savedSolution])
-
+
+ initializedRef.current = true;
+ setIsHydrated(true);
+ }, [storageKey, savedSolution]);
+
// Save to localStorage when query changes
- const setQuery = useCallback((value: string) => {
- setQueryState(value)
- if (typeof window !== 'undefined') {
- if (value.trim()) {
- localStorage.setItem(storageKey, value)
- } else {
- localStorage.removeItem(storageKey)
+ const setQuery = useCallback(
+ (value: string) => {
+ setQueryState(value);
+ if (typeof window !== "undefined") {
+ if (value.trim()) {
+ localStorage.setItem(storageKey, value);
+ } else {
+ localStorage.removeItem(storageKey);
+ }
}
- }
- }, [storageKey])
-
+ },
+ [storageKey],
+ );
+
// Clear from localStorage (call on successful submission)
const clearQuery = useCallback(() => {
- setQueryState('')
- if (typeof window !== 'undefined') {
- localStorage.removeItem(storageKey)
+ setQueryState("");
+ if (typeof window !== "undefined") {
+ localStorage.removeItem(storageKey);
}
- }, [storageKey])
-
+ }, [storageKey]);
+
return {
query,
setQuery,
clearQuery,
isHydrated,
- }
+ };
}
-
diff --git a/src/hooks/use-profile-sync.ts b/src/hooks/use-profile-sync.ts
index c200f1c..2fe30e8 100644
--- a/src/hooks/use-profile-sync.ts
+++ b/src/hooks/use-profile-sync.ts
@@ -1,29 +1,28 @@
-'use client'
+"use client";
-import { useEffect, useRef } from 'react'
-import { useUser } from '@clerk/nextjs'
+import { useUser } from "@clerk/nextjs";
+import { useEffect, useRef } from "react";
export function useProfileSync() {
- const { user, isLoaded } = useUser()
- const hasSynced = useRef(false)
+ const { user, isLoaded } = useUser();
+ const hasSynced = useRef(false);
useEffect(() => {
- if (!isLoaded || !user || hasSynced.current) return
+ if (!isLoaded || !user || hasSynced.current) return;
// Sync profile on login
const syncProfile = async () => {
try {
- await fetch('/api/profiles/sync', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- })
- hasSynced.current = true
+ await fetch("/api/profiles/sync", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ });
+ hasSynced.current = true;
} catch (error) {
- console.warn('Failed to sync profile:', error)
+ console.warn("Failed to sync profile:", error);
}
- }
+ };
- syncProfile()
- }, [isLoaded, user])
+ syncProfile();
+ }, [isLoaded, user]);
}
-
diff --git a/src/hooks/use-profile.ts b/src/hooks/use-profile.ts
index 6c703cc..e8da2fa 100644
--- a/src/hooks/use-profile.ts
+++ b/src/hooks/use-profile.ts
@@ -1,61 +1,60 @@
-'use client'
+"use client";
-import { useQuery } from '@tanstack/react-query'
-import { useUser } from '@clerk/nextjs'
-import { queryKeys } from '@/lib/query-client'
+import { useUser } from "@clerk/nextjs";
+import { useQuery } from "@tanstack/react-query";
+import { queryKeys } from "@/lib/query-client";
interface HistoryItem {
- exerciseId: string
- exerciseTitle: string
- difficulty: string
- solvedAt: string
- score: number
+ exerciseId: string;
+ exerciseTitle: string;
+ difficulty: string;
+ solvedAt: string;
+ score: number;
}
async function fetchUserHistory(userId: string): Promise {
- const res = await fetch(`/api/users/${userId}/history`)
+ const res = await fetch(`/api/users/${userId}/history`);
if (!res.ok) {
- console.error('Failed to fetch history:', res.status, res.statusText)
- throw new Error('Failed to fetch history')
+ console.error("Failed to fetch history:", res.status, res.statusText);
+ throw new Error("Failed to fetch history");
}
- const data = await res.json()
- console.log('[useUserHistory] API response:', data)
+ const data = await res.json();
+ console.log("[useUserHistory] API response:", data);
if (!data.history || !Array.isArray(data.history)) {
- console.warn('[useUserHistory] Invalid history data:', data)
- return []
+ console.warn("[useUserHistory] Invalid history data:", data);
+ return [];
}
- return data.history
+ return data.history;
}
async function fetchHeatmapData(userId: string): Promise {
- const res = await fetch(`/api/users/${userId}/heatmap`)
+ const res = await fetch(`/api/users/${userId}/heatmap`);
if (!res.ok) {
- console.error('Failed to fetch heatmap data:', res.status, res.statusText)
- throw new Error('Failed to fetch heatmap data')
+ console.error("Failed to fetch heatmap data:", res.status, res.statusText);
+ throw new Error("Failed to fetch heatmap data");
}
- const data = await res.json()
- return data.dates || []
+ const data = await res.json();
+ return data.dates || [];
}
export function useUserHistory() {
- const { user } = useUser()
- const userId = user?.id
+ const { user } = useUser();
+ const userId = user?.id;
return useQuery({
- queryKey: [...queryKeys.solvedExercises(userId ?? ''), 'history'],
+ queryKey: [...queryKeys.solvedExercises(userId ?? ""), "history"],
queryFn: () => fetchUserHistory(userId!),
enabled: !!userId,
- })
+ });
}
export function useHeatmapData() {
- const { user } = useUser()
- const userId = user?.id
+ const { user } = useUser();
+ const userId = user?.id;
return useQuery({
- queryKey: [...queryKeys.solvedExercises(userId ?? ''), 'heatmap'],
+ queryKey: [...queryKeys.solvedExercises(userId ?? ""), "heatmap"],
queryFn: () => fetchHeatmapData(userId!),
enabled: !!userId,
- })
+ });
}
-
diff --git a/src/hooks/use-submissions.ts b/src/hooks/use-submissions.ts
index 28bc13a..a10de09 100644
--- a/src/hooks/use-submissions.ts
+++ b/src/hooks/use-submissions.ts
@@ -1,221 +1,276 @@
-'use client'
+"use client";
-import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
-import { useUser } from '@clerk/nextjs'
-import { queryKeys } from '@/lib/query-client'
-import type { WeekProgress } from '@/lib/validations'
+import { useUser } from "@clerk/nextjs";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { queryKeys } from "@/lib/query-client";
+import type { WeekProgress } from "@/lib/validations";
async function fetchUserScore(userId: string): Promise {
- console.log('[fetchUserScore] Fetching for userId:', userId)
- const res = await fetch(`/api/users/${userId}/score`)
- if (!res.ok) throw new Error('Failed to fetch score')
- const data = await res.json()
- console.log('[fetchUserScore] Response:', data)
- return data.score
+ console.log("[fetchUserScore] Fetching for userId:", userId);
+ const res = await fetch(`/api/users/${userId}/score`);
+ if (!res.ok) throw new Error("Failed to fetch score");
+ const data = await res.json();
+ console.log("[fetchUserScore] Response:", data);
+ return data.score;
}
async function fetchUserStreak(userId: string): Promise {
- console.log('[fetchUserStreak] Fetching for userId:', userId)
- const res = await fetch(`/api/users/${userId}/streak`)
- if (!res.ok) throw new Error('Failed to fetch streak')
- const data = await res.json()
- console.log('[fetchUserStreak] Response:', data)
- return data.streak
+ console.log("[fetchUserStreak] Fetching for userId:", userId);
+ const res = await fetch(`/api/users/${userId}/streak`);
+ if (!res.ok) throw new Error("Failed to fetch streak");
+ const data = await res.json();
+ console.log("[fetchUserStreak] Response:", data);
+ return data.streak;
}
async function fetchWeekProgress(userId: string): Promise {
- const res = await fetch(`/api/users/${userId}/week-progress`)
- if (!res.ok) throw new Error('Failed to fetch week progress')
- const data = await res.json()
- return data.progress
+ const res = await fetch(`/api/users/${userId}/week-progress`);
+ if (!res.ok) throw new Error("Failed to fetch week progress");
+ const data = await res.json();
+ return data.progress;
}
async function fetchSolvedExercises(userId: string): Promise> {
- console.log('[fetchSolvedExercises] Fetching for userId:', userId)
- const res = await fetch(`/api/users/${userId}/solved-exercises`)
+ console.log("[fetchSolvedExercises] Fetching for userId:", userId);
+ const res = await fetch(`/api/users/${userId}/solved-exercises`);
if (!res.ok) {
- console.error('[fetchSolvedExercises] Failed:', res.status, res.statusText)
- throw new Error('Failed to fetch solved exercises')
+ console.error("[fetchSolvedExercises] Failed:", res.status, res.statusText);
+ throw new Error("Failed to fetch solved exercises");
}
- const data = await res.json()
- console.log('[fetchSolvedExercises] Raw response:', data)
- const exerciseSet = new Set(data.exerciseIds)
- console.log('[fetchSolvedExercises] Created Set with size:', exerciseSet.size, 'ids:', Array.from(exerciseSet))
- return exerciseSet
+ const data = await res.json();
+ console.log("[fetchSolvedExercises] Raw response:", data);
+ const exerciseSet = new Set(data.exerciseIds);
+ console.log(
+ "[fetchSolvedExercises] Created Set with size:",
+ exerciseSet.size,
+ "ids:",
+ Array.from(exerciseSet),
+ );
+ return exerciseSet;
}
interface CreateSubmissionParams {
- exerciseId: string
- solution?: string
+ exerciseId: string;
+ solution?: string;
}
-async function createSubmission({ exerciseId, solution }: CreateSubmissionParams): Promise<{ submission: unknown }> {
- const res = await fetch('/api/submissions', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
+async function createSubmission({
+ exerciseId,
+ solution,
+}: CreateSubmissionParams): Promise<{ submission: unknown }> {
+ const res = await fetch("/api/submissions", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
body: JSON.stringify({ exerciseId, solution }),
- })
-
- const data = await res.json()
-
+ });
+
+ const data = await res.json();
+
if (!res.ok) {
- console.error('Submission failed:', data)
- throw new Error(data.error || 'Failed to create submission')
+ console.error("Submission failed:", data);
+ throw new Error(data.error || "Failed to create submission");
}
-
- return data
+
+ return data;
}
-async function fetchSavedSolution(userId: string, exerciseId: string): Promise {
- const res = await fetch(`/api/users/${userId}/solutions/${exerciseId}`)
- if (!res.ok) return null
- const data = await res.json()
- return data.solution
+async function fetchSavedSolution(
+ userId: string,
+ exerciseId: string,
+): Promise {
+ const res = await fetch(`/api/users/${userId}/solutions/${exerciseId}`);
+ if (!res.ok) return null;
+ const data = await res.json();
+ return data.solution;
}
export function useUserScore() {
- const { user, isLoaded } = useUser()
- const userId = user?.id
+ const { user, isLoaded } = useUser();
+ const userId = user?.id;
const query = useQuery({
- queryKey: queryKeys.userScore(userId ?? ''),
+ queryKey: queryKeys.userScore(userId ?? ""),
queryFn: () => fetchUserScore(userId!),
enabled: !!userId,
- })
+ });
- console.log('[useUserScore] State:', {
- isLoaded,
- userId,
+ console.log("[useUserScore] State:", {
+ isLoaded,
+ userId,
enabled: !!userId,
status: query.status,
- data: query.data
- })
+ data: query.data,
+ });
- return query
+ return query;
}
export function useUserStreak() {
- const { user, isLoaded } = useUser()
- const userId = user?.id
+ const { user, isLoaded } = useUser();
+ const userId = user?.id;
const query = useQuery({
- queryKey: queryKeys.userStreak(userId ?? ''),
+ queryKey: queryKeys.userStreak(userId ?? ""),
queryFn: () => fetchUserStreak(userId!),
enabled: !!userId,
- })
+ });
- console.log('[useUserStreak] State:', {
- isLoaded,
- userId,
+ console.log("[useUserStreak] State:", {
+ isLoaded,
+ userId,
enabled: !!userId,
status: query.status,
- data: query.data
- })
+ data: query.data,
+ });
- return query
+ return query;
}
export function useWeekProgress() {
- const { user } = useUser()
- const userId = user?.id
+ const { user } = useUser();
+ const userId = user?.id;
return useQuery({
- queryKey: queryKeys.weekProgress(userId ?? ''),
+ queryKey: queryKeys.weekProgress(userId ?? ""),
queryFn: () => fetchWeekProgress(userId!),
enabled: !!userId,
initialData: [],
- })
+ });
}
export function useSolvedExercises() {
- const { user, isLoaded: isUserLoaded } = useUser()
- const userId = user?.id
+ const { user, isLoaded: isUserLoaded } = useUser();
+ const userId = user?.id;
- console.log('[useSolvedExercises] Hook called:', {
- isUserLoaded,
- hasUser: !!user,
+ console.log("[useSolvedExercises] Hook called:", {
+ isUserLoaded,
+ hasUser: !!user,
userId,
- enabled: !!userId
- })
+ enabled: !!userId,
+ });
const query = useQuery({
- queryKey: queryKeys.solvedExercises(userId ?? ''),
+ queryKey: queryKeys.solvedExercises(userId ?? ""),
queryFn: () => fetchSolvedExercises(userId!),
enabled: !!userId,
- })
+ });
- console.log('[useSolvedExercises] Query state:', {
+ console.log("[useSolvedExercises] Query state:", {
status: query.status,
isLoading: query.isLoading,
isFetching: query.isFetching,
isFetched: query.isFetched,
dataSize: query.data?.size ?? 0,
- dataIds: query.data ? Array.from(query.data) : []
- })
+ dataIds: query.data ? Array.from(query.data) : [],
+ });
- return query
+ return query;
}
export function useSavedSolution(exerciseId: string) {
- const { user } = useUser()
- const userId = user?.id
+ const { user } = useUser();
+ const userId = user?.id;
return useQuery({
- queryKey: ['savedSolution', userId, exerciseId],
+ queryKey: ["savedSolution", userId, exerciseId],
queryFn: () => fetchSavedSolution(userId!, exerciseId),
enabled: !!userId && !!exerciseId,
- })
+ });
}
export function useCreateSubmission() {
- const { user } = useUser()
- const userId = user?.id
- const queryClient = useQueryClient()
+ const { user } = useUser();
+ const userId = user?.id;
+ const queryClient = useQueryClient();
return useMutation({
mutationFn: createSubmission,
onMutate: async ({ exerciseId }) => {
- await queryClient.cancelQueries({ queryKey: queryKeys.userScore(userId ?? '') })
- await queryClient.cancelQueries({ queryKey: queryKeys.userStreak(userId ?? '') })
- await queryClient.cancelQueries({ queryKey: queryKeys.solvedExercises(userId ?? '') })
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.userScore(userId ?? ""),
+ });
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.userStreak(userId ?? ""),
+ });
+ await queryClient.cancelQueries({
+ queryKey: queryKeys.solvedExercises(userId ?? ""),
+ });
- const previousScore = queryClient.getQueryData(queryKeys.userScore(userId ?? ''))
- const previousStreak = queryClient.getQueryData(queryKeys.userStreak(userId ?? ''))
- const previousSolved = queryClient.getQueryData>(queryKeys.solvedExercises(userId ?? ''))
+ const previousScore = queryClient.getQueryData(
+ queryKeys.userScore(userId ?? ""),
+ );
+ const previousStreak = queryClient.getQueryData(
+ queryKeys.userStreak(userId ?? ""),
+ );
+ const previousSolved = queryClient.getQueryData>(
+ queryKeys.solvedExercises(userId ?? ""),
+ );
// Optimistic updates
- queryClient.setQueryData(queryKeys.userScore(userId ?? ''), (old) => (old ?? 0) + 2)
- queryClient.setQueryData(queryKeys.userStreak(userId ?? ''), (old) => (old ?? 0) + 1)
- queryClient.setQueryData>(queryKeys.solvedExercises(userId ?? ''), (old) => {
- const newSet = new Set(old)
- newSet.add(exerciseId)
- return newSet
- })
-
- return { previousScore, previousStreak, previousSolved }
+ queryClient.setQueryData(
+ queryKeys.userScore(userId ?? ""),
+ (old) => (old ?? 0) + 2,
+ );
+ queryClient.setQueryData(
+ queryKeys.userStreak(userId ?? ""),
+ (old) => (old ?? 0) + 1,
+ );
+ queryClient.setQueryData>(
+ queryKeys.solvedExercises(userId ?? ""),
+ (old) => {
+ const newSet = new Set(old);
+ newSet.add(exerciseId);
+ return newSet;
+ },
+ );
+
+ return { previousScore, previousStreak, previousSolved };
},
onSuccess: (data, { exerciseId }) => {
- console.log('Mutation succeeded - submission created:', { data, exerciseId, userId })
+ console.log("Mutation succeeded - submission created:", {
+ data,
+ exerciseId,
+ userId,
+ });
},
onError: (err, _params, context) => {
- console.error('Mutation failed:', err)
+ console.error("Mutation failed:", err);
if (context?.previousScore !== undefined) {
- queryClient.setQueryData(queryKeys.userScore(userId ?? ''), context.previousScore)
+ queryClient.setQueryData(
+ queryKeys.userScore(userId ?? ""),
+ context.previousScore,
+ );
}
if (context?.previousStreak !== undefined) {
- queryClient.setQueryData(queryKeys.userStreak(userId ?? ''), context.previousStreak)
+ queryClient.setQueryData(
+ queryKeys.userStreak(userId ?? ""),
+ context.previousStreak,
+ );
}
if (context?.previousSolved !== undefined) {
- queryClient.setQueryData(queryKeys.solvedExercises(userId ?? ''), context.previousSolved)
+ queryClient.setQueryData(
+ queryKeys.solvedExercises(userId ?? ""),
+ context.previousSolved,
+ );
}
},
onSettled: (data, error, { exerciseId }) => {
- console.log('Mutation settled:', { data, error, exerciseId })
- queryClient.invalidateQueries({ queryKey: queryKeys.userScore(userId ?? '') })
- queryClient.invalidateQueries({ queryKey: queryKeys.userStreak(userId ?? '') })
- queryClient.invalidateQueries({ queryKey: queryKeys.weekProgress(userId ?? '') })
- queryClient.invalidateQueries({ queryKey: queryKeys.solvedExercises(userId ?? '') })
- queryClient.invalidateQueries({ queryKey: ['savedSolution', userId, exerciseId] })
+ console.log("Mutation settled:", { data, error, exerciseId });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.userScore(userId ?? ""),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.userStreak(userId ?? ""),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.weekProgress(userId ?? ""),
+ });
+ queryClient.invalidateQueries({
+ queryKey: queryKeys.solvedExercises(userId ?? ""),
+ });
+ queryClient.invalidateQueries({
+ queryKey: ["savedSolution", userId, exerciseId],
+ });
},
- })
+ });
}
diff --git a/src/lib/db-service.ts b/src/lib/db-service.ts
index 59f49a1..b59f6f2 100644
--- a/src/lib/db-service.ts
+++ b/src/lib/db-service.ts
@@ -1,68 +1,68 @@
-import { PGlite } from '@electric-sql/pglite'
-import { handleSQLError } from './sql-error-handler'
+import { PGlite } from "@electric-sql/pglite";
+import { handleSQLError } from "./sql-error-handler";
export interface QueryResult {
- error: boolean
- message?: string
- example?: string
- rows: Record[]
- fields: { name: string }[]
+ error: boolean;
+ message?: string;
+ example?: string;
+ rows: Record[];
+ fields: { name: string }[];
}
export interface ColumnInfo {
- name: string
- type: string
- nullable: boolean
- defaultValue: string | null
+ name: string;
+ type: string;
+ nullable: boolean;
+ defaultValue: string | null;
}
export interface ConstraintInfo {
- name: string
- type: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK' | 'NOT NULL'
- columns: string[]
- definition?: string
+ name: string;
+ type: "PRIMARY KEY" | "FOREIGN KEY" | "UNIQUE" | "CHECK" | "NOT NULL";
+ columns: string[];
+ definition?: string;
}
export interface IndexInfo {
- name: string
- columns: string[]
- isUnique: boolean
+ name: string;
+ columns: string[];
+ isUnique: boolean;
}
export interface TableInfo {
- name: string
- columns: ColumnInfo[]
- constraints: ConstraintInfo[]
- indexes: IndexInfo[]
+ name: string;
+ columns: ColumnInfo[];
+ constraints: ConstraintInfo[];
+ indexes: IndexInfo[];
}
export interface SchemaInfo {
- tables: TableInfo[]
+ tables: TableInfo[];
}
-const DDL_SCHEMA = 'practice_ddl'
+const DDL_SCHEMA = "practice_ddl";
class DatabaseService {
- private db: PGlite | null = null
- private initPromise: Promise | null = null
- private ddlSchemaInitialized = false
- private ddlResetInProgress = false
- private ddlResetPromise: Promise | null = null
+ private db: PGlite | null = null;
+ private initPromise: Promise | null = null;
+ private ddlSchemaInitialized = false;
+ private ddlResetInProgress = false;
+ private ddlResetPromise: Promise | null = null;
async initialize() {
- if (typeof window === 'undefined') {
- throw new Error('Database can only be initialized in the browser')
+ if (typeof window === "undefined") {
+ throw new Error("Database can only be initialized in the browser");
}
- if (this.db) return this.db
+ if (this.db) return this.db;
if (this.initPromise) {
- return this.initPromise
+ return this.initPromise;
}
- this.initPromise = new Promise(async (resolve, reject) => {
+ this.initPromise = (async () => {
try {
- this.db = new PGlite()
+ this.db = new PGlite();
await this.db.exec(`
CREATE TABLE IF NOT EXISTS usuarios (
@@ -75,14 +75,23 @@ class DatabaseService {
activo BOOLEAN
);
+ CREATE TABLE IF NOT EXISTS productos (
+ id SERIAL PRIMARY KEY,
+ nombre VARCHAR(100),
+ precio DECIMAL(10,2),
+ categoria VARCHAR(50)
+ );
+
CREATE TABLE IF NOT EXISTS pedidos (
id SERIAL PRIMARY KEY,
usuario_id INTEGER REFERENCES usuarios(id),
+ producto_id INTEGER REFERENCES productos(id),
monto DECIMAL(10,2),
fecha DATE
);
TRUNCATE TABLE pedidos RESTART IDENTITY;
+ TRUNCATE TABLE productos RESTART IDENTITY CASCADE;
TRUNCATE TABLE usuarios RESTART IDENTITY CASCADE;
INSERT INTO usuarios (nombre, email, fecha_registro, edad, ciudad, activo) VALUES
@@ -97,101 +106,110 @@ class DatabaseService {
('Carmen Ruiz', 'carmen.ruiz@email.com', '2023-09-14', 27, 'Granada', false),
('Miguel Flores', 'miguel.flores@email.com', '2023-10-25', 36, 'Murcia', true);
- INSERT INTO pedidos (usuario_id, monto, fecha) VALUES
- (1, 150.50, '2023-02-01'),
- (1, 200.75, '2023-03-15'),
- (2, 350.00, '2023-02-28'),
- (3, 125.25, '2023-04-10'),
- (4, 475.00, '2023-05-05'),
- (4, 225.50, '2023-06-20'),
- (5, 180.75, '2023-07-12'),
- (6, 300.00, '2023-08-18'),
- (7, 425.25, '2023-09-22'),
- (7, 150.00, '2023-10-05'),
- (8, 275.50, '2023-11-15'),
- (9, 190.75, '2023-12-01'),
- (10, 400.00, '2023-12-10'),
- (10, 325.25, '2023-12-20');
- `)
-
- resolve(this.db)
+ INSERT INTO productos (nombre, precio, categoria) VALUES
+ ('Laptop Pro', 1299.99, 'Electrónica'),
+ ('Smartphone X', 899.50, 'Electrónica'),
+ ('Auriculares Wireless', 149.99, 'Electrónica'),
+ ('Camiseta Premium', 45.00, 'Ropa'),
+ ('Zapatillas Running', 120.00, 'Ropa'),
+ ('Libro SQL Avanzado', 35.50, 'Libros'),
+ ('Teclado Mecánico', 89.99, 'Electrónica'),
+ ('Mochila Viaje', 75.00, 'Accesorios');
+
+ INSERT INTO pedidos (usuario_id, producto_id, monto, fecha) VALUES
+ (1, 1, 150.50, '2023-02-01'),
+ (1, 3, 200.75, '2023-03-15'),
+ (2, 2, 350.00, '2023-02-28'),
+ (3, 4, 125.25, '2023-04-10'),
+ (4, 1, 475.00, '2023-05-05'),
+ (4, 5, 225.50, '2023-06-20'),
+ (5, 6, 180.75, '2023-07-12'),
+ (6, 7, 300.00, '2023-08-18'),
+ (7, 2, 425.25, '2023-09-22'),
+ (7, 8, 150.00, '2023-10-05'),
+ (8, 3, 275.50, '2023-11-15'),
+ (9, 4, 190.75, '2023-12-01'),
+ (10, 1, 400.00, '2023-12-10'),
+ (10, 5, 325.25, '2023-12-20');
+ `);
+
+ return this.db;
} catch (error) {
- reject(error)
- } finally {
- this.initPromise = null
+ this.initPromise = null;
+ throw error;
}
- })
+ })();
- return this.initPromise
+ return this.initPromise;
}
async initializeDDLSchema(): Promise {
if (!this.db) {
- await this.initialize()
+ await this.initialize();
}
- if (this.ddlSchemaInitialized) return
+ if (this.ddlSchemaInitialized) return;
try {
await this.db!.exec(`
CREATE SCHEMA IF NOT EXISTS ${DDL_SCHEMA};
SET search_path TO ${DDL_SCHEMA}, public;
- `)
- this.ddlSchemaInitialized = true
+ `);
+ this.ddlSchemaInitialized = true;
} catch (error) {
- console.error('Error initializing DDL schema:', error)
- throw error
+ console.error("Error initializing DDL schema:", error);
+ throw error;
}
}
async resetDDLSchema(setupSQL?: string): Promise {
// If a reset is already in progress, wait for it to complete
if (this.ddlResetInProgress && this.ddlResetPromise) {
- console.log('[DDL] Reset already in progress, waiting...')
- await this.ddlResetPromise
- return
+ console.log("[DDL] Reset already in progress, waiting...");
+ await this.ddlResetPromise;
+ return;
}
- this.ddlResetInProgress = true
-
+ this.ddlResetInProgress = true;
+
this.ddlResetPromise = (async () => {
if (!this.db) {
- await this.initialize()
+ await this.initialize();
}
try {
- console.log('[DDL] Starting schema reset...')
-
+ console.log("[DDL] Starting schema reset...");
+
// Drop and recreate schema
await this.db!.exec(`
DROP SCHEMA IF EXISTS ${DDL_SCHEMA} CASCADE;
CREATE SCHEMA ${DDL_SCHEMA};
SET search_path TO ${DDL_SCHEMA}, public;
- `)
-
- console.log('[DDL] Schema reset complete, running setup SQL...')
+ `);
+
+ console.log("[DDL] Schema reset complete, running setup SQL...");
if (setupSQL) {
- await this.db!.exec(setupSQL)
- console.log('[DDL] Setup SQL executed successfully')
+ await this.db!.exec(setupSQL);
+ console.log("[DDL] Setup SQL executed successfully");
}
- this.ddlSchemaInitialized = true
+ this.ddlSchemaInitialized = true;
} catch (error) {
- console.error('[DDL] Error resetting DDL schema:', error)
- throw error
+ console.error("[DDL] Error resetting DDL schema:", error);
+ throw error;
} finally {
- this.ddlResetInProgress = false
- this.ddlResetPromise = null
+ this.ddlResetInProgress = false;
+ this.ddlResetPromise = null;
}
- })()
+ })();
- await this.ddlResetPromise
+ await this.ddlResetPromise;
}
async inspectSchema(): Promise {
if (!this.db) {
- await this.initialize()
+ await this.initialize();
}
try {
@@ -200,12 +218,12 @@ class DatabaseService {
FROM information_schema.tables
WHERE table_schema = '${DDL_SCHEMA}'
ORDER BY table_name
- `)
+ `);
- const tables: TableInfo[] = []
+ const tables: TableInfo[] = [];
for (const row of tablesResult.rows as { table_name: string }[]) {
- const tableName = row.table_name
+ const tableName = row.table_name;
const columnsResult = await this.db!.query(`
SELECT
@@ -216,19 +234,21 @@ class DatabaseService {
FROM information_schema.columns
WHERE table_schema = '${DDL_SCHEMA}' AND table_name = '${tableName}'
ORDER BY ordinal_position
- `)
-
- const columns: ColumnInfo[] = (columnsResult.rows as {
- column_name: string
- data_type: string
- is_nullable: string
- column_default: string | null
- }[]).map(col => ({
+ `);
+
+ const columns: ColumnInfo[] = (
+ columnsResult.rows as {
+ column_name: string;
+ data_type: string;
+ is_nullable: string;
+ column_default: string | null;
+ }[]
+ ).map((col) => ({
name: col.column_name,
type: col.data_type,
- nullable: col.is_nullable === 'YES',
+ nullable: col.is_nullable === "YES",
defaultValue: col.column_default,
- }))
+ }));
const constraintsResult = await this.db!.query(`
SELECT
@@ -242,23 +262,23 @@ class DatabaseService {
WHERE tc.table_schema = '${DDL_SCHEMA}'
AND tc.table_name = '${tableName}'
ORDER BY tc.constraint_name, kcu.ordinal_position
- `)
+ `);
- const constraintMap = new Map()
+ const constraintMap = new Map();
for (const row of constraintsResult.rows as {
- constraint_name: string
- constraint_type: string
- column_name: string
+ constraint_name: string;
+ constraint_type: string;
+ column_name: string;
}[]) {
- const existing = constraintMap.get(row.constraint_name)
+ const existing = constraintMap.get(row.constraint_name);
if (existing) {
- existing.columns.push(row.column_name)
+ existing.columns.push(row.column_name);
} else {
constraintMap.set(row.constraint_name, {
name: row.constraint_name,
- type: row.constraint_type as ConstraintInfo['type'],
+ type: row.constraint_type as ConstraintInfo["type"],
columns: [row.column_name],
- })
+ });
}
}
@@ -272,19 +292,19 @@ class DatabaseService {
WHERE tc.table_schema = '${DDL_SCHEMA}'
AND tc.table_name = '${tableName}'
AND tc.constraint_type = 'CHECK'
- `)
+ `);
for (const row of checkConstraintsResult.rows as {
- constraint_name: string
- check_clause: string
+ constraint_name: string;
+ check_clause: string;
}[]) {
- const existing = constraintMap.get(row.constraint_name)
+ const existing = constraintMap.get(row.constraint_name);
if (existing) {
- existing.definition = row.check_clause
+ existing.definition = row.check_clause;
}
}
- const constraints = Array.from(constraintMap.values())
+ const constraints = Array.from(constraintMap.values());
const indexesResult = await this.db!.query(`
SELECT
@@ -301,78 +321,85 @@ class DatabaseService {
AND NOT ix.indisprimary
GROUP BY i.relname, ix.indisunique
ORDER BY i.relname
- `)
-
- const indexes: IndexInfo[] = (indexesResult.rows as {
- index_name: string
- columns: string[]
- is_unique: boolean
- }[]).map(idx => ({
+ `);
+
+ const indexes: IndexInfo[] = (
+ indexesResult.rows as {
+ index_name: string;
+ columns: string[];
+ is_unique: boolean;
+ }[]
+ ).map((idx) => ({
name: idx.index_name,
columns: idx.columns,
isUnique: idx.is_unique,
- }))
+ }));
tables.push({
name: tableName,
columns,
constraints,
indexes,
- })
+ });
}
- return { tables }
+ return { tables };
} catch (error) {
- console.error('Error inspecting schema:', error)
- return { tables: [] }
+ console.error("Error inspecting schema:", error);
+ return { tables: [] };
}
}
async executeDDLQuery(query: string): Promise {
- if (typeof window === 'undefined') {
- throw new Error('Queries can only be executed in the browser')
+ if (typeof window === "undefined") {
+ throw new Error("Queries can only be executed in the browser");
}
if (!this.db) {
return {
error: true,
- message: 'Base de datos no inicializada',
+ message: "Base de datos no inicializada",
rows: [],
fields: [],
- }
+ };
}
try {
if (!query.trim()) {
return {
error: true,
- message: 'La consulta SQL no puede estar vacía',
- example: 'CREATE TABLE productos (id SERIAL PRIMARY KEY, nombre VARCHAR(100))',
+ message: "La consulta SQL no puede estar vacía",
+ example:
+ "CREATE TABLE productos (id SERIAL PRIMARY KEY, nombre VARCHAR(100))",
rows: [],
fields: [],
- }
+ };
}
- await this.db.exec(`SET search_path TO ${DDL_SCHEMA}, public;`)
+ await this.db.exec(`SET search_path TO ${DDL_SCHEMA}, public;`);
try {
- const result = await this.db.query(query)
+ const result = await this.db.query(query);
return {
error: false,
rows: result.rows as Record[],
fields: result.fields as { name: string }[],
- }
+ };
} catch (sqlError: unknown) {
- console.error('DDL Error:', sqlError)
+ console.error("DDL Error:", sqlError);
- const errorObj = sqlError as { message?: string; stack?: string; code?: string }
- const errorMessage = errorObj?.message || 'Error desconocido'
+ const errorObj = sqlError as {
+ message?: string;
+ stack?: string;
+ code?: string;
+ };
+ const errorMessage = errorObj?.message || "Error desconocido";
const formattedError = handleSQLError({
message: errorMessage,
stack: errorObj?.stack,
code: errorObj?.code,
- })
+ });
return {
error: true,
@@ -380,77 +407,86 @@ class DatabaseService {
example: formattedError.example,
rows: [],
fields: [],
- }
+ };
}
} catch {
return {
error: true,
- message: 'Error inesperado al ejecutar la consulta DDL',
- example: 'Intenta verificar la sintaxis de tu consulta',
+ message: "Error inesperado al ejecutar la consulta DDL",
+ example: "Intenta verificar la sintaxis de tu consulta",
rows: [],
fields: [],
- }
+ };
}
}
- async executeTestQuery(query: string): Promise<{ success: boolean; error?: string }> {
+ async executeTestQuery(
+ query: string,
+ ): Promise<{ success: boolean; error?: string }> {
if (!this.db) {
- return { success: false, error: 'Base de datos no inicializada' }
+ return { success: false, error: "Base de datos no inicializada" };
}
try {
- await this.db.exec(`SET search_path TO ${DDL_SCHEMA}, public;`)
- await this.db.query(query)
- return { success: true }
+ await this.db.exec(`SET search_path TO ${DDL_SCHEMA}, public;`);
+ await this.db.query(query);
+ return { success: true };
} catch (error) {
- const errorObj = error as { message?: string }
- return { success: false, error: errorObj?.message || 'Error desconocido' }
+ const errorObj = error as { message?: string };
+ return {
+ success: false,
+ error: errorObj?.message || "Error desconocido",
+ };
}
}
async executeQuery(query: string): Promise {
- if (typeof window === 'undefined') {
- throw new Error('Queries can only be executed in the browser')
+ if (typeof window === "undefined") {
+ throw new Error("Queries can only be executed in the browser");
}
if (!this.db) {
return {
error: true,
- message: 'Base de datos no inicializada',
+ message: "Base de datos no inicializada",
rows: [],
fields: [],
- }
+ };
}
try {
if (!query.trim()) {
return {
error: true,
- message: 'La consulta SQL no puede estar vacía',
- example: 'SELECT * FROM usuarios',
+ message: "La consulta SQL no puede estar vacía",
+ example: "SELECT * FROM usuarios",
rows: [],
fields: [],
- }
+ };
}
try {
- const result = await this.db.query(query)
+ const result = await this.db.query(query);
return {
error: false,
rows: result.rows as Record[],
fields: result.fields as { name: string }[],
- }
+ };
} catch (sqlError: unknown) {
- console.error('SQL Error:', sqlError)
+ console.error("SQL Error:", sqlError);
- const errorObj = sqlError as { message?: string; stack?: string; code?: string }
- const errorMessage = errorObj?.message || 'Error desconocido'
+ const errorObj = sqlError as {
+ message?: string;
+ stack?: string;
+ code?: string;
+ };
+ const errorMessage = errorObj?.message || "Error desconocido";
const formattedError = handleSQLError({
message: errorMessage,
stack: errorObj?.stack,
code: errorObj?.code,
- })
+ });
return {
error: true,
@@ -458,18 +494,18 @@ class DatabaseService {
example: formattedError.example,
rows: [],
fields: [],
- }
+ };
}
} catch {
return {
error: true,
- message: 'Error inesperado al ejecutar la consulta',
- example: 'Intenta verificar la sintaxis de tu consulta',
+ message: "Error inesperado al ejecutar la consulta",
+ example: "Intenta verificar la sintaxis de tu consulta",
rows: [],
fields: [],
- }
+ };
}
}
}
-export const dbService = new DatabaseService()
+export const dbService = new DatabaseService();
diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts
index 84ab12b..d2586c2 100644
--- a/src/lib/db/index.ts
+++ b/src/lib/db/index.ts
@@ -1,12 +1,9 @@
-import { neon } from '@neondatabase/serverless'
-import { drizzle } from 'drizzle-orm/neon-http'
-import * as schema from './schema'
-
-const sql = neon(process.env.DATABASE_URL!)
-
-export const db = drizzle(sql, { schema })
-
-export type Database = typeof db
+import { neon } from "@neondatabase/serverless";
+import { drizzle } from "drizzle-orm/neon-http";
+import * as schema from "./schema";
+const sql = neon(process.env.DATABASE_URL!);
+export const db = drizzle(sql, { schema });
+export type Database = typeof db;
diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts
index dce1b68..a0f0cb3 100644
--- a/src/lib/db/schema.ts
+++ b/src/lib/db/schema.ts
@@ -1,56 +1,68 @@
-import { pgTable, uuid, text, timestamp, boolean, integer, jsonb } from 'drizzle-orm/pg-core'
-import { relations } from 'drizzle-orm'
+import { relations } from "drizzle-orm";
+import {
+ boolean,
+ integer,
+ jsonb,
+ pgTable,
+ text,
+ timestamp,
+ uuid,
+} from "drizzle-orm/pg-core";
// User profiles - synced from Clerk
-export const profiles = pgTable('profiles', {
- id: text('id').primaryKey(), // Clerk user ID
- displayName: text('display_name'),
- email: text('email'),
- imageUrl: text('image_url'),
- countryCode: text('country_code'), // ISO 3166-1 alpha-2 (e.g., 'CO', 'US')
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
-})
+export const profiles = pgTable("profiles", {
+ id: text("id").primaryKey(), // Clerk user ID
+ displayName: text("display_name"),
+ email: text("email"),
+ imageUrl: text("image_url"),
+ countryCode: text("country_code"), // ISO 3166-1 alpha-2 (e.g., 'CO', 'US')
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});
-export const exercises = pgTable('exercises', {
- id: uuid('id').primaryKey().defaultRandom(),
- title: text('title').notNull(),
- difficulty: text('difficulty').notNull(),
- description: text('description').notNull(),
- details: text('details').notNull(),
- hint: text('hint').notNull(),
- successMessage: text('success_message').notNull(),
- example: jsonb('example').notNull().$type<{ entrada?: string; salida?: string }>(),
- type: text('type').default('dml').notNull().$type<'dml' | 'ddl'>(),
- validation: jsonb('validation').notNull().$type<{
- type: 'exact' | 'partial' | 'ddl_schema'
- conditions: Record
+export const exercises = pgTable("exercises", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ title: text("title").notNull(),
+ difficulty: text("difficulty").notNull(),
+ description: text("description").notNull(),
+ details: text("details").notNull(),
+ hint: text("hint").notNull(),
+ successMessage: text("success_message").notNull(),
+ example: jsonb("example")
+ .notNull()
+ .$type<{ entrada?: string; salida?: string }>(),
+ type: text("type").default("dml").notNull().$type<"dml" | "ddl">(),
+ validation: jsonb("validation").notNull().$type<{
+ type: "exact" | "partial" | "ddl_schema";
+ conditions: Record;
}>(),
- isDeleted: boolean('is_deleted').default(false).notNull(),
- createdAt: timestamp('created_at').defaultNow().notNull(),
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
-})
+ isDeleted: boolean("is_deleted").default(false).notNull(),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
+});
-export const submissions = pgTable('submissions', {
- id: uuid('id').primaryKey().defaultRandom(),
- userId: text('user_id').notNull(), // Clerk user ID (not UUID)
- exerciseId: uuid('exercise_id').notNull().references(() => exercises.id, { onDelete: 'cascade' }),
- score: integer('score').default(2).notNull(),
- solution: text('solution'), // User's SQL solution
- feedback: text('feedback'),
- attempts: integer('attempts').default(1).notNull(),
- timeSpentSeconds: integer('time_spent_seconds'),
- createdAt: timestamp('created_at').defaultNow().notNull(),
-})
+export const submissions = pgTable("submissions", {
+ id: uuid("id").primaryKey().defaultRandom(),
+ userId: text("user_id").notNull(), // Clerk user ID (not UUID)
+ exerciseId: uuid("exercise_id")
+ .notNull()
+ .references(() => exercises.id, { onDelete: "cascade" }),
+ score: integer("score").default(2).notNull(),
+ solution: text("solution"), // User's SQL solution
+ feedback: text("feedback"),
+ attempts: integer("attempts").default(1).notNull(),
+ timeSpentSeconds: integer("time_spent_seconds"),
+ createdAt: timestamp("created_at").defaultNow().notNull(),
+});
// Relations
export const profilesRelations = relations(profiles, ({ many }) => ({
submissions: many(submissions),
-}))
+}));
export const exercisesRelations = relations(exercises, ({ many }) => ({
submissions: many(submissions),
-}))
+}));
export const submissionsRelations = relations(submissions, ({ one }) => ({
exercise: one(exercises, {
@@ -61,4 +73,4 @@ export const submissionsRelations = relations(submissions, ({ one }) => ({
fields: [submissions.userId],
references: [profiles.id],
}),
-}))
+}));
diff --git a/src/lib/db/seed-data.ts b/src/lib/db/seed-data.ts
index 5200948..84e594d 100644
--- a/src/lib/db/seed-data.ts
+++ b/src/lib/db/seed-data.ts
@@ -1,133 +1,182 @@
export type ExerciseData = {
- title: string
- difficulty: 'Principiante' | 'Intermedio' | 'Avanzado'
- description: string
- details: string
- hint: string
- successMessage: string
- example: { entrada?: string; salida?: string }
- type?: 'dml' | 'ddl'
+ title: string;
+ difficulty: "Principiante" | "Intermedio" | "Avanzado";
+ description: string;
+ details: string;
+ hint: string;
+ successMessage: string;
+ example: { entrada?: string; salida?: string };
+ type?: "dml" | "ddl";
validation: {
- type: 'exact' | 'partial' | 'ddl_schema'
- conditions: Record
- }
-}
+ type: "exact" | "partial" | "ddl_schema";
+ conditions: Record;
+ };
+};
export const exercisesData: ExerciseData[] = [
{
- title: 'Consulta Básica de Selección',
- difficulty: 'Principiante',
- description: 'Selecciona todas las columnas de la tabla usuarios, limitando a 5 resultados.',
+ title: "Consulta Básica de Selección",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todas las columnas de la tabla usuarios, limitando a 5 resultados.",
details: `En este ejercicio, practicarás:
1. La consulta SELECT básica
2. Uso de LIMIT para restringir resultados
3. Los resultados se ordenarán automáticamente por ID`,
- hint: 'Estructura: SELECT * FROM tabla LIMIT número',
- successMessage: '¡Excelente trabajo! Has demostrado un buen entendimiento de cómo usar SELECT y limitar la cantidad de registros con LIMIT.',
+ hint: "Estructura: SELECT * FROM tabla LIMIT número",
+ successMessage:
+ "¡Excelente trabajo! Has demostrado un buen entendimiento de cómo usar SELECT y limitar la cantidad de registros con LIMIT.",
example: {
- entrada: "La tabla 'usuarios' con columnas: id, nombre, email, fecha_registro, edad, ciudad, activo",
- salida: '5 registros (ordenados automáticamente por ID)',
+ entrada:
+ "La tabla 'usuarios' con columnas: id, nombre, email, fecha_registro, edad, ciudad, activo",
+ salida: "5 registros (ordenados automáticamente por ID)",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 5,
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
values: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }, { id: 5 }],
},
},
},
{
- title: 'Selección de Columnas Específicas',
- difficulty: 'Principiante',
- description: 'Selecciona solo el nombre y email de todos los usuarios.',
+ title: "Selección de Columnas Específicas",
+ difficulty: "Principiante",
+ description: "Selecciona solo el nombre y email de todos los usuarios.",
details: `Aprenderás a:
1. Seleccionar columnas específicas en lugar de usar *
2. Optimizar consultas solicitando solo los datos necesarios`,
- hint: 'Usa SELECT columna1, columna2 FROM tabla',
- successMessage: '¡Muy bien! Seleccionar columnas específicas es una práctica importante para optimizar consultas.',
+ hint: "Usa SELECT columna1, columna2 FROM tabla",
+ successMessage:
+ "¡Muy bien! Seleccionar columnas específicas es una práctica importante para optimizar consultas.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Listado con nombre y email de todos los usuarios',
+ salida: "Listado con nombre y email de todos los usuarios",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
- columns: ['nombre', 'email'],
+ columns: ["nombre", "email"],
},
},
},
{
- title: 'Filtrado con WHERE',
- difficulty: 'Principiante',
- description: 'Selecciona todos los usuarios que tienen más de 30 años.',
+ title: "Filtrado con WHERE",
+ difficulty: "Principiante",
+ description: "Selecciona todos los usuarios que tienen más de 30 años.",
details: `Este ejercicio te enseñará a usar la cláusula WHERE para filtrar resultados basándote en condiciones numéricas.`,
- hint: 'Usa WHERE columna > valor',
- successMessage: '¡Excelente! Has aprendido a filtrar datos usando condiciones numéricas con WHERE.',
+ hint: "Usa WHERE columna > valor",
+ successMessage:
+ "¡Excelente! Has aprendido a filtrar datos usando condiciones numéricas con WHERE.",
example: {
entrada: "Tabla 'usuarios' con columna 'edad'",
- salida: 'Usuarios con edad mayor a 30',
+ salida: "Usuarios con edad mayor a 30",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
minAge: 30,
},
},
},
{
- title: 'Ordenamiento con ORDER BY',
- difficulty: 'Principiante',
- description: 'Selecciona todos los usuarios ordenados por nombre de forma alfabética.',
+ title: "Ordenamiento con ORDER BY",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todos los usuarios ordenados por nombre de forma alfabética.",
details: `Aprenderás a ordenar los resultados de tus consultas usando ORDER BY.`,
- hint: 'Usa ORDER BY columna ASC (o DESC para orden descendente)',
- successMessage: '¡Perfecto! El ordenamiento es fundamental para presentar datos de forma organizada.',
+ hint: "Usa ORDER BY columna ASC (o DESC para orden descendente)",
+ successMessage:
+ "¡Perfecto! El ordenamiento es fundamental para presentar datos de forma organizada.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Usuarios ordenados alfabéticamente por nombre',
+ salida: "Usuarios ordenados alfabéticamente por nombre",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
- orderBy: 'nombre',
- orderDirection: 'ASC',
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
+ orderBy: "nombre",
+ orderDirection: "ASC",
},
},
},
{
- title: 'Filtrado con Fechas',
- difficulty: 'Intermedio',
- description: "Selecciona usuarios que se registraron después del '2023-05-10'.",
+ title: "Filtrado con Fechas",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona usuarios que se registraron después del '2023-05-10'.",
details: `Este ejercicio te enseñará a usar la cláusula WHERE para filtrar resultados basándote en fechas.`,
hint: "Usa WHERE fecha_registro > '2023-05-10'",
- successMessage: '¡Muy bien! Has dominado el uso de la cláusula WHERE con comparaciones de fechas.',
+ successMessage:
+ "¡Muy bien! Has dominado el uso de la cláusula WHERE con comparaciones de fechas.",
example: {
entrada: "Tabla 'usuarios' con columna 'fecha_registro'",
salida: "Registros de usuarios con fecha_registro > '2023-05-10'",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
- values: [{ id: 5 }, { id: 6 }, { id: 7 }, { id: 8 }, { id: 9 }, { id: 10 }],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
+ values: [
+ { id: 5 },
+ { id: 6 },
+ { id: 7 },
+ { id: 8 },
+ { id: 9 },
+ { id: 10 },
+ ],
},
},
},
{
- title: 'Conteo con COUNT',
- difficulty: 'Intermedio',
- description: 'Cuenta cuántos usuarios hay en total en la tabla usuarios.',
+ title: "Conteo con COUNT",
+ difficulty: "Intermedio",
+ description: "Cuenta cuántos usuarios hay en total en la tabla usuarios.",
details: `Aprenderás a usar funciones de agregación como COUNT para obtener estadísticas de tus datos.`,
- hint: 'Usa SELECT COUNT(*) FROM tabla',
- successMessage: '¡Excelente! Las funciones de agregación son esenciales para análisis de datos.',
+ hint: "Usa SELECT COUNT(*) FROM tabla",
+ successMessage:
+ "¡Excelente! Las funciones de agregación son esenciales para análisis de datos.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Un número representando el total de usuarios',
+ salida: "Un número representando el total de usuarios",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 1,
hasCount: true,
@@ -135,75 +184,88 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'Operador LIKE',
- difficulty: 'Intermedio',
+ title: "Operador LIKE",
+ difficulty: "Intermedio",
description: "Encuentra todos los usuarios cuyo email contenga 'gmail'.",
details: `Aprenderás a buscar patrones en texto usando el operador LIKE con comodines (%).`,
hint: "Usa WHERE columna LIKE '%patron%'",
- successMessage: '¡Muy bien! El operador LIKE es muy útil para búsquedas de texto parciales.',
+ successMessage:
+ "¡Muy bien! El operador LIKE es muy útil para búsquedas de texto parciales.",
example: {
entrada: "Tabla 'usuarios' con columna 'email'",
salida: "Usuarios con emails que contengan 'gmail'",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
- emailPattern: 'gmail',
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
+ emailPattern: "gmail",
},
},
},
{
- title: 'Agrupamiento con GROUP BY',
- difficulty: 'Intermedio',
- description: 'Cuenta cuántos usuarios hay por cada ciudad.',
+ title: "Agrupamiento con GROUP BY",
+ difficulty: "Intermedio",
+ description: "Cuenta cuántos usuarios hay por cada ciudad.",
details: `Aprenderás a agrupar datos y aplicar funciones de agregación por grupos usando GROUP BY.`,
- hint: 'Usa SELECT ciudad, COUNT(*) FROM usuarios GROUP BY ciudad',
- successMessage: '¡Perfecto! GROUP BY es fundamental para crear reportes y estadísticas agrupadas.',
+ hint: "Usa SELECT ciudad, COUNT(*) FROM usuarios GROUP BY ciudad",
+ successMessage:
+ "¡Perfecto! GROUP BY es fundamental para crear reportes y estadísticas agrupadas.",
example: {
entrada: "Tabla 'usuarios' con columna 'ciudad'",
- salida: 'Lista de ciudades con el conteo de usuarios en cada una',
+ salida: "Lista de ciudades con el conteo de usuarios en cada una",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['ciudad', 'count'],
+ columns: ["ciudad", "count"],
hasGroupBy: true,
},
},
},
{
- title: 'JOIN Básico',
- difficulty: 'Avanzado',
- description: 'Obtén el nombre del usuario y el monto de cada pedido usando un JOIN entre usuarios y pedidos.',
+ title: "JOIN Básico",
+ difficulty: "Avanzado",
+ description:
+ "Obtén el nombre del usuario y el monto de cada pedido usando un JOIN entre usuarios y pedidos.",
details: `Aprenderás a combinar datos de múltiples tablas usando JOIN.`,
- hint: 'Usa SELECT ... FROM usuarios JOIN pedidos ON usuarios.id = pedidos.usuario_id',
- successMessage: '¡Excelente! Los JOINs son fundamentales para trabajar con bases de datos relacionales.',
+ hint: "Usa SELECT ... FROM usuarios JOIN pedidos ON usuarios.id = pedidos.usuario_id",
+ successMessage:
+ "¡Excelente! Los JOINs son fundamentales para trabajar con bases de datos relacionales.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos' relacionadas por usuario_id",
- salida: 'Listado con nombre de usuario y monto de cada pedido',
+ salida: "Listado con nombre de usuario y monto de cada pedido",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'monto'],
+ columns: ["nombre", "monto"],
hasJoin: true,
},
},
},
{
- title: 'Suma con SUM',
- difficulty: 'Avanzado',
- description: 'Calcula el monto total de todos los pedidos.',
+ title: "Suma con SUM",
+ difficulty: "Avanzado",
+ description: "Calcula el monto total de todos los pedidos.",
details: `Aprenderás a usar la función de agregación SUM para calcular totales.`,
- hint: 'Usa SELECT SUM(columna) FROM tabla',
- successMessage: '¡Muy bien! SUM es esencial para cálculos financieros y reportes.',
+ hint: "Usa SELECT SUM(columna) FROM tabla",
+ successMessage:
+ "¡Muy bien! SUM es esencial para cálculos financieros y reportes.",
example: {
entrada: "Tabla 'pedidos' con columna 'monto'",
- salida: 'Un número representando la suma total de todos los montos',
+ salida: "Un número representando la suma total de todos los montos",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 1,
hasSum: true,
@@ -211,20 +273,23 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'Total de Pedidos por Usuario',
- difficulty: 'Avanzado',
- description: 'Obtén el nombre de cada usuario junto con el número total de pedidos que ha realizado. Ordena por nombre.',
+ title: "Total de Pedidos por Usuario",
+ difficulty: "Avanzado",
+ description:
+ "Obtén el nombre de cada usuario junto con el número total de pedidos que ha realizado. Ordena por nombre.",
details: `Combinarás JOIN, COUNT y GROUP BY para crear un reporte completo de pedidos por usuario.`,
- hint: 'Usa LEFT JOIN para incluir usuarios sin pedidos, COUNT para contar y GROUP BY para agrupar.',
- successMessage: '¡Impresionante! Has combinado múltiples conceptos avanzados de SQL.',
+ hint: "Usa LEFT JOIN para incluir usuarios sin pedidos, COUNT para contar y GROUP BY para agrupar.",
+ successMessage:
+ "¡Impresionante! Has combinado múltiples conceptos avanzados de SQL.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Lista de usuarios con el total de sus pedidos (nombre, email, total_pedidos)',
+ salida:
+ "Lista de usuarios con el total de sus pedidos (nombre, email, total_pedidos)",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'email', 'total_pedidos'],
+ columns: ["nombre", "email", "total_pedidos"],
hasJoin: true,
hasGroupBy: true,
hasCount: true,
@@ -232,18 +297,20 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'Subconsulta Básica',
- difficulty: 'Avanzado',
- description: 'Encuentra los usuarios que han realizado pedidos con monto mayor al promedio de todos los pedidos.',
+ title: "Subconsulta Básica",
+ difficulty: "Avanzado",
+ description:
+ "Encuentra los usuarios que han realizado pedidos con monto mayor al promedio de todos los pedidos.",
details: `Aprenderás a usar subconsultas para crear condiciones más complejas.`,
- hint: 'Usa una subconsulta con SELECT AVG(monto) FROM pedidos en el WHERE',
- successMessage: '¡Excelente! Las subconsultas permiten resolver problemas complejos de manera elegante.',
+ hint: "Usa una subconsulta con SELECT AVG(monto) FROM pedidos en el WHERE",
+ successMessage:
+ "¡Excelente! Las subconsultas permiten resolver problemas complejos de manera elegante.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Usuarios con pedidos superiores al promedio',
+ salida: "Usuarios con pedidos superiores al promedio",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
hasSubquery: true,
hasAvg: true,
@@ -253,112 +320,154 @@ export const exercisesData: ExerciseData[] = [
// New DML Exercises - Principiante
{
- title: 'Uso de Alias (AS)',
- difficulty: 'Principiante',
- description: 'Selecciona el nombre y email de los usuarios, renombrando las columnas como "nombre_usuario" y "correo" respectivamente.',
+ title: "Uso de Alias (AS)",
+ difficulty: "Principiante",
+ description:
+ 'Selecciona el nombre y email de los usuarios, renombrando las columnas como "nombre_usuario" y "correo" respectivamente.',
details: `Aprenderás a:
1. Usar AS para renombrar columnas en el resultado
2. Mejorar la legibilidad de los resultados
3. Crear nombres más descriptivos para tus datos`,
- hint: 'Usa SELECT columna AS nuevo_nombre FROM tabla',
- successMessage: '¡Muy bien! Los alias hacen tus consultas más legibles y profesionales.',
+ hint: "Usa SELECT columna AS nuevo_nombre FROM tabla",
+ successMessage:
+ "¡Muy bien! Los alias hacen tus consultas más legibles y profesionales.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Listado con columnas renombradas a nombre_usuario y correo',
+ salida: "Listado con columnas renombradas a nombre_usuario y correo",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
- columns: ['nombre_usuario', 'correo'],
+ columns: ["nombre_usuario", "correo"],
},
},
},
{
- title: 'Manejo de NULL',
- difficulty: 'Principiante',
- description: 'Selecciona todos los usuarios que NO tienen ciudad registrada (ciudad es NULL).',
+ title: "Manejo de NULL",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todos los usuarios que NO tienen ciudad registrada (ciudad es NULL).",
details: `Este ejercicio te enseñará:
1. Cómo funciona NULL en SQL
2. Usar IS NULL para encontrar valores vacíos
3. La diferencia entre NULL y cadena vacía`,
- hint: 'Usa WHERE columna IS NULL (no uses = NULL)',
- successMessage: '¡Excelente! Entender NULL es fundamental para manejar datos incompletos.',
+ hint: "Usa WHERE columna IS NULL (no uses = NULL)",
+ successMessage:
+ "¡Excelente! Entender NULL es fundamental para manejar datos incompletos.",
example: {
entrada: "Tabla 'usuarios' con algunos valores NULL en ciudad",
- salida: 'Usuarios sin ciudad registrada',
+ salida: "Usuarios sin ciudad registrada",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasNullCheck: true,
- columnToCheck: 'ciudad',
+ columnToCheck: "ciudad",
},
},
},
{
- title: 'Ordenamiento Descendente',
- difficulty: 'Principiante',
- description: 'Selecciona todos los usuarios ordenados por edad de mayor a menor.',
+ title: "Ordenamiento Descendente",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todos los usuarios ordenados por edad de mayor a menor.",
details: `Aprenderás a:
1. Usar ORDER BY con DESC para orden descendente
2. Entender la diferencia entre ASC y DESC
3. Ordenar datos numéricos de mayor a menor`,
- hint: 'Usa ORDER BY columna DESC',
- successMessage: '¡Muy bien! ORDER BY DESC es útil para ver primero los valores más altos.',
+ hint: "Usa ORDER BY columna DESC",
+ successMessage:
+ "¡Muy bien! ORDER BY DESC es útil para ver primero los valores más altos.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Usuarios ordenados de mayor a menor edad',
+ salida: "Usuarios ordenados de mayor a menor edad",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
- orderBy: 'edad',
- orderDirection: 'DESC',
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
+ orderBy: "edad",
+ orderDirection: "DESC",
},
},
},
{
- title: 'Operadores Lógicos (AND/OR)',
- difficulty: 'Principiante',
- description: "Selecciona todos los usuarios que tienen más de 25 años Y viven en 'Madrid'.",
+ title: "Operadores Lógicos (AND/OR)",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todos los usuarios que tienen más de 25 años Y viven en 'Madrid'.",
details: `Aprenderás a:
1. Combinar múltiples condiciones con AND
2. Entender la diferencia entre AND y OR
3. Crear filtros más precisos`,
- hint: 'Usa WHERE condicion1 AND condicion2',
- successMessage: '¡Muy bien! Los operadores lógicos te permiten crear filtros muy específicos.',
+ hint: "Usa WHERE condicion1 AND condicion2",
+ successMessage:
+ "¡Muy bien! Los operadores lógicos te permiten crear filtros muy específicos.",
example: {
entrada: "Tabla 'usuarios'",
salida: "Usuarios mayores de 25 años que viven en Madrid",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasAnd: true,
},
},
},
{
- title: 'Operador NOT',
- difficulty: 'Principiante',
- description: "Selecciona todos los usuarios que NO están activos (activo = false).",
+ title: "Operador NOT",
+ difficulty: "Principiante",
+ description:
+ "Selecciona todos los usuarios que NO están activos (activo = false).",
details: `Este ejercicio te enseñará:
1. Usar NOT para negar condiciones
2. Filtrar por valores booleanos
3. Excluir registros específicos`,
- hint: 'Usa WHERE NOT activo o WHERE activo = false',
- successMessage: '¡Excelente! NOT es útil para excluir registros que cumplen ciertas condiciones.',
+ hint: "Usa WHERE NOT activo o WHERE activo = false",
+ successMessage:
+ "¡Excelente! NOT es útil para excluir registros que cumplen ciertas condiciones.",
example: {
entrada: "Tabla 'usuarios' con columna 'activo'",
- salida: 'Usuarios que no están activos',
+ salida: "Usuarios que no están activos",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
checkInactive: true,
},
},
@@ -366,114 +475,139 @@ export const exercisesData: ExerciseData[] = [
// New DML Exercises - Intermedio
{
- title: 'DISTINCT - Valores Únicos',
- difficulty: 'Intermedio',
- description: 'Selecciona todas las ciudades únicas (sin repetir) de la tabla usuarios.',
+ title: "DISTINCT - Valores Únicos",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona todas las ciudades únicas (sin repetir) de la tabla usuarios.",
details: `Aprenderás a:
1. Usar DISTINCT para eliminar duplicados
2. Obtener valores únicos de una columna
3. Identificar la variedad de datos en tu tabla`,
- hint: 'Usa SELECT DISTINCT columna FROM tabla',
- successMessage: '¡Perfecto! DISTINCT es muy útil para analizar la variedad de datos.',
+ hint: "Usa SELECT DISTINCT columna FROM tabla",
+ successMessage:
+ "¡Perfecto! DISTINCT es muy útil para analizar la variedad de datos.",
example: {
entrada: "Tabla 'usuarios' con columna 'ciudad'",
- salida: 'Lista de ciudades únicas sin repeticiones',
+ salida: "Lista de ciudades únicas sin repeticiones",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['ciudad'],
+ columns: ["ciudad"],
hasDistinct: true,
},
},
},
{
- title: 'Operador IN',
- difficulty: 'Intermedio',
- description: "Selecciona todos los usuarios que viven en 'Madrid', 'Barcelona' o 'Valencia'.",
+ title: "Operador IN",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona todos los usuarios que viven en 'Madrid', 'Barcelona' o 'Valencia'.",
details: `Este ejercicio te enseñará:
1. Usar IN para filtrar por múltiples valores
2. Simplificar consultas con múltiples OR
3. Mejorar la legibilidad de tus filtros`,
hint: "Usa WHERE columna IN ('valor1', 'valor2', 'valor3')",
- successMessage: '¡Muy bien! IN es más limpio que múltiples condiciones OR.',
+ successMessage: "¡Muy bien! IN es más limpio que múltiples condiciones OR.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Usuarios de Madrid, Barcelona o Valencia',
+ salida: "Usuarios de Madrid, Barcelona o Valencia",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasIn: true,
- inValues: ['Madrid', 'Barcelona', 'Valencia'],
+ inValues: ["Madrid", "Barcelona", "Valencia"],
},
},
},
{
- title: 'BETWEEN - Rango de Valores',
- difficulty: 'Intermedio',
- description: 'Selecciona todos los usuarios con edad entre 25 y 35 años (inclusive).',
+ title: "BETWEEN - Rango de Valores",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona todos los usuarios con edad entre 25 y 35 años (inclusive).",
details: `Aprenderás a:
1. Filtrar valores dentro de un rango con BETWEEN
2. Entender que BETWEEN incluye los límites
3. Simplificar condiciones de rango`,
- hint: 'Usa WHERE columna BETWEEN valor1 AND valor2',
- successMessage: '¡Excelente! BETWEEN es ideal para filtrar rangos de números o fechas.',
+ hint: "Usa WHERE columna BETWEEN valor1 AND valor2",
+ successMessage:
+ "¡Excelente! BETWEEN es ideal para filtrar rangos de números o fechas.",
example: {
entrada: "Tabla 'usuarios' con columna 'edad'",
- salida: 'Usuarios con edad entre 25 y 35',
+ salida: "Usuarios con edad entre 25 y 35",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasBetween: true,
- betweenColumn: 'edad',
+ betweenColumn: "edad",
betweenMin: 25,
betweenMax: 35,
},
},
},
{
- title: 'HAVING - Filtrar Agregaciones',
- difficulty: 'Intermedio',
- description: 'Cuenta los usuarios por ciudad y muestra solo las ciudades con más de 1 usuario.',
+ title: "HAVING - Filtrar Agregaciones",
+ difficulty: "Intermedio",
+ description:
+ "Cuenta los usuarios por ciudad y muestra solo las ciudades con más de 1 usuario.",
details: `Este ejercicio te enseñará:
1. Usar HAVING para filtrar resultados agrupados
2. La diferencia entre WHERE y HAVING
3. Combinar GROUP BY con filtros de agregación`,
- hint: 'Usa GROUP BY ciudad HAVING COUNT(*) > 1',
- successMessage: '¡Perfecto! HAVING filtra después de agrupar, a diferencia de WHERE.',
+ hint: "Usa GROUP BY ciudad HAVING COUNT(*) > 1",
+ successMessage:
+ "¡Perfecto! HAVING filtra después de agrupar, a diferencia de WHERE.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Ciudades con más de 1 usuario y su conteo',
+ salida: "Ciudades con más de 1 usuario y su conteo",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['ciudad', 'count'],
+ columns: ["ciudad", "count"],
hasGroupBy: true,
hasHaving: true,
},
},
},
{
- title: 'LOWER y UPPER - Transformar Texto',
- difficulty: 'Intermedio',
- description: 'Selecciona el nombre en mayúsculas y el email en minúsculas de todos los usuarios.',
+ title: "LOWER y UPPER - Transformar Texto",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona el nombre en mayúsculas y el email en minúsculas de todos los usuarios.",
details: `Aprenderás a:
1. Usar UPPER() para convertir texto a mayúsculas
2. Usar LOWER() para convertir texto a minúsculas
3. Transformar datos de texto en consultas`,
- hint: 'Usa SELECT UPPER(nombre), LOWER(email) FROM tabla',
- successMessage: '¡Perfecto! Las funciones de texto son muy útiles para normalizar datos.',
+ hint: "Usa SELECT UPPER(nombre), LOWER(email) FROM tabla",
+ successMessage:
+ "¡Perfecto! Las funciones de texto son muy útiles para normalizar datos.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Nombres en MAYÚSCULAS y emails en minúsculas',
+ salida: "Nombres en MAYÚSCULAS y emails en minúsculas",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
hasUpper: true,
hasLower: true,
@@ -481,21 +615,23 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'ROUND - Redondear Números',
- difficulty: 'Intermedio',
- description: 'Calcula el promedio de los montos de pedidos y redondéalo a 2 decimales.',
+ title: "ROUND - Redondear Números",
+ difficulty: "Intermedio",
+ description:
+ "Calcula el promedio de los montos de pedidos y redondéalo a 2 decimales.",
details: `Este ejercicio te enseñará:
1. Usar ROUND() para redondear números
2. Especificar la cantidad de decimales
3. Combinar funciones de agregación con ROUND`,
- hint: 'Usa SELECT ROUND(AVG(columna), 2) FROM tabla',
- successMessage: '¡Muy bien! ROUND es esencial para presentar números de forma legible.',
+ hint: "Usa SELECT ROUND(AVG(columna), 2) FROM tabla",
+ successMessage:
+ "¡Muy bien! ROUND es esencial para presentar números de forma legible.",
example: {
entrada: "Tabla 'pedidos'",
- salida: 'Promedio de montos redondeado a 2 decimales',
+ salida: "Promedio de montos redondeado a 2 decimales",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 1,
hasRound: true,
@@ -504,21 +640,22 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'Promedio con AVG',
- difficulty: 'Intermedio',
- description: 'Calcula la edad promedio de todos los usuarios.',
+ title: "Promedio con AVG",
+ difficulty: "Intermedio",
+ description: "Calcula la edad promedio de todos los usuarios.",
details: `Aprenderás a:
1. Usar la función de agregación AVG
2. Calcular promedios de columnas numéricas
3. Obtener estadísticas básicas de tus datos`,
- hint: 'Usa SELECT AVG(columna) FROM tabla',
- successMessage: '¡Muy bien! AVG es fundamental para análisis estadísticos básicos.',
+ hint: "Usa SELECT AVG(columna) FROM tabla",
+ successMessage:
+ "¡Muy bien! AVG es fundamental para análisis estadísticos básicos.",
example: {
entrada: "Tabla 'usuarios' con columna 'edad'",
- salida: 'Un número decimal representando la edad promedio',
+ salida: "Un número decimal representando la edad promedio",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 1,
hasAvg: true,
@@ -526,21 +663,23 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'MIN y MAX',
- difficulty: 'Intermedio',
- description: 'Obtén la edad mínima y máxima de los usuarios en una sola consulta.',
+ title: "MIN y MAX",
+ difficulty: "Intermedio",
+ description:
+ "Obtén la edad mínima y máxima de los usuarios en una sola consulta.",
details: `Este ejercicio te enseñará:
1. Usar MIN para encontrar el valor más pequeño
2. Usar MAX para encontrar el valor más grande
3. Combinar múltiples funciones de agregación`,
- hint: 'Usa SELECT MIN(columna), MAX(columna) FROM tabla',
- successMessage: '¡Excelente! MIN y MAX son muy útiles para encontrar extremos en tus datos.',
+ hint: "Usa SELECT MIN(columna), MAX(columna) FROM tabla",
+ successMessage:
+ "¡Excelente! MIN y MAX son muy útiles para encontrar extremos en tus datos.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Dos valores: la edad mínima y la edad máxima',
+ salida: "Dos valores: la edad mínima y la edad máxima",
},
validation: {
- type: 'exact' as const,
+ type: "exact" as const,
conditions: {
rows: 1,
hasMin: true,
@@ -549,45 +688,49 @@ export const exercisesData: ExerciseData[] = [
},
},
{
- title: 'COALESCE - Valores por Defecto',
- difficulty: 'Intermedio',
- description: "Selecciona el nombre y ciudad de cada usuario, mostrando 'Sin ciudad' cuando la ciudad sea NULL.",
+ title: "COALESCE - Valores por Defecto",
+ difficulty: "Intermedio",
+ description:
+ "Selecciona el nombre y ciudad de cada usuario, mostrando 'Sin ciudad' cuando la ciudad sea NULL.",
details: `Aprenderás a:
1. Usar COALESCE para manejar valores NULL
2. Proporcionar valores por defecto
3. Mejorar la presentación de datos incompletos`,
hint: "Usa COALESCE(columna, 'valor_por_defecto')",
- successMessage: '¡Perfecto! COALESCE es muy útil para manejar datos faltantes de forma elegante.',
+ successMessage:
+ "¡Perfecto! COALESCE es muy útil para manejar datos faltantes de forma elegante.",
example: {
entrada: "Tabla 'usuarios' con algunos NULL en ciudad",
salida: "Usuarios con 'Sin ciudad' en lugar de NULL",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'ciudad'],
+ columns: ["nombre", "ciudad"],
hasCoalesce: true,
},
},
},
{
- title: 'Concatenación de Texto',
- difficulty: 'Intermedio',
- description: "Crea una columna llamada 'info_usuario' que combine el nombre y email en formato: 'Nombre (email)'.",
+ title: "Concatenación de Texto",
+ difficulty: "Intermedio",
+ description:
+ "Crea una columna llamada 'info_usuario' que combine el nombre y email en formato: 'Nombre (email)'.",
details: `Este ejercicio te enseñará:
1. Concatenar columnas de texto con ||
2. Agregar texto literal entre columnas
3. Crear campos calculados de texto`,
hint: "Usa columna1 || ' (' || columna2 || ')' AS info_usuario",
- successMessage: '¡Muy bien! La concatenación es útil para crear campos personalizados.',
+ successMessage:
+ "¡Muy bien! La concatenación es útil para crear campos personalizados.",
example: {
entrada: "Tabla 'usuarios' con nombre y email",
salida: "Columna con formato 'Juan García (juan@email.com)'",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['info_usuario'],
+ columns: ["info_usuario"],
hasConcat: true,
},
},
@@ -595,223 +738,260 @@ export const exercisesData: ExerciseData[] = [
// New DML Exercises - Avanzado
{
- title: 'Múltiples JOINs',
- difficulty: 'Avanzado',
- description: 'Obtén el nombre del usuario, el monto de cada pedido y el nombre del producto. Une las tablas usuarios, pedidos y productos.',
+ title: "Múltiples JOINs",
+ difficulty: "Avanzado",
+ description:
+ "Obtén el nombre del usuario, el monto de cada pedido y el nombre del producto. Une las tablas usuarios, pedidos y productos.",
details: `Este ejercicio avanzado te enseñará:
1. Unir más de dos tablas en una consulta
2. Seguir las relaciones entre múltiples tablas
3. Construir consultas complejas paso a paso`,
- hint: 'Encadena los JOINs: FROM usuarios JOIN pedidos ON ... JOIN productos ON ...',
- successMessage: '¡Impresionante! Dominar múltiples JOINs te permite crear reportes muy completos.',
+ hint: "Encadena los JOINs: FROM usuarios JOIN pedidos ON ... JOIN productos ON ...",
+ successMessage:
+ "¡Impresionante! Dominar múltiples JOINs te permite crear reportes muy completos.",
example: {
entrada: "Tablas 'usuarios', 'pedidos' y 'productos'",
- salida: 'Listado con nombre de usuario, monto del pedido y nombre del producto',
+ salida:
+ "Listado con nombre de usuario, monto del pedido y nombre del producto",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'monto', 'producto'],
+ columns: ["nombre", "monto", "producto"],
hasMultipleJoins: true,
minJoins: 2,
},
},
},
{
- title: 'LEFT JOIN con NULL',
- difficulty: 'Avanzado',
- description: 'Encuentra todos los usuarios que NO han realizado ningún pedido usando LEFT JOIN.',
+ title: "LEFT JOIN con NULL",
+ difficulty: "Avanzado",
+ description:
+ "Encuentra todos los usuarios que NO han realizado ningún pedido usando LEFT JOIN.",
details: `Aprenderás a:
1. Usar LEFT JOIN para incluir registros sin coincidencias
2. Identificar registros huérfanos con IS NULL
3. Patrones comunes de análisis de datos`,
- hint: 'Usa LEFT JOIN y filtra WHERE pedidos.id IS NULL',
- successMessage: '¡Excelente! Este patrón es muy útil para encontrar datos faltantes.',
+ hint: "Usa LEFT JOIN y filtra WHERE pedidos.id IS NULL",
+ successMessage:
+ "¡Excelente! Este patrón es muy útil para encontrar datos faltantes.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Usuarios sin ningún pedido registrado',
+ salida: "Usuarios sin ningún pedido registrado",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'email'],
+ columns: ["nombre", "email"],
hasLeftJoin: true,
hasNullCheck: true,
},
},
},
{
- title: 'UNION - Combinar Resultados',
- difficulty: 'Avanzado',
- description: 'Combina en una sola lista los nombres de todos los usuarios y los nombres de todos los productos, en una columna llamada "nombre".',
+ title: "UNION - Combinar Resultados",
+ difficulty: "Avanzado",
+ description:
+ 'Combina en una sola lista los nombres de todos los usuarios y los nombres de todos los productos, en una columna llamada "nombre".',
details: `Este ejercicio te enseñará:
1. Usar UNION para combinar resultados de múltiples consultas
2. Entender que UNION elimina duplicados
3. La diferencia entre UNION y UNION ALL`,
- hint: 'SELECT nombre FROM tabla1 UNION SELECT nombre FROM tabla2',
- successMessage: '¡Muy bien! UNION es poderoso para combinar datos de diferentes fuentes.',
+ hint: "SELECT nombre FROM tabla1 UNION SELECT nombre FROM tabla2",
+ successMessage:
+ "¡Muy bien! UNION es poderoso para combinar datos de diferentes fuentes.",
example: {
entrada: "Tablas 'usuarios' y 'productos'",
- salida: 'Lista combinada de nombres de usuarios y productos',
+ salida: "Lista combinada de nombres de usuarios y productos",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre'],
+ columns: ["nombre"],
hasUnion: true,
},
},
},
{
- title: 'CASE WHEN - Expresiones Condicionales',
- difficulty: 'Avanzado',
- description: "Selecciona el nombre de cada usuario y una columna 'categoria_edad' que muestre 'Joven' si edad < 30, 'Adulto' si edad entre 30 y 50, y 'Senior' si edad > 50.",
+ title: "CASE WHEN - Expresiones Condicionales",
+ difficulty: "Avanzado",
+ description:
+ "Selecciona el nombre de cada usuario y una columna 'categoria_edad' que muestre 'Joven' si edad < 30, 'Adulto' si edad entre 30 y 50, y 'Senior' si edad > 50.",
details: `Aprenderás a:
1. Crear columnas calculadas con CASE WHEN
2. Implementar lógica condicional en SQL
3. Categorizar datos dinámicamente`,
hint: "Usa CASE WHEN condicion THEN 'valor' WHEN ... ELSE 'valor' END AS columna",
- successMessage: '¡Impresionante! CASE WHEN te permite crear análisis muy sofisticados.',
+ successMessage:
+ "¡Impresionante! CASE WHEN te permite crear análisis muy sofisticados.",
example: {
entrada: "Tabla 'usuarios' con columna 'edad'",
- salida: 'Usuarios con su categoría de edad calculada',
+ salida: "Usuarios con su categoría de edad calculada",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'categoria_edad'],
+ columns: ["nombre", "categoria_edad"],
hasCaseWhen: true,
},
},
},
{
- title: 'Self JOIN - Auto-unión',
- difficulty: 'Avanzado',
- description: 'Encuentra pares de usuarios que viven en la misma ciudad (cada par debe aparecer una sola vez).',
+ title: "Self JOIN - Auto-unión",
+ difficulty: "Avanzado",
+ description:
+ "Encuentra pares de usuarios que viven en la misma ciudad (cada par debe aparecer una sola vez).",
details: `Aprenderás a:
1. Unir una tabla consigo misma (Self JOIN)
2. Usar alias para diferenciar las instancias
3. Evitar duplicados en los resultados`,
- hint: 'Usa FROM usuarios u1 JOIN usuarios u2 ON u1.ciudad = u2.ciudad WHERE u1.id < u2.id',
- successMessage: '¡Impresionante! Self JOIN es útil para comparar registros dentro de la misma tabla.',
+ hint: "Usa FROM usuarios u1 JOIN usuarios u2 ON u1.ciudad = u2.ciudad WHERE u1.id < u2.id",
+ successMessage:
+ "¡Impresionante! Self JOIN es útil para comparar registros dentro de la misma tabla.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Pares de usuarios que comparten ciudad',
+ salida: "Pares de usuarios que comparten ciudad",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
hasSelfJoin: true,
},
},
},
{
- title: 'NOT IN con Subconsulta',
- difficulty: 'Avanzado',
- description: 'Selecciona todos los usuarios que NO han realizado ningún pedido usando NOT IN.',
+ title: "NOT IN con Subconsulta",
+ difficulty: "Avanzado",
+ description:
+ "Selecciona todos los usuarios que NO han realizado ningún pedido usando NOT IN.",
details: `Este ejercicio te enseñará:
1. Usar NOT IN con una subconsulta
2. Excluir registros basándose en otra tabla
3. Comparar NOT IN vs LEFT JOIN con NULL`,
- hint: 'Usa WHERE id NOT IN (SELECT usuario_id FROM pedidos)',
- successMessage: '¡Excelente! NOT IN es otra forma de encontrar registros sin coincidencias.',
+ hint: "Usa WHERE id NOT IN (SELECT usuario_id FROM pedidos)",
+ successMessage:
+ "¡Excelente! NOT IN es otra forma de encontrar registros sin coincidencias.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Usuarios sin pedidos',
+ salida: "Usuarios sin pedidos",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasNotIn: true,
hasSubquery: true,
},
},
},
{
- title: 'EXISTS - Verificar Existencia',
- difficulty: 'Avanzado',
- description: 'Selecciona los usuarios que tienen al menos un pedido usando EXISTS.',
+ title: "EXISTS - Verificar Existencia",
+ difficulty: "Avanzado",
+ description:
+ "Selecciona los usuarios que tienen al menos un pedido usando EXISTS.",
details: `Aprenderás a:
1. Usar EXISTS para verificar si existen registros relacionados
2. Entender cuándo usar EXISTS vs JOIN
3. Crear subconsultas correlacionadas simples`,
- hint: 'Usa WHERE EXISTS (SELECT 1 FROM pedidos WHERE pedidos.usuario_id = usuarios.id)',
- successMessage: '¡Excelente! EXISTS es muy eficiente para verificar la existencia de registros.',
+ hint: "Usa WHERE EXISTS (SELECT 1 FROM pedidos WHERE pedidos.usuario_id = usuarios.id)",
+ successMessage:
+ "¡Excelente! EXISTS es muy eficiente para verificar la existencia de registros.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Solo usuarios que han realizado al menos un pedido',
+ salida: "Solo usuarios que han realizado al menos un pedido",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['id', 'nombre', 'email', 'fecha_registro', 'edad', 'ciudad', 'activo'],
+ columns: [
+ "id",
+ "nombre",
+ "email",
+ "fecha_registro",
+ "edad",
+ "ciudad",
+ "activo",
+ ],
hasExists: true,
},
},
},
{
- title: 'Subconsulta Correlacionada',
- difficulty: 'Avanzado',
- description: 'Para cada usuario, muestra su nombre y el monto total de sus pedidos usando una subconsulta en el SELECT.',
+ title: "Subconsulta Correlacionada",
+ difficulty: "Avanzado",
+ description:
+ "Para cada usuario, muestra su nombre y el monto total de sus pedidos usando una subconsulta en el SELECT.",
details: `Este ejercicio avanzado te enseñará:
1. Crear subconsultas correlacionadas en SELECT
2. Calcular valores por cada fila del resultado
3. Entender la diferencia con JOINs y GROUP BY`,
- hint: 'Usa SELECT nombre, (SELECT SUM(monto) FROM pedidos WHERE usuario_id = usuarios.id) FROM usuarios',
- successMessage: '¡Impresionante! Las subconsultas correlacionadas son poderosas para cálculos por fila.',
+ hint: "Usa SELECT nombre, (SELECT SUM(monto) FROM pedidos WHERE usuario_id = usuarios.id) FROM usuarios",
+ successMessage:
+ "¡Impresionante! Las subconsultas correlacionadas son poderosas para cálculos por fila.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Cada usuario con el total de sus pedidos',
+ salida: "Cada usuario con el total de sus pedidos",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'total'],
+ columns: ["nombre", "total"],
hasCorrelatedSubquery: true,
},
},
},
{
- title: 'ROW_NUMBER - Funciones de Ventana',
- difficulty: 'Avanzado',
- description: 'Asigna un número de fila a cada usuario ordenado por fecha de registro, mostrando nombre, fecha_registro y el número de fila.',
+ title: "ROW_NUMBER - Funciones de Ventana",
+ difficulty: "Avanzado",
+ description:
+ "Asigna un número de fila a cada usuario ordenado por fecha de registro, mostrando nombre, fecha_registro y el número de fila.",
details: `Aprenderás a:
1. Usar funciones de ventana (window functions)
2. Numerar filas con ROW_NUMBER()
3. Usar OVER() para definir el orden`,
- hint: 'Usa ROW_NUMBER() OVER (ORDER BY fecha_registro) AS numero_fila',
- successMessage: '¡Muy bien! Las funciones de ventana son herramientas muy poderosas en SQL avanzado.',
+ hint: "Usa ROW_NUMBER() OVER (ORDER BY fecha_registro) AS numero_fila",
+ successMessage:
+ "¡Muy bien! Las funciones de ventana son herramientas muy poderosas en SQL avanzado.",
example: {
entrada: "Tabla 'usuarios'",
- salida: 'Usuarios con su número de fila según orden de registro',
+ salida: "Usuarios con su número de fila según orden de registro",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'fecha_registro', 'numero_fila'],
+ columns: ["nombre", "fecha_registro", "numero_fila"],
hasRowNumber: true,
},
},
},
{
- title: 'Pedido Máximo por Usuario',
- difficulty: 'Avanzado',
- description: 'Muestra el nombre de cada usuario junto con el monto de su pedido más alto.',
+ title: "Pedido Máximo por Usuario",
+ difficulty: "Avanzado",
+ description:
+ "Muestra el nombre de cada usuario junto con el monto de su pedido más alto.",
details: `Este ejercicio práctico combina:
1. JOINs con subconsultas
2. Funciones de agregación en subconsultas
3. Correlación entre consulta principal y subconsulta`,
- hint: 'Usa una subconsulta con MAX(monto) correlacionada por usuario_id',
- successMessage: '¡Excelente! Has dominado la combinación de múltiples técnicas avanzadas de SQL.',
+ hint: "Usa una subconsulta con MAX(monto) correlacionada por usuario_id",
+ successMessage:
+ "¡Excelente! Has dominado la combinación de múltiples técnicas avanzadas de SQL.",
example: {
entrada: "Tablas 'usuarios' y 'pedidos'",
- salida: 'Nombre de usuario y su pedido de mayor monto',
+ salida: "Nombre de usuario y su pedido de mayor monto",
},
validation: {
- type: 'partial' as const,
+ type: "partial" as const,
conditions: {
- columns: ['nombre', 'max_monto'],
+ columns: ["nombre", "max_monto"],
hasMax: true,
hasJoin: true,
},
@@ -820,116 +1000,126 @@ export const exercisesData: ExerciseData[] = [
// DDL Exercises - Principiante
{
- title: 'CREATE TABLE - Tabla Básica',
- difficulty: 'Principiante',
- description: 'Crea una tabla llamada "productos" con columnas: id (SERIAL), nombre (VARCHAR(100)) y precio (DECIMAL(10,2)).',
+ title: "CREATE TABLE - Tabla Básica",
+ difficulty: "Principiante",
+ description:
+ 'Crea una tabla llamada "productos" con columnas: id (SERIAL), nombre (VARCHAR(100)) y precio (DECIMAL(10,2)).',
details: `En este ejercicio aprenderás:
1. La sintaxis básica de CREATE TABLE
2. Definir columnas con diferentes tipos de datos
3. Usar SERIAL para auto-incremento`,
- hint: 'CREATE TABLE nombre_tabla (columna1 tipo1, columna2 tipo2, ...)',
- successMessage: '¡Excelente! Has creado tu primera tabla. CREATE TABLE es fundamental para diseñar bases de datos.',
+ hint: "CREATE TABLE nombre_tabla (columna1 tipo1, columna2 tipo2, ...)",
+ successMessage:
+ "¡Excelente! Has creado tu primera tabla. CREATE TABLE es fundamental para diseñar bases de datos.",
example: {
- entrada: 'Esquema vacío',
+ entrada: "Esquema vacío",
salida: 'Tabla "productos" con 3 columnas',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
schemaInspection: {
- table: 'productos',
+ table: "productos",
columns: [
- { name: 'id', type: 'integer' },
- { name: 'nombre', type: 'character varying' },
- { name: 'precio', type: 'numeric' },
+ { name: "id", type: "integer" },
+ { name: "nombre", type: "character varying" },
+ { name: "precio", type: "numeric" },
],
},
testQueries: [
- { query: "INSERT INTO productos (nombre, precio) VALUES ('Test', 9.99)", shouldSucceed: true },
- { query: 'SELECT * FROM productos', shouldSucceed: true },
+ {
+ query:
+ "INSERT INTO productos (nombre, precio) VALUES ('Test', 9.99)",
+ shouldSucceed: true,
+ },
+ { query: "SELECT * FROM productos", shouldSucceed: true },
],
},
},
},
{
- title: 'DROP TABLE - Eliminar Tabla',
- difficulty: 'Principiante',
+ title: "DROP TABLE - Eliminar Tabla",
+ difficulty: "Principiante",
description: 'Elimina la tabla "temporal" que ya existe en el esquema.',
details: `Aprenderás a:
1. Eliminar tablas existentes con DROP TABLE
2. Usar IF EXISTS para evitar errores si la tabla no existe`,
- hint: 'DROP TABLE nombre_tabla o DROP TABLE IF EXISTS nombre_tabla',
- successMessage: '¡Muy bien! Has eliminado la tabla correctamente. DROP TABLE es útil para limpiar estructuras no necesarias.',
+ hint: "DROP TABLE nombre_tabla o DROP TABLE IF EXISTS nombre_tabla",
+ successMessage:
+ "¡Muy bien! Has eliminado la tabla correctamente. DROP TABLE es útil para limpiar estructuras no necesarias.",
example: {
entrada: 'Tabla "temporal" existente',
- salida: 'Tabla eliminada del esquema',
+ salida: "Tabla eliminada del esquema",
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE temporal (id SERIAL PRIMARY KEY, dato TEXT);`,
schemaInspection: {
- table: 'temporal',
+ table: "temporal",
shouldExist: false,
},
},
},
},
{
- title: 'ALTER TABLE - Agregar Columna',
- difficulty: 'Principiante',
- description: 'Agrega una columna "email" de tipo VARCHAR(255) a la tabla "clientes" existente.',
+ title: "ALTER TABLE - Agregar Columna",
+ difficulty: "Principiante",
+ description:
+ 'Agrega una columna "email" de tipo VARCHAR(255) a la tabla "clientes" existente.',
details: `Este ejercicio te enseñará:
1. Modificar tablas existentes con ALTER TABLE
2. Agregar nuevas columnas con ADD COLUMN`,
- hint: 'ALTER TABLE nombre_tabla ADD COLUMN nombre_columna tipo',
- successMessage: '¡Perfecto! Has agregado una columna exitosamente. ALTER TABLE es esencial para evolucionar el esquema.',
+ hint: "ALTER TABLE nombre_tabla ADD COLUMN nombre_columna tipo",
+ successMessage:
+ "¡Perfecto! Has agregado una columna exitosamente. ALTER TABLE es esencial para evolucionar el esquema.",
example: {
entrada: 'Tabla "clientes" con columnas id y nombre',
salida: 'Tabla "clientes" ahora incluye columna "email"',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE clientes (id SERIAL PRIMARY KEY, nombre VARCHAR(100));`,
schemaInspection: {
- table: 'clientes',
+ table: "clientes",
columns: [
- { name: 'id', type: 'integer' },
- { name: 'nombre', type: 'character varying' },
- { name: 'email', type: 'character varying' },
+ { name: "id", type: "integer" },
+ { name: "nombre", type: "character varying" },
+ { name: "email", type: "character varying" },
],
},
},
},
},
{
- title: 'ALTER TABLE - Eliminar Columna',
- difficulty: 'Principiante',
+ title: "ALTER TABLE - Eliminar Columna",
+ difficulty: "Principiante",
description: 'Elimina la columna "obsoleto" de la tabla "inventario".',
details: `Aprenderás a:
1. Eliminar columnas existentes con DROP COLUMN
2. Entender el impacto de eliminar columnas en datos existentes`,
- hint: 'ALTER TABLE nombre_tabla DROP COLUMN nombre_columna',
- successMessage: '¡Excelente! Has eliminado la columna correctamente. Recuerda que esta operación es irreversible.',
+ hint: "ALTER TABLE nombre_tabla DROP COLUMN nombre_columna",
+ successMessage:
+ "¡Excelente! Has eliminado la columna correctamente. Recuerda que esta operación es irreversible.",
example: {
entrada: 'Tabla "inventario" con columna "obsoleto"',
salida: 'Tabla "inventario" sin la columna "obsoleto"',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE inventario (id SERIAL PRIMARY KEY, producto VARCHAR(100), cantidad INTEGER, obsoleto BOOLEAN DEFAULT false);`,
schemaInspection: {
- table: 'inventario',
+ table: "inventario",
columns: [
- { name: 'id', type: 'integer' },
- { name: 'producto', type: 'character varying' },
- { name: 'cantidad', type: 'integer' },
+ { name: "id", type: "integer" },
+ { name: "producto", type: "character varying" },
+ { name: "cantidad", type: "integer" },
],
},
},
@@ -938,89 +1128,106 @@ export const exercisesData: ExerciseData[] = [
// DDL Exercises - Intermedio
{
- title: 'CREATE TABLE con PRIMARY KEY',
- difficulty: 'Intermedio',
- description: 'Crea una tabla "empleados" con id (INTEGER PRIMARY KEY), nombre (VARCHAR(100) NOT NULL) y departamento (VARCHAR(50)).',
+ title: "CREATE TABLE con PRIMARY KEY",
+ difficulty: "Intermedio",
+ description:
+ 'Crea una tabla "empleados" con id (INTEGER PRIMARY KEY), nombre (VARCHAR(100) NOT NULL) y departamento (VARCHAR(50)).',
details: `Aprenderás a:
1. Definir PRIMARY KEY en la creación de tabla
2. Usar NOT NULL para campos requeridos
3. Entender la importancia de las claves primarias`,
- hint: 'Puedes definir PRIMARY KEY inline: columna tipo PRIMARY KEY',
- successMessage: '¡Muy bien! Las claves primarias garantizan la unicidad de cada registro.',
+ hint: "Puedes definir PRIMARY KEY inline: columna tipo PRIMARY KEY",
+ successMessage:
+ "¡Muy bien! Las claves primarias garantizan la unicidad de cada registro.",
example: {
- entrada: 'Esquema vacío',
+ entrada: "Esquema vacío",
salida: 'Tabla "empleados" con PRIMARY KEY en id',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
schemaInspection: {
- table: 'empleados',
+ table: "empleados",
columns: [
- { name: 'id', type: 'integer', nullable: false },
- { name: 'nombre', type: 'character varying', nullable: false },
- { name: 'departamento', type: 'character varying' },
- ],
- constraints: [
- { type: 'PRIMARY KEY', columns: ['id'] },
+ { name: "id", type: "integer", nullable: false },
+ { name: "nombre", type: "character varying", nullable: false },
+ { name: "departamento", type: "character varying" },
],
+ constraints: [{ type: "PRIMARY KEY", columns: ["id"] }],
},
testQueries: [
- { query: "INSERT INTO empleados (id, nombre, departamento) VALUES (1, 'Juan', 'IT')", shouldSucceed: true },
- { query: "INSERT INTO empleados (id, nombre, departamento) VALUES (1, 'Maria', 'HR')", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO empleados (id, nombre, departamento) VALUES (1, 'Juan', 'IT')",
+ shouldSucceed: true,
+ },
+ {
+ query:
+ "INSERT INTO empleados (id, nombre, departamento) VALUES (1, 'Maria', 'HR')",
+ shouldSucceed: false,
+ },
],
},
},
},
{
- title: 'ALTER TABLE - Agregar PRIMARY KEY',
- difficulty: 'Intermedio',
- description: 'Agrega una constraint PRIMARY KEY a la columna "codigo" de la tabla "categorias".',
+ title: "ALTER TABLE - Agregar PRIMARY KEY",
+ difficulty: "Intermedio",
+ description:
+ 'Agrega una constraint PRIMARY KEY a la columna "codigo" de la tabla "categorias".',
details: `Este ejercicio te enseñará:
1. Agregar constraints a tablas existentes
2. Usar ADD CONSTRAINT para definir claves primarias`,
- hint: 'ALTER TABLE tabla ADD CONSTRAINT nombre_constraint PRIMARY KEY (columna)',
- successMessage: '¡Perfecto! Has agregado una clave primaria a una tabla existente.',
+ hint: "ALTER TABLE tabla ADD CONSTRAINT nombre_constraint PRIMARY KEY (columna)",
+ successMessage:
+ "¡Perfecto! Has agregado una clave primaria a una tabla existente.",
example: {
entrada: 'Tabla "categorias" sin PRIMARY KEY',
salida: 'Tabla "categorias" con PRIMARY KEY en "codigo"',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE categorias (codigo INTEGER NOT NULL, nombre VARCHAR(100), descripcion TEXT);`,
schemaInspection: {
- table: 'categorias',
- constraints: [
- { type: 'PRIMARY KEY', columns: ['codigo'] },
- ],
+ table: "categorias",
+ constraints: [{ type: "PRIMARY KEY", columns: ["codigo"] }],
},
testQueries: [
- { query: "INSERT INTO categorias (codigo, nombre) VALUES (1, 'Electrónica')", shouldSucceed: true },
- { query: "INSERT INTO categorias (codigo, nombre) VALUES (1, 'Ropa')", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO categorias (codigo, nombre) VALUES (1, 'Electrónica')",
+ shouldSucceed: true,
+ },
+ {
+ query: "INSERT INTO categorias (codigo, nombre) VALUES (1, 'Ropa')",
+ shouldSucceed: false,
+ },
],
},
},
},
{
- title: 'ALTER TABLE - Agregar FOREIGN KEY',
- difficulty: 'Intermedio',
- description: 'Agrega una FOREIGN KEY en la columna "categoria_id" de la tabla "articulos" que referencia a "categorias(id)".',
+ title: "ALTER TABLE - Agregar FOREIGN KEY",
+ difficulty: "Intermedio",
+ description:
+ 'Agrega una FOREIGN KEY en la columna "categoria_id" de la tabla "articulos" que referencia a "categorias(id)".',
details: `Aprenderás a:
1. Crear relaciones entre tablas con FOREIGN KEY
2. Entender la integridad referencial
3. Usar REFERENCES para definir la relación`,
- hint: 'ALTER TABLE tabla ADD CONSTRAINT nombre FOREIGN KEY (columna) REFERENCES otra_tabla(columna)',
- successMessage: '¡Excelente! Las claves foráneas mantienen la integridad de las relaciones entre tablas.',
+ hint: "ALTER TABLE tabla ADD CONSTRAINT nombre FOREIGN KEY (columna) REFERENCES otra_tabla(columna)",
+ successMessage:
+ "¡Excelente! Las claves foráneas mantienen la integridad de las relaciones entre tablas.",
example: {
entrada: 'Tablas "categorias" y "articulos" sin relación',
- salida: 'Relación establecida entre las tablas',
+ salida: "Relación establecida entre las tablas",
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `
CREATE TABLE categorias (id SERIAL PRIMARY KEY, nombre VARCHAR(100));
@@ -1028,49 +1235,69 @@ export const exercisesData: ExerciseData[] = [
INSERT INTO categorias (nombre) VALUES ('Electrónica');
`,
schemaInspection: {
- table: 'articulos',
- constraints: [
- { type: 'FOREIGN KEY', columns: ['categoria_id'] },
- ],
+ table: "articulos",
+ constraints: [{ type: "FOREIGN KEY", columns: ["categoria_id"] }],
},
testQueries: [
- { query: "INSERT INTO articulos (nombre, categoria_id) VALUES ('Laptop', 1)", shouldSucceed: true },
- { query: "INSERT INTO articulos (nombre, categoria_id) VALUES ('Phone', 999)", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO articulos (nombre, categoria_id) VALUES ('Laptop', 1)",
+ shouldSucceed: true,
+ },
+ {
+ query:
+ "INSERT INTO articulos (nombre, categoria_id) VALUES ('Phone', 999)",
+ shouldSucceed: false,
+ },
],
},
},
},
{
- title: 'CREATE TABLE con NOT NULL',
- difficulty: 'Intermedio',
- description: 'Crea una tabla "ordenes" con id (SERIAL PRIMARY KEY), cliente (VARCHAR(100) NOT NULL), total (DECIMAL(10,2) NOT NULL) y fecha (DATE).',
+ title: "CREATE TABLE con NOT NULL",
+ difficulty: "Intermedio",
+ description:
+ 'Crea una tabla "ordenes" con id (SERIAL PRIMARY KEY), cliente (VARCHAR(100) NOT NULL), total (DECIMAL(10,2) NOT NULL) y fecha (DATE).',
details: `Este ejercicio te enseñará:
1. Definir múltiples columnas NOT NULL
2. Combinar diferentes tipos de datos
3. Entender la importancia de los campos requeridos`,
- hint: 'Usa NOT NULL después del tipo de dato para campos requeridos',
- successMessage: '¡Muy bien! NOT NULL previene datos incompletos en campos críticos.',
+ hint: "Usa NOT NULL después del tipo de dato para campos requeridos",
+ successMessage:
+ "¡Muy bien! NOT NULL previene datos incompletos en campos críticos.",
example: {
- entrada: 'Esquema vacío',
+ entrada: "Esquema vacío",
salida: 'Tabla "ordenes" con campos requeridos',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
schemaInspection: {
- table: 'ordenes',
+ table: "ordenes",
columns: [
- { name: 'id', type: 'integer', nullable: false },
- { name: 'cliente', type: 'character varying', nullable: false },
- { name: 'total', type: 'numeric', nullable: false },
- { name: 'fecha', type: 'date' },
+ { name: "id", type: "integer", nullable: false },
+ { name: "cliente", type: "character varying", nullable: false },
+ { name: "total", type: "numeric", nullable: false },
+ { name: "fecha", type: "date" },
],
},
testQueries: [
- { query: "INSERT INTO ordenes (cliente, total, fecha) VALUES ('Cliente 1', 100.00, '2024-01-15')", shouldSucceed: true },
- { query: "INSERT INTO ordenes (cliente, fecha) VALUES ('Cliente 2', '2024-01-15')", shouldSucceed: false },
- { query: "INSERT INTO ordenes (total, fecha) VALUES (50.00, '2024-01-15')", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO ordenes (cliente, total, fecha) VALUES ('Cliente 1', 100.00, '2024-01-15')",
+ shouldSucceed: true,
+ },
+ {
+ query:
+ "INSERT INTO ordenes (cliente, fecha) VALUES ('Cliente 2', '2024-01-15')",
+ shouldSucceed: false,
+ },
+ {
+ query:
+ "INSERT INTO ordenes (total, fecha) VALUES (50.00, '2024-01-15')",
+ shouldSucceed: false,
+ },
],
},
},
@@ -1078,156 +1305,182 @@ export const exercisesData: ExerciseData[] = [
// DDL Exercises - Avanzado
{
- title: 'ALTER TABLE - Agregar UNIQUE',
- difficulty: 'Avanzado',
- description: 'Agrega una constraint UNIQUE a la columna "email" de la tabla "usuarios_app".',
+ title: "ALTER TABLE - Agregar UNIQUE",
+ difficulty: "Avanzado",
+ description:
+ 'Agrega una constraint UNIQUE a la columna "email" de la tabla "usuarios_app".',
details: `Aprenderás a:
1. Garantizar unicidad en columnas específicas
2. Usar UNIQUE para prevenir duplicados
3. Diferencia entre PRIMARY KEY y UNIQUE`,
- hint: 'ALTER TABLE tabla ADD CONSTRAINT nombre UNIQUE (columna)',
- successMessage: '¡Perfecto! UNIQUE garantiza que no haya valores duplicados en la columna.',
+ hint: "ALTER TABLE tabla ADD CONSTRAINT nombre UNIQUE (columna)",
+ successMessage:
+ "¡Perfecto! UNIQUE garantiza que no haya valores duplicados en la columna.",
example: {
entrada: 'Tabla "usuarios_app" sin constraint UNIQUE',
salida: 'Columna "email" con constraint UNIQUE',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE usuarios_app (id SERIAL PRIMARY KEY, nombre VARCHAR(100), email VARCHAR(255));`,
schemaInspection: {
- table: 'usuarios_app',
- constraints: [
- { type: 'UNIQUE', columns: ['email'] },
- ],
+ table: "usuarios_app",
+ constraints: [{ type: "UNIQUE", columns: ["email"] }],
},
testQueries: [
- { query: "INSERT INTO usuarios_app (nombre, email) VALUES ('Ana', 'ana@test.com')", shouldSucceed: true },
- { query: "INSERT INTO usuarios_app (nombre, email) VALUES ('Ana 2', 'ana@test.com')", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO usuarios_app (nombre, email) VALUES ('Ana', 'ana@test.com')",
+ shouldSucceed: true,
+ },
+ {
+ query:
+ "INSERT INTO usuarios_app (nombre, email) VALUES ('Ana 2', 'ana@test.com')",
+ shouldSucceed: false,
+ },
],
},
},
},
{
- title: 'ALTER TABLE - Agregar CHECK',
- difficulty: 'Avanzado',
- description: 'Agrega una constraint CHECK a la tabla "productos_venta" para que el precio sea siempre mayor a 0.',
+ title: "ALTER TABLE - Agregar CHECK",
+ difficulty: "Avanzado",
+ description:
+ 'Agrega una constraint CHECK a la tabla "productos_venta" para que el precio sea siempre mayor a 0.',
details: `Este ejercicio te enseñará:
1. Validar datos con CHECK constraints
2. Definir reglas de negocio a nivel de base de datos
3. Prevenir datos inválidos automáticamente`,
- hint: 'ALTER TABLE tabla ADD CONSTRAINT nombre CHECK (condición)',
- successMessage: '¡Excelente! CHECK constraints validan datos antes de insertarlos.',
+ hint: "ALTER TABLE tabla ADD CONSTRAINT nombre CHECK (condición)",
+ successMessage:
+ "¡Excelente! CHECK constraints validan datos antes de insertarlos.",
example: {
entrada: 'Tabla "productos_venta" sin validación de precio',
- salida: 'Constraint que impide precios negativos o cero',
+ salida: "Constraint que impide precios negativos o cero",
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE productos_venta (id SERIAL PRIMARY KEY, nombre VARCHAR(100), precio DECIMAL(10,2));`,
schemaInspection: {
- table: 'productos_venta',
- constraints: [
- { type: 'CHECK', columns: [] },
- ],
+ table: "productos_venta",
+ constraints: [{ type: "CHECK", columns: [] }],
},
testQueries: [
- { query: "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto A', 25.99)", shouldSucceed: true },
- { query: "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto B', 0)", shouldSucceed: false },
- { query: "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto C', -5.00)", shouldSucceed: false },
+ {
+ query:
+ "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto A', 25.99)",
+ shouldSucceed: true,
+ },
+ {
+ query:
+ "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto B', 0)",
+ shouldSucceed: false,
+ },
+ {
+ query:
+ "INSERT INTO productos_venta (nombre, precio) VALUES ('Producto C', -5.00)",
+ shouldSucceed: false,
+ },
],
},
},
},
{
- title: 'CREATE INDEX - Índice Simple',
- difficulty: 'Avanzado',
- description: 'Crea un índice llamado "idx_ventas_fecha" en la columna "fecha" de la tabla "ventas".',
+ title: "CREATE INDEX - Índice Simple",
+ difficulty: "Avanzado",
+ description:
+ 'Crea un índice llamado "idx_ventas_fecha" en la columna "fecha" de la tabla "ventas".',
details: `Aprenderás a:
1. Crear índices para optimizar consultas
2. Entender cuándo usar índices
3. La sintaxis de CREATE INDEX`,
- hint: 'CREATE INDEX nombre_indice ON tabla (columna)',
- successMessage: '¡Muy bien! Los índices mejoran significativamente el rendimiento de las consultas.',
+ hint: "CREATE INDEX nombre_indice ON tabla (columna)",
+ successMessage:
+ "¡Muy bien! Los índices mejoran significativamente el rendimiento de las consultas.",
example: {
entrada: 'Tabla "ventas" sin índice en fecha',
- salida: 'Índice creado para búsquedas por fecha',
+ salida: "Índice creado para búsquedas por fecha",
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE ventas (id SERIAL PRIMARY KEY, producto VARCHAR(100), cantidad INTEGER, fecha DATE, total DECIMAL(10,2));`,
schemaInspection: {
- table: 'ventas',
- indexes: [
- { name: 'idx_ventas_fecha', columns: ['fecha'] },
- ],
+ table: "ventas",
+ indexes: [{ name: "idx_ventas_fecha", columns: ["fecha"] }],
},
},
},
},
{
- title: 'CREATE INDEX - Índice Compuesto',
- difficulty: 'Avanzado',
- description: 'Crea un índice compuesto llamado "idx_logs_usuario_fecha" en las columnas "usuario_id" y "fecha" de la tabla "logs".',
+ title: "CREATE INDEX - Índice Compuesto",
+ difficulty: "Avanzado",
+ description:
+ 'Crea un índice compuesto llamado "idx_logs_usuario_fecha" en las columnas "usuario_id" y "fecha" de la tabla "logs".',
details: `Este ejercicio avanzado te enseñará:
1. Crear índices en múltiples columnas
2. El orden de las columnas en índices compuestos
3. Cuándo usar índices compuestos`,
- hint: 'CREATE INDEX nombre ON tabla (columna1, columna2)',
- successMessage: '¡Impresionante! Los índices compuestos optimizan consultas que filtran por múltiples columnas.',
+ hint: "CREATE INDEX nombre ON tabla (columna1, columna2)",
+ successMessage:
+ "¡Impresionante! Los índices compuestos optimizan consultas que filtran por múltiples columnas.",
example: {
entrada: 'Tabla "logs" sin índices',
- salida: 'Índice compuesto en usuario_id y fecha',
+ salida: "Índice compuesto en usuario_id y fecha",
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE logs (id SERIAL PRIMARY KEY, usuario_id INTEGER, accion VARCHAR(100), fecha TIMESTAMP);`,
schemaInspection: {
- table: 'logs',
+ table: "logs",
indexes: [
- { name: 'idx_logs_usuario_fecha', columns: ['usuario_id', 'fecha'] },
+ {
+ name: "idx_logs_usuario_fecha",
+ columns: ["usuario_id", "fecha"],
+ },
],
},
},
},
},
{
- title: 'ALTER TABLE - Renombrar Columna',
- difficulty: 'Avanzado',
- description: 'Renombra la columna "nombre_completo" a "nombre" en la tabla "contactos".',
+ title: "ALTER TABLE - Renombrar Columna",
+ difficulty: "Avanzado",
+ description:
+ 'Renombra la columna "nombre_completo" a "nombre" en la tabla "contactos".',
details: `Aprenderás a:
1. Renombrar columnas existentes
2. Usar RENAME COLUMN
3. Mantener la compatibilidad al cambiar nombres`,
- hint: 'ALTER TABLE tabla RENAME COLUMN nombre_viejo TO nombre_nuevo',
- successMessage: '¡Perfecto! Renombrar columnas es útil para refactorizar el esquema de la base de datos.',
+ hint: "ALTER TABLE tabla RENAME COLUMN nombre_viejo TO nombre_nuevo",
+ successMessage:
+ "¡Perfecto! Renombrar columnas es útil para refactorizar el esquema de la base de datos.",
example: {
entrada: 'Tabla "contactos" con columna "nombre_completo"',
salida: 'Columna renombrada a "nombre"',
},
- type: 'ddl',
+ type: "ddl",
validation: {
- type: 'ddl_schema' as const,
+ type: "ddl_schema" as const,
conditions: {
setupSQL: `CREATE TABLE contactos (id SERIAL PRIMARY KEY, nombre_completo VARCHAR(200), telefono VARCHAR(20), email VARCHAR(100));`,
schemaInspection: {
- table: 'contactos',
+ table: "contactos",
columns: [
- { name: 'id', type: 'integer' },
- { name: 'nombre', type: 'character varying' },
- { name: 'telefono', type: 'character varying' },
- { name: 'email', type: 'character varying' },
+ { name: "id", type: "integer" },
+ { name: "nombre", type: "character varying" },
+ { name: "telefono", type: "character varying" },
+ { name: "email", type: "character varying" },
],
},
},
},
},
-]
-
+];
diff --git a/src/lib/db/seed.ts b/src/lib/db/seed.ts
index 6d26dc6..2f3d9db 100644
--- a/src/lib/db/seed.ts
+++ b/src/lib/db/seed.ts
@@ -1,33 +1,34 @@
-import { config } from 'dotenv'
+import { config } from "dotenv";
-config({ path: '.env.local' })
-import { neon } from '@neondatabase/serverless'
-import { drizzle } from 'drizzle-orm/neon-http'
-import { exercises } from './schema'
-import { exercisesData } from './seed-data'
+config({ path: ".env.local" });
+
+import { neon } from "@neondatabase/serverless";
+import { drizzle } from "drizzle-orm/neon-http";
+import { exercises } from "./schema";
+import { exercisesData } from "./seed-data";
async function seed() {
- const databaseUrl = process.env.DATABASE_URL
-
+ const databaseUrl = process.env.DATABASE_URL;
+
if (!databaseUrl) {
- console.error('❌ DATABASE_URL environment variable is not set')
- console.error(' Make sure you have a .env.local file with DATABASE_URL')
- process.exit(1)
+ console.error("❌ DATABASE_URL environment variable is not set");
+ console.error(" Make sure you have a .env.local file with DATABASE_URL");
+ process.exit(1);
}
- console.log('🌱 Starting database seed...')
-
- const sql = neon(databaseUrl)
- const db = drizzle(sql)
+ console.log("🌱 Starting database seed...");
+
+ const sql = neon(databaseUrl);
+ const db = drizzle(sql);
try {
// Clear existing exercises (optional - comment out to keep existing)
- console.log('🗑️ Clearing existing exercises...')
- await db.delete(exercises)
+ console.log("🗑️ Clearing existing exercises...");
+ await db.delete(exercises);
// Insert new exercises
- console.log('📝 Inserting exercises...')
-
+ console.log("📝 Inserting exercises...");
+
for (const exercise of exercisesData) {
await db.insert(exercises).values({
title: exercise.title,
@@ -37,18 +38,17 @@ async function seed() {
hint: exercise.hint,
successMessage: exercise.successMessage,
example: exercise.example,
- type: exercise.type || 'dml',
+ type: exercise.type || "dml",
validation: exercise.validation,
- })
- console.log(` ✓ Added: ${exercise.title} (${exercise.type || 'dml'})`)
+ });
+ console.log(` ✓ Added: ${exercise.title} (${exercise.type || "dml"})`);
}
- console.log(`\n✅ Successfully seeded ${exercisesData.length} exercises!`)
+ console.log(`\n✅ Successfully seeded ${exercisesData.length} exercises!`);
} catch (error) {
- console.error('❌ Error seeding database:', error)
- process.exit(1)
+ console.error("❌ Error seeding database:", error);
+ process.exit(1);
}
}
-seed()
-
+seed();
diff --git a/src/lib/ddl-validator.ts b/src/lib/ddl-validator.ts
index 0cd5f8f..9c4ede8 100644
--- a/src/lib/ddl-validator.ts
+++ b/src/lib/ddl-validator.ts
@@ -1,226 +1,266 @@
-import { dbService, type SchemaInfo, type TableInfo, type ColumnInfo, type ConstraintInfo, type IndexInfo } from './db-service'
+import {
+ type ColumnInfo,
+ type ConstraintInfo,
+ dbService,
+ type IndexInfo,
+ type SchemaInfo,
+ type TableInfo,
+} from "./db-service";
export interface ExpectedColumn {
- name: string
- type?: string
- nullable?: boolean
+ name: string;
+ type?: string;
+ nullable?: boolean;
}
export interface ExpectedConstraint {
- type: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK'
- columns: string[]
- definition?: string
+ type: "PRIMARY KEY" | "FOREIGN KEY" | "UNIQUE" | "CHECK";
+ columns: string[];
+ definition?: string;
}
export interface ExpectedIndex {
- name?: string
- columns: string[]
- isUnique?: boolean
+ name?: string;
+ columns: string[];
+ isUnique?: boolean;
}
export interface SchemaInspectionConfig {
- table: string
- shouldExist?: boolean
- columns?: ExpectedColumn[]
- constraints?: ExpectedConstraint[]
- indexes?: ExpectedIndex[]
+ table: string;
+ shouldExist?: boolean;
+ columns?: ExpectedColumn[];
+ constraints?: ExpectedConstraint[];
+ indexes?: ExpectedIndex[];
}
export interface TestQueryConfig {
- query: string
- shouldSucceed: boolean
- description?: string
+ query: string;
+ shouldSucceed: boolean;
+ description?: string;
}
export interface DDLValidationConfig {
- schemaInspection?: SchemaInspectionConfig
- testQueries?: TestQueryConfig[]
+ schemaInspection?: SchemaInspectionConfig;
+ testQueries?: TestQueryConfig[];
}
export interface DDLValidationResult {
- isValid: boolean
+ isValid: boolean;
schemaValidation?: {
- passed: boolean
- errors: string[]
- tableFound: boolean
- columnsMatched: boolean
- constraintsMatched: boolean
- indexesMatched: boolean
- }
+ passed: boolean;
+ errors: string[];
+ tableFound: boolean;
+ columnsMatched: boolean;
+ constraintsMatched: boolean;
+ indexesMatched: boolean;
+ };
testQueryResults?: {
- passed: boolean
+ passed: boolean;
results: {
- query: string
- expected: boolean
- actual: boolean
- error?: string
- }[]
- }
+ query: string;
+ expected: boolean;
+ actual: boolean;
+ error?: string;
+ }[];
+ };
}
function normalizeType(type: string): string {
const typeMap: Record = {
- 'integer': ['integer', 'int', 'int4', 'serial'],
- 'bigint': ['bigint', 'int8', 'bigserial'],
- 'smallint': ['smallint', 'int2'],
- 'text': ['text', 'character varying', 'varchar'],
- 'boolean': ['boolean', 'bool'],
- 'numeric': ['numeric', 'decimal'],
- 'timestamp': ['timestamp without time zone', 'timestamp with time zone', 'timestamp'],
- 'date': ['date'],
- 'real': ['real', 'float4'],
- 'double precision': ['double precision', 'float8'],
- }
+ integer: ["integer", "int", "int4", "serial"],
+ bigint: ["bigint", "int8", "bigserial"],
+ smallint: ["smallint", "int2"],
+ text: ["text", "character varying", "varchar"],
+ boolean: ["boolean", "bool"],
+ numeric: ["numeric", "decimal"],
+ timestamp: [
+ "timestamp without time zone",
+ "timestamp with time zone",
+ "timestamp",
+ ],
+ date: ["date"],
+ real: ["real", "float4"],
+ "double precision": ["double precision", "float8"],
+ };
+
+ const lowerType = type
+ .toLowerCase()
+ .replace(/\(\d+.*\)/, "")
+ .trim();
- const lowerType = type.toLowerCase().replace(/\(\d+.*\)/, '').trim()
-
for (const [normalized, variants] of Object.entries(typeMap)) {
if (variants.includes(lowerType)) {
- return normalized
+ return normalized;
}
}
-
- return lowerType
+
+ return lowerType;
}
function validateColumns(
actualColumns: ColumnInfo[],
- expectedColumns: ExpectedColumn[]
+ expectedColumns: ExpectedColumn[],
): { matched: boolean; errors: string[] } {
- const errors: string[] = []
+ const errors: string[] = [];
for (const expected of expectedColumns) {
- const actual = actualColumns.find(c => c.name.toLowerCase() === expected.name.toLowerCase())
-
+ const actual = actualColumns.find(
+ (c) => c.name.toLowerCase() === expected.name.toLowerCase(),
+ );
+
if (!actual) {
- errors.push(`Columna '${expected.name}' no encontrada`)
- continue
+ errors.push(`Columna '${expected.name}' no encontrada`);
+ continue;
}
if (expected.type) {
- const normalizedActual = normalizeType(actual.type)
- const normalizedExpected = normalizeType(expected.type)
-
+ const normalizedActual = normalizeType(actual.type);
+ const normalizedExpected = normalizeType(expected.type);
+
if (normalizedActual !== normalizedExpected) {
- errors.push(`Columna '${expected.name}' tiene tipo '${actual.type}' pero se esperaba '${expected.type}'`)
+ errors.push(
+ `Columna '${expected.name}' tiene tipo '${actual.type}' pero se esperaba '${expected.type}'`,
+ );
}
}
if (expected.nullable !== undefined) {
if (actual.nullable !== expected.nullable) {
- errors.push(`Columna '${expected.name}' ${actual.nullable ? 'permite' : 'no permite'} NULL pero se esperaba ${expected.nullable ? 'que lo permitiera' : 'NOT NULL'}`)
+ errors.push(
+ `Columna '${expected.name}' ${actual.nullable ? "permite" : "no permite"} NULL pero se esperaba ${expected.nullable ? "que lo permitiera" : "NOT NULL"}`,
+ );
}
}
}
- return { matched: errors.length === 0, errors }
+ return { matched: errors.length === 0, errors };
}
function validateConstraints(
actualConstraints: ConstraintInfo[],
- expectedConstraints: ExpectedConstraint[]
+ expectedConstraints: ExpectedConstraint[],
): { matched: boolean; errors: string[] } {
- const errors: string[] = []
+ const errors: string[] = [];
for (const expected of expectedConstraints) {
- const matching = actualConstraints.filter(c => c.type === expected.type)
-
+ const matching = actualConstraints.filter((c) => c.type === expected.type);
+
if (matching.length === 0) {
- errors.push(`Constraint de tipo '${expected.type}' no encontrada`)
- continue
+ errors.push(`Constraint de tipo '${expected.type}' no encontrada`);
+ continue;
}
- const hasMatchingColumns = matching.some(c => {
- const actualCols = c.columns.map(col => col.toLowerCase()).sort()
- const expectedCols = expected.columns.map(col => col.toLowerCase()).sort()
- return actualCols.length === expectedCols.length &&
- actualCols.every((col, i) => col === expectedCols[i])
- })
+ const hasMatchingColumns = matching.some((c) => {
+ const actualCols = c.columns.map((col) => col.toLowerCase()).sort();
+ const expectedCols = expected.columns
+ .map((col) => col.toLowerCase())
+ .sort();
+ return (
+ actualCols.length === expectedCols.length &&
+ actualCols.every((col, i) => col === expectedCols[i])
+ );
+ });
if (!hasMatchingColumns) {
- errors.push(`Constraint '${expected.type}' existe pero no tiene las columnas esperadas: ${expected.columns.join(', ')}`)
+ errors.push(
+ `Constraint '${expected.type}' existe pero no tiene las columnas esperadas: ${expected.columns.join(", ")}`,
+ );
}
}
- return { matched: errors.length === 0, errors }
+ return { matched: errors.length === 0, errors };
}
function validateIndexes(
actualIndexes: IndexInfo[],
- expectedIndexes: ExpectedIndex[]
+ expectedIndexes: ExpectedIndex[],
): { matched: boolean; errors: string[] } {
- const errors: string[] = []
+ const errors: string[] = [];
for (const expected of expectedIndexes) {
- let found = false
+ let found = false;
for (const actual of actualIndexes) {
- const actualCols = actual.columns.map(c => c.toLowerCase())
- const expectedCols = expected.columns.map(c => c.toLowerCase())
-
- if (expected.name && actual.name.toLowerCase() !== expected.name.toLowerCase()) {
- continue
+ const actualCols = actual.columns.map((c) => c.toLowerCase());
+ const expectedCols = expected.columns.map((c) => c.toLowerCase());
+
+ if (
+ expected.name &&
+ actual.name.toLowerCase() !== expected.name.toLowerCase()
+ ) {
+ continue;
}
- const columnsMatch = actualCols.length === expectedCols.length &&
- actualCols.every((col, i) => col === expectedCols[i])
+ const columnsMatch =
+ actualCols.length === expectedCols.length &&
+ actualCols.every((col, i) => col === expectedCols[i]);
if (columnsMatch) {
- if (expected.isUnique !== undefined && actual.isUnique !== expected.isUnique) {
- errors.push(`Índice en columnas (${expected.columns.join(', ')}) ${actual.isUnique ? 'es único' : 'no es único'} pero se esperaba ${expected.isUnique ? 'UNIQUE' : 'no único'}`)
+ if (
+ expected.isUnique !== undefined &&
+ actual.isUnique !== expected.isUnique
+ ) {
+ errors.push(
+ `Índice en columnas (${expected.columns.join(", ")}) ${actual.isUnique ? "es único" : "no es único"} pero se esperaba ${expected.isUnique ? "UNIQUE" : "no único"}`,
+ );
}
- found = true
- break
+ found = true;
+ break;
}
}
if (!found) {
- const indexDesc = expected.name
- ? `'${expected.name}'`
- : `en columnas (${expected.columns.join(', ')})`
- errors.push(`Índice ${indexDesc} no encontrado`)
+ const indexDesc = expected.name
+ ? `'${expected.name}'`
+ : `en columnas (${expected.columns.join(", ")})`;
+ errors.push(`Índice ${indexDesc} no encontrado`);
}
}
- return { matched: errors.length === 0, errors }
+ return { matched: errors.length === 0, errors };
}
-export async function validateDDL(config: DDLValidationConfig): Promise {
- const result: DDLValidationResult = { isValid: true }
+export async function validateDDL(
+ config: DDLValidationConfig,
+): Promise {
+ const result: DDLValidationResult = { isValid: true };
if (config.schemaInspection) {
- const schemaInfo = await dbService.inspectSchema()
- const schemaValidation = validateSchemaInspection(schemaInfo, config.schemaInspection)
- result.schemaValidation = schemaValidation
-
+ const schemaInfo = await dbService.inspectSchema();
+ const schemaValidation = validateSchemaInspection(
+ schemaInfo,
+ config.schemaInspection,
+ );
+ result.schemaValidation = schemaValidation;
+
if (schemaValidation && !schemaValidation.passed) {
- result.isValid = false
+ result.isValid = false;
}
}
if (config.testQueries && config.testQueries.length > 0) {
- const testResults = await runTestQueries(config.testQueries)
- result.testQueryResults = testResults
-
+ const testResults = await runTestQueries(config.testQueries);
+ result.testQueryResults = testResults;
+
if (testResults && !testResults.passed) {
- result.isValid = false
+ result.isValid = false;
}
}
- return result
+ return result;
}
function validateSchemaInspection(
schemaInfo: SchemaInfo,
- config: SchemaInspectionConfig
-): DDLValidationResult['schemaValidation'] {
- const errors: string[] = []
-
+ config: SchemaInspectionConfig,
+): DDLValidationResult["schemaValidation"] {
+ const errors: string[] = [];
+
const table = schemaInfo.tables.find(
- t => t.name.toLowerCase() === config.table.toLowerCase()
- )
+ (t) => t.name.toLowerCase() === config.table.toLowerCase(),
+ );
- const shouldExist = config.shouldExist !== false
+ const shouldExist = config.shouldExist !== false;
if (shouldExist && !table) {
return {
@@ -230,18 +270,20 @@ function validateSchemaInspection(
columnsMatched: false,
constraintsMatched: false,
indexesMatched: false,
- }
+ };
}
if (!shouldExist && table) {
return {
passed: false,
- errors: [`Tabla '${config.table}' todavía existe cuando debería haber sido eliminada`],
+ errors: [
+ `Tabla '${config.table}' todavía existe cuando debería haber sido eliminada`,
+ ],
tableFound: true,
columnsMatched: false,
constraintsMatched: false,
indexesMatched: false,
- }
+ };
}
if (!shouldExist && !table) {
@@ -252,29 +294,32 @@ function validateSchemaInspection(
columnsMatched: true,
constraintsMatched: true,
indexesMatched: true,
- }
+ };
}
- let columnsMatched = true
- let constraintsMatched = true
- let indexesMatched = true
+ let columnsMatched = true;
+ let constraintsMatched = true;
+ let indexesMatched = true;
if (config.columns) {
- const columnValidation = validateColumns(table!.columns, config.columns)
- columnsMatched = columnValidation.matched
- errors.push(...columnValidation.errors)
+ const columnValidation = validateColumns(table!.columns, config.columns);
+ columnsMatched = columnValidation.matched;
+ errors.push(...columnValidation.errors);
}
if (config.constraints) {
- const constraintValidation = validateConstraints(table!.constraints, config.constraints)
- constraintsMatched = constraintValidation.matched
- errors.push(...constraintValidation.errors)
+ const constraintValidation = validateConstraints(
+ table!.constraints,
+ config.constraints,
+ );
+ constraintsMatched = constraintValidation.matched;
+ errors.push(...constraintValidation.errors);
}
if (config.indexes) {
- const indexValidation = validateIndexes(table!.indexes, config.indexes)
- indexesMatched = indexValidation.matched
- errors.push(...indexValidation.errors)
+ const indexValidation = validateIndexes(table!.indexes, config.indexes);
+ indexesMatched = indexValidation.matched;
+ errors.push(...indexValidation.errors);
}
return {
@@ -284,35 +329,34 @@ function validateSchemaInspection(
columnsMatched,
constraintsMatched,
indexesMatched,
- }
+ };
}
async function runTestQueries(
- testQueries: TestQueryConfig[]
-): Promise {
- const results: DDLValidationResult['testQueryResults'] = {
+ testQueries: TestQueryConfig[],
+): Promise {
+ const results: DDLValidationResult["testQueryResults"] = {
passed: true,
results: [],
- }
+ };
for (const test of testQueries) {
- const queryResult = await dbService.executeTestQuery(test.query)
- const actual = queryResult.success
+ const queryResult = await dbService.executeTestQuery(test.query);
+ const actual = queryResult.success;
const testResult = {
query: test.query,
expected: test.shouldSucceed,
actual,
error: queryResult.error,
- }
+ };
- results.results.push(testResult)
+ results.results.push(testResult);
if (actual !== test.shouldSucceed) {
- results.passed = false
+ results.passed = false;
}
}
- return results
+ return results;
}
-
diff --git a/src/lib/exercises-service.ts b/src/lib/exercises-service.ts
index 69f9180..a3835cd 100644
--- a/src/lib/exercises-service.ts
+++ b/src/lib/exercises-service.ts
@@ -1,26 +1,26 @@
-import { db } from '@/lib/db'
-import { exercises } from '@/lib/db/schema'
-import { eq, and, asc } from 'drizzle-orm'
-import type { Exercise } from '@/lib/validations'
+import { and, asc, eq } from "drizzle-orm";
+import { db } from "@/lib/db";
+import { exercises } from "@/lib/db/schema";
+import type { Exercise } from "@/lib/validations";
-const difficultyOrder = ['Principiante', 'Intermedio', 'Avanzado'] as const
+const difficultyOrder = ["Principiante", "Intermedio", "Avanzado"] as const;
function mapExercise(ex: typeof exercises.$inferSelect): Exercise {
return {
id: ex.id,
title: ex.title,
- difficulty: ex.difficulty as Exercise['difficulty'],
+ difficulty: ex.difficulty as Exercise["difficulty"],
description: ex.description,
details: ex.details,
hint: ex.hint,
successMessage: ex.successMessage,
- example: ex.example as Exercise['example'],
- type: ex.type as Exercise['type'],
- validation: ex.validation as Exercise['validation'],
+ example: ex.example as Exercise["example"],
+ type: ex.type as Exercise["type"],
+ validation: ex.validation as Exercise["validation"],
isDeleted: ex.isDeleted,
createdAt: ex.createdAt,
updatedAt: ex.updatedAt,
- }
+ };
}
export async function getExercises(): Promise {
@@ -28,9 +28,9 @@ export async function getExercises(): Promise {
.select()
.from(exercises)
.where(eq(exercises.isDeleted, false))
- .orderBy(asc(exercises.createdAt))
+ .orderBy(asc(exercises.createdAt));
- return data.map(mapExercise)
+ return data.map(mapExercise);
}
export async function getExerciseById(id: string): Promise {
@@ -38,70 +38,79 @@ export async function getExerciseById(id: string): Promise {
.select()
.from(exercises)
.where(and(eq(exercises.id, id), eq(exercises.isDeleted, false)))
- .limit(1)
+ .limit(1);
- if (!exercise) return null
- return mapExercise(exercise)
+ if (!exercise) return null;
+ return mapExercise(exercise);
}
interface ExerciseContext {
- nextExercise: Exercise | null
- prevExercise: Exercise | null
- currentIndex: number
- totalExercises: number
+ nextExercise: Exercise | null;
+ prevExercise: Exercise | null;
+ currentIndex: number;
+ totalExercises: number;
}
-export async function getExerciseContext(currentExerciseId: string): Promise {
- const currentExercise = await getExerciseById(currentExerciseId)
- if (!currentExercise) return null
+export async function getExerciseContext(
+ currentExerciseId: string,
+): Promise {
+ const currentExercise = await getExerciseById(currentExerciseId);
+ if (!currentExercise) return null;
- const allExercises = await getExercises()
- const flatIndex = allExercises.findIndex((ex) => ex.id === currentExerciseId)
+ const allExercises = await getExercises();
+ const flatIndex = allExercises.findIndex((ex) => ex.id === currentExerciseId);
- const groupedExercises = allExercises.reduce>((acc, ex) => {
- if (!acc[ex.difficulty]) {
- acc[ex.difficulty] = []
- }
- acc[ex.difficulty].push(ex)
- return acc
- }, {})
+ const groupedExercises = allExercises.reduce>(
+ (acc, ex) => {
+ if (!acc[ex.difficulty]) {
+ acc[ex.difficulty] = [];
+ }
+ acc[ex.difficulty].push(ex);
+ return acc;
+ },
+ {},
+ );
- const currentDifficultyExercises = groupedExercises[currentExercise.difficulty] || []
- const currentIndex = currentDifficultyExercises.findIndex((ex) => ex.id === currentExerciseId)
+ const currentDifficultyExercises =
+ groupedExercises[currentExercise.difficulty] || [];
+ const currentIndex = currentDifficultyExercises.findIndex(
+ (ex) => ex.id === currentExerciseId,
+ );
- let nextExercise: Exercise | null = null
- let prevExercise: Exercise | null = null
+ let nextExercise: Exercise | null = null;
+ let prevExercise: Exercise | null = null;
// Previous in same difficulty
if (currentIndex > 0) {
- prevExercise = currentDifficultyExercises[currentIndex - 1]
+ prevExercise = currentDifficultyExercises[currentIndex - 1];
} else {
// Move to previous difficulty
const currentDifficultyIndex = difficultyOrder.indexOf(
- currentExercise.difficulty as (typeof difficultyOrder)[number]
- )
+ currentExercise.difficulty as (typeof difficultyOrder)[number],
+ );
if (currentDifficultyIndex > 0) {
- const prevDifficulty = difficultyOrder[currentDifficultyIndex - 1]
- const prevDifficultyExercises = groupedExercises[prevDifficulty] || []
+ const prevDifficulty = difficultyOrder[currentDifficultyIndex - 1];
+ const prevDifficultyExercises = groupedExercises[prevDifficulty] || [];
if (prevDifficultyExercises.length > 0) {
- prevExercise = prevDifficultyExercises[prevDifficultyExercises.length - 1]
+ prevExercise =
+ prevDifficultyExercises[prevDifficultyExercises.length - 1];
}
}
}
// Next in same difficulty
if (currentIndex < currentDifficultyExercises.length - 1) {
- nextExercise = currentDifficultyExercises[currentIndex + 1]
+ nextExercise = currentDifficultyExercises[currentIndex + 1];
} else {
// Move to next difficulty
const currentDifficultyIndex = difficultyOrder.indexOf(
- currentExercise.difficulty as (typeof difficultyOrder)[number]
- )
+ currentExercise.difficulty as (typeof difficultyOrder)[number],
+ );
if (currentDifficultyIndex < difficultyOrder.length - 1) {
- const nextDifficulty = difficultyOrder[currentDifficultyIndex + 1]
- const nextDifficultyExercises = groupedExercises[nextDifficulty] || []
+ const nextDifficulty = difficultyOrder[currentDifficultyIndex + 1];
+ const nextDifficultyExercises = groupedExercises[nextDifficulty] || [];
if (nextDifficultyExercises.length > 0) {
- nextExercise = nextDifficultyExercises[0]
+ nextExercise = nextDifficultyExercises[0];
}
}
}
@@ -111,10 +120,12 @@ export async function getExerciseContext(currentExerciseId: string): Promise {
- const context = await getExerciseContext(currentExerciseId)
- return context?.nextExercise ?? null
+export async function getNextExercise(
+ currentExerciseId: string,
+): Promise {
+ const context = await getExerciseContext(currentExerciseId);
+ return context?.nextExercise ?? null;
}
diff --git a/src/lib/query-client.ts b/src/lib/query-client.ts
index 9a22f9c..b2e618d 100644
--- a/src/lib/query-client.ts
+++ b/src/lib/query-client.ts
@@ -1,4 +1,8 @@
-import { QueryClient, defaultShouldDehydrateQuery, isServer } from '@tanstack/react-query'
+import {
+ defaultShouldDehydrateQuery,
+ isServer,
+ QueryClient,
+} from "@tanstack/react-query";
function makeQueryClient() {
return new QueryClient({
@@ -10,33 +14,30 @@ function makeQueryClient() {
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
- query.state.status === 'pending',
+ query.state.status === "pending",
},
},
- })
+ });
}
-let browserQueryClient: QueryClient | undefined = undefined
+let browserQueryClient: QueryClient | undefined;
export function getQueryClient() {
if (isServer) {
- return makeQueryClient()
+ return makeQueryClient();
}
if (!browserQueryClient) {
- browserQueryClient = makeQueryClient()
+ browserQueryClient = makeQueryClient();
}
- return browserQueryClient
+ return browserQueryClient;
}
// Query keys
export const queryKeys = {
- exercises: ['exercises'] as const,
- exercise: (id: string) => ['exercise', id] as const,
- userScore: (userId: string) => ['userScore', userId] as const,
- userStreak: (userId: string) => ['userStreak', userId] as const,
- weekProgress: (userId: string) => ['weekProgress', userId] as const,
- solvedExercises: (userId: string) => ['solvedExercises', userId] as const,
-}
-
-
-
+ exercises: ["exercises"] as const,
+ exercise: (id: string) => ["exercise", id] as const,
+ userScore: (userId: string) => ["userScore", userId] as const,
+ userStreak: (userId: string) => ["userStreak", userId] as const,
+ weekProgress: (userId: string) => ["weekProgress", userId] as const,
+ solvedExercises: (userId: string) => ["solvedExercises", userId] as const,
+};
diff --git a/src/lib/query-validator.ts b/src/lib/query-validator.ts
index 1c65539..163be7e 100644
--- a/src/lib/query-validator.ts
+++ b/src/lib/query-validator.ts
@@ -4,149 +4,174 @@ interface ValidationResult {
example?: string;
}
-export const validateQuery = (query: string, ejercicioId: number): ValidationResult => {
+export const validateQuery = (
+ query: string,
+ ejercicioId: number,
+): ValidationResult => {
// Convertir la consulta a minúsculas y eliminar espacios extra para comparación
- const normalizedQuery = query.toLowerCase().replace(/\s+/g, ' ').trim();
+ const normalizedQuery = query.toLowerCase().replace(/\s+/g, " ").trim();
switch (ejercicioId) {
case 1: {
// Validar la consulta básica de selección
const expectedParts = [
- 'select',
- 'from usuarios',
- 'limit 5',
- 'order by id'
+ "select",
+ "from usuarios",
+ "limit 5",
+ "order by id",
];
- const isValid = expectedParts.every(part => normalizedQuery.includes(part));
-
+ const isValid = expectedParts.every((part) =>
+ normalizedQuery.includes(part),
+ );
+
if (!isValid) {
return {
isValid: false,
- message: "La consulta debe seleccionar todas las columnas de la tabla usuarios, limitada a 5 resultados y ordenada por ID"
+ message:
+ "La consulta debe seleccionar todas las columnas de la tabla usuarios, limitada a 5 resultados y ordenada por ID",
};
}
// Verificar que no haya cláusulas adicionales innecesarias
- const hasUnexpectedClauses = normalizedQuery.includes('group by') ||
- normalizedQuery.includes('having');
-
+ const hasUnexpectedClauses =
+ normalizedQuery.includes("group by") ||
+ normalizedQuery.includes("having");
+
if (hasUnexpectedClauses) {
return {
isValid: false,
- message: "La consulta contiene cláusulas innecesarias"
+ message: "La consulta contiene cláusulas innecesarias",
};
}
return {
isValid: true,
- message: "¡Excelente! Has completado el ejercicio correctamente."
+ message: "¡Excelente! Has completado el ejercicio correctamente.",
};
}
case 2: {
// Validar la consulta de filtrado con WHERE
const expectedParts = [
- 'select',
- 'from usuarios',
- 'where',
- 'fecha_registro',
- '2023-01-01'
+ "select",
+ "from usuarios",
+ "where",
+ "fecha_registro",
+ "2023-01-01",
];
- const isValid = expectedParts.every(part => normalizedQuery.includes(part)) &&
- (normalizedQuery.includes('>') || normalizedQuery.includes('>='));
+ const isValid =
+ expectedParts.every((part) => normalizedQuery.includes(part)) &&
+ (normalizedQuery.includes(">") || normalizedQuery.includes(">="));
if (!isValid) {
return {
isValid: false,
- message: "La consulta debe filtrar usuarios registrados después del '2023-01-01' usando WHERE"
+ message:
+ "La consulta debe filtrar usuarios registrados después del '2023-01-01' usando WHERE",
};
}
// Verificar que la comparación de fecha sea correcta
- const hasValidDateComparison = normalizedQuery.match(/fecha_registro\s*>[=]?\s*'2023-01-01'/);
-
+ const hasValidDateComparison = normalizedQuery.match(
+ /fecha_registro\s*>[=]?\s*'2023-01-01'/,
+ );
+
if (!hasValidDateComparison) {
return {
isValid: false,
- message: "La comparación de fecha no es correcta. Usa '>' o '>=' con el formato de fecha correcto"
+ message:
+ "La comparación de fecha no es correcta. Usa '>' o '>=' con el formato de fecha correcto",
};
}
return {
isValid: true,
- message: "¡Excelente! Has completado el ejercicio de filtrado correctamente."
+ message:
+ "¡Excelente! Has completado el ejercicio de filtrado correctamente.",
};
}
case 3: {
// Validar la consulta de JOIN y agregación
const expectedParts = [
- 'select',
- 'from usuarios',
- 'join',
- 'pedidos',
- 'count',
- 'group by'
+ "select",
+ "from usuarios",
+ "join",
+ "pedidos",
+ "count",
+ "group by",
];
- const isValid = expectedParts.every(part => normalizedQuery.includes(part));
+ const isValid = expectedParts.every((part) =>
+ normalizedQuery.includes(part),
+ );
if (!isValid) {
return {
isValid: false,
- message: "La consulta debe incluir un JOIN entre usuarios y pedidos, y usar COUNT para totalizar",
+ message:
+ "La consulta debe incluir un JOIN entre usuarios y pedidos, y usar COUNT para totalizar",
example: `
Estructura sugerida:
SELECT usuarios.id, usuarios.nombre, usuarios.email, COUNT(pedidos.id) as total_pedidos
FROM usuarios
LEFT JOIN pedidos ON usuarios.id = pedidos.usuario_id
-GROUP BY usuarios.id, usuarios.nombre, usuarios.email`
+GROUP BY usuarios.id, usuarios.nombre, usuarios.email`,
};
}
// Verificar que el JOIN sea correcto
- const hasValidJoin = normalizedQuery.match(/usuarios\s+(?:inner\s+|left\s+)?join\s+pedidos\s+on\s+usuarios\.id\s*=\s*pedidos\.usuario_id/);
-
+ const hasValidJoin = normalizedQuery.match(
+ /usuarios\s+(?:inner\s+|left\s+)?join\s+pedidos\s+on\s+usuarios\.id\s*=\s*pedidos\.usuario_id/,
+ );
+
if (!hasValidJoin) {
return {
isValid: false,
- message: "El JOIN debe relacionar las tablas usuarios y pedidos usando la condición ON usuarios.id = pedidos.usuario_id",
- example: "LEFT JOIN pedidos ON usuarios.id = pedidos.usuario_id"
+ message:
+ "El JOIN debe relacionar las tablas usuarios y pedidos usando la condición ON usuarios.id = pedidos.usuario_id",
+ example: "LEFT JOIN pedidos ON usuarios.id = pedidos.usuario_id",
};
}
// Verificar que se incluyan las columnas necesarias en GROUP BY
- const hasRequiredColumns = normalizedQuery.includes('group by usuarios.id');
+ const hasRequiredColumns = normalizedQuery.includes(
+ "group by usuarios.id",
+ );
if (!hasRequiredColumns) {
return {
isValid: false,
- message: "Debes incluir usuarios.id en la cláusula GROUP BY. Además, incluye todas las columnas no agregadas del SELECT.",
- example: "GROUP BY usuarios.id, usuarios.nombre, usuarios.email"
+ message:
+ "Debes incluir usuarios.id en la cláusula GROUP BY. Además, incluye todas las columnas no agregadas del SELECT.",
+ example: "GROUP BY usuarios.id, usuarios.nombre, usuarios.email",
};
}
// Verificar que se use COUNT correctamente
- const hasValidCount = normalizedQuery.match(/count\s*\(\s*(?:pedidos\.id|\*)\s*\)/i);
+ const hasValidCount = normalizedQuery.match(
+ /count\s*\(\s*(?:pedidos\.id|\*)\s*\)/i,
+ );
if (!hasValidCount) {
return {
isValid: false,
message: "Usa COUNT para contar los pedidos de cada usuario",
- example: "COUNT(pedidos.id) as total_pedidos"
+ example: "COUNT(pedidos.id) as total_pedidos",
};
}
return {
isValid: true,
- message: "¡Excelente! Has completado el ejercicio avanzado correctamente."
+ message:
+ "¡Excelente! Has completado el ejercicio avanzado correctamente.",
};
}
default:
return {
isValid: false,
- message: "Ejercicio no encontrado"
+ message: "Ejercicio no encontrado",
};
}
};
diff --git a/src/lib/sql-error-handler.ts b/src/lib/sql-error-handler.ts
index 853b773..a82abbc 100644
--- a/src/lib/sql-error-handler.ts
+++ b/src/lib/sql-error-handler.ts
@@ -13,60 +13,72 @@ export function handleSQLError(error: SQLError): SQLErrorResponse {
const errorMessage = error.message.toLowerCase();
// Manejar errores de sintaxis
- if (errorMessage.includes('syntax error')) {
- const nearPart = errorMessage.match(/near "([^"]+)"/)?.[1] || '';
+ if (errorMessage.includes("syntax error")) {
+ const nearPart = errorMessage.match(/near "([^"]+)"/)?.[1] || "";
return {
- message: `Error de sintaxis ${nearPart ? `cerca de "${nearPart}"` : ''}. Verifica tu consulta SQL.`,
+ message: `Error de sintaxis ${nearPart ? `cerca de "${nearPart}"` : ""}. Verifica tu consulta SQL.`,
example: `
Ejemplos de sintaxis correcta:
SELECT * FROM usuarios
SELECT columna FROM tabla WHERE condicion
-SELECT * FROM tabla1 JOIN tabla2 ON tabla1.id = tabla2.id`
+SELECT * FROM tabla1 JOIN tabla2 ON tabla1.id = tabla2.id`,
};
}
// Manejar errores de tabla no existente
- if (errorMessage.includes('relation') && errorMessage.includes('does not exist')) {
- const tableName = errorMessage.match(/relation "([^"]+)" does not exist/)?.[1];
+ if (
+ errorMessage.includes("relation") &&
+ errorMessage.includes("does not exist")
+ ) {
+ const tableName = errorMessage.match(
+ /relation "([^"]+)" does not exist/,
+ )?.[1];
return {
message: `La tabla "${tableName}" no existe.`,
example: `
Tablas disponibles:
- usuarios (id, nombre, email, fecha_registro, edad, ciudad, activo)
-- pedidos (id, usuario_id, monto, fecha)`
+- pedidos (id, usuario_id, monto, fecha)`,
};
}
// Manejar errores de columna no existente
- if (errorMessage.includes('column') && errorMessage.includes('does not exist')) {
- const columnName = errorMessage.match(/column "([^"]+)" does not exist/)?.[1];
+ if (
+ errorMessage.includes("column") &&
+ errorMessage.includes("does not exist")
+ ) {
+ const columnName = errorMessage.match(
+ /column "([^"]+)" does not exist/,
+ )?.[1];
return {
message: `La columna "${columnName}" no existe en la tabla.`,
example: `
Columnas disponibles:
usuarios: id, nombre, email, fecha_registro, edad, ciudad, activo
-pedidos: id, usuario_id, monto, fecha`
+pedidos: id, usuario_id, monto, fecha`,
};
}
// Manejar errores de GROUP BY
- if (errorMessage.includes('must appear in the group by clause')) {
+ if (errorMessage.includes("must appear in the group by clause")) {
return {
- message: 'Todas las columnas en el SELECT deben aparecer en el GROUP BY o ser parte de una función de agregación.',
+ message:
+ "Todas las columnas en el SELECT deben aparecer en el GROUP BY o ser parte de una función de agregación.",
example: `
SELECT usuarios.id, usuarios.nombre, COUNT(*) as total
FROM usuarios
-GROUP BY usuarios.id, usuarios.nombre`
+GROUP BY usuarios.id, usuarios.nombre`,
};
}
// Error genérico con sugerencias
return {
- message: 'Error en la consulta SQL. Verifica la sintaxis y los nombres de tablas/columnas.',
+ message:
+ "Error en la consulta SQL. Verifica la sintaxis y los nombres de tablas/columnas.",
example: `
Sugerencias:
1. Verifica que los nombres de tablas y columnas estén escritos correctamente
2. Asegúrate de usar la sintaxis correcta para cada tipo de consulta
-3. Revisa que las tablas y columnas referenciadas existan`
+3. Revisa que las tablas y columnas referenciadas existan`,
};
}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index e9d4272..1e2c50f 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -1,24 +1,24 @@
-import { type ClassValue, clsx } from "clsx"
-import { twMerge } from "tailwind-merge"
+import { type ClassValue, clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
- return twMerge(clsx(inputs))
+ return twMerge(clsx(inputs));
}
export function formatDistanceToNow(date: Date): string {
- const now = new Date()
- const diffMs = now.getTime() - date.getTime()
- const diffSecs = Math.floor(diffMs / 1000)
- const diffMins = Math.floor(diffSecs / 60)
- const diffHours = Math.floor(diffMins / 60)
- const diffDays = Math.floor(diffHours / 24)
- const diffWeeks = Math.floor(diffDays / 7)
- const diffMonths = Math.floor(diffDays / 30)
+ const now = new Date();
+ const diffMs = now.getTime() - date.getTime();
+ const diffSecs = Math.floor(diffMs / 1000);
+ const diffMins = Math.floor(diffSecs / 60);
+ const diffHours = Math.floor(diffMins / 60);
+ const diffDays = Math.floor(diffHours / 24);
+ const diffWeeks = Math.floor(diffDays / 7);
+ const diffMonths = Math.floor(diffDays / 30);
- if (diffSecs < 60) return 'hace un momento'
- if (diffMins < 60) return `hace ${diffMins} min`
- if (diffHours < 24) return `hace ${diffHours}h`
- if (diffDays < 7) return `hace ${diffDays}d`
- if (diffWeeks < 4) return `hace ${diffWeeks} sem`
- return `hace ${diffMonths} mes${diffMonths !== 1 ? 'es' : ''}`
+ if (diffSecs < 60) return "hace un momento";
+ if (diffMins < 60) return `hace ${diffMins} min`;
+ if (diffHours < 24) return `hace ${diffHours}h`;
+ if (diffDays < 7) return `hace ${diffDays}d`;
+ if (diffWeeks < 4) return `hace ${diffWeeks} sem`;
+ return `hace ${diffMonths} mes${diffMonths !== 1 ? "es" : ""}`;
}
diff --git a/src/lib/validation-service.ts b/src/lib/validation-service.ts
index 6bc795a..3a7e72e 100644
--- a/src/lib/validation-service.ts
+++ b/src/lib/validation-service.ts
@@ -1,102 +1,106 @@
-import type { ExpectedOutput, DDLConditions } from '@/types/exercises'
-import { validateDDL, type DDLValidationConfig, type DDLValidationResult } from './ddl-validator'
+import type { DDLConditions, ExpectedOutput } from "@/types/exercises";
+import {
+ type DDLValidationConfig,
+ type DDLValidationResult,
+ validateDDL,
+} from "./ddl-validator";
interface QueryResult {
- rows: Record[]
- fields: { name: string }[]
+ rows: Record[];
+ fields: { name: string }[];
}
interface ValidationConfig {
- type: 'exact' | 'partial' | 'ddl_schema'
- conditions: Record
+ type: "exact" | "partial" | "ddl_schema";
+ conditions: Record;
}
export function validateQueryResult(
result: QueryResult,
- expectedOutput: ValidationConfig | ExpectedOutput
+ expectedOutput: ValidationConfig | ExpectedOutput,
): boolean {
- const { type, conditions } = expectedOutput
+ const { type, conditions } = expectedOutput;
switch (type) {
- case 'exact': {
+ case "exact": {
const conds = conditions as {
- rows?: number
- columns?: string[]
- values?: Record[]
- }
+ rows?: number;
+ columns?: string[];
+ values?: Record[];
+ };
if (conds.rows !== undefined && result.rows.length !== conds.rows) {
- return false
+ return false;
}
if (conds.columns) {
const hasAllColumns = conds.columns.every((col) =>
- result.fields.some((field) => field.name === col)
- )
- if (!hasAllColumns) return false
+ result.fields.some((field) => field.name === col),
+ );
+ if (!hasAllColumns) return false;
}
if (conds.values) {
return conds.values.every((expectedRow, index) => {
- const actualRow = result.rows[index]
- if (!actualRow) return false
+ const actualRow = result.rows[index];
+ if (!actualRow) return false;
return Object.entries(expectedRow).every(
- ([key, value]) => actualRow[key] === value
- )
- })
+ ([key, value]) => actualRow[key] === value,
+ );
+ });
}
- return true
+ return true;
}
- case 'partial': {
+ case "partial": {
const conds = conditions as {
- columns?: string[]
- customValidation?: (result: QueryResult) => boolean
- }
+ columns?: string[];
+ customValidation?: (result: QueryResult) => boolean;
+ };
if (conds.columns) {
const hasRequiredColumns = conds.columns.every((col) =>
- result.fields.some((field) => field.name === col)
- )
- if (!hasRequiredColumns) return false
+ result.fields.some((field) => field.name === col),
+ );
+ if (!hasRequiredColumns) return false;
}
if (conds.customValidation) {
- return conds.customValidation(result)
+ return conds.customValidation(result);
}
- return true
+ return true;
}
- case 'ddl_schema': {
+ case "ddl_schema": {
// DDL validation is handled asynchronously by validateDDLExercise
// This case should not be reached in synchronous validation
- return false
+ return false;
}
default:
- return false
+ return false;
}
}
export async function validateDDLExercise(
- conditions: DDLConditions
+ conditions: DDLConditions,
): Promise {
const config: DDLValidationConfig = {
schemaInspection: conditions.schemaInspection,
testQueries: conditions.testQueries,
- }
+ };
- return validateDDL(config)
+ return validateDDL(config);
}
export function isDDLValidation(
- expectedOutput: ValidationConfig | ExpectedOutput
-): expectedOutput is ExpectedOutput & { type: 'ddl_schema' } {
- return expectedOutput.type === 'ddl_schema'
+ expectedOutput: ValidationConfig | ExpectedOutput,
+): expectedOutput is ExpectedOutput & { type: "ddl_schema" } {
+ return expectedOutput.type === "ddl_schema";
}
export function getDDLSetupSQL(conditions: DDLConditions): string | undefined {
- return conditions.setupSQL
+ return conditions.setupSQL;
}
diff --git a/src/lib/validations/index.ts b/src/lib/validations/index.ts
index f411ea9..42b72cf 100644
--- a/src/lib/validations/index.ts
+++ b/src/lib/validations/index.ts
@@ -1,27 +1,27 @@
-import { z } from 'zod'
+import { z } from "zod";
export const exerciseExampleSchema = z.object({
entrada: z.string().optional(),
salida: z.string().optional(),
-})
+});
export const expectedColumnSchema = z.object({
name: z.string(),
type: z.string().optional(),
nullable: z.boolean().optional(),
-})
+});
export const expectedConstraintSchema = z.object({
- type: z.enum(['PRIMARY KEY', 'FOREIGN KEY', 'UNIQUE', 'CHECK']),
+ type: z.enum(["PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"]),
columns: z.array(z.string()),
definition: z.string().optional(),
-})
+});
export const expectedIndexSchema = z.object({
name: z.string().optional(),
columns: z.array(z.string()),
isUnique: z.boolean().optional(),
-})
+});
export const schemaInspectionSchema = z.object({
table: z.string(),
@@ -29,42 +29,42 @@ export const schemaInspectionSchema = z.object({
columns: z.array(expectedColumnSchema).optional(),
constraints: z.array(expectedConstraintSchema).optional(),
indexes: z.array(expectedIndexSchema).optional(),
-})
+});
export const testQuerySchema = z.object({
query: z.string(),
shouldSucceed: z.boolean(),
description: z.string().optional(),
-})
+});
export const ddlConditionsSchema = z.object({
schemaInspection: schemaInspectionSchema.optional(),
testQueries: z.array(testQuerySchema).optional(),
setupSQL: z.string().optional(),
-})
+});
export const exerciseValidationSchema = z.object({
- type: z.enum(['exact', 'partial', 'ddl_schema']),
+ type: z.enum(["exact", "partial", "ddl_schema"]),
conditions: z.record(z.unknown()),
-})
+});
-export const exerciseTypeSchema = z.enum(['dml', 'ddl'])
+export const exerciseTypeSchema = z.enum(["dml", "ddl"]);
export const exerciseSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1),
- difficulty: z.enum(['Principiante', 'Intermedio', 'Avanzado']),
+ difficulty: z.enum(["Principiante", "Intermedio", "Avanzado"]),
description: z.string().min(1),
details: z.string().min(1),
hint: z.string().min(1),
successMessage: z.string().min(1),
example: exerciseExampleSchema,
- type: exerciseTypeSchema.default('dml'),
+ type: exerciseTypeSchema.default("dml"),
validation: exerciseValidationSchema,
isDeleted: z.boolean().default(false),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
-})
+});
export const userSchema = z.object({
id: z.string().uuid(),
@@ -73,7 +73,7 @@ export const userSchema = z.object({
image: z.string().url().nullable(),
createdAt: z.coerce.date(),
updatedAt: z.coerce.date(),
-})
+});
export const submissionSchema = z.object({
id: z.string().uuid(),
@@ -85,12 +85,12 @@ export const submissionSchema = z.object({
attempts: z.number().int().min(1).default(1),
timeSpentSeconds: z.number().int().nullable(),
createdAt: z.coerce.date(),
-})
+});
export const createSubmissionSchema = z.object({
exerciseId: z.string().uuid(),
solution: z.string().optional(),
-})
+});
export const queryResultSchema = z.object({
error: z.boolean(),
@@ -98,50 +98,49 @@ export const queryResultSchema = z.object({
example: z.string().optional(),
rows: z.array(z.record(z.unknown())),
fields: z.array(z.object({ name: z.string() })),
-})
+});
// API Response schemas
export const exercisesResponseSchema = z.object({
exercises: z.array(exerciseSchema),
-})
+});
export const submissionResponseSchema = z.object({
submission: submissionSchema,
-})
+});
export const scoreResponseSchema = z.object({
score: z.number(),
-})
+});
export const streakResponseSchema = z.object({
streak: z.number(),
-})
+});
export const weekProgressSchema = z.object({
day: z.string(),
completed: z.boolean(),
-})
+});
export const weekProgressResponseSchema = z.object({
progress: z.array(weekProgressSchema),
-})
+});
export const solvedExercisesResponseSchema = z.object({
exerciseIds: z.array(z.string().uuid()),
-})
+});
// Type exports
-export type Exercise = z.infer
-export type ExerciseType = z.infer
-export type User = z.infer
-export type Submission = z.infer
-export type CreateSubmission = z.infer
-export type QueryResult = z.infer
-export type WeekProgress = z.infer
-export type ExpectedColumn = z.infer
-export type ExpectedConstraint = z.infer
-export type ExpectedIndex = z.infer
-export type SchemaInspection = z.infer
-export type TestQuery = z.infer
-export type DDLConditions = z.infer
-
+export type Exercise = z.infer;
+export type ExerciseType = z.infer;
+export type User = z.infer;
+export type Submission = z.infer;
+export type CreateSubmission = z.infer;
+export type QueryResult = z.infer;
+export type WeekProgress = z.infer;
+export type ExpectedColumn = z.infer;
+export type ExpectedConstraint = z.infer;
+export type ExpectedIndex = z.infer;
+export type SchemaInspection = z.infer;
+export type TestQuery = z.infer;
+export type DDLConditions = z.infer;
diff --git a/src/proxy.ts b/src/proxy.ts
index edeae84..7543980 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,13 +1,10 @@
-import { clerkMiddleware } from '@clerk/nextjs/server'
+import { clerkMiddleware } from "@clerk/nextjs/server";
-export default clerkMiddleware()
+export default clerkMiddleware();
export const config = {
matcher: [
- '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
- '/(api|trpc)(.*)',
+ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
+ "/(api|trpc)(.*)",
],
-}
-
-
-
+};
diff --git a/src/stores/index.ts b/src/stores/index.ts
index 2abeddd..60d9f75 100644
--- a/src/stores/index.ts
+++ b/src/stores/index.ts
@@ -1,2 +1 @@
-export { useThemeStore } from './theme-store'
-
+export { useThemeStore } from "./theme-store";
diff --git a/src/stores/theme-store.ts b/src/stores/theme-store.ts
index 8ec6ad7..89d787f 100644
--- a/src/stores/theme-store.ts
+++ b/src/stores/theme-store.ts
@@ -1,20 +1,19 @@
-import { create } from 'zustand'
+import { create } from "zustand";
interface ThemeTransition {
- x: number
- y: number
- isAnimating: boolean
+ x: number;
+ y: number;
+ isAnimating: boolean;
}
interface ThemeStore {
- transition: ThemeTransition | null
- startTransition: (x: number, y: number) => void
- endTransition: () => void
+ transition: ThemeTransition | null;
+ startTransition: (x: number, y: number) => void;
+ endTransition: () => void;
}
export const useThemeStore = create((set) => ({
transition: null,
startTransition: (x, y) => set({ transition: { x, y, isAnimating: true } }),
endTransition: () => set({ transition: null }),
-}))
-
+}));
diff --git a/src/types/database.ts b/src/types/database.ts
index e9772d1..d3a629c 100644
--- a/src/types/database.ts
+++ b/src/types/database.ts
@@ -1,4 +1,4 @@
export interface SQLResult {
- rows: Record[]
- fields: { name: string }[]
+ rows: Record[];
+ fields: { name: string }[];
}
diff --git a/src/types/exercises.ts b/src/types/exercises.ts
index d142abe..b99619b 100644
--- a/src/types/exercises.ts
+++ b/src/types/exercises.ts
@@ -1,56 +1,56 @@
-import type { QueryResult } from '@/lib/validations'
+import type { QueryResult } from "@/lib/validations";
-export type { QueryResult }
+export type { QueryResult };
export interface ExpectedColumn {
- name: string
- type?: string
- nullable?: boolean
+ name: string;
+ type?: string;
+ nullable?: boolean;
}
export interface ExpectedConstraint {
- type: 'PRIMARY KEY' | 'FOREIGN KEY' | 'UNIQUE' | 'CHECK'
- columns: string[]
- definition?: string
+ type: "PRIMARY KEY" | "FOREIGN KEY" | "UNIQUE" | "CHECK";
+ columns: string[];
+ definition?: string;
}
export interface ExpectedIndex {
- name?: string
- columns: string[]
- isUnique?: boolean
+ name?: string;
+ columns: string[];
+ isUnique?: boolean;
}
export interface SchemaInspectionConfig {
- table: string
- shouldExist?: boolean
- columns?: ExpectedColumn[]
- constraints?: ExpectedConstraint[]
- indexes?: ExpectedIndex[]
+ table: string;
+ shouldExist?: boolean;
+ columns?: ExpectedColumn[];
+ constraints?: ExpectedConstraint[];
+ indexes?: ExpectedIndex[];
}
export interface TestQueryConfig {
- query: string
- shouldSucceed: boolean
- description?: string
+ query: string;
+ shouldSucceed: boolean;
+ description?: string;
}
export interface DDLConditions {
- schemaInspection?: SchemaInspectionConfig
- testQueries?: TestQueryConfig[]
- setupSQL?: string
+ schemaInspection?: SchemaInspectionConfig;
+ testQueries?: TestQueryConfig[];
+ setupSQL?: string;
}
export interface DMLConditions {
- rows?: number
- columns?: string[]
- values?: Record[]
- customValidation?: (result: QueryResult) => boolean
- [key: string]: unknown
+ rows?: number;
+ columns?: string[];
+ values?: Record[];
+ customValidation?: (result: QueryResult) => boolean;
+ [key: string]: unknown;
}
export interface ExpectedOutput {
- type: 'exact' | 'partial' | 'count' | 'custom' | 'ddl_schema'
- conditions: DMLConditions | DDLConditions
+ type: "exact" | "partial" | "count" | "custom" | "ddl_schema";
+ conditions: DMLConditions | DDLConditions;
}
-export type ExerciseType = 'dml' | 'ddl'
+export type ExerciseType = "dml" | "ddl";
diff --git a/tailwind.config.ts b/tailwind.config.ts
index 84f079b..867265f 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -1,108 +1,111 @@
-import type { Config } from 'tailwindcss'
+import type { Config } from "tailwindcss";
const config: Config = {
- darkMode: ['class'],
+ darkMode: ["class"],
content: [
- './pages/**/*.{ts,tsx}',
- './components/**/*.{ts,tsx}',
- './app/**/*.{ts,tsx}',
- './src/**/*.{ts,tsx}',
+ "./pages/**/*.{ts,tsx}",
+ "./components/**/*.{ts,tsx}",
+ "./app/**/*.{ts,tsx}",
+ "./src/**/*.{ts,tsx}",
],
- prefix: '',
+ prefix: "",
theme: {
container: {
center: true,
- padding: '2rem',
+ padding: "2rem",
screens: {
- '2xl': '1400px',
+ "2xl": "1400px",
},
},
extend: {
fontFamily: {
- sans: ['var(--font-sans)', 'system-ui', 'sans-serif'],
- mono: ['var(--font-mono)', 'monospace'],
+ sans: ["var(--font-sans)", "system-ui", "sans-serif"],
+ mono: ["var(--font-mono)", "monospace"],
},
fontSize: {
- '2xl': ['1.5rem', { lineHeight: '2rem', letterSpacing: '-0.01em' }],
- '3xl': ['1.875rem', { lineHeight: '2.25rem', letterSpacing: '-0.02em' }],
- '4xl': ['2.25rem', { lineHeight: '2.5rem', letterSpacing: '-0.02em' }],
- '5xl': ['3rem', { lineHeight: '3.5rem', letterSpacing: '-0.02em' }],
- '6xl': ['3.75rem', { lineHeight: '1', letterSpacing: '-0.02em' }],
+ "2xl": ["1.5rem", { lineHeight: "2rem", letterSpacing: "-0.01em" }],
+ "3xl": [
+ "1.875rem",
+ { lineHeight: "2.25rem", letterSpacing: "-0.02em" },
+ ],
+ "4xl": ["2.25rem", { lineHeight: "2.5rem", letterSpacing: "-0.02em" }],
+ "5xl": ["3rem", { lineHeight: "3.5rem", letterSpacing: "-0.02em" }],
+ "6xl": ["3.75rem", { lineHeight: "1", letterSpacing: "-0.02em" }],
},
colors: {
- border: 'var(--border)',
- input: 'var(--input)',
- ring: 'var(--ring)',
- background: 'var(--background)',
- foreground: 'var(--foreground)',
+ border: "var(--border)",
+ input: "var(--input)",
+ ring: "var(--ring)",
+ background: "var(--background)",
+ foreground: "var(--foreground)",
primary: {
- DEFAULT: 'var(--primary)',
- foreground: 'var(--primary-foreground)',
+ DEFAULT: "var(--primary)",
+ foreground: "var(--primary-foreground)",
},
secondary: {
- DEFAULT: 'var(--secondary)',
- foreground: 'var(--secondary-foreground)',
+ DEFAULT: "var(--secondary)",
+ foreground: "var(--secondary-foreground)",
},
destructive: {
- DEFAULT: 'var(--destructive)',
- foreground: 'var(--destructive-foreground)',
+ DEFAULT: "var(--destructive)",
+ foreground: "var(--destructive-foreground)",
},
muted: {
- DEFAULT: 'var(--muted)',
- foreground: 'var(--muted-foreground)',
+ DEFAULT: "var(--muted)",
+ foreground: "var(--muted-foreground)",
},
accent: {
- DEFAULT: 'var(--accent)',
- foreground: 'var(--accent-foreground)',
+ DEFAULT: "var(--accent)",
+ foreground: "var(--accent-foreground)",
},
popover: {
- DEFAULT: 'var(--popover)',
- foreground: 'var(--popover-foreground)',
+ DEFAULT: "var(--popover)",
+ foreground: "var(--popover-foreground)",
},
card: {
- DEFAULT: 'var(--card)',
- foreground: 'var(--card-foreground)',
+ DEFAULT: "var(--card)",
+ foreground: "var(--card-foreground)",
},
sidebar: {
- DEFAULT: 'var(--sidebar)',
- foreground: 'var(--sidebar-foreground)',
- primary: 'var(--sidebar-primary)',
- 'primary-foreground': 'var(--sidebar-primary-foreground)',
- accent: 'var(--sidebar-accent)',
- 'accent-foreground': 'var(--sidebar-accent-foreground)',
- border: 'var(--sidebar-border)',
- ring: 'var(--sidebar-ring)',
+ DEFAULT: "var(--sidebar)",
+ foreground: "var(--sidebar-foreground)",
+ primary: "var(--sidebar-primary)",
+ "primary-foreground": "var(--sidebar-primary-foreground)",
+ accent: "var(--sidebar-accent)",
+ "accent-foreground": "var(--sidebar-accent-foreground)",
+ border: "var(--sidebar-border)",
+ ring: "var(--sidebar-ring)",
},
chart: {
- '1': 'var(--chart-1)',
- '2': 'var(--chart-2)',
- '3': 'var(--chart-3)',
- '4': 'var(--chart-4)',
- '5': 'var(--chart-5)',
+ "1": "var(--chart-1)",
+ "2": "var(--chart-2)",
+ "3": "var(--chart-3)",
+ "4": "var(--chart-4)",
+ "5": "var(--chart-5)",
},
},
borderRadius: {
- lg: 'var(--radius)',
- md: 'calc(var(--radius) - 2px)',
- sm: 'calc(var(--radius) - 4px)',
+ lg: "var(--radius)",
+ md: "calc(var(--radius) - 2px)",
+ sm: "calc(var(--radius) - 4px)",
},
keyframes: {
- 'accordion-down': {
- from: { height: '0' },
- to: { height: 'var(--radix-accordion-content-height)' },
+ "accordion-down": {
+ from: { height: "0" },
+ to: { height: "var(--radix-accordion-content-height)" },
},
- 'accordion-up': {
- from: { height: 'var(--radix-accordion-content-height)' },
- to: { height: '0' },
+ "accordion-up": {
+ from: { height: "var(--radix-accordion-content-height)" },
+ to: { height: "0" },
},
},
animation: {
- 'accordion-down': 'accordion-down 0.2s ease-out',
- 'accordion-up': 'accordion-up 0.2s ease-out',
+ "accordion-down": "accordion-down 0.2s ease-out",
+ "accordion-up": "accordion-up 0.2s ease-out",
},
},
},
- plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
-}
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
+};
-export default config
+export default config;
diff --git a/tsconfig.json b/tsconfig.json
index b575f7d..19c51c8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -23,9 +19,7 @@
}
],
"paths": {
- "@/*": [
- "./src/*"
- ]
+ "@/*": ["./src/*"]
}
},
"include": [
@@ -35,7 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": [
- "node_modules"
- ]
+ "exclude": ["node_modules"]
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..76d4ac1
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,16 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/__tests__/**/*.test.ts"],
+ globals: true,
+ testTimeout: 30000,
+ },
+ resolve: {
+ alias: {
+ "@": resolve(__dirname, "./src"),
+ },
+ },
+});