diff --git a/.eslintrc.json b/.eslintrc.json
index f31dc8e..1fdbba3 100644
--- a/.eslintrc.json
+++ b/.eslintrc.json
@@ -1,7 +1,7 @@
{
"extends": [
"plugin:react/recommended",
- "plugin:react/jsx-runtime",
+ "plugin:@typescript-eslint/recommended",
"plugin:jsx-a11y/recommended",
"prettier"
],
@@ -10,13 +10,17 @@
},
"parserOptions": {
"ecmaVersion": "latest",
- "sourceType": "module"
+ "sourceType": "module",
+ "ecmaFeatures": {
+ "jsx": true
+ }
},
"settings": { "react": { "version": "detect" } },
"rules": {
+ "react/react-in-jsx-scope": "off",
"no-duplicate-imports": "warn",
- "no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }],
- "react/jsx-filename-extension": [1, { "allow": "as-needed" }],
+ "no-unused-vars": "warn",
+ "react/jsx-filename-extension": [1, { "extensions": [".tsx", ".jsx"] }],
"react/prop-types": "off",
"react/jsx-no-target-blank": "off"
}
diff --git a/index.html b/index.html
index 5016fc3..8b17180 100644
--- a/index.html
+++ b/index.html
@@ -1,4 +1,4 @@
-
+
@@ -13,7 +13,7 @@
Smart Shopping List
-
+
diff --git a/package-lock.json b/package-lock.json
index dd7f84e..2b9e2c9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,9 @@
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
+ "@types/react": "^18.3.6",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^8.10.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
@@ -4381,21 +4384,31 @@
"license": "MIT"
},
"node_modules/@types/prop-types": {
- "version": "15.7.13",
- "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz",
- "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==",
+ "version": "15.7.12",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
+ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==",
"license": "MIT"
},
"node_modules/@types/react": {
- "version": "18.3.10",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.10.tgz",
- "integrity": "sha512-02sAAlBnP39JgXwkAq3PeU9DVaaGpZyF3MGcC0MKgQVkZor5IiiDAipVaxQHtDJAmO4GIy/rVBy/LzVj76Cyqg==",
+ "version": "18.3.6",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.6.tgz",
+ "integrity": "sha512-CnGaRYNu2iZlkGXGrOYtdg5mLK8neySj0woZ4e2wF/eli2E6Sazmq5X+Nrj6OBrrFVQfJWTUFeqAzoRhWQXYvg==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
+ "node_modules/@types/react-dom": {
+ "version": "18.3.0",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz",
+ "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/react-transition-group": {
"version": "4.4.11",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.11.tgz",
@@ -4419,6 +4432,511 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.10.0.tgz",
+ "integrity": "sha512-phuB3hoP7FFKbRXxjl+DRlQDuJqhpOnm5MmtROXyWi3uS/Xg2ZXqiQfcG2BJHiN4QKyzdOJi3NEn/qTnjUlkmQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.10.0",
+ "@typescript-eslint/scope-manager": "8.10.0",
+ "@typescript-eslint/type-utils": "8.10.0",
+ "@typescript-eslint/utils": "8.10.0",
+ "@typescript-eslint/visitor-keys": "8.10.0",
+ "graphemer": "^1.4.0",
+ "ignore": "^5.3.1",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0",
+ "eslint": "^8.57.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz",
+ "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "@typescript-eslint/visitor-keys": "8.10.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz",
+ "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz",
+ "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.5.0.tgz",
+ "integrity": "sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.5.0",
+ "@typescript-eslint/types": "8.5.0",
+ "@typescript-eslint/typescript-estree": "8.5.0",
+ "@typescript-eslint/visitor-keys": "8.5.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.5.0.tgz",
+ "integrity": "sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.5.0",
+ "@typescript-eslint/visitor-keys": "8.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.10.0.tgz",
+ "integrity": "sha512-PCpUOpyQSpxBn230yIcK+LeCQaXuxrgCm2Zk1S+PTIRJsEfU6nJ0TtwyH8pIwPK/vJoA+7TZtzyAJSGBz+s/dg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "8.10.0",
+ "@typescript-eslint/utils": "8.10.0",
+ "debug": "^4.3.4",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz",
+ "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz",
+ "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "@typescript-eslint/visitor-keys": "8.10.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz",
+ "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.5.0.tgz",
+ "integrity": "sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.5.0.tgz",
+ "integrity": "sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.5.0",
+ "@typescript-eslint/visitor-keys": "8.5.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "license": "ISC",
+ "peer": true,
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.10.0.tgz",
+ "integrity": "sha512-Oq4uZ7JFr9d1ZunE/QKy5egcDRXT/FrS2z/nlxzPua2VHFtmMvFNDvpq1m/hq0ra+T52aUezfcjGRIB7vNJF9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@typescript-eslint/scope-manager": "8.10.0",
+ "@typescript-eslint/types": "8.10.0",
+ "@typescript-eslint/typescript-estree": "8.10.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.10.0.tgz",
+ "integrity": "sha512-AgCaEjhfql9MDKjMUxWvH7HjLeBqMCBfIaBbzzIcBbQPZE7CPh1m6FF+L75NUMJFMLYhCywJXIDEMa3//1A0dw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "@typescript-eslint/visitor-keys": "8.10.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.10.0.tgz",
+ "integrity": "sha512-k/E48uzsfJCRRbGLapdZgrX52csmWJ2rcowwPvOZ8lwPUv3xW6CcFeJAXgx4uJm+Ge4+a4tFOkdYvSpxhRhg1w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.10.0.tgz",
+ "integrity": "sha512-3OE0nlcOHaMvQ8Xu5gAfME3/tWVDpb/HxtpUZ1WeOAksZ/h/gwrBzCklaGzwZT97/lBbbxJ16dMA98JMEngW4w==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "@typescript-eslint/visitor-keys": "8.10.0",
+ "debug": "^4.3.4",
+ "fast-glob": "^3.3.2",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.10.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.10.0.tgz",
+ "integrity": "sha512-k8nekgqwr7FadWk548Lfph6V3r9OVqjzAIVskE7orMZR23cGJjAOVazsZSJW+ElyjfTM4wx/1g88Mi70DDtG9A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.10.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.5.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.5.0.tgz",
+ "integrity": "sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@typescript-eslint/types": "8.5.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
"node_modules/@ungap/structured-clone": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz",
@@ -6307,6 +6825,36 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-glob": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
+ "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
+ "dev": true,
+ "license": "MIT",
+ "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.4"
+ },
+ "engines": {
+ "node": ">=8.6.0"
+ }
+ },
+ "node_modules/fast-glob/node_modules/glob-parent": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+ "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -8088,6 +8636,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/merge2": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
@@ -10023,6 +10581,19 @@
"node": ">=18"
}
},
+ "node_modules/ts-api-utils": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
+ "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.2.0"
+ }
+ },
"node_modules/tslib": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz",
diff --git a/package.json b/package.json
index fe826c5..5eb9f89 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,9 @@
"@testing-library/jest-dom": "^6.4.8",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.5.2",
+ "@types/react": "^18.3.6",
+ "@types/react-dom": "^18.3.0",
+ "@typescript-eslint/eslint-plugin": "^8.10.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
diff --git a/src/App.jsx b/src/App.tsx
similarity index 95%
rename from src/App.jsx
rename to src/App.tsx
index 05c9e6c..1b2df45 100644
--- a/src/App.jsx
+++ b/src/App.tsx
@@ -1,3 +1,5 @@
+import React from 'react';
+
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { Home, Layout, List, ManageList } from './views';
@@ -19,7 +21,7 @@ export function App() {
*/
const [listPath, setListPath] = useStateWithStorage(
'tcl-shopping-list-path',
- null,
+ '/',
);
/**
@@ -69,7 +71,7 @@ export function App() {
/>
}
+ element={
}
/>
} />
diff --git a/src/api/config.js b/src/api/config.ts
similarity index 52%
rename from src/api/config.js
rename to src/api/config.ts
index 102f148..daabe82 100644
--- a/src/api/config.js
+++ b/src/api/config.ts
@@ -4,13 +4,13 @@ import { getAuth } from 'firebase/auth';
// Your web app's Firebase configuration
const firebaseConfig = {
- apiKey: "AIzaSyDmGxC-ITYFOyD6U1IrJAszf1KplXbG1Bs",
- authDomain: "tcl-75-smart-shopping-list.firebaseapp.com",
- projectId: "tcl-75-smart-shopping-list",
- storageBucket: "tcl-75-smart-shopping-list.appspot.com",
- messagingSenderId: "1081566633611",
- appId: "1:1081566633611:web:b07badada6839daf2e3e8e"
- };
+ apiKey: 'AIzaSyDmGxC-ITYFOyD6U1IrJAszf1KplXbG1Bs',
+ authDomain: 'tcl-75-smart-shopping-list.firebaseapp.com',
+ projectId: 'tcl-75-smart-shopping-list',
+ storageBucket: 'tcl-75-smart-shopping-list.appspot.com',
+ messagingSenderId: '1081566633611',
+ appId: '1:1081566633611:web:b07badada6839daf2e3e8e',
+};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
diff --git a/src/api/firebase.js b/src/api/firebase.ts
similarity index 66%
rename from src/api/firebase.js
rename to src/api/firebase.ts
index f0584dd..abe5df0 100644
--- a/src/api/firebase.js
+++ b/src/api/firebase.ts
@@ -8,23 +8,27 @@ import {
doc,
onSnapshot,
updateDoc,
+ DocumentData,
deleteDoc,
} from 'firebase/firestore';
import { useEffect, useState } from 'react';
import { db } from './config';
import { addDaysFromToday } from '../utils';
+export type ListPath = {
+ [key: string]: string | null;
+};
+
/**
* A custom hook that subscribes to the user's shopping lists in our Firestore
* database and returns new data whenever the lists change.
- * @param {string | null} userId
- * @param {string | null} userEmail
- * @returns
*/
-export function useShoppingLists(userId, userEmail) {
+export const useShoppingLists = (
+ userId?: string | null,
+ userEmail?: string | null,
+): ListPath[] => {
// Start with an empty array for our data.
- const initialState = [];
- const [data, setData] = useState(initialState);
+ const [data, setData] = useState([]);
useEffect(() => {
// If we don't have a userId or userEmail (the user isn't signed in),
@@ -36,7 +40,8 @@ export function useShoppingLists(userId, userEmail) {
onSnapshot(userDocRef, (docSnap) => {
if (docSnap.exists()) {
- const listRefs = docSnap.data().sharedLists;
+ const listRefs: ListPath[] = docSnap.data().sharedLists;
+
const newData = listRefs.map((listRef) => {
// We keep the list's id and path so we can use them later.
return { name: listRef.id, path: listRef.path };
@@ -47,13 +52,12 @@ export function useShoppingLists(userId, userEmail) {
}, [userId, userEmail]);
return data;
-}
+};
/**
* Add a new user to the users collection in Firestore.
- * @param {Object} user The user object from Firebase Auth.
*/
-export async function addUserToDatabase(user) {
+export const addUserToDatabase = async (user: DocumentData): Promise => {
// Check if the user already exists in the database.
const userDoc = await getDoc(doc(db, 'users', user.email));
// If the user already exists, we don't need to do anything.
@@ -70,15 +74,16 @@ export async function addUserToDatabase(user) {
uid: user.uid,
});
}
-}
+};
/**
* Create a new list and add it to a user's lists in Firestore.
- * @param {string} userId The id of the user who owns the list.
- * @param {string} userEmail The email of the user who owns the list.
- * @param {string} listName The name of the new list.
*/
-export async function createList(userId, userEmail, listName) {
+export const createList = async (
+ userId: string,
+ userEmail: string,
+ listName: string,
+): Promise => {
const listDocRef = doc(db, userId, listName);
await setDoc(listDocRef, {
@@ -91,24 +96,28 @@ export async function createList(userId, userEmail, listName) {
sharedLists: arrayUnion(listDocRef),
});
return listDocRef.path;
-}
+};
/**
* Shares a list with another user.
- * @param {string} listPath The path to the list to share.
- * @param {string} recipientEmail The email of the user to share the list with.
*/
-export async function shareList(listPath, currentUserId, recipientEmail) {
+export const shareList = async (
+ listPath: string,
+ currentUserId: string,
+ recipientEmail: string,
+): Promise => {
// Check if current user is owner.
- if (!listPath.includes(currentUserId)) {
- return '!owner';
+ if (!listPath?.includes(currentUserId)) {
+ throw new Error('You cannot share the list you do not own.');
}
// Get the document for the recipient user.
const usersCollectionRef = collection(db, 'users');
const recipientDoc = await getDoc(doc(usersCollectionRef, recipientEmail));
// If the recipient user doesn't exist, we can't share the list.
if (!recipientDoc.exists()) {
- return;
+ throw new Error(
+ "The list was not shared because the recipient's email address does not exist in the system.",
+ );
}
// Add the list to the recipient user's sharedLists array.
const listDocumentRef = doc(db, listPath);
@@ -117,11 +126,13 @@ export async function shareList(listPath, currentUserId, recipientEmail) {
updateDoc(userDocumentRef, {
sharedLists: arrayUnion(listDocumentRef),
});
- return 'shared';
- } catch {
- return;
+ return true;
+ } catch (error) {
+ throw new Error(
+ `List was not shared: ${error instanceof Error ? error.message : error}`,
+ );
}
-}
+};
/**
* Delete a list from a user's lists in Firestore.
@@ -154,39 +165,43 @@ export async function deleteList(deletionType, listPath, userId, userEmail) {
/**
* Add a new item to the user's list in Firestore.
- * @param {string} listPath The path of the list we're adding to.
- * @param {Object} itemData Information about the new item.
- * @param {string} itemData.itemName The name of the item.
- * @param {number} itemData.daysUntilNextPurchase The number of days until the user thinks they'll need to buy the item again.
*/
-export async function addItem(listPath, { itemName, daysUntilNextPurchase }) {
+export const addItem = async (
+ listPath: string,
+ {
+ itemName,
+ daysUntilNextPurchase,
+ }: {
+ itemName: string;
+ daysUntilNextPurchase: string;
+ },
+): Promise => {
const listCollectionRef = collection(db, listPath, 'items');
return addDoc(listCollectionRef, {
dateCreated: new Date(),
dateLastPurchased: null,
- dateNextPurchased: addDaysFromToday(daysUntilNextPurchase),
+ dateNextPurchased: addDaysFromToday(parseInt(daysUntilNextPurchase)),
name: itemName,
totalPurchases: 0,
});
-}
+};
/**
* Update an item in the user's list in Firestore with new purchase information.
- * @param {string} listPath The path of the list the item belongs to.
- * @param {string} itemId The ID of the item being updated.
- * @param {Object} updatedData Object containing the updated item data.
- * @param {Date} updatedData.dateLastPurchased The date the item was last purchased.
- * @param {Date} updatedData.dateNextPurchased The estimated date for the next purchase.
- * @param {number} updatedData.totalPurchases The total number of times the item has been purchased.
- * @returns {Promise} A message confirming the item was successfully updated.
- * @throws {Error} If the item update fails.
*/
-export async function updateItem(
- listPath,
- itemId,
- { dateLastPurchased, dateNextPurchased, totalPurchases },
-) {
- // reference the item path
+export const updateItem = async (
+ listPath: string,
+ itemId: string,
+ {
+ dateLastPurchased,
+ dateNextPurchased,
+ totalPurchases,
+ }: {
+ dateLastPurchased: Date;
+ dateNextPurchased: Date;
+ totalPurchases: number;
+ },
+): Promise => {
const itemDocRef = doc(db, listPath, 'items', itemId);
// update the item with the purchase date and increment the total purchases made
try {
@@ -197,17 +212,24 @@ export async function updateItem(
});
return 'item purchased';
} catch (error) {
- throw new Error(`Failed updating item: ${error.message}`);
+ throw new Error(
+ `Failed updating item: ${error instanceof Error ? error.message : error}`,
+ );
}
-}
+};
-export async function deleteItem(listPath, itemId) {
+/**
+ * Delete an item from the user's list in Firestore.
+ */
+export async function deleteItem(listPath: string, itemId: string) {
// reference the item path
const itemDocRef = doc(db, listPath, 'items', itemId);
try {
// delete the item from the database
await deleteDoc(itemDocRef);
} catch (error) {
- throw new Error(`Failed updating item: ${error.message}`);
+ throw new Error(
+ `Failed updating item: ${error instanceof Error ? error.message : error}`,
+ );
}
}
diff --git a/src/api/index.js b/src/api/index.ts
similarity index 100%
rename from src/api/index.js
rename to src/api/index.ts
diff --git a/src/components/AddItems.jsx b/src/components/AddItems.jsx
deleted file mode 100644
index 7b0d01a..0000000
--- a/src/components/AddItems.jsx
+++ /dev/null
@@ -1,85 +0,0 @@
-import { useCallback } from 'react';
-import { normalizeItemName } from '../utils';
-import { useStateWithStorage } from '../hooks';
-import { addItem } from '../api';
-import { RadioInputElement, TextInputElement } from './index.js';
-import { toast } from 'react-toastify';
-
-const daysUntilPurchaseOptions = {
- Soon: 7,
- 'Kind of soon': 14,
- 'Not soon': 30,
-};
-
-export function AddItems({ items }) {
- const [listPath] = useStateWithStorage('tcl-shopping-list-path', null);
-
- const handleSubmit = useCallback(
- async (event) => {
- event.preventDefault();
-
- const daysUntilNextPurchase =
- event.target.elements['purchase-date'].value;
-
- const itemName = event.target.elements['item-name'].value;
-
- try {
- if (itemName.trim() === '') {
- toast.error('Please add an item name.');
- return;
- }
- // normalize the name by removing all punctuation and spaces to check if the normalized item is already in the list
- const normalizedItemName = normalizeItemName(itemName);
- if (items) {
- // normalize the existing list items to compare them to the new input
- const currentItems = items.map((item) =>
- normalizeItemName(item.name),
- );
- if (currentItems.includes(normalizedItemName)) {
- toast.error('This item already exists in the list');
- return;
- }
- }
- await addItem(listPath, {
- itemName,
- daysUntilNextPurchase,
- });
- toast.success(
- `${itemName} was added to the list! The next purchase date is set to ${daysUntilNextPurchase} days from now.`,
- );
- } catch (error) {
- toast.error(
- `Item was not added to the database, Error: ${error.message}`,
- );
- } finally {
- event.target.reset();
- }
- },
- [listPath],
- );
-
- return (
-
-
-
- );
-}
diff --git a/src/components/AddItems.tsx b/src/components/AddItems.tsx
new file mode 100644
index 0000000..ba84930
--- /dev/null
+++ b/src/components/AddItems.tsx
@@ -0,0 +1,89 @@
+import { useCallback } from 'react';
+import { useStateWithStorage } from '../hooks';
+import { addItem } from '../api';
+import { normalizeAndVerifyItem } from '../utils';
+import { DocumentData } from 'firebase/firestore';
+import { toast } from 'react-toastify';
+import { RadioInputElement, TextInputElement } from '../components';
+
+const daysUntilPurchaseOptions = {
+ Soon: 7,
+ 'Kind of soon': 14,
+ 'Not soon': 30,
+};
+
+const addNormalizedItem = async (
+ itemName: string,
+ daysUntilNextPurchase: string,
+ listPath: string,
+) => {
+ try {
+ const isItemAdded = await addItem(listPath, {
+ itemName,
+ daysUntilNextPurchase,
+ });
+
+ if (isItemAdded) {
+ alert(
+ `${itemName} was added to the list! The next purchase date is set to ${daysUntilNextPurchase} days from now.`,
+ );
+ }
+ } catch (error) {
+ alert(
+ `Item was not added to the database, Error: ${error instanceof Error ? error.message : error}`,
+ );
+ }
+};
+
+export function AddItems({ items }: { items: DocumentData[] }) {
+ const [listPath] = useStateWithStorage('tcl-shopping-list-path', '/');
+
+ const handleSubmit = useCallback(
+ (event: React.FormEvent) => {
+ event.preventDefault();
+
+ const form = event.target as HTMLFormElement;
+ const itemName = (
+ form.elements.namedItem('item-name') as HTMLInputElement
+ ).value;
+ const daysUntilNextPurchase = (
+ form.elements.namedItem('purchase-date') as HTMLInputElement
+ ).value;
+
+ try {
+ const normalizedItemName = normalizeAndVerifyItem(items, itemName);
+ addNormalizedItem(normalizedItemName, daysUntilNextPurchase, listPath);
+ } catch (error) {
+ toast.error(`Item was not added, Error: ${error}`);
+ } finally {
+ form.reset();
+ }
+ },
+ [listPath, items],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.tsx
similarity index 78%
rename from src/components/ConfirmDialog.jsx
rename to src/components/ConfirmDialog.tsx
index 69eb804..e05e89c 100644
--- a/src/components/ConfirmDialog.jsx
+++ b/src/components/ConfirmDialog.tsx
@@ -6,16 +6,21 @@ import {
Dialog,
DialogTitle,
Button,
+ SlideProps,
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { buttonStyle } from './SingleList';
import './ConfirmDialog.css';
+import { ReactJSXElement } from '@emotion/react/types/jsx-namespace';
// MUI's Dialog already comes with built-in focus management and accessibility features.
// It automatically traps focus inside the modal, moves focus to the modal when it opens,
// and returns it to the previously focused element when it closes.
-const Transition = React.forwardRef(function Transition(props, ref) {
+const Transition = React.forwardRef(function Transition(
+ props: SlideProps,
+ ref,
+) {
return ;
});
@@ -28,16 +33,29 @@ const typographyStyle = {
color: 'white',
};
-export function ConfirmDialog({ props }) {
+export function ConfirmDialog({
+ props,
+}: {
+ props: {
+ handleDelete: () => void;
+ title: ReactJSXElement | string;
+ setOpen: (open: boolean) => void;
+ open: boolean;
+ };
+}) {
const { handleDelete, title, setOpen, open } = props;
- const handleClose = (event) => {
+ const handleClose = (
+ event: React.MouseEvent,
+ ) => {
setOpen(false);
- event.target.type === 'submit' && handleDelete();
+ if (event.currentTarget.type === 'submit') {
+ handleDelete();
+ }
};
const dialogProps = {
- open: open,
+ open,
TransitionComponent: Transition,
onClose: handleClose,
PaperProps: {
@@ -102,7 +120,7 @@ export function ConfirmDialog({ props }) {
type="submit"
color="success"
variant="outlined"
- fontSize="large"
+ // fontSize="large"
sx={buttonStyle}
onClick={handleClose}
>
diff --git a/src/components/DeleteIconWithTooltip.jsx b/src/components/DeleteIconWithTooltip.tsx
similarity index 74%
rename from src/components/DeleteIconWithTooltip.jsx
rename to src/components/DeleteIconWithTooltip.tsx
index 1d46c02..65ef5be 100644
--- a/src/components/DeleteIconWithTooltip.jsx
+++ b/src/components/DeleteIconWithTooltip.tsx
@@ -1,3 +1,4 @@
+import { MouseEventHandler } from 'react';
import { DeleteOutlineOutlined } from '@mui/icons-material';
import { Tooltip, IconButton } from '@mui/material';
@@ -7,7 +8,13 @@ export const tooltipStyle = {
marginBlockEnd: '0',
};
-export const DeleteIconWithTooltip = ({ ariaLabel, toggleDialog }) => {
+export const DeleteIconWithTooltip = ({
+ ariaLabel,
+ toggleDialog,
+}: {
+ ariaLabel: string;
+ toggleDialog: MouseEventHandler;
+}) => {
return (
Delete} placement="right" arrow>
diff --git a/src/components/ListItem.jsx b/src/components/ListItem.tsx
similarity index 72%
rename from src/components/ListItem.jsx
rename to src/components/ListItem.tsx
index c1220a0..1892bbb 100644
--- a/src/components/ListItem.jsx
+++ b/src/components/ListItem.tsx
@@ -1,10 +1,10 @@
import { useState } from 'react';
import { updateItem, deleteItem } from '../api';
import { calculateDateNextPurchased, ONE_DAY_IN_MILLISECONDS } from '../utils';
+import { DocumentData, Timestamp } from 'firebase/firestore';
import { toast } from 'react-toastify';
-import { useConfirmDialog } from '../hooks/useConfirmDialog';
-import { ConfirmDialog } from './ConfirmDialog';
-import { DeleteIconWithTooltip, tooltipStyle } from './DeleteIconWithTooltip';
+import { UrgencyStatus, useConfirmDialog } from '../hooks';
+import { DeleteIconWithTooltip, tooltipStyle, ConfirmDialog } from './index';
import {
ListItem as MaterialListItem,
Tooltip,
@@ -13,6 +13,7 @@ import {
ListItemIcon,
ListItemText,
Checkbox,
+ SvgIconTypeMap,
} from '@mui/material';
import {
Restore as OverdueIcon,
@@ -21,16 +22,21 @@ import {
RemoveCircle as NotSoonIcon,
RadioButtonChecked as InactiveIcon,
} from '@mui/icons-material';
+import { OverridableComponent } from '@mui/material/OverridableComponent';
import './ListItem.css';
const currentDate = new Date();
-const urgencyStatusIcons = {
+type UrgencyStatusIconsType = {
+ [key in UrgencyStatus]: OverridableComponent;
+};
+
+const urgencyStatusIcons: UrgencyStatusIconsType = {
overdue: OverdueIcon,
soon: SoonIcon,
- kindOfSoon: KindOfSoonIcon,
- notSoon: NotSoonIcon,
+ 'kind of soon': KindOfSoonIcon,
+ 'not soon': NotSoonIcon,
inactive: InactiveIcon,
};
@@ -39,13 +45,7 @@ const urgencyStatusStyle = {
color: 'white',
};
-const toolTipStyle = {
- fontSize: '1.5rem',
- marginBlockStart: '0',
- marginBlockEnd: '0',
-};
-
-const calculateIsPurchased = (dateLastPurchased) => {
+const calculateIsPurchased = (dateLastPurchased: Timestamp) => {
if (!dateLastPurchased) {
return false;
}
@@ -57,7 +57,15 @@ const calculateIsPurchased = (dateLastPurchased) => {
return currentDate < oneDayLater;
};
-export function ListItem({ item, listPath, itemUrgencyStatus }) {
+export function ListItem({
+ item,
+ listPath,
+ itemUrgencyStatus,
+}: {
+ item: DocumentData;
+ listPath: string;
+ itemUrgencyStatus: string;
+}) {
const { open, isOpen, toggleDialog } = useConfirmDialog();
const [isPurchased, setIsPurchased] = useState(() =>
calculateIsPurchased(item.dateLastPurchased),
@@ -74,35 +82,39 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) {
const handleChange = async () => {
setIsPurchased(!isPurchased);
+
if (!isPurchased) {
try {
const updatedItem = updateItemOnPurchase();
-
await updateItem(listPath, id, { ...updatedItem });
} catch (error) {
- toast.error(`Item was not marked as purchased`, error.message);
+ toast.error(
+ `Item was not marked as purchased. Error: ${error instanceof Error ? error.message : error}`,
+ );
}
}
};
const handleDeleteItem = async () => {
- console.log('attempting item deletion');
try {
await deleteItem(listPath, id);
toast.success('Item deleted');
} catch (error) {
- toast.error('Item was not deleted');
+ toast.error(
+ `Item was not deleted. Error: ${error instanceof Error ? error.message : error}`,
+ );
}
return;
};
- const UrgencyStatusIcon = urgencyStatusIcons[itemUrgencyStatus];
+ const UrgencyStatusIcon =
+ urgencyStatusIcons[itemUrgencyStatus as UrgencyStatus];
const props = {
handleDelete: handleDeleteItem,
title: `Are you sure you want to delete ${name}?`,
setOpen: isOpen,
- open: open,
+ open,
};
const tooltipTitle = isPurchased
@@ -115,7 +127,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) {
{UrgencyStatusIcon && (
{itemUrgencyStatus}}
+ title={{itemUrgencyStatus}
}
placement="left"
arrow
>
@@ -149,7 +161,7 @@ export function ListItem({ item, listPath, itemUrgencyStatus }) {
>
diff --git a/src/components/RadioInputElement.jsx b/src/components/RadioInputElement.tsx
similarity index 56%
rename from src/components/RadioInputElement.jsx
rename to src/components/RadioInputElement.tsx
index 3a38645..8f84a52 100644
--- a/src/components/RadioInputElement.jsx
+++ b/src/components/RadioInputElement.tsx
@@ -1,4 +1,14 @@
-export const RadioInputElement = ({ label, id, value, required }) => {
+export const RadioInputElement = ({
+ label,
+ id,
+ value,
+ required,
+}: {
+ label: string;
+ id: string;
+ value: number;
+ required: boolean;
+}) => {
return (
<>
{
- const listShared = await shareList(listPath, userId, emailData);
+ const shareCurrentList = async (emailData: string) => {
+ if (!userId || !listPath) return;
+ try {
+ const listShared = await shareList(listPath, userId, emailData);
- if (listShared === '!owner') {
- toast.error('You cannot share the list you do not own.');
- } else if (listShared === 'shared') {
- toast.success('List was shared with recipient.');
- } else {
+ if (listShared) {
+ toast.success('List was shared with recipient.');
+ }
+ } catch (error) {
toast.error(
- "The list was not shared because the recipient's email address does not exist in the system.",
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while sharing the list.',
);
}
};
- const handleEmailInputSubmit = (event) => {
+ const handleEmailInputSubmit = (event: React.FormEvent) => {
event.preventDefault();
-
- const emailData = event.target['email-input'].value;
+ const form = event.target as HTMLFormElement;
+ const emailData = (
+ form.elements.namedItem('email-input') as HTMLInputElement
+ ).value;
if (userEmail === emailData) {
toast.error('You cannot share the list with yourself.');
@@ -35,7 +40,7 @@ export function ShareList() {
shareCurrentList(emailData);
}
- event.target.reset();
+ form.reset();
};
return (
diff --git a/src/components/SingleList.jsx b/src/components/SingleList.tsx
similarity index 84%
rename from src/components/SingleList.jsx
rename to src/components/SingleList.tsx
index f8e8ae7..c468d3e 100644
--- a/src/components/SingleList.jsx
+++ b/src/components/SingleList.tsx
@@ -1,13 +1,12 @@
+import { useState, Dispatch } from 'react';
import { useNavigate } from 'react-router-dom';
-import { useState } from 'react';
+import { deleteList } from '../api';
+import { DocumentData } from 'firebase/firestore';
+import { useAuth, useConfirmDialog } from '../hooks';
import { toast } from 'react-toastify';
import { PushPin, PushPinOutlined } from '@mui/icons-material';
import { Tooltip, IconButton, Button } from '@mui/material';
-import { deleteList } from '../api';
-import { useAuth } from '../hooks';
-import { useConfirmDialog } from '../hooks/useConfirmDialog';
-import { ConfirmDialog } from './ConfirmDialog';
-import { tooltipStyle, DeleteIconWithTooltip } from './DeleteIconWithTooltip';
+import { tooltipStyle, DeleteIconWithTooltip, ConfirmDialog } from './index';
import './SingleList.css';
const deletionResponse = {
@@ -26,6 +25,11 @@ export function SingleList({
setListPath,
setImportantList,
isImportant,
+}: {
+ item: DocumentData;
+ setListPath: Dispatch;
+ setImportantList: Dispatch;
+ isImportant: string;
}) {
const navigate = useNavigate();
const { user } = useAuth();
@@ -58,7 +62,7 @@ export function SingleList({
toast.success(deletionResponse[deletionType]);
} catch (error) {
toast.error(
- `List was not deleted. Error: ${error.message ? error.message : error}`,
+ `List was not deleted. Error: ${error instanceof Error ? error.message : error}`,
);
}
diff --git a/src/components/TextInputElement.jsx b/src/components/TextInputElement.tsx
similarity index 64%
rename from src/components/TextInputElement.jsx
rename to src/components/TextInputElement.tsx
index f76414c..94b7ba4 100644
--- a/src/components/TextInputElement.jsx
+++ b/src/components/TextInputElement.tsx
@@ -1,3 +1,5 @@
+import { ChangeEventHandler } from 'react';
+
export function TextInputElement({
label,
type,
@@ -5,6 +7,13 @@ export function TextInputElement({
placeholder,
onChange,
required,
+}: {
+ label: string;
+ type: string;
+ id: string;
+ placeholder: string;
+ onChange?: ChangeEventHandler;
+ required: boolean;
}) {
return (
<>
diff --git a/src/components/index.js b/src/components/index.ts
similarity index 88%
rename from src/components/index.js
rename to src/components/index.ts
index b1b83c6..8c3c8c0 100644
--- a/src/components/index.js
+++ b/src/components/index.ts
@@ -1,6 +1,7 @@
export * from './ListItem';
export * from './SingleList';
export * from './AddItems';
+export * from './ShareList';
export * from './TextInputElement';
export * from './RadioInputElement';
export * from './ConfirmDialog';
diff --git a/src/hooks/useAuth.jsx b/src/hooks/useAuth.tsx
similarity index 89%
rename from src/hooks/useAuth.jsx
rename to src/hooks/useAuth.tsx
index d8d7b51..e3f7aed 100644
--- a/src/hooks/useAuth.jsx
+++ b/src/hooks/useAuth.tsx
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { auth } from '../api/config.js';
import { GoogleAuthProvider, signInWithPopup } from 'firebase/auth';
import { addUserToDatabase } from '../api/firebase.js';
+import { DocumentData } from 'firebase/firestore';
/**
* A button that signs the user in using Google OAuth. When clicked,
@@ -32,7 +33,8 @@ export const SignOutButton = () => (
* @see https://firebase.google.com/docs/auth/web/start#set_an_authentication_state_observer_and_get_user_data
*/
export const useAuth = () => {
- const [user, setUser] = useState(null);
+ const initialState = null;
+ const [user, setUser] = useState(initialState);
useEffect(() => {
auth.onAuthStateChanged((user) => {
diff --git a/src/hooks/useStateWithStorage.js b/src/hooks/useStateWithStorage.ts
similarity index 54%
rename from src/hooks/useStateWithStorage.js
rename to src/hooks/useStateWithStorage.ts
index 92a06ca..2e3730f 100644
--- a/src/hooks/useStateWithStorage.js
+++ b/src/hooks/useStateWithStorage.ts
@@ -2,16 +2,16 @@ import { useEffect, useState } from 'react';
/**
* Set some state in React, and also persist that value in localStorage.
- * @param {string} storageKey The key of the value in localStorage.
- * @param {string | null} initialValue The initial value to store in localStorage and React state.
- * @returns {[string | null, React.Dispatch]}
*/
-export function useStateWithStorage(storageKey, initialValue) {
+export function useStateWithStorage(
+ storageKey: string,
+ initialValue: string,
+): [string, React.Dispatch] {
const [value, setValue] = useState(
() => localStorage.getItem(storageKey) ?? initialValue,
);
useEffect(() => {
- if (value === null || value === undefined) {
+ if (value === undefined) {
return localStorage.removeItem(storageKey);
}
return localStorage.setItem(storageKey, value);
diff --git a/src/hooks/useUrgency.js b/src/hooks/useUrgency.js
deleted file mode 100644
index aedfaea..0000000
--- a/src/hooks/useUrgency.js
+++ /dev/null
@@ -1,40 +0,0 @@
-import { useEffect, useState } from 'react';
-import { calculateUrgency } from '../utils';
-
-export function useUrgency(items) {
- const [urgencyObject, setUrgencyObject] = useState({
- overdue: new Set(),
- soon: new Set(),
- kindOfSoon: new Set(),
- notSoon: new Set(),
- inactive: new Set(),
- });
-
- useEffect(() => {
- if (items.length === 0) return;
-
- let initialUrgencyState = {
- overdue: new Set(),
- soon: new Set(),
- kindOfSoon: new Set(),
- notSoon: new Set(),
- inactive: new Set(),
- };
-
- items.forEach((item) => {
- const urgency = calculateUrgency(item);
- initialUrgencyState[urgency].add(item);
- });
-
- const sortedUrgencyState = Object.fromEntries(
- Object.entries(initialUrgencyState).map(([key, set]) => [
- key,
- Array.from(set).sort((a, b) => a.name.localeCompare(b.name)),
- ]),
- );
-
- setUrgencyObject(sortedUrgencyState);
- }, [items]);
-
- return { urgencyObject };
-}
diff --git a/src/hooks/useUrgency.ts b/src/hooks/useUrgency.ts
new file mode 100644
index 0000000..1ed1f5d
--- /dev/null
+++ b/src/hooks/useUrgency.ts
@@ -0,0 +1,73 @@
+import { useEffect, useState } from 'react';
+import { calculateUrgency } from '../utils';
+import { DocumentData } from 'firebase/firestore';
+
+export type UrgencyStatus =
+ | 'overdue'
+ | 'soon'
+ | 'kind of soon'
+ | 'not soon'
+ | 'inactive';
+
+type UrgencyObject = {
+ [_key in UrgencyStatus]: Set;
+};
+
+export function useUrgency(items: DocumentData[]) {
+ const [urgencyObject, setUrgencyObject] = useState({
+ overdue: new Set(),
+ soon: new Set(),
+ 'kind of soon': new Set(),
+ 'not soon': new Set(),
+ inactive: new Set(),
+ });
+
+ useEffect(() => {
+ if (items.length === 0) return;
+
+ const initialUrgencyState: UrgencyObject = {
+ overdue: new Set(),
+ soon: new Set(),
+ 'kind of soon': new Set(),
+ 'not soon': new Set(),
+ inactive: new Set(),
+ };
+
+ items.forEach((item) => {
+ const urgency = calculateUrgency(item) as UrgencyStatus;
+ initialUrgencyState[urgency].add(item);
+ });
+
+ const sortedUrgencyState: UrgencyObject = {
+ overdue: new Set(
+ Array.from(initialUrgencyState.overdue).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ ),
+ soon: new Set(
+ Array.from(initialUrgencyState.soon).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ ),
+ 'kind of soon': new Set(
+ Array.from(initialUrgencyState['kind of soon']).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ ),
+ 'not soon': new Set(
+ Array.from(initialUrgencyState['not soon']).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ ),
+ inactive: new Set(
+ Array.from(initialUrgencyState.inactive).sort((a, b) =>
+ a.name.localeCompare(b.name),
+ ),
+ ),
+ };
+
+ setUrgencyObject(sortedUrgencyState);
+ }, [items]);
+
+ return { urgencyObject };
+}
diff --git a/src/index.jsx b/src/index.jsx
deleted file mode 100644
index 551896b..0000000
--- a/src/index.jsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import { StrictMode } from 'react';
-import { createRoot } from 'react-dom/client';
-import { App } from './App';
-
-import './index.css';
-
-const root = createRoot(document.getElementById('root'));
-root.render(
-
-
- ,
-);
diff --git a/src/index.tsx b/src/index.tsx
new file mode 100644
index 0000000..8f7d396
--- /dev/null
+++ b/src/index.tsx
@@ -0,0 +1,19 @@
+import React, { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import { App } from './App';
+
+import './index.css';
+
+const rootElement = document.getElementById('root');
+try {
+ if (rootElement) {
+ const root = createRoot(rootElement);
+ root.render(
+
+
+ ,
+ );
+ }
+} catch (error) {
+ throw new Error(`Could not get root element. Error: ${error}`);
+}
diff --git a/src/mocks/__fixtures__/auth.js b/src/mocks/__fixtures__/auth.ts
similarity index 100%
rename from src/mocks/__fixtures__/auth.js
rename to src/mocks/__fixtures__/auth.ts
diff --git a/src/mocks/__fixtures__/shoppingListData.js b/src/mocks/__fixtures__/shoppingListData.ts
similarity index 100%
rename from src/mocks/__fixtures__/shoppingListData.js
rename to src/mocks/__fixtures__/shoppingListData.ts
diff --git a/src/mocks/__fixtures__/shoppingLists.js b/src/mocks/__fixtures__/shoppingLists.ts
similarity index 100%
rename from src/mocks/__fixtures__/shoppingLists.js
rename to src/mocks/__fixtures__/shoppingLists.ts
diff --git a/src/utils/dates.js b/src/utils/dates.js
deleted file mode 100644
index 6f78a6f..0000000
--- a/src/utils/dates.js
+++ /dev/null
@@ -1,127 +0,0 @@
-import { calculateEstimate } from '@the-collab-lab/shopping-list-utils';
-
-export const ONE_DAY_IN_MILLISECONDS = 86400000;
-
-/**
- * Get a new JavaScript Date that is `offset` days in the future.
- * @example
- * // Returns a Date 3 days in the future
- * addDaysFromToday(3)
- * @param {number} daysOffset
- */
-export function addDaysFromToday(daysOffset) {
- return new Date(Date.now() + daysOffset * ONE_DAY_IN_MILLISECONDS);
-}
-
-/**
- * Calculates the estimated date for the next purchase based on current date, purchase history,
- * and total purchases.
- * @param {Date} currentDate - The current date to calculate against.
- * @param {Object} item - The item object containing purchase data.
- * @param {Date} item.dateCreated - The date the item was created.
- * @param {Date} item.dateNextPurchased - The previously estimated next purchase date.
- * @param {Date|null} item.dateLastPurchased - The last date the item was actually purchased, or null if not purchased yet.
- * @param {number} item.totalPurchases - The total number of purchases made for the item.
- * @returns {Date} - The estimated date of the next purchase.
- * @throws {Error} - Throws an error if the next purchase date cannot be calculated.
- */
-export const calculateDateNextPurchased = (currentDate, item) => {
- try {
- // get purchase intervals and get new estimation for next purchase date
- const purchaseIntervals = calculatePurchaseIntervals(
- currentDate,
- item.dateCreated,
- item.dateNextPurchased,
- item.dateLastPurchased,
- );
- const estimatedNextPurchaseDate = getNextPurchaseEstimate(
- purchaseIntervals,
- item.totalPurchases + 1,
- );
-
- return estimatedNextPurchaseDate;
- } catch (error) {
- throw new Error(`Failed getting next purchase date: ${error}`);
- }
-};
-
-/**
- * Calculate the number of days between two dates.
- * @param {Date} earlierDate The starting date.
- * @param {Date} laterDate The ending date.
- * @returns {number} The number of days between the two dates.
- */
-export function getDaysBetweenDates(earlierDate, laterDate) {
- return Math.floor(
- (laterDate.getTime() - earlierDate.getTime()) / ONE_DAY_IN_MILLISECONDS,
- );
-}
-
-// takes in a Date
-// calculates days since date that was passed and today
-export const getDaysFromDate = (pastDate) =>
- getDaysBetweenDates(pastDate, new Date());
-
-// takes in Timestamps, returns a Date
-export const getDateLastPurchasedOrDateCreated = (
- dateLastPurchased,
- dateCreated,
-) => dateLastPurchased?.toDate() ?? dateCreated.toDate();
-
-/**
- * Calculate the purchase intervals between current, next, and last purchase dates.
- * @param {Date} currentDate The current date.
- * @param {Date} dateNextPurchased The previously estimated next purchase date.
- * @param {Date|null} dateLastPurchased The date the item was last purchased (can be null).
- * @returns {Object} An object containing the last estimated interval and days since last purchase.
- */
-function calculatePurchaseIntervals(
- currentDate,
- dateCreated,
- dateNextPurchased,
- dateLastPurchased,
-) {
- const lastEstimatedIntervalStartDate = getDateLastPurchasedOrDateCreated(
- dateLastPurchased,
- dateCreated,
- );
-
- const lastEstimatedInterval = getDaysBetweenDates(
- lastEstimatedIntervalStartDate,
- dateNextPurchased.toDate(),
- );
-
- const daysSinceLastPurchase =
- dateLastPurchased?.toDate() === undefined
- ? 0
- : getDaysBetweenDates(dateLastPurchased.toDate(), currentDate);
-
- return { lastEstimatedInterval, daysSinceLastPurchase };
-}
-
-/**
- * Calculate the next purchase estimate based on purchase intervals and total purchases.
- * @param {Object} purchaseIntervals The intervals between the previous and current purchases.
- * @param {number} purchaseIntervals.lastEstimatedInterval The previously estimated number of days between purchases.
- * @param {number} purchaseIntervals.daysSinceLastPurchase The number of days since the last purchase.
- * @param {number} totalPurchases The total number of purchases made.
- * @returns {Date} The estimated next purchase date.
- * @throws {Error} If an error occurs during the next purchase estimation process.
- */
-function getNextPurchaseEstimate(purchaseIntervals, totalPurchases) {
- const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals;
-
- try {
- const estimatedDaysUntilPurchase = calculateEstimate(
- lastEstimatedInterval,
- daysSinceLastPurchase,
- totalPurchases,
- );
-
- const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase);
-
- return nextPurchaseEstimate;
- } catch (error) {
- throw new Error(`Failed updaing date next purchased: ${error}`);
- }
-}
diff --git a/src/utils/dates.ts b/src/utils/dates.ts
new file mode 100644
index 0000000..6877aa7
--- /dev/null
+++ b/src/utils/dates.ts
@@ -0,0 +1,104 @@
+import { calculateEstimate } from '@the-collab-lab/shopping-list-utils';
+import { DocumentData, Timestamp } from 'firebase/firestore';
+
+export const ONE_DAY_IN_MILLISECONDS: number = 86400000;
+
+/**
+ * Get a new JavaScript Date that is `offset` days in the future.
+ */
+export function addDaysFromToday(daysOffset: number): Date {
+ return new Date(Date.now() + daysOffset * ONE_DAY_IN_MILLISECONDS);
+}
+
+/**
+ * Calculates the estimated date for the next purchase based on current date, purchase history,
+ * and total purchases.
+ */
+export const calculateDateNextPurchased = (
+ currentDate: Date,
+ item: DocumentData,
+): Date => {
+ try {
+ // get purchase intervals and get new estimation for next purchase date
+ const purchaseIntervals = calculatePurchaseIntervals(
+ currentDate,
+ item.dateCreated,
+ item.dateNextPurchased,
+ item.dateLastPurchased,
+ );
+ const estimatedNextPurchaseDate = getNextPurchaseEstimate(
+ purchaseIntervals,
+ item.totalPurchases + 1,
+ );
+
+ return estimatedNextPurchaseDate;
+ } catch (error) {
+ throw new Error(`Failed getting next purchase date: ${error}`);
+ }
+};
+
+/**
+ * Calculate the number of days between two dates.
+ */
+function getDaysBetweenDates(earlierDate: Date, laterDate: Date): number {
+ return Math.floor(
+ (laterDate.getTime() - earlierDate.getTime()) / ONE_DAY_IN_MILLISECONDS,
+ );
+}
+
+/**
+ * Calculate the purchase intervals between current, next, and last purchase dates.
+ */
+function calculatePurchaseIntervals(
+ currentDate: Date,
+ dateCreated: Timestamp,
+ dateNextPurchased: Timestamp,
+ dateLastPurchased: Timestamp | null,
+): {
+ lastEstimatedInterval: number;
+ daysSinceLastPurchase: number;
+} {
+ const lastPurchaseDate = dateLastPurchased?.toDate();
+
+ const lastEstimatedIntervalStartDate =
+ lastPurchaseDate ?? dateCreated.toDate();
+
+ const lastEstimatedInterval = getDaysBetweenDates(
+ lastEstimatedIntervalStartDate,
+ dateNextPurchased.toDate(),
+ );
+
+ const daysSinceLastPurchase =
+ lastPurchaseDate === undefined
+ ? 0
+ : getDaysBetweenDates(lastPurchaseDate, currentDate);
+
+ return { lastEstimatedInterval, daysSinceLastPurchase };
+}
+
+/**
+ * Calculate the next purchase estimate based on purchase intervals and total purchases.
+ */
+function getNextPurchaseEstimate(
+ purchaseIntervals: {
+ lastEstimatedInterval: number;
+ daysSinceLastPurchase: number;
+ },
+ totalPurchases: number,
+): Date {
+ const { lastEstimatedInterval, daysSinceLastPurchase } = purchaseIntervals;
+
+ try {
+ const estimatedDaysUntilPurchase = calculateEstimate(
+ lastEstimatedInterval,
+ daysSinceLastPurchase,
+ totalPurchases,
+ );
+
+ const nextPurchaseEstimate = addDaysFromToday(estimatedDaysUntilPurchase);
+
+ return nextPurchaseEstimate;
+ } catch (error) {
+ throw new Error(`Failed updaing date next purchased: ${error}`);
+ }
+}
diff --git a/src/utils/importanceUtils.js b/src/utils/importanceUtils.ts
similarity index 100%
rename from src/utils/importanceUtils.js
rename to src/utils/importanceUtils.ts
diff --git a/src/utils/index.js b/src/utils/index.ts
similarity index 100%
rename from src/utils/index.js
rename to src/utils/index.ts
diff --git a/src/utils/normalize.js b/src/utils/normalize.js
deleted file mode 100644
index 621af77..0000000
--- a/src/utils/normalize.js
+++ /dev/null
@@ -1,6 +0,0 @@
-export function normalizeItemName(itemName) {
- return itemName
- .trim()
- .toLowerCase()
- .replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, '');
-}
diff --git a/src/utils/normalize.ts b/src/utils/normalize.ts
new file mode 100644
index 0000000..aac1935
--- /dev/null
+++ b/src/utils/normalize.ts
@@ -0,0 +1,26 @@
+import { DocumentData } from 'firebase/firestore';
+
+export function normalizeItemName(itemName: string): string {
+ return itemName
+ .trim()
+ .toLowerCase()
+ .replace(/[&\/\\#, +$!,~%.'":*?<>{}]/g, '');
+}
+
+export function normalizeAndVerifyItem(
+ items: DocumentData[],
+ itemName: string,
+) {
+ const normalizedItemName = normalizeItemName(itemName);
+
+ // normalize the existing list items to compare them to the new input
+ const currentItems = items.map((item) => {
+ return normalizeItemName(item.name);
+ });
+
+ if (currentItems.includes(normalizedItemName)) {
+ throw new Error('This item already exists in the list');
+ }
+
+ return normalizedItemName;
+}
diff --git a/src/utils/urgencyUtils.js b/src/utils/urgencyUtils.js
index d1aede2..a198fb9 100644
--- a/src/utils/urgencyUtils.js
+++ b/src/utils/urgencyUtils.js
@@ -10,9 +10,9 @@ export const sortByUrgency = (item, daysUntilNextPurchase) => {
} else if (daysUntilNextPurchase < 7) {
return 'soon';
} else if (daysUntilNextPurchase >= 7 && daysUntilNextPurchase < 30) {
- return 'kindOfSoon';
+ return 'kind of soon';
} else if (daysUntilNextPurchase >= 30) {
- return 'notSoon';
+ return 'not soon';
} else {
throw new Error(`Failed to place [${item.name}]`);
}
diff --git a/src/views/Home.jsx b/src/views/Home.tsx
similarity index 76%
rename from src/views/Home.jsx
rename to src/views/Home.tsx
index dc3219b..1d50b35 100644
--- a/src/views/Home.jsx
+++ b/src/views/Home.tsx
@@ -1,4 +1,6 @@
import { useNavigate } from 'react-router-dom';
+import { DocumentData } from 'firebase/firestore';
+import { Dispatch } from 'react';
import { createList } from '../api';
import { toast } from 'react-toastify';
import { useImportance } from '../hooks';
@@ -11,14 +13,29 @@ export const buttonStyle = {
fontSize: '1.5rem',
};
-export function Home({ data, setListPath, userId, userEmail }) {
+export function Home({
+ data,
+ setListPath,
+ userId,
+ userEmail,
+}: {
+ data: DocumentData[];
+ setListPath: Dispatch;
+ userId?: string | null;
+ userEmail?: string | null;
+}) {
const { sortedLists, setImportantList, isListImportant } =
useImportance(data);
const navigate = useNavigate();
- const handleSubmit = async (event) => {
+ const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
- const listName = event.target['list-name'].value;
+
+ if (!userId || !userEmail) return;
+
+ const form = event.target as HTMLFormElement;
+ const listName = (form.elements.namedItem('list-name') as HTMLInputElement)
+ .value;
const currentLists = sortedLists.map((list) => {
return list.name.toLowerCase();
});
@@ -28,8 +45,8 @@ export function Home({ data, setListPath, userId, userEmail }) {
toast.error('The list already exists. Please enter a different name.');
return;
}
-
const listPath = await createList(userId, userEmail, listName);
+
setListPath(listPath);
toast.success('List added');
navigate('/list');
@@ -37,7 +54,7 @@ export function Home({ data, setListPath, userId, userEmail }) {
console.error(err);
toast.error('List not created');
} finally {
- event.target.reset();
+ form.reset();
}
};
diff --git a/src/views/Layout.jsx b/src/views/Layout.tsx
similarity index 53%
rename from src/views/Layout.jsx
rename to src/views/Layout.tsx
index ff58ad7..736945f 100644
--- a/src/views/Layout.jsx
+++ b/src/views/Layout.tsx
@@ -1,17 +1,19 @@
+import './Layout.css';
/* eslint-disable jsx-a11y/anchor-is-valid */
import { Outlet, NavLink } from 'react-router-dom';
import { useAuth, SignInButton, SignOutButton } from '../hooks/useAuth';
-// import { Home, List, ManageList } from '../views';
-import './Layout.css';
+const navLinkOptions: { [key: string]: string } = {
+ '/': 'Home',
+ '/list': 'List',
+ '/manage-list': 'Manage List',
+};
-/**
- * TODO: The links defined in this file don't work!
- *
- * Instead of anchor element, they should use a component
- * from `react-router-dom` to navigate to the routes
- * defined in `App.jsx`.
- */
+const navLinks = Object.entries(navLinkOptions).map(([path, linkText]) => (
+
+ {linkText}
+
+));
export function Layout() {
const { user } = useAuth();
@@ -26,15 +28,7 @@ export function Layout() {
diff --git a/src/views/List.jsx b/src/views/List.tsx
similarity index 61%
rename from src/views/List.jsx
rename to src/views/List.tsx
index 4f5727a..302adb5 100644
--- a/src/views/List.jsx
+++ b/src/views/List.tsx
@@ -1,45 +1,47 @@
import React, { useState } from 'react';
+import { DocumentData } from 'firebase/firestore';
import { useEnsureListPath, useUrgency } from '../hooks';
import { getUrgency } from '../utils/urgencyUtils';
-import { List as UnorderedList, Box, Grid } from '@mui/material';
+import { List as UnorderedList, Box } from '@mui/material';
+import Grid from '@mui/material/Grid2';
import { ListItem, AddItems, TextInputElement } from '../components';
-// React.memo is needed to prevent unnecessary re-renders of the List component
-// when the props (data and listPath) haven't changed,
-// optimizing performance by avoiding re-computation of expensive
-// operations like filtering and sorting items.
-
-export const List = React.memo(function List({ data, listPath }) {
+export const List = React.memo(function List({
+ items,
+ listPath,
+}: {
+ items: DocumentData[];
+ listPath: string;
+}) {
+ const { urgencyObject } = useUrgency(items);
const [searchItem, setSearchItem] = useState('');
- const { urgencyObject } = useUrgency(data);
// Redirect to home if no list path is null
if (useEnsureListPath()) return <>>;
- const listName = listPath.slice(listPath.indexOf('/') + 1);
-
- const sortedItems = Object.values(urgencyObject).flat();
+ const listName = listPath?.slice(listPath.indexOf('/') + 1);
- const handleTextChange = (event) => {
+ const handleTextChange = (event: React.ChangeEvent) => {
setSearchItem(event.target.value);
};
- const filteredItems = sortedItems.filter((item) =>
- item?.name?.toLowerCase().includes(searchItem.toLowerCase()),
+ const filteredItems = items.filter((item) =>
+ item.name.toLowerCase().includes(searchItem.toLowerCase()),
);
return (
<>
- {!data?.length ? (
+ {!items.length ? (
<>
Welcome to {listName}!
Ready to add your first item? Start adding below!
-
+
>
) : (
<>
{listName}
+
-
-
+
+
-
+