;
@@ -20,29 +20,26 @@ export function AddTodo({ onAdd }: AddTodoProps) {
resolver: zodResolver(addTodoSchema),
defaultValues: { text: '' },
submitHandlers: {
- onValid: (data) => {
+ onValid: data => {
+ // Since we're not using a database we're catching this early and not actually submitting the form to a server
onAdd(data.text);
methods.reset();
- },
- },
+ }
+ }
});
return (
-
-
+
);
}
diff --git a/apps/todo-app/app/components/todo-filters.tsx b/apps/todo-app/app/components/todo-filters.tsx
index 3da6a54..8bc8f7a 100644
--- a/apps/todo-app/app/components/todo-filters.tsx
+++ b/apps/todo-app/app/components/todo-filters.tsx
@@ -36,7 +36,7 @@ export function TodoFilters({
))}
-
+
{activeCount} active
{completedCount > 0 && (
@@ -53,4 +53,3 @@ export function TodoFilters({
);
}
-
diff --git a/apps/todo-app/app/components/todo-item.tsx b/apps/todo-app/app/components/todo-item.tsx
index 28b9a3f..f6d62a0 100644
--- a/apps/todo-app/app/components/todo-item.tsx
+++ b/apps/todo-app/app/components/todo-item.tsx
@@ -10,7 +10,7 @@ import { cn } from '@todo-starter/utils';
import type { Todo } from '@todo-starter/utils';
const editTodoSchema = z.object({
- text: z.string().min(1, 'Todo text is required').trim(),
+ text: z.string().min(1, 'Todo text is required').trim()
});
type EditTodoFormData = z.infer;
@@ -29,13 +29,13 @@ export function TodoItem({ todo, onToggle, onDelete, onUpdate }: TodoItemProps)
resolver: zodResolver(editTodoSchema),
defaultValues: { text: todo.text },
submitHandlers: {
- onValid: (data) => {
+ onValid: data => {
if (data.text !== todo.text) {
onUpdate(todo.id, data.text);
}
setIsEditing(false);
- },
- },
+ }
+ }
});
const handleCancel = () => {
@@ -50,21 +50,13 @@ export function TodoItem({ todo, onToggle, onDelete, onUpdate }: TodoItemProps)
return (
-
onToggle(todo.id)}
- className="flex-shrink-0"
- />
-
+ onToggle(todo.id)} className="flex-shrink-0" />
+
{isEditing ? (
-
+
) : (
<>
-
+
{todo.text}
-
-
diff --git a/apps/todo-app/app/routes/home.tsx b/apps/todo-app/app/routes/home.tsx
index b1bbcd0..171b88d 100644
--- a/apps/todo-app/app/routes/home.tsx
+++ b/apps/todo-app/app/routes/home.tsx
@@ -16,16 +16,7 @@ export const meta: MetaFunction = () => {
};
export default function Home() {
- const {
- todos,
- filter,
- addTodo,
- toggleTodo,
- deleteTodo,
- updateTodo,
- setFilter,
- clearCompleted
- } = useTodoStore();
+ const { todos, filter, addTodo, toggleTodo, deleteTodo, updateTodo, setFilter, clearCompleted } = useTodoStore();
const filteredTodos = getFilteredTodos(todos, filter);
const activeCount = todos.filter(todo => !todo.completed).length;
@@ -54,9 +45,7 @@ export default function Home() {
Add New Todo
-
- What would you like to accomplish today?
-
+ What would you like to accomplish today?
@@ -102,9 +91,7 @@ export default function Home() {
{todos.length === 0 && (
-
- No todos yet. Add one above to get started!
-
+ No todos yet. Add one above to get started!
)}
diff --git a/apps/todo-app/package.json b/apps/todo-app/package.json
index e6a101e..8dea112 100644
--- a/apps/todo-app/package.json
+++ b/apps/todo-app/package.json
@@ -17,7 +17,8 @@
"typecheck": "tsc --noEmit",
"lint": "biome lint .",
"format": "biome format --write .",
- "test": "vitest",
+ "test": "vitest run",
+ "test:watch": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
"test:ci": "vitest run"
diff --git a/apps/todo-app/react-router.config.ts b/apps/todo-app/react-router.config.ts
index 4018ad2..d59819f 100644
--- a/apps/todo-app/react-router.config.ts
+++ b/apps/todo-app/react-router.config.ts
@@ -4,4 +4,3 @@ export default {
ssr: true,
prerender: ['/']
} satisfies Config;
-
diff --git a/apps/todo-app/test/setup.ts b/apps/todo-app/test/setup.ts
index adee3c8..ddc5165 100644
--- a/apps/todo-app/test/setup.ts
+++ b/apps/todo-app/test/setup.ts
@@ -1,2 +1,11 @@
-import '@testing-library/jest-dom';
+import '@testing-library/jest-dom/vitest';
+import { afterEach } from 'vitest';
+import { cleanup } from '@testing-library/react';
+
+// React Router's useRemixForm calls useHref; wrap renders in a Router for components that need it.
+// For tests that require Router context, prefer rendering the component within a MemoryRouter in the test itself.
+
+afterEach(() => {
+ cleanup();
+});
diff --git a/apps/todo-app/tsconfig.json b/apps/todo-app/tsconfig.json
index 7715c6a..83c49b9 100644
--- a/apps/todo-app/tsconfig.json
+++ b/apps/todo-app/tsconfig.json
@@ -2,6 +2,11 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": ".",
+ "types": [
+ "vitest/globals",
+ "@testing-library/jest-dom",
+ "vite/client"
+ ],
"paths": {
"~/*": ["./app/*"],
"@todo-starter/ui": ["../../packages/ui/src"],
@@ -17,5 +22,10 @@
"**/.server/**/*.tsx",
"**/.client/**/*.ts",
"**/.client/**/*.tsx"
+ ],
+ "exclude": [
+ "node_modules",
+ "build",
+ "dist"
]
}
diff --git a/apps/todo-app/vite.config.ts b/apps/todo-app/vite.config.ts
index 4c13f61..10b3286 100644
--- a/apps/todo-app/vite.config.ts
+++ b/apps/todo-app/vite.config.ts
@@ -7,4 +7,3 @@ export default defineConfig({
// Plugin order can matter; React Router first, then path resolutions, then Tailwind
plugins: [reactRouter(), tsconfigPaths(), tailwindcss()]
});
-
diff --git a/apps/todo-app/vitest.config.ts b/apps/todo-app/vitest.config.ts
index f8ea910..c6c470b 100644
--- a/apps/todo-app/vitest.config.ts
+++ b/apps/todo-app/vitest.config.ts
@@ -9,4 +9,3 @@ export default defineConfig({
setupFiles: ['./test/setup.ts']
}
});
-
diff --git a/biome.json b/biome.json
index df47c6a..df5c5f2 100644
--- a/biome.json
+++ b/biome.json
@@ -25,17 +25,7 @@
"trailingCommas": "none",
"arrowParentheses": "asNeeded"
},
- "globals": [
- "vi",
- "describe",
- "it",
- "expect",
- "beforeEach",
- "afterEach",
- "beforeAll",
- "afterAll",
- "test"
- ]
+ "globals": ["vi", "describe", "it", "expect", "beforeEach", "afterEach", "beforeAll", "afterAll", "test"]
},
"linter": {
"enabled": true,
@@ -78,4 +68,3 @@
}
}
}
-
diff --git a/bun.lock b/bun.lock
index 06540da..180af7e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -384,13 +384,13 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
- "@react-router/dev": ["@react-router/dev@7.8.0", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.8.0", "@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-rsc": "0.4.11", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "tinyglobby": "^0.2.14", "valibot": "^0.41.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.8.0", "react-router": "^7.8.0", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-5NA9yLZComM+kCD3zNPL3rjrAFjzzODY8hjAJlpz/6jpyXoF28W8QTSo8rxc56XVNLONM75Y5nq1wzeEcWFFKA=="],
+ "@react-router/dev": ["@react-router/dev@7.7.1", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.7.1", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "tinyglobby": "^0.2.14", "valibot": "^0.41.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.7.1", "react-router": "^7.7.1", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-ByfgHmAyfx/JQYN/QwUx1sFJlBA5Z3HQAZ638wHSb+m6khWtHqSaKCvPqQh1P00wdEAeV3tX5L1aUM/ceCF6+w=="],
- "@react-router/express": ["@react-router/express@7.8.0", "", { "dependencies": { "@react-router/node": "7.8.0" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.8.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-lNUwux5IfMqczIL3gXZ/mauPUoVz65fSLPnUTkP7hkh/P7fcsPtYkmcixuaWb+882lY+Glf157OdoIMbcSMBaA=="],
+ "@react-router/express": ["@react-router/express@7.7.1", "", { "dependencies": { "@react-router/node": "7.7.1" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.7.1", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-OEZwIM7i/KPSDjwVRg3LqeNIwG41U+SeFOwMjhZRFfyrnwghHfvWsDajf73r4ccMh+RRHcP1GIN6VSU3XZk7MA=="],
- "@react-router/node": ["@react-router/node@7.8.0", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.8.0", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-/FFN9vqI2EHPwDCHTvsMInhrYvwJ5SlCeyUr1oWUxH47JyYkooVFks5++M4VkrTgj2ZBsMjPPKy0xRNTQdtBDA=="],
+ "@react-router/node": ["@react-router/node@7.7.1", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.7.1", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-EHd6PEcw2nmcJmcYTPA0MmRWSqOaJ/meycfCp0ADA9T/6b7+fUHfr9XcNyf7UeZtYwu4zGyuYfPmLU5ic6Ugyg=="],
- "@react-router/serve": ["@react-router/serve@7.8.0", "", { "dependencies": { "@react-router/express": "7.8.0", "@react-router/node": "7.8.0", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.8.0" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-DokCv1GfOMt9KHu+k3WYY9sP5nOEzq7za+Vi3dWPHoY5oP0wgv8S4DnTPU08ASY8iFaF38NAzapbSFfu6Xfr0Q=="],
+ "@react-router/serve": ["@react-router/serve@7.7.1", "", { "dependencies": { "@react-router/express": "7.7.1", "@react-router/node": "7.7.1", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.7.1" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-LyAiX+oI+6O6j2xWPUoKW+cgayUf3USBosSMv73Jtwi99XUhSDu2MUhM+BB+AbrYRubauZ83QpZTROiXoaf8jA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
@@ -494,7 +494,7 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
- "@types/node": ["@types/node@20.19.10", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-iAFpG6DokED3roLSP0K+ybeDdIX6Bc0Vd3mLW5uDqThPWtNos3E+EqOM11mPQHKzfWHqEBuLjIlsBQQ8CsISmQ=="],
+ "@types/node": ["@types/node@20.19.9", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw=="],
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
@@ -502,8 +502,6 @@
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
- "@vitejs/plugin-rsc": ["@vitejs/plugin-rsc@0.4.11", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.7.0", "es-module-lexer": "^1.7.0", "estree-walker": "^3.0.3", "magic-string": "^0.30.17", "periscopic": "^4.0.2", "turbo-stream": "^3.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "react": "*", "react-dom": "*", "vite": "*" } }, "sha512-+4H4wLi+Y9yF58znBfKgGfX8zcqUGt8ngnmNgzrdGdF1SVz7EO0sg7WnhK5fFVHt6fUxsVEjmEabsCWHKPL1Tw=="],
-
"@vitest/expect": ["@vitest/expect@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "tinyrainbow": "^2.0.0" } }, "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig=="],
"@vitest/mocker": ["@vitest/mocker@3.2.4", "", { "dependencies": { "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", "magic-string": "^0.30.17" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ=="],
@@ -560,7 +558,7 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
- "caniuse-lite": ["caniuse-lite@1.0.30001733", "", {}, "sha512-e4QKw/O2Kavj2VQTKZWrwzkt3IxOmIlU6ajRb6LP64LHpBo1J67k2Hi4Vu/TgJWsNtynurfS0uK3MaUTCPfu5Q=="],
+ "caniuse-lite": ["caniuse-lite@1.0.30001731", "", {}, "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg=="],
"chai": ["chai@5.2.1", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-5nFxhUrX0PqtyogoYOA8IPswy5sZFTOsBFl/9bNsmDLgsxYTzSZQJDPppDnZPTQbzSEm0hqGjWPzRemQCYbD6A=="],
@@ -634,7 +632,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "electron-to-chromium": ["electron-to-chromium@1.5.199", "", {}, "sha512-3gl0S7zQd88kCAZRO/DnxtBKuhMO4h0EaQIN3YgZfV6+pW+5+bf2AdQeHNESCoaQqo/gjGVYEf2YM4O5HJQqpQ=="],
+ "electron-to-chromium": ["electron-to-chromium@1.5.197", "", {}, "sha512-m1xWB3g7vJ6asIFz+2pBUbq3uGmfmln1M9SSvBe4QIFWYrRHylP73zL/3nMjDmwz8V+1xAXQDfBd6+HPW0WvDQ=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
@@ -738,8 +736,6 @@
"is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="],
- "is-reference": ["is-reference@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.6" } }, "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw=="],
-
"isbot": ["isbot@5.1.29", "", {}, "sha512-DelDWWoa3mBoyWTq3wjp+GIWx/yZdN7zLUE7NFhKjAiJ+uJVRkbLlwykdduCE4sPUUy8mlTYTmdhBUYu91F+sw=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -804,7 +800,7 @@
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
- "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
@@ -866,8 +862,6 @@
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
- "periscopic": ["periscopic@4.0.2", "", { "dependencies": { "@types/estree": "*", "is-reference": "^3.0.2", "zimmerframe": "^1.0.0" } }, "sha512-sqpQDUy8vgB7ycLkendSKS6HnVz1Rneoc3Rc+ZBUCe2pbqlVuCC5vF52l0NJ1aiMg/r1qfYF9/myz8CZeI2rjA=="],
-
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
@@ -910,7 +904,7 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
- "react-router": ["react-router@7.8.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg=="],
+ "react-router": ["react-router@7.7.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-jVKHXoWRIsD/qS6lvGveckwb862EekvapdHJN/cGmzw40KnJH5gg53ujOJ4qX6EKIK9LSBfFed/xiQ5yeXNrUA=="],
"react-router-dom": ["react-router-dom@7.8.0", "", { "dependencies": { "react-router": "7.8.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-ntInsnDVnVRdtSu6ODmTQ41cbluak/ENeTif7GBce0L6eztFg6/e1hXAysFQI8X25C8ipKmT9cClbJwxx3Kaqw=="],
@@ -1050,8 +1044,6 @@
"turbo-linux-arm64": ["turbo-linux-arm64@2.5.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-DW+8CjCjybu0d7TFm9dovTTVg1VRnlkZ1rceO4zqsaLrit3DgHnN4to4uwyuf9s2V/BwS3IYcRy+HG9BL596Iw=="],
- "turbo-stream": ["turbo-stream@3.1.0", "", {}, "sha512-tVI25WEXl4fckNEmrq70xU1XumxUwEx/FZD5AgEcV8ri7Wvrg2o7GEq8U7htrNx3CajciGm+kDyhRf5JB6t7/A=="],
-
"turbo-windows-64": ["turbo-windows-64@2.5.5", "", { "os": "win32", "cpu": "x64" }, "sha512-q5p1BOy8ChtSZfULuF1BhFMYIx6bevXu4fJ+TE/hyNfyHJIfjl90Z6jWdqAlyaFLmn99X/uw+7d6T/Y/dr5JwQ=="],
"turbo-windows-arm64": ["turbo-windows-arm64@2.5.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-AXbF1KmpHUq3PKQwddMGoKMYhHsy5t1YBQO8HZ04HLMR0rWv9adYlQ8kaeQJTko1Ay1anOBFTqaxfVOOsu7+1Q=="],
@@ -1090,8 +1082,6 @@
"vite-tsconfig-paths": ["vite-tsconfig-paths@5.1.4", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w=="],
- "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="],
-
"vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="],
"w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="],
@@ -1120,16 +1110,12 @@
"yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
- "zimmerframe": ["zimmerframe@1.1.2", "", {}, "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="],
-
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@asamuzakjp/css-color/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@babel/generator/jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
-
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
@@ -1156,8 +1142,6 @@
"@vitejs/plugin-react/react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
- "@vitejs/plugin-rsc/@mjackson/node-fetch-server": ["@mjackson/node-fetch-server@0.7.0", "", {}, "sha512-un8diyEBKU3BTVj3GzlTPA1kIjCkGdD+AMYQy31Gf9JCkfoZzwgJ79GUtHrF2BN3XPNMLpubbzPcxys+a3uZEw=="],
-
"@vitest/runner/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"@vitest/snapshot/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
@@ -1186,6 +1170,8 @@
"lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"morgan/on-finished": ["on-finished@2.3.0", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww=="],
@@ -1194,6 +1180,8 @@
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
+ "react-router-dom/react-router": ["react-router@7.8.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-r15M3+LHKgM4SOapNmsH3smAizWds1vJ0Z9C4mWaKnT9/wD7+d/0jYcj6LmOvonkrO4Rgdyp4KQ/29gWN2i1eg=="],
+
"send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="],
diff --git a/package.json b/package.json
index 8e8f9ab..b77bb28 100644
--- a/package.json
+++ b/package.json
@@ -12,6 +12,7 @@
"typecheck": "bun run turbo run typecheck",
"biome-fix": "bun run biome check --fix",
"test": "bun run turbo run test",
+ "test:watch": "bun run turbo run test:watch",
"test:ci": "bun run turbo run test:ci"
},
"dependencies": {
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 44d831d..efa6b0f 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -16,8 +16,8 @@
"lint": "biome lint .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
- "test": "vitest",
- "test:ci": "vitest run"
+ "test": "vitest --passWithNoTests",
+ "test:ci": "vitest run --passWithNoTests"
},
"devDependencies": {
"@biomejs/biome": "1.9.3",
@@ -43,4 +43,3 @@
"react-dom": "^19.1.0"
}
}
-
diff --git a/packages/ui/src/components/ui/button.test.tsx b/packages/ui/src/components/ui/button.test.tsx
new file mode 100644
index 0000000..889a937
--- /dev/null
+++ b/packages/ui/src/components/ui/button.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { Button, buttonVariants } from './button';
+
+describe('Button component', () => {
+ it('should render with default props', () => {
+ render(Click me);
+ const button = screen.getByRole('button', { name: 'Click me' });
+ expect(button).toBeInTheDocument();
+ });
+
+ it('should render with custom variant', () => {
+ render(Delete);
+ const button = screen.getByRole('button', { name: 'Delete' });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveClass('bg-destructive');
+ });
+
+ it('should render with custom size', () => {
+ render(Small button);
+ const button = screen.getByRole('button', { name: 'Small button' });
+ expect(button).toBeInTheDocument();
+ expect(button).toHaveClass('h-9');
+ });
+
+ it('should be disabled when disabled prop is true', () => {
+ render(Disabled button);
+ const button = screen.getByRole('button', { name: 'Disabled button' });
+ expect(button).toBeDisabled();
+ });
+
+ it('should render as child component when asChild is true', () => {
+ render(
+
+ Link button
+
+ );
+ const link = screen.getByRole('link', { name: 'Link button' });
+ expect(link).toBeInTheDocument();
+ expect(link).toHaveAttribute('href', '/test');
+ });
+});
+
+describe('buttonVariants', () => {
+ it('should generate correct classes for default variant', () => {
+ const classes = buttonVariants();
+ expect(classes).toContain('bg-primary');
+ expect(classes).toContain('text-primary-foreground');
+ });
+
+ it('should generate correct classes for destructive variant', () => {
+ const classes = buttonVariants({ variant: 'destructive' });
+ expect(classes).toContain('bg-destructive');
+ expect(classes).toContain('text-destructive-foreground');
+ });
+
+ it('should generate correct classes for small size', () => {
+ const classes = buttonVariants({ size: 'sm' });
+ expect(classes).toContain('h-9');
+ expect(classes).toContain('px-3');
+ });
+});
diff --git a/packages/ui/src/components/ui/button.tsx b/packages/ui/src/components/ui/button.tsx
index 6915082..aa9629c 100644
--- a/packages/ui/src/components/ui/button.tsx
+++ b/packages/ui/src/components/ui/button.tsx
@@ -1,4 +1,4 @@
-import * as React from 'react';
+import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@todo-starter/utils';
@@ -30,12 +30,12 @@ const buttonVariants = cva(
);
export interface ButtonProps
- extends React.ButtonHTMLAttributes,
+ extends ButtonHTMLAttributes,
VariantProps {
asChild?: boolean;
}
-const Button = React.forwardRef(
+const Button = forwardRef(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return ;
@@ -44,4 +44,3 @@ const Button = React.forwardRef(
Button.displayName = 'Button';
export { Button, buttonVariants };
-
diff --git a/packages/ui/src/components/ui/card.tsx b/packages/ui/src/components/ui/card.tsx
index 3c6b9dd..d9853d2 100644
--- a/packages/ui/src/components/ui/card.tsx
+++ b/packages/ui/src/components/ui/card.tsx
@@ -1,40 +1,40 @@
-import * as React from 'react';
+import { forwardRef, type HTMLAttributes } from 'react';
import { cn } from '@todo-starter/utils';
-const Card = React.forwardRef>(
+const Card = forwardRef>(
({ className, ...props }, ref) => (
)
);
Card.displayName = 'Card';
-const CardHeader = React.forwardRef>(
+const CardHeader = forwardRef>(
({ className, ...props }, ref) => (
)
);
CardHeader.displayName = 'CardHeader';
-const CardTitle = React.forwardRef>(
+const CardTitle = forwardRef>(
({ className, ...props }, ref) => (
)
);
CardTitle.displayName = 'CardTitle';
-const CardDescription = React.forwardRef>(
+const CardDescription = forwardRef>(
({ className, ...props }, ref) => (
)
);
CardDescription.displayName = 'CardDescription';
-const CardContent = React.forwardRef>(
+const CardContent = forwardRef>(
({ className, ...props }, ref) =>
);
CardContent.displayName = 'CardContent';
-const CardFooter = React.forwardRef>(
+const CardFooter = forwardRef>(
({ className, ...props }, ref) => (
)
@@ -42,4 +42,3 @@ const CardFooter = React.forwardRef,
- React.ComponentPropsWithoutRef
+const Checkbox = forwardRef<
+ ElementRef,
+ ComponentPropsWithoutRef
>(({ className, ...props }, ref) => (
-
-
+
-
-
+
+
));
-Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+Checkbox.displayName = 'Checkbox';
export { Checkbox };
-
diff --git a/packages/ui/src/components/ui/input.tsx b/packages/ui/src/components/ui/input.tsx
index 339c21a..9010468 100644
--- a/packages/ui/src/components/ui/input.tsx
+++ b/packages/ui/src/components/ui/input.tsx
@@ -1,9 +1,9 @@
-import * as React from 'react';
+import { forwardRef, type InputHTMLAttributes } from 'react';
import { cn } from '@todo-starter/utils';
-export interface InputProps extends React.InputHTMLAttributes {}
+export interface InputProps extends InputHTMLAttributes {}
-const Input = React.forwardRef(({ className, type, ...props }, ref) => {
+const Input = forwardRef(({ className, type, ...props }, ref) => {
return (
(({ className, type,
Input.displayName = 'Input';
export { Input };
-
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index c14b6a1..411d6d1 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -1,5 +1,4 @@
-export * from './components/ui/button';
-export * from './components/ui/input';
-export * from './components/ui/checkbox';
-export * from './components/ui/card';
-
+export { Button, buttonVariants } from './components/ui/button';
+export { Input } from './components/ui/input';
+export { Checkbox } from './components/ui/checkbox';
+export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } from './components/ui/card';
diff --git a/packages/ui/src/test/setup.ts b/packages/ui/src/test/setup.ts
new file mode 100644
index 0000000..fe87aa4
--- /dev/null
+++ b/packages/ui/src/test/setup.ts
@@ -0,0 +1,3 @@
+// Enable @testing-library/jest-dom matchers for Vitest and provide type augmentation for TS
+import '@testing-library/jest-dom/vitest';
+
diff --git a/packages/ui/test/setup.ts b/packages/ui/test/setup.ts
new file mode 100644
index 0000000..7b0828b
--- /dev/null
+++ b/packages/ui/test/setup.ts
@@ -0,0 +1 @@
+import '@testing-library/jest-dom';
diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json
new file mode 100644
index 0000000..9f305d7
--- /dev/null
+++ b/packages/ui/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "noEmit": true,
+ "baseUrl": "."
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist",
+ "build",
+ "**/*.test.ts",
+ "**/*.test.tsx"
+ ]
+}
diff --git a/packages/ui/vitest.config.ts b/packages/ui/vitest.config.ts
new file mode 100644
index 0000000..ce9c2fa
--- /dev/null
+++ b/packages/ui/vitest.config.ts
@@ -0,0 +1,11 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./src/test/setup.ts'],
+ globals: true,
+ css: false,
+ },
+});
+
diff --git a/packages/utils/package.json b/packages/utils/package.json
index ac2d812..4a3f5d4 100644
--- a/packages/utils/package.json
+++ b/packages/utils/package.json
@@ -16,8 +16,9 @@
"lint": "biome lint .",
"format": "biome format --write .",
"typecheck": "tsc --noEmit",
- "test": "vitest",
- "test:ci": "vitest run"
+ "test": "vitest run --passWithNoTests",
+ "test:watch": "vitest",
+ "test:ci": "vitest run --passWithNoTests"
},
"devDependencies": {
"@biomejs/biome": "1.9.3",
@@ -28,5 +29,4 @@
"clsx": "^2.0.0",
"tailwind-merge": "^2.2.0"
}
-}
-
+}
\ No newline at end of file
diff --git a/packages/utils/src/__tests__/storage.test.ts b/packages/utils/src/__tests__/storage.test.ts
new file mode 100644
index 0000000..ddfb42d
--- /dev/null
+++ b/packages/utils/src/__tests__/storage.test.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { loadFromStorage, saveToStorage, removeFromStorage } from '@todo-starter/utils';
+
+const KEY = 'test/storage@v1';
+
+// Save original env to restore between tests
+const ORIGINAL_ENV = process.env.NODE_ENV;
+
+describe('storage utils', () => {
+ function ensureWindowWithLocalStorage() {
+ if (typeof window === 'undefined') {
+ Object.defineProperty(globalThis, 'window', {
+ value: {} as unknown as Window & typeof globalThis,
+ configurable: true
+ });
+ }
+ if (!('localStorage' in window)) {
+ const store = new Map();
+ Object.defineProperty(window, 'localStorage', {
+ value: {
+ getItem: (k: string) => store.get(k) ?? null,
+ setItem: (k: string, v: string) => {
+ store.set(k, v);
+ },
+ removeItem: (k: string) => {
+ store.delete(k);
+ }
+ },
+ configurable: true
+ });
+ }
+ }
+
+ beforeEach(() => {
+ ensureWindowWithLocalStorage();
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ // ignore
+ }
+ });
+
+ afterEach(() => {
+ process.env.NODE_ENV = ORIGINAL_ENV;
+ try {
+ window.localStorage.removeItem(KEY);
+ } catch {
+ // ignore
+ }
+ });
+
+ it('SSR/test guard disables storage (returns fallback in test env)', () => {
+ window.localStorage.setItem(KEY, JSON.stringify({ value: 123 }));
+ const result = loadFromStorage(KEY, { value: 999 });
+ expect(result).toEqual({ value: 999 });
+ });
+
+ it('Malformed JSON returns fallback', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+ window.localStorage.setItem(KEY, '{not json');
+ const result = loadFromStorage(KEY, { good: true });
+ expect(result).toEqual({ good: true });
+ });
+
+ it('save/remove round-trip behavior works', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ const value = { a: 1, b: 'two' };
+ saveToStorage(KEY, value);
+
+ const loaded = loadFromStorage(KEY, null);
+ expect(loaded).toEqual(value);
+
+ removeFromStorage(KEY);
+ const afterRemove = loadFromStorage(KEY, null);
+ expect(afterRemove).toBeNull();
+ });
+
+ it('validate guard: rejects invalid shape and returns fallback', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ window.localStorage.setItem(KEY, JSON.stringify({ nope: true }));
+
+ const fallback = { ok: true };
+ const result = loadFromStorage(
+ KEY,
+ fallback,
+ (v): v is typeof fallback =>
+ typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
+ );
+ expect(result).toEqual(fallback);
+ });
+
+ it('validate guard: accepts valid shape', () => {
+ process.env.NODE_ENV = 'development';
+ ensureWindowWithLocalStorage();
+
+ const value = { ok: true };
+ window.localStorage.setItem(KEY, JSON.stringify(value));
+
+ const result = loadFromStorage(
+ KEY,
+ { ok: false },
+ (v): v is typeof value =>
+ typeof v === 'object' && v !== null && 'ok' in v && typeof (v as { ok: unknown }).ok === 'boolean'
+ );
+ expect(result).toEqual(value);
+ });
+});
diff --git a/packages/utils/src/cn.test.ts b/packages/utils/src/cn.test.ts
new file mode 100644
index 0000000..d72b990
--- /dev/null
+++ b/packages/utils/src/cn.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from 'vitest';
+import { cn } from './cn';
+
+// Simplified boolean logic for linter
+const truthy = 'conditional-class';
+const falsy = false as const;
+
+describe('cn utility function', () => {
+ it('should combine class names correctly', () => {
+ const result = cn('text-red-500', 'bg-blue-100');
+ expect(result).toBe('text-red-500 bg-blue-100');
+ });
+
+ it('should handle conditional classes', () => {
+ const result = cn('base-class', truthy, falsy && 'hidden-class');
+ expect(result).toBe('base-class conditional-class');
+ });
+
+ it('should merge conflicting Tailwind classes', () => {
+ const result = cn('text-red-500', 'text-blue-500');
+ expect(result).toBe('text-blue-500');
+ });
+
+ it('should handle empty inputs', () => {
+ const result = cn();
+ expect(result).toBe('');
+ });
+
+ it('should handle undefined and null values', () => {
+ const result = cn('valid-class', undefined, null, 'another-class');
+ expect(result).toBe('valid-class another-class');
+ });
+
+ it('should handle arrays of classes', () => {
+ const result = cn(['class1', 'class2'], 'class3');
+ expect(result).toBe('class1 class2 class3');
+ });
+});
diff --git a/packages/utils/src/cn.ts b/packages/utils/src/cn.ts
index 38033df..9ad0df4 100644
--- a/packages/utils/src/cn.ts
+++ b/packages/utils/src/cn.ts
@@ -4,4 +4,3 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
-
diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts
index 6ed6aa3..176d12f 100644
--- a/packages/utils/src/index.ts
+++ b/packages/utils/src/index.ts
@@ -1,3 +1,4 @@
-export * from './cn';
-export * from './types';
-
+export { cn } from './cn';
+export type { Todo, TodoFilter, TodoStore } from './types';
+export { loadFromStorage, saveToStorage, removeFromStorage } from './storage';
+export type { StorageLike } from './storage';
diff --git a/packages/utils/src/storage.ts b/packages/utils/src/storage.ts
new file mode 100644
index 0000000..0d7515b
--- /dev/null
+++ b/packages/utils/src/storage.ts
@@ -0,0 +1,53 @@
+// Minimal localStorage helpers with safe JSON and SSR/test guards
+
+export type StorageLike = Pick;
+
+function getStorage(): StorageLike | null {
+ // Allow tests to opt-in to real storage by setting a runtime flag
+ const allowInTests =
+ typeof globalThis !== 'undefined' &&
+ // Use `unknown` and index signature to avoid `any`
+ (globalThis as unknown as Record).__ALLOW_STORAGE_IN_TESTS__ === true;
+ // Disable in test environments unless explicitly allowed
+ if (typeof process !== 'undefined' && process.env?.NODE_ENV === 'test' && !allowInTests) return null;
+ if (typeof window === 'undefined') return null;
+ try {
+ return window.localStorage;
+ } catch {
+ return null;
+ }
+}
+
+export function loadFromStorage(key: string, fallback: T, validate?: (value: unknown) => value is T): T {
+ const storage = getStorage();
+ if (!storage) return fallback;
+ try {
+ const raw = storage.getItem(key);
+ if (!raw) return fallback;
+ const parsed = JSON.parse(raw) as unknown;
+ if (validate && !validate(parsed)) return fallback;
+ return parsed as T;
+ } catch {
+ return fallback;
+ }
+}
+
+export function saveToStorage(key: string, value: T): void {
+ const storage = getStorage();
+ if (!storage) return;
+ try {
+ storage.setItem(key, JSON.stringify(value));
+ } catch {
+ // ignore write errors (quota, etc.)
+ }
+}
+
+export function removeFromStorage(key: string): void {
+ const storage = getStorage();
+ if (!storage) return;
+ try {
+ storage.removeItem(key);
+ } catch {
+ // ignore
+ }
+}
diff --git a/packages/utils/src/types.test.ts b/packages/utils/src/types.test.ts
new file mode 100644
index 0000000..b6e6337
--- /dev/null
+++ b/packages/utils/src/types.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from 'vitest';
+import type { Todo, TodoFilter, TodoStore } from './types';
+
+describe('Todo types', () => {
+ it('should create a valid Todo object', () => {
+ const todo: Todo = {
+ id: '1',
+ text: 'Test todo',
+ completed: false,
+ createdAt: new Date('2024-01-01'),
+ updatedAt: new Date('2024-01-01')
+ };
+
+ expect(todo.id).toBe('1');
+ expect(todo.text).toBe('Test todo');
+ expect(todo.completed).toBe(false);
+ expect(todo.createdAt).toBeInstanceOf(Date);
+ expect(todo.updatedAt).toBeInstanceOf(Date);
+ });
+
+ it('should accept valid TodoFilter values', () => {
+ const filters: TodoFilter[] = ['all', 'active', 'completed'];
+
+ filters.forEach(filter => {
+ expect(['all', 'active', 'completed']).toContain(filter);
+ });
+ });
+
+ it('should define TodoStore interface correctly', () => {
+ // This is a type-only test to ensure the interface compiles
+ const mockStore: TodoStore = {
+ todos: [],
+ filter: 'all',
+ addTodo: (_text: string) => { return; },
+ toggleTodo: (_id: string) => { return; },
+ deleteTodo: (_id: string) => { return; },
+ updateTodo: (_id: string, _text: string) => { return; },
+ setFilter: (_filter: TodoFilter) => { return; },
+ clearCompleted: () => { return; }
+ };
+
+ expect(mockStore.todos).toEqual([]);
+ expect(mockStore.filter).toBe('all');
+ expect(typeof mockStore.addTodo).toBe('function');
+ expect(typeof mockStore.toggleTodo).toBe('function');
+ expect(typeof mockStore.deleteTodo).toBe('function');
+ expect(typeof mockStore.updateTodo).toBe('function');
+ expect(typeof mockStore.setFilter).toBe('function');
+ expect(typeof mockStore.clearCompleted).toBe('function');
+ });
+});
diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts
index 64b900a..89401bb 100644
--- a/packages/utils/src/types.ts
+++ b/packages/utils/src/types.ts
@@ -18,4 +18,3 @@ export interface TodoStore {
setFilter: (filter: TodoFilter) => void;
clearCompleted: () => void;
}
-
diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json
new file mode 100644
index 0000000..9f305d7
--- /dev/null
+++ b/packages/utils/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "composite": false,
+ "noEmit": true,
+ "baseUrl": "."
+ },
+ "include": [
+ "src/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "dist",
+ "build",
+ "**/*.test.ts",
+ "**/*.test.tsx"
+ ]
+}
diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts
new file mode 100644
index 0000000..0a5d623
--- /dev/null
+++ b/packages/utils/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+ test: {
+ environment: 'jsdom'
+ }
+});
diff --git a/tsconfig.json b/tsconfig.json
index e3eab7d..935133e 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,7 +24,6 @@
}
},
"include": [
- "apps/**/*",
"packages/**/*"
],
"exclude": [
diff --git a/turbo.json b/turbo.json
index 0733f13..bdf7c32 100644
--- a/turbo.json
+++ b/turbo.json
@@ -29,11 +29,18 @@
},
"test": {
"dependsOn": ["^build"],
- "inputs": ["$TURBO_DEFAULT$", ".env*"],
+ "inputs": ["$TURBO_DEFAULT$", ".env*", "vitest.config.*"],
"outputs": []
},
+ "test:watch": {
+ "dependsOn": ["^build"],
+ "inputs": ["$TURBO_DEFAULT$", ".env*", "vitest.config.*"],
+ "outputs": [],
+ "cache": false,
+ "persistent": true
+ },
"test:ci": {
- "inputs": ["$TURBO_DEFAULT$", ".env*"],
+ "inputs": ["$TURBO_DEFAULT$", ".env*", "vitest.config.*"],
"outputs": []
}
}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..b32cc33
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'vitest/config';
+
+// Root Vitest configuration for monorepo
+// This enables running tests from the root with proper workspace support
+export default defineConfig({
+ test: {
+ projects: [
+ 'apps/*/vitest.config.{ts,js}',
+ 'packages/*/vitest.config.{ts,js}',
+ ],
+ },
+});