diff --git a/eslint.config.js b/eslint.config.js
index 0a019971..36ca9cdd 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -70,7 +70,8 @@ export default [
...typescriptPlugin.configs.recommended.rules,
// ESLint rules
- 'no-unused-vars': 'warn',
+ 'no-unused-vars': 'off',
+ '@typescript-eslint/no-unused-vars': ['error'],
// React rules
'react/prop-types': 'off',
diff --git a/package.json b/package.json
index b01b2b4b..4408059a 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,8 @@
"build": "tsc -b && vite build",
"lint:eslint": "eslint . --ext ts,tsx --report-unused-disable-directives",
"lint:tsc": "tsc --pretty",
- "lint": "pnpm lint:eslint && pnpm lint:tsc"
+ "lint": "pnpm lint:eslint && pnpm lint:tsc",
+ "preview": "vitest-preview"
},
"dependencies": {
"@emotion/react": "^11.11.4",
@@ -26,9 +27,11 @@
"msw": "^2.10.3",
"notistack": "^3.0.2",
"react": "19.1.0",
- "react-dom": "19.1.0"
+ "react-dom": "19.1.0",
+ "vitest-preview": "^0.0.1"
},
"devDependencies": {
+ "@eslint/js": "^9.33.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.5.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 093f3ec7..e0a550a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,7 +38,13 @@ importers:
react-dom:
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
+ vitest-preview:
+ specifier: ^0.0.1
+ version: 0.0.1
devDependencies:
+ '@eslint/js':
+ specifier: ^9.33.0
+ version: 9.33.0
'@testing-library/jest-dom':
specifier: ^6.6.3
version: 6.6.3
@@ -290,6 +296,12 @@ packages:
cpu: [arm64]
os: [android]
+ '@esbuild/android-arm@0.15.18':
+ resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [android]
+
'@esbuild/android-arm@0.25.5':
resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==}
engines: {node: '>=18'}
@@ -344,6 +356,12 @@ packages:
cpu: [ia32]
os: [linux]
+ '@esbuild/linux-loong64@0.15.18':
+ resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==}
+ engines: {node: '>=12'}
+ cpu: [loong64]
+ os: [linux]
+
'@esbuild/linux-loong64@0.25.5':
resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==}
engines: {node: '>=18'}
@@ -468,6 +486,10 @@ packages:
resolution: {integrity: sha512-Wzw3wQwPvc9sHM+NjakWTcPx11mbZyiYHuwWa/QfZ7cIRX7WK54PSk7bdyXDaoaopUcMatv1zaQvOAAO8hCdww==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+ '@eslint/js@9.33.0':
+ resolution: {integrity: sha512-5K1/mKhWaMfreBGJTwval43JJmkip0RmM+3+IuqupeSKNC/Th2Kc7ucaq5ovTSra/OOKB9c58CGSz3QMVbWt0A==}
+ engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
+
'@eslint/object-schema@2.1.6':
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -895,9 +917,15 @@ packages:
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+ '@types/body-parser@1.19.6':
+ resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
+ '@types/connect@3.4.38':
+ resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
'@types/cookie@0.6.0':
resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==}
@@ -913,12 +941,27 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
+ '@types/express-serve-static-core@4.19.6':
+ resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==}
+
+ '@types/express@4.17.23':
+ resolution: {integrity: sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==}
+
+ '@types/http-errors@2.0.5':
+ resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/json5@0.0.29':
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
+ '@types/mime@1.3.5':
+ resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==}
+
+ '@types/node@18.19.123':
+ resolution: {integrity: sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg==}
+
'@types/node@22.8.1':
resolution: {integrity: sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==}
@@ -928,6 +971,12 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
+ '@types/qs@6.14.0':
+ resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
+
+ '@types/range-parser@1.2.7':
+ resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
'@types/react-dom@19.1.6':
resolution: {integrity: sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==}
peerDependencies:
@@ -941,6 +990,12 @@ packages:
'@types/react@19.1.8':
resolution: {integrity: sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==}
+ '@types/send@0.17.5':
+ resolution: {integrity: sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==}
+
+ '@types/serve-static@1.15.8':
+ resolution: {integrity: sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==}
+
'@types/statuses@2.0.5':
resolution: {integrity: sha512-jmIUGWrAiwu3dZpxntxieC+1n/5c3mjrImkmOSQ2NC5uP6cYO4aAZDdSmRcI5C1oiTmqlZGHC+/NmJrKogbP5A==}
@@ -1038,6 +1093,9 @@ packages:
peerDependencies:
vite: ^4 || ^5
+ '@vitest-preview/dev-utils@0.0.1':
+ resolution: {integrity: sha512-KLr4IvFz73dMao1tCHWgwqNJfHEcGOqHaQ7SHYfumrMvs2BBD4PKMBtePO2AV7+gq4iEPuIJY8INR3Oq5EnTUw==}
+
'@vitest/coverage-v8@2.1.3':
resolution: {integrity: sha512-2OJ3c7UPoFSmBZwqD2VEkUw6A/tzPF0LmW0ZZhhB8PFxuc+9IBG/FaSM+RLEenc7ljzFvGN+G0nGQoZnh7sy2A==}
peerDependencies:
@@ -1549,11 +1607,136 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
+ esbuild-android-64@0.15.18:
+ resolution: {integrity: sha512-wnpt3OXRhcjfIDSZu9bnzT4/TNTDsOUvip0foZOUBG7QbSt//w3QV4FInVJxNhKc/ErhUxc5z4QjHtMi7/TbgA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [android]
+
+ esbuild-android-arm64@0.15.18:
+ resolution: {integrity: sha512-G4xu89B8FCzav9XU8EjsXacCKSG2FT7wW9J6hOc18soEHJdtWu03L3TQDGf0geNxfLTtxENKBzMSq9LlbjS8OQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [android]
+
+ esbuild-darwin-64@0.15.18:
+ resolution: {integrity: sha512-2WAvs95uPnVJPuYKP0Eqx+Dl/jaYseZEUUT1sjg97TJa4oBtbAKnPnl3b5M9l51/nbx7+QAEtuummJZW0sBEmg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [darwin]
+
+ esbuild-darwin-arm64@0.15.18:
+ resolution: {integrity: sha512-tKPSxcTJ5OmNb1btVikATJ8NftlyNlc8BVNtyT/UAr62JFOhwHlnoPrhYWz09akBLHI9nElFVfWSTSRsrZiDUA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [darwin]
+
+ esbuild-freebsd-64@0.15.18:
+ resolution: {integrity: sha512-TT3uBUxkteAjR1QbsmvSsjpKjOX6UkCstr8nMr+q7zi3NuZ1oIpa8U41Y8I8dJH2fJgdC3Dj3CXO5biLQpfdZA==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [freebsd]
+
+ esbuild-freebsd-arm64@0.15.18:
+ resolution: {integrity: sha512-R/oVr+X3Tkh+S0+tL41wRMbdWtpWB8hEAMsOXDumSSa6qJR89U0S/PpLXrGF7Wk/JykfpWNokERUpCeHDl47wA==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ esbuild-linux-32@0.15.18:
+ resolution: {integrity: sha512-lphF3HiCSYtaa9p1DtXndiQEeQDKPl9eN/XNoBf2amEghugNuqXNZA/ZovthNE2aa4EN43WroO0B85xVSjYkbg==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [linux]
+
+ esbuild-linux-64@0.15.18:
+ resolution: {integrity: sha512-hNSeP97IviD7oxLKFuii5sDPJ+QHeiFTFLoLm7NZQligur8poNOWGIgpQ7Qf8Balb69hptMZzyOBIPtY09GZYw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [linux]
+
+ esbuild-linux-arm64@0.15.18:
+ resolution: {integrity: sha512-54qr8kg/6ilcxd+0V3h9rjT4qmjc0CccMVWrjOEM/pEcUzt8X62HfBSeZfT2ECpM7104mk4yfQXkosY8Quptug==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [linux]
+
+ esbuild-linux-arm@0.15.18:
+ resolution: {integrity: sha512-UH779gstRblS4aoS2qpMl3wjg7U0j+ygu3GjIeTonCcN79ZvpPee12Qun3vcdxX+37O5LFxz39XeW2I9bybMVA==}
+ engines: {node: '>=12'}
+ cpu: [arm]
+ os: [linux]
+
+ esbuild-linux-mips64le@0.15.18:
+ resolution: {integrity: sha512-Mk6Ppwzzz3YbMl/ZZL2P0q1tnYqh/trYZ1VfNP47C31yT0K8t9s7Z077QrDA/guU60tGNp2GOwCQnp+DYv7bxQ==}
+ engines: {node: '>=12'}
+ cpu: [mips64el]
+ os: [linux]
+
+ esbuild-linux-ppc64le@0.15.18:
+ resolution: {integrity: sha512-b0XkN4pL9WUulPTa/VKHx2wLCgvIAbgwABGnKMY19WhKZPT+8BxhZdqz6EgkqCLld7X5qiCY2F/bfpUUlnFZ9w==}
+ engines: {node: '>=12'}
+ cpu: [ppc64]
+ os: [linux]
+
+ esbuild-linux-riscv64@0.15.18:
+ resolution: {integrity: sha512-ba2COaoF5wL6VLZWn04k+ACZjZ6NYniMSQStodFKH/Pu6RxzQqzsmjR1t9QC89VYJxBeyVPTaHuBMCejl3O/xg==}
+ engines: {node: '>=12'}
+ cpu: [riscv64]
+ os: [linux]
+
+ esbuild-linux-s390x@0.15.18:
+ resolution: {integrity: sha512-VbpGuXEl5FCs1wDVp93O8UIzl3ZrglgnSQ+Hu79g7hZu6te6/YHgVJxCM2SqfIila0J3k0csfnf8VD2W7u2kzQ==}
+ engines: {node: '>=12'}
+ cpu: [s390x]
+ os: [linux]
+
+ esbuild-netbsd-64@0.15.18:
+ resolution: {integrity: sha512-98ukeCdvdX7wr1vUYQzKo4kQ0N2p27H7I11maINv73fVEXt2kyh4K4m9f35U1K43Xc2QGXlzAw0K9yoU7JUjOg==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [netbsd]
+
+ esbuild-openbsd-64@0.15.18:
+ resolution: {integrity: sha512-yK5NCcH31Uae076AyQAXeJzt/vxIo9+omZRKj1pauhk3ITuADzuOx5N2fdHrAKPxN+zH3w96uFKlY7yIn490xQ==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [openbsd]
+
esbuild-register@3.6.0:
resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==}
peerDependencies:
esbuild: '>=0.12 <1'
+ esbuild-sunos-64@0.15.18:
+ resolution: {integrity: sha512-On22LLFlBeLNj/YF3FT+cXcyKPEI263nflYlAhz5crxtp3yRG1Ugfr7ITyxmCmjm4vbN/dGrb/B7w7U8yJR9yw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [sunos]
+
+ esbuild-windows-32@0.15.18:
+ resolution: {integrity: sha512-o+eyLu2MjVny/nt+E0uPnBxYuJHBvho8vWsC2lV61A7wwTWC3jkN2w36jtA+yv1UgYkHRihPuQsL23hsCYGcOQ==}
+ engines: {node: '>=12'}
+ cpu: [ia32]
+ os: [win32]
+
+ esbuild-windows-64@0.15.18:
+ resolution: {integrity: sha512-qinug1iTTaIIrCorAUjR0fcBk24fjzEedFYhhispP8Oc7SFvs+XeW3YpAKiKp8dRpizl4YYAhxMjlftAMJiaUw==}
+ engines: {node: '>=12'}
+ cpu: [x64]
+ os: [win32]
+
+ esbuild-windows-arm64@0.15.18:
+ resolution: {integrity: sha512-q9bsYzegpZcLziq0zgUi5KqGVtfhjxGbnksaBFYmWLxeV/S1fK4OLdq2DFYnXcLMjlZw2L0jLsk1eGoB522WXQ==}
+ engines: {node: '>=12'}
+ cpu: [arm64]
+ os: [win32]
+
+ esbuild@0.15.18:
+ resolution: {integrity: sha512-x/R72SmW3sSFRm5zrrIjAhCeQSAWoni3CmHEqfQrZIQTM3lVCdehdwuIqaOtfC2slvpdlLa62GYoN8SxT23m6Q==}
+ engines: {node: '>=12'}
+ hasBin: true
+
esbuild@0.25.5:
resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==}
engines: {node: '>=18'}
@@ -3076,6 +3259,9 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
+ undici-types@5.26.5:
+ resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+
undici-types@6.19.8:
resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
@@ -3112,6 +3298,31 @@ packages:
eslint: '>=7'
vite: '>=2'
+ vite@3.2.11:
+ resolution: {integrity: sha512-K/jGKL/PgbIgKCiJo5QbASQhFiV02X9Jh+Qq0AKCRCRKZtOTVi4t6wh75FDpGf2N9rYOnzH87OEFQNaFy6pdxQ==}
+ engines: {node: ^14.18.0 || >=16.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': '>= 14'
+ less: '*'
+ sass: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.4.0
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+
vite@7.0.2:
resolution: {integrity: sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -3152,6 +3363,10 @@ packages:
yaml:
optional: true
+ vitest-preview@0.0.1:
+ resolution: {integrity: sha512-rKh+rzW54HYfgYjCU/9n8t0V8rnxYiH67uJGYUKKqW5L87Cl8NESDzNe2BbD6WmNvM4ojQdc0VqLXv6QsDt1Jw==}
+ hasBin: true
+
vitest@3.2.4:
resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -3513,6 +3728,9 @@ snapshots:
'@esbuild/android-arm64@0.25.5':
optional: true
+ '@esbuild/android-arm@0.15.18':
+ optional: true
+
'@esbuild/android-arm@0.25.5':
optional: true
@@ -3540,6 +3758,9 @@ snapshots:
'@esbuild/linux-ia32@0.25.5':
optional: true
+ '@esbuild/linux-loong64@0.15.18':
+ optional: true
+
'@esbuild/linux-loong64@0.25.5':
optional: true
@@ -3628,6 +3849,8 @@ snapshots:
'@eslint/js@9.30.0': {}
+ '@eslint/js@9.33.0': {}
+
'@eslint/object-schema@2.1.6': {}
'@eslint/plugin-kit@0.3.3':
@@ -3990,10 +4213,19 @@ snapshots:
'@types/aria-query@5.0.4': {}
+ '@types/body-parser@1.19.6':
+ dependencies:
+ '@types/connect': 3.4.38
+ '@types/node': 22.8.1
+
'@types/chai@5.2.2':
dependencies:
'@types/deep-eql': 4.0.2
+ '@types/connect@3.4.38':
+ dependencies:
+ '@types/node': 22.8.1
+
'@types/cookie@0.6.0': {}
'@types/deep-eql@4.0.2': {}
@@ -4007,10 +4239,32 @@ snapshots:
'@types/estree@1.0.8': {}
+ '@types/express-serve-static-core@4.19.6':
+ dependencies:
+ '@types/node': 22.8.1
+ '@types/qs': 6.14.0
+ '@types/range-parser': 1.2.7
+ '@types/send': 0.17.5
+
+ '@types/express@4.17.23':
+ dependencies:
+ '@types/body-parser': 1.19.6
+ '@types/express-serve-static-core': 4.19.6
+ '@types/qs': 6.14.0
+ '@types/serve-static': 1.15.8
+
+ '@types/http-errors@2.0.5': {}
+
'@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {}
+ '@types/mime@1.3.5': {}
+
+ '@types/node@18.19.123':
+ dependencies:
+ undici-types: 5.26.5
+
'@types/node@22.8.1':
dependencies:
undici-types: 6.19.8
@@ -4019,6 +4273,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
+ '@types/qs@6.14.0': {}
+
+ '@types/range-parser@1.2.7': {}
+
'@types/react-dom@19.1.6(@types/react@19.1.8)':
dependencies:
'@types/react': 19.1.8
@@ -4031,6 +4289,17 @@ snapshots:
dependencies:
csstype: 3.1.3
+ '@types/send@0.17.5':
+ dependencies:
+ '@types/mime': 1.3.5
+ '@types/node': 22.8.1
+
+ '@types/serve-static@1.15.8':
+ dependencies:
+ '@types/http-errors': 2.0.5
+ '@types/node': 22.8.1
+ '@types/send': 0.17.5
+
'@types/statuses@2.0.5': {}
'@types/tough-cookie@4.0.5': {}
@@ -4172,6 +4441,10 @@ snapshots:
transitivePeerDependencies:
- '@swc/helpers'
+ '@vitest-preview/dev-utils@0.0.1':
+ dependencies:
+ open: 8.4.2
+
'@vitest/coverage-v8@2.1.3(vitest@3.2.4)':
dependencies:
'@ampproject/remapping': 2.3.0
@@ -4861,6 +5134,54 @@ snapshots:
is-date-object: 1.0.5
is-symbol: 1.0.4
+ esbuild-android-64@0.15.18:
+ optional: true
+
+ esbuild-android-arm64@0.15.18:
+ optional: true
+
+ esbuild-darwin-64@0.15.18:
+ optional: true
+
+ esbuild-darwin-arm64@0.15.18:
+ optional: true
+
+ esbuild-freebsd-64@0.15.18:
+ optional: true
+
+ esbuild-freebsd-arm64@0.15.18:
+ optional: true
+
+ esbuild-linux-32@0.15.18:
+ optional: true
+
+ esbuild-linux-64@0.15.18:
+ optional: true
+
+ esbuild-linux-arm64@0.15.18:
+ optional: true
+
+ esbuild-linux-arm@0.15.18:
+ optional: true
+
+ esbuild-linux-mips64le@0.15.18:
+ optional: true
+
+ esbuild-linux-ppc64le@0.15.18:
+ optional: true
+
+ esbuild-linux-riscv64@0.15.18:
+ optional: true
+
+ esbuild-linux-s390x@0.15.18:
+ optional: true
+
+ esbuild-netbsd-64@0.15.18:
+ optional: true
+
+ esbuild-openbsd-64@0.15.18:
+ optional: true
+
esbuild-register@3.6.0(esbuild@0.25.5):
dependencies:
debug: 4.4.1
@@ -4868,6 +5189,43 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ esbuild-sunos-64@0.15.18:
+ optional: true
+
+ esbuild-windows-32@0.15.18:
+ optional: true
+
+ esbuild-windows-64@0.15.18:
+ optional: true
+
+ esbuild-windows-arm64@0.15.18:
+ optional: true
+
+ esbuild@0.15.18:
+ optionalDependencies:
+ '@esbuild/android-arm': 0.15.18
+ '@esbuild/linux-loong64': 0.15.18
+ esbuild-android-64: 0.15.18
+ esbuild-android-arm64: 0.15.18
+ esbuild-darwin-64: 0.15.18
+ esbuild-darwin-arm64: 0.15.18
+ esbuild-freebsd-64: 0.15.18
+ esbuild-freebsd-arm64: 0.15.18
+ esbuild-linux-32: 0.15.18
+ esbuild-linux-64: 0.15.18
+ esbuild-linux-arm: 0.15.18
+ esbuild-linux-arm64: 0.15.18
+ esbuild-linux-mips64le: 0.15.18
+ esbuild-linux-ppc64le: 0.15.18
+ esbuild-linux-riscv64: 0.15.18
+ esbuild-linux-s390x: 0.15.18
+ esbuild-netbsd-64: 0.15.18
+ esbuild-openbsd-64: 0.15.18
+ esbuild-sunos-64: 0.15.18
+ esbuild-windows-32: 0.15.18
+ esbuild-windows-64: 0.15.18
+ esbuild-windows-arm64: 0.15.18
+
esbuild@0.25.5:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.5
@@ -6627,6 +6985,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
+ undici-types@5.26.5: {}
+
undici-types@6.19.8: {}
universalify@0.2.0: {}
@@ -6675,6 +7035,16 @@ snapshots:
rollup: 2.79.2
vite: 7.0.2(@types/node@22.8.1)
+ vite@3.2.11(@types/node@18.19.123):
+ dependencies:
+ esbuild: 0.15.18
+ postcss: 8.5.6
+ resolve: 1.22.8
+ rollup: 2.79.2
+ optionalDependencies:
+ '@types/node': 18.19.123
+ fsevents: 2.3.3
+
vite@7.0.2(@types/node@22.8.1):
dependencies:
esbuild: 0.25.5
@@ -6687,6 +7057,21 @@ snapshots:
'@types/node': 22.8.1
fsevents: 2.3.3
+ vitest-preview@0.0.1:
+ dependencies:
+ '@types/express': 4.17.23
+ '@types/node': 18.19.123
+ '@vitest-preview/dev-utils': 0.0.1
+ express: 4.21.1
+ vite: 3.2.11(@types/node@18.19.123)
+ transitivePeerDependencies:
+ - less
+ - sass
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+
vitest@3.2.4(@types/node@22.8.1)(@vitest/ui@3.2.4)(jsdom@26.1.0)(msw@2.10.3(@types/node@22.8.1)(typescript@5.6.3)):
dependencies:
'@types/chai': 5.2.2
diff --git a/src/App.tsx b/src/App.tsx
index 195c5b05..6c1c6e06 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,64 +1,18 @@
-import { Notifications, ChevronLeft, ChevronRight, Delete, Edit, Close } from '@mui/icons-material';
-import {
- Alert,
- AlertTitle,
- Box,
- Button,
- Checkbox,
- Dialog,
- DialogActions,
- DialogContent,
- DialogContentText,
- DialogTitle,
- FormControl,
- FormControlLabel,
- FormLabel,
- IconButton,
- MenuItem,
- Select,
- Stack,
- Table,
- TableBody,
- TableCell,
- TableContainer,
- TableHead,
- TableRow,
- TextField,
- Tooltip,
- Typography,
-} from '@mui/material';
+import { Box, Stack } from '@mui/material';
import { useSnackbar } from 'notistack';
import { useState } from 'react';
+import { CalendarView } from './components/calendar';
+import { OverlapDialog } from './components/dialog';
+import { EventFormComponent, EventList } from './components/event';
+import { NotificationToast } from './components/notification';
import { useCalendarView } from './hooks/useCalendarView.ts';
import { useEventForm } from './hooks/useEventForm.ts';
import { useEventOperations } from './hooks/useEventOperations.ts';
import { useNotifications } from './hooks/useNotifications.ts';
import { useSearch } from './hooks/useSearch.ts';
-// import { Event, EventForm, RepeatType } from './types';
import { Event, EventForm } from './types';
-import {
- formatDate,
- formatMonth,
- formatWeek,
- getEventsForDay,
- getWeekDates,
- getWeeksAtMonth,
-} from './utils/dateUtils';
import { findOverlappingEvents } from './utils/eventOverlap';
-import { getTimeErrorMessage } from './utils/timeValidation';
-
-const categories = ['업무', '개인', '가족', '기타'];
-
-const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
-
-const notificationOptions = [
- { value: 1, label: '1분 전' },
- { value: 10, label: '10분 전' },
- { value: 60, label: '1시간 전' },
- { value: 120, label: '2시간 전' },
- { value: 1440, label: '1일 전' },
-];
function App() {
const {
@@ -107,6 +61,27 @@ function App() {
const { enqueueSnackbar } = useSnackbar();
+ const handleOverlapConfirm = () => {
+ setIsOverlapDialogOpen(false);
+ saveEvent({
+ id: editingEvent ? editingEvent.id : undefined,
+ title,
+ date,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ repeat: {
+ type: isRepeating ? repeatType : 'none',
+ interval: repeatInterval,
+ endDate: repeatEndDate || undefined,
+ },
+ notificationTime,
+ });
+ resetForm();
+ };
+
const addOrUpdateEvent = async () => {
if (!title || !date || !startTime || !endTime) {
enqueueSnackbar('필수 정보를 모두 입력해주세요.', { variant: 'error' });
@@ -145,514 +120,73 @@ function App() {
}
};
- const renderWeekView = () => {
- const weekDates = getWeekDates(currentDate);
- return (
-
- {formatWeek(currentDate)}
-
-
-
-
- {weekDays.map((day) => (
-
- {day}
-
- ))}
-
-
-
-
- {weekDates.map((date) => (
-
-
- {date.getDate()}
-
- {filteredEvents
- .filter(
- (event) => new Date(event.date).toDateString() === date.toDateString()
- )
- .map((event) => {
- const isNotified = notifiedEvents.includes(event.id);
- return (
-
-
- {isNotified && }
-
- {event.title}
-
-
-
- );
- })}
-
- ))}
-
-
-
-
-
- );
- };
-
- const renderMonthView = () => {
- const weeks = getWeeksAtMonth(currentDate);
-
- return (
-
- {formatMonth(currentDate)}
-
-
-
-
- {weekDays.map((day) => (
-
- {day}
-
- ))}
-
-
-
- {weeks.map((week, weekIndex) => (
-
- {week.map((day, dayIndex) => {
- const dateString = day ? formatDate(currentDate, day) : '';
- const holiday = holidays[dateString];
-
- return (
-
- {day && (
- <>
-
- {day}
-
- {holiday && (
-
- {holiday}
-
- )}
- {getEventsForDay(filteredEvents, day).map((event) => {
- const isNotified = notifiedEvents.includes(event.id);
- return (
-
-
- {isNotified && }
-
- {event.title}
-
-
-
- );
- })}
- >
- )}
-
- );
- })}
-
- ))}
-
-
-
-
- );
- };
-
return (
-
- {editingEvent ? '일정 수정' : '일정 추가'}
-
-
- 제목
- setTitle(e.target.value)}
- />
-
-
-
- 날짜
- setDate(e.target.value)}
- />
-
-
-
-
- 시작 시간
-
- getTimeErrorMessage(startTime, endTime)}
- error={!!startTimeError}
- />
-
-
-
- 종료 시간
-
- getTimeErrorMessage(startTime, endTime)}
- error={!!endTimeError}
- />
-
-
-
-
-
- 설명
- setDescription(e.target.value)}
- />
-
-
-
- 위치
- setLocation(e.target.value)}
- />
-
-
-
- 카테고리
-
-
-
-
- setIsRepeating(e.target.checked)}
- />
- }
- label="반복 일정"
- />
-
-
-
- 알림 설정
-
-
-
- {/* ! 반복은 8주차 과제에 포함됩니다. 구현하고 싶어도 참아주세요~ */}
- {/* {isRepeating && (
-
-
- 반복 유형
-
-
-
-
- 반복 간격
- setRepeatInterval(Number(e.target.value))}
- slotProps={{ htmlInput: { min: 1 } }}
- />
-
-
- 반복 종료일
- setRepeatEndDate(e.target.value)}
- />
-
-
-
- )} */}
-
-
-
-
-
- 일정 보기
-
-
- navigate('prev')}>
-
-
-
- navigate('next')}>
-
-
-
-
- {view === 'week' && renderWeekView()}
- {view === 'month' && renderMonthView()}
-
-
-
-
- 일정 검색
- setSearchTerm(e.target.value)}
- />
-
-
- {filteredEvents.length === 0 ? (
- 검색 결과가 없습니다.
- ) : (
- filteredEvents.map((event) => (
-
-
-
-
- {notifiedEvents.includes(event.id) && }
-
- {event.title}
-
-
- {event.date}
-
- {event.startTime} - {event.endTime}
-
- {event.description}
- {event.location}
- 카테고리: {event.category}
- {event.repeat.type !== 'none' && (
-
- 반복: {event.repeat.interval}
- {event.repeat.type === 'daily' && '일'}
- {event.repeat.type === 'weekly' && '주'}
- {event.repeat.type === 'monthly' && '월'}
- {event.repeat.type === 'yearly' && '년'}
- 마다
- {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`}
-
- )}
-
- 알림:{' '}
- {
- notificationOptions.find(
- (option) => option.value === event.notificationTime
- )?.label
- }
-
-
-
- editEvent(event)}>
-
-
- deleteEvent(event.id)}>
-
-
-
-
-
- ))
- )}
-
+
+
+
+
+
-
+ setIsOverlapDialogOpen(false)}
+ overlappingEvents={overlappingEvents}
+ onConfirm={handleOverlapConfirm}
+ />
- {notifications.length > 0 && (
-
- {notifications.map((notification, index) => (
- setNotifications((prev) => prev.filter((_, i) => i !== index))}
- >
-
-
- }
- >
- {notification.message}
-
- ))}
-
- )}
+
);
}
diff --git a/src/__tests__/components/CalendarView.test.tsx b/src/__tests__/components/CalendarView.test.tsx
new file mode 100644
index 00000000..247eea37
--- /dev/null
+++ b/src/__tests__/components/CalendarView.test.tsx
@@ -0,0 +1,51 @@
+import { render, screen } from '@testing-library/react';
+
+import { CalendarView } from '../../components/calendar/CalendarView';
+import { createMockEvents } from '../utils';
+
+const renderCalendarView = (props: Parameters[0]) =>
+ render();
+
+describe('CalendarView', () => {
+ const mockEvents = createMockEvents();
+
+ const mockHolidays = {
+ '2025-01-01': '신정',
+ '2025-01-15': '설날',
+ };
+
+ const mockSetView = vi.fn();
+ const mockNavigate = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+ it('Week이 선택되었을 때 해당 날짜의 주간 일정이 렌더링된다', () => {
+ renderCalendarView({
+ view: 'week',
+ setView: mockSetView,
+ currentDate: new Date('2025-01-15'),
+ navigate: mockNavigate,
+ filteredEvents: mockEvents,
+ notifiedEvents: [],
+ holidays: mockHolidays,
+ });
+
+ expect(screen.getByTestId('week-view')).toBeInTheDocument();
+ expect(screen.queryByTestId('month-view')).not.toBeInTheDocument();
+ });
+
+ it('휴일 정보가 올바르게 표시된다', () => {
+ renderCalendarView({
+ view: 'month',
+ setView: mockSetView,
+ currentDate: new Date('2025-01-15'),
+ navigate: mockNavigate,
+ filteredEvents: mockEvents,
+ notifiedEvents: [],
+ holidays: mockHolidays,
+ });
+
+ expect(screen.getByText('설날')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/components/EventList.test.tsx b/src/__tests__/components/EventList.test.tsx
new file mode 100644
index 00000000..b88688bf
--- /dev/null
+++ b/src/__tests__/components/EventList.test.tsx
@@ -0,0 +1,73 @@
+import { render, screen } from '@testing-library/react';
+
+import { EventList } from '../../components/event/EventList';
+import { createMockEvents } from '../utils';
+
+const renderEventList = (props: Parameters[0]) =>
+ render();
+
+describe('EventList', () => {
+ const mockEvents = createMockEvents();
+
+ const mockSetSearchTerm = vi.fn();
+ const mockEditEvent = vi.fn();
+ const mockDeleteEvent = vi.fn();
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('검색 입력창과 일정 목록이 표시된다', () => {
+ renderEventList({
+ searchTerm: '',
+ setSearchTerm: mockSetSearchTerm,
+ filteredEvents: mockEvents,
+ notifiedEvents: [],
+ editEvent: mockEditEvent,
+ deleteEvent: mockDeleteEvent,
+ });
+
+ expect(screen.getByPlaceholderText('검색어를 입력하세요')).toBeInTheDocument();
+ expect(screen.getByTestId('event-list')).toBeInTheDocument();
+ });
+
+ it('일정 정보가 올바르게 표시된다', () => {
+ renderEventList({
+ searchTerm: '',
+ setSearchTerm: mockSetSearchTerm,
+ filteredEvents: mockEvents,
+ notifiedEvents: [],
+ editEvent: mockEditEvent,
+ deleteEvent: mockDeleteEvent,
+ });
+
+ expect(screen.getByText('팀 회의')).toBeInTheDocument();
+ expect(screen.getByText('회의실 A')).toBeInTheDocument();
+ });
+
+ it('반복 일정의 정보가 올바르게 표시된다', () => {
+ renderEventList({
+ searchTerm: '',
+ setSearchTerm: mockSetSearchTerm,
+ filteredEvents: mockEvents,
+ notifiedEvents: [],
+ editEvent: mockEditEvent,
+ deleteEvent: mockDeleteEvent,
+ });
+
+ expect(screen.getByText('반복: 1주마다 (종료: 2025-12-31)')).toBeInTheDocument();
+ });
+
+ it('검색 결과가 없을 때 검색 결과가 없습니다. 라는 메시지가 표시된다', () => {
+ renderEventList({
+ searchTerm: '',
+ setSearchTerm: mockSetSearchTerm,
+ filteredEvents: [],
+ notifiedEvents: [],
+ editEvent: mockEditEvent,
+ deleteEvent: mockDeleteEvent,
+ });
+
+ expect(screen.getByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/hooks/easy.useCalendarView.spec.ts b/src/__tests__/hooks/easy.useCalendarView.spec.ts
index 93b57f0e..84011180 100644
--- a/src/__tests__/hooks/easy.useCalendarView.spec.ts
+++ b/src/__tests__/hooks/easy.useCalendarView.spec.ts
@@ -1,24 +1,86 @@
import { act, renderHook } from '@testing-library/react';
import { useCalendarView } from '../../hooks/useCalendarView.ts';
-import { assertDate } from '../utils.ts';
describe('초기 상태', () => {
- it('view는 "month"이어야 한다', () => {});
+ it('view는 "month"이어야 한다', () => {
+ const { result } = renderHook(() => useCalendarView());
+ expect(result.current.view).toBe('month');
+ });
- it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {});
+ it('currentDate는 오늘 날짜인 "2025-10-01"이어야 한다', () => {
+ const { result } = renderHook(() => useCalendarView());
+ expect(result.current.currentDate).toStrictEqual(new Date('2025-10-01'));
+ });
- it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {});
+ it('holidays는 10월 휴일인 개천절, 한글날, 추석이 지정되어 있어야 한다', () => {
+ const { result } = renderHook(() => useCalendarView());
+ expect(result.current.holidays).toEqual({
+ '2025-10-03': '개천절',
+ '2025-10-05': '추석',
+ '2025-10-06': '추석',
+ '2025-10-07': '추석',
+ '2025-10-09': '한글날',
+ });
+ });
});
-it("view를 'week'으로 변경 시 적절하게 반영된다", () => {});
+it("view를 'week'으로 변경 시 적절하게 반영된다", () => {
+ const { result } = renderHook(() => useCalendarView());
+ // act는 배치 업데이트가 완료될때까지 기다린다.
+ act(() => {
+ result.current.setView('week');
+ });
+ expect(result.current.view).toBe('week');
+});
-it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {});
+it("주간 뷰에서 다음으로 navigate시 7일 후 '2025-10-08' 날짜로 지정이 된다", () => {
+ const { result } = renderHook(() => useCalendarView());
+ act(() => {
+ result.current.setView('week');
+ });
+ act(() => {
+ result.current.navigate('next');
+ });
+ expect(result.current.currentDate).toStrictEqual(new Date('2025-10-08'));
+});
-it("주간 뷰에서 이전으로 navigate시 7일 후 '2025-09-24' 날짜로 지정이 된다", () => {});
+it("주간 뷰에서 이전으로 navigate시 7일 전 '2025-09-24' 날짜로 지정이 된다", () => {
+ const { result } = renderHook(() => useCalendarView());
-it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {});
+ act(() => {
+ result.current.setView('week');
+ });
+ act(() => {
+ result.current.navigate('prev');
+ });
+ expect(result.current.currentDate).toStrictEqual(new Date('2025-09-24'));
+});
-it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {});
+it("월간 뷰에서 다음으로 navigate시 한 달 후 '2025-11-01' 날짜여야 한다", () => {
+ const { result } = renderHook(() => useCalendarView());
+ act(() => {
+ result.current.navigate('next');
+ });
+ expect(result.current.currentDate).toStrictEqual(new Date('2025-11-01'));
+});
-it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {});
+it("월간 뷰에서 이전으로 navigate시 한 달 전 '2025-09-01' 날짜여야 한다", () => {
+ const { result } = renderHook(() => useCalendarView());
+ act(() => {
+ result.current.navigate('prev');
+ });
+ expect(result.current.currentDate).toStrictEqual(new Date('2025-09-01'));
+});
+
+it("currentDate가 '2025-03-01' 변경되면 3월 휴일 '삼일절'로 업데이트되어야 한다", async () => {
+ const { result } = renderHook(() => useCalendarView());
+
+ act(() => {
+ result.current.setCurrentDate(new Date('2025-03-01'));
+ });
+
+ expect(result.current.holidays).toEqual({
+ '2025-03-01': '삼일절',
+ });
+});
diff --git a/src/__tests__/hooks/easy.useSearch.spec.ts b/src/__tests__/hooks/easy.useSearch.spec.ts
index 80f57fa3..090390cf 100644
--- a/src/__tests__/hooks/easy.useSearch.spec.ts
+++ b/src/__tests__/hooks/easy.useSearch.spec.ts
@@ -3,12 +3,129 @@ import { act, renderHook } from '@testing-library/react';
import { useSearch } from '../../hooks/useSearch.ts';
import { Event } from '../../types.ts';
-it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {});
+it('검색어가 비어있을 때 모든 이벤트를 반환해야 한다', () => {
+ const events = [
+ {
+ title: '기존 회의',
+ date: '2025-10-15',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ },
+ {
+ title: '변경 회의',
+ date: '2025-10-16',
+ description: '변경 팀 미팅',
+ location: '회의실 B2',
+ },
+ ] as Event[];
+ const { result } = renderHook(() => useSearch(events, new Date('2025-10-15'), 'week'));
+ expect(result.current.filteredEvents).toEqual(events);
+});
-it('검색어에 맞는 이벤트만 필터링해야 한다', () => {});
+it('검색어에 맞는 이벤트만 필터링해야 한다', () => {
+ const events = [
+ {
+ title: '기존 회의',
+ date: '2025-10-15',
+ description: '기존 팀 미팅',
+ location: '회의실 B',
+ },
+ {
+ title: '변경 회의',
+ date: '2025-10-16',
+ description: '변경 팀 미팅',
+ location: '회의실 B2',
+ },
+ ] as Event[];
-it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {});
+ const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'month'));
+ act(() => {
+ result.current.setSearchTerm('변경');
+ });
+ expect(result.current.filteredEvents).toEqual([events[1]]);
+});
-it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {});
+it('검색어가 제목, 설명, 위치 중 하나라도 일치하면 해당 이벤트를 반환해야 한다', () => {
+ const events = [
+ {
+ title: '팀 변경 회의',
+ date: '2025-10-15',
+ description: '팀 미팅 회의',
+ location: '팀 회의실 B1',
+ },
+ {
+ title: 'React 회의',
+ date: '2025-10-16',
+ description: '미팅 회의',
+ location: '팀 회의실 B2',
+ },
+ ] as Event[];
+ const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'month'));
+ act(() => {
+ result.current.setSearchTerm('B1');
+ });
+ expect(result.current.filteredEvents).toEqual([events[0]]);
+});
-it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {});
+it('현재 뷰(주간/월간)에 해당하는 이벤트만 반환해야 한다', () => {
+ const events = [
+ {
+ title: '팀 변경 회의',
+ date: '2025-09-01',
+ description: '월간 미팅 회의',
+ location: '팀 회의실 B1',
+ },
+ {
+ title: '중간 회의',
+ date: '2025-09-15',
+ description: '팀 미팅 회의',
+ location: '팀 회의실 B2',
+ },
+ {
+ title: '월간 회의',
+ date: '2025-10-01',
+ description: '월간 미팅 회의',
+ location: '팀 회의실 B3',
+ },
+ ] as Event[];
+ const { result: result1 } = renderHook(() => useSearch(events, new Date('2025-10-03'), 'week'));
+ expect(result1.current.filteredEvents).toEqual([events[2]]);
+
+ const { result: result2 } = renderHook(() => useSearch(events, new Date('2025-09-29'), 'month'));
+ expect(result2.current.filteredEvents).toEqual([events[0], events[1]]);
+});
+
+it("검색어를 '회의'에서 '점심'으로 변경하면 필터링된 결과가 즉시 업데이트되어야 한다", () => {
+ const events = [
+ {
+ title: '회의',
+ date: '2025-10-01',
+ description: '회의',
+ location: '팀 회의실 B1',
+ },
+ {
+ title: '점심',
+ date: '2025-10-01',
+ description: '점심',
+ location: '팀 회의실 B1',
+ },
+ {
+ title: '기타',
+ date: '2025-10-01',
+ description: '기타',
+ location: '팀 회의실 B1',
+ },
+ ] as Event[];
+
+ const { result } = renderHook(() => useSearch(events, new Date('2025-10-01'), 'week'));
+ act(() => {
+ result.current.setSearchTerm('회의');
+ });
+
+ expect(result.current.filteredEvents).toEqual(events);
+
+ act(() => {
+ result.current.setSearchTerm('점심');
+ });
+ expect(result.current.filteredEvents).toEqual([events[1]]);
+});
diff --git a/src/__tests__/hooks/medium.useEventOperations.spec.ts b/src/__tests__/hooks/medium.useEventOperations.spec.ts
index 566ecbb0..037ea90f 100644
--- a/src/__tests__/hooks/medium.useEventOperations.spec.ts
+++ b/src/__tests__/hooks/medium.useEventOperations.spec.ts
@@ -10,8 +10,10 @@ import { useEventOperations } from '../../hooks/useEventOperations.ts';
import { server } from '../../setupTests.ts';
import { Event } from '../../types.ts';
+// 테스트에서 토스트 메시지 호출을 추적하기 위한 mock 함수 생성
const enqueueSnackbarFn = vi.fn();
+// notistack의 useSnackbar 훅을 mock 처리하여 실제 토스트 대신 mock 함수가 호출되도록 설정
vi.mock('notistack', async () => {
const actual = await vi.importActual('notistack');
return {
@@ -22,16 +24,215 @@ vi.mock('notistack', async () => {
};
});
-it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {});
+// events.json, realEvent.json 파일은 실제 handlers에서 쓰는 데이터(DB)이기 때문에 테스트 환경에서 쓰면 문제가 생김.
+// 따로 이벤트 데이터를 만들어서 테스트 해야함.
-it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {});
+const events = [
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '주간 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-10-01',
+ startTime: '12:30',
+ endTime: '13:30',
+ description: '동료와 점심 식사',
+ location: '구내식당',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ },
+ {
+ id: '3',
+ title: '기본 업무 마감',
+ date: '2025-10-01',
+ startTime: '14:00',
+ endTime: '16:00',
+ description: '기본 업무 마감',
+ location: '사무실',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ },
+ {
+ id: '4',
+ title: '생일 파티',
+ date: '2025-10-01',
+ startTime: '19:00',
+ endTime: '22:00',
+ description: '내 생일 파티',
+ location: '집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ },
+ {
+ id: '5',
+ title: '과제',
+ date: '2025-10-01',
+ startTime: '22:30',
+ endTime: '01:30',
+ description: '기본 과제',
+ location: '집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ },
+] as Event[];
-it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {});
+it('저장되어있는 초기 이벤트 데이터를 적절하게 불러온다', async () => {
+ setupMockHandlerCreation(events);
+ const { result } = renderHook(() => useEventOperations(false));
-it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {});
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
-it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {});
+ expect(result.current.events).toEqual(events);
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 로딩 완료!', { variant: 'info' });
+});
+
+it('정의된 이벤트 정보를 기준으로 적절하게 저장이 된다', async () => {
+ setupMockHandlerCreation(events);
+ const { result } = renderHook(() => useEventOperations(false));
+
+ await act(async () => {
+ await result.current.saveEvent({
+ id: '6',
+ title: '아침 기상',
+ date: '2025-10-02',
+ startTime: '06:30',
+ endTime: '06:50',
+ description: '아침 기상',
+ location: '집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ } as Event);
+ });
+
+ expect(result.current.events).toEqual([
+ ...events,
+ {
+ id: '6',
+ title: '아침 기상',
+ date: '2025-10-02',
+ startTime: '06:30',
+ endTime: '06:50',
+ description: '아침 기상',
+ location: '집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ } as Event,
+ ]);
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 추가되었습니다.', { variant: 'success' });
+});
+
+it("새로 정의된 'title', 'endTime' 기준으로 적절하게 일정이 업데이트 된다", async () => {
+ setupMockHandlerUpdating();
+
+ const { result } = renderHook(() => useEventOperations(true));
+
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
+
+ const prevEvent = result.current.events[0];
+
+ await act(async () => {
+ await result.current.saveEvent({
+ ...prevEvent,
+ title: '부서 회의',
+ endTime: '11:30',
+ } as Event);
+ });
+
+ expect(result.current.events[0].title).toBe('부서 회의');
+ expect(result.current.events[0].endTime).toBe('11:30');
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 수정되었습니다.', { variant: 'success' });
+});
-it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {});
+it('존재하는 이벤트 삭제 시 에러없이 아이템이 삭제된다.', async () => {
+ setupMockHandlerDeletion();
+ const { result } = renderHook(() => useEventOperations(false));
-it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {});
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
+
+ await act(async () => {
+ await result.current.deleteEvent('1');
+ });
+
+ expect(result.current.events).not.toContain(events.find((event) => event.id === '1'));
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정이 삭제되었습니다.', { variant: 'info' });
+});
+
+it("이벤트 로딩 실패 시 '이벤트 로딩 실패'라는 텍스트와 함께 에러 토스트가 표시되어야 한다", async () => {
+ server.use(
+ http.get('/api/events', () => {
+ return HttpResponse.json({ error: '이벤트 로딩 실패' }, { status: 500 });
+ })
+ );
+ const { result } = renderHook(() => useEventOperations(false));
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('이벤트 로딩 실패', { variant: 'error' });
+});
+
+it("존재하지 않는 이벤트 수정 시 '일정 저장 실패'라는 토스트가 노출되며 에러 처리가 되어야 한다", async () => {
+ server.use(
+ http.put('/api/events/:id', async () => {
+ return HttpResponse.json({ error: '이벤트가 존재하지 않습니다.' }, { status: 500 });
+ })
+ );
+ const { result } = renderHook(() => useEventOperations(true));
+
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
+
+ await act(async () => {
+ await result.current.saveEvent({
+ id: '9',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ description: '팀 회의',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 1,
+ } as Event);
+ });
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 저장 실패', { variant: 'error' });
+});
+
+it("네트워크 오류 시 '일정 삭제 실패'라는 텍스트가 노출되며 이벤트 삭제가 실패해야 한다", async () => {
+ server.use(
+ http.delete('/api/events/:id', async () => {
+ return HttpResponse.json({ error: '일정 삭제 실패' }, { status: 500 });
+ })
+ );
+ const { result } = renderHook(() => useEventOperations(false));
+ await act(async () => {
+ await result.current.fetchEvents();
+ });
+ await act(async () => {
+ await result.current.deleteEvent('1');
+ });
+ expect(enqueueSnackbarFn).toHaveBeenCalledWith('일정 삭제 실패', { variant: 'error' });
+});
diff --git a/src/__tests__/hooks/medium.useNotifications.spec.ts b/src/__tests__/hooks/medium.useNotifications.spec.ts
index 7f585ea8..4960a968 100644
--- a/src/__tests__/hooks/medium.useNotifications.spec.ts
+++ b/src/__tests__/hooks/medium.useNotifications.spec.ts
@@ -2,13 +2,65 @@ import { act, renderHook } from '@testing-library/react';
import { useNotifications } from '../../hooks/useNotifications.ts';
import { Event } from '../../types.ts';
-import { formatDate } from '../../utils/dateUtils.ts';
-import { parseHM } from '../utils.ts';
-it('초기 상태에서는 알림이 없어야 한다', () => {});
+const events = [
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '00:20',
+ endTime: '07:00',
+ description: '주간 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+] as Event[];
-it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {});
+beforeAll(() => {
+ vi.useFakeTimers();
+});
-it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {});
+afterAll(() => {
+ vi.useRealTimers();
+});
-it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {});
+const skipTime = (ms: number) => {
+ act(() => {
+ vi.advanceTimersByTime(ms);
+ });
+};
+
+it('초기 상태에서는 알림이 없어야 한다', () => {
+ const { result } = renderHook(() => useNotifications(events));
+ expect(result.current.notifications).toEqual([]);
+});
+
+it('지정된 시간이 된 경우 알림이 새롭게 생성되어 추가된다', () => {
+ const { result } = renderHook(() => useNotifications(events));
+ skipTime(1000 * 60 * 10);
+
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 팀 회의 일정이 시작됩니다.' },
+ ]);
+});
+
+it('index를 기준으로 알림을 적절하게 제거할 수 있다', () => {
+ const { result } = renderHook(() => useNotifications(events));
+ skipTime(1000 * 60 * 10);
+ act(() => {
+ result.current.removeNotification(0);
+ });
+
+ expect(result.current.notifications).toEqual([]);
+});
+
+it('이미 알림이 발생한 이벤트에 대해서는 중복 알림이 발생하지 않아야 한다', () => {
+ const { result } = renderHook(() => useNotifications(events));
+ skipTime(1000 * 60 * 10);
+ expect(result.current.notifications).toEqual([
+ { id: '1', message: '10분 후 팀 회의 일정이 시작됩니다.' },
+ ]);
+ expect(result.current.notifiedEvents).toEqual(['1']);
+});
diff --git a/src/__tests__/medium.integration.spec.tsx b/src/__tests__/medium.integration.spec.tsx
index 0b559b44..c18735ca 100644
--- a/src/__tests__/medium.integration.spec.tsx
+++ b/src/__tests__/medium.integration.spec.tsx
@@ -1,8 +1,7 @@
import CssBaseline from '@mui/material/CssBaseline';
import { ThemeProvider, createTheme } from '@mui/material/styles';
-import { render, screen, within, act } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import { UserEvent, userEvent } from '@testing-library/user-event';
-import { http, HttpResponse } from 'msw';
import { SnackbarProvider } from 'notistack';
import { ReactElement } from 'react';
@@ -12,7 +11,6 @@ import {
setupMockHandlerUpdating,
} from '../__mocks__/handlersUtils';
import App from '../App';
-import { server } from '../setupTests';
import { Event } from '../types';
const theme = createTheme();
@@ -59,37 +57,324 @@ const saveSchedule = async (
describe('일정 CRUD 및 기본 기능', () => {
it('입력한 새로운 일정 정보에 맞춰 모든 필드가 이벤트 리스트에 정확히 저장된다.', async () => {
// ! HINT. event를 추가 제거하고 저장하는 로직을 잘 살펴보고, 만약 그대로 구현한다면 어떤 문제가 있을 지 고민해보세요.
+ setupMockHandlerCreation([]);
+
+ const { user } = setup();
+ await saveSchedule(user, {
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '06:30',
+ endTime: '07:00',
+ description: '주간 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ });
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('팀 회의')).toBeInTheDocument();
});
- it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {});
+ it('기존 일정의 세부 정보를 수정하고 변경사항이 정확히 반영된다', async () => {
+ setupMockHandlerUpdating();
+
+ const { user } = setup();
+
+ const [editButton] = await screen.findAllByLabelText('Edit event');
+ await user.click(editButton);
+
+ await user.clear(screen.getByLabelText('제목'));
+ await user.type(screen.getByLabelText('제목'), '가족 회의');
+ await user.clear(screen.getByLabelText('위치'));
+ await user.type(screen.getByLabelText('위치'), '회의실 B');
+ await user.click(screen.getByTestId('event-submit-button'));
+
+ await screen.findByText('일정이 수정되었습니다.');
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('가족 회의')).toBeInTheDocument();
+ expect(await eventList.findByText('회의실 B')).toBeInTheDocument();
+ });
- it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {});
+ it('일정을 삭제하고 더 이상 조회되지 않는지 확인한다', async () => {
+ setupMockHandlerDeletion();
+
+ const { user } = setup();
+
+ const [deleteButton] = await screen.findAllByLabelText('Delete event');
+ await user.click(deleteButton);
+
+ await screen.findByText('일정이 삭제되었습니다.');
+ expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
});
describe('일정 뷰', () => {
- it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {});
+ it('주별 뷰를 선택 후 해당 주에 일정이 없으면, 일정이 표시되지 않는다.', async () => {
+ const { user } = setup();
+
+ await user.click(screen.getByLabelText('뷰 타입 선택'));
+ await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'week-option' }));
- it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {});
+ expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
- it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {});
+ it('주별 뷰 선택 후 해당 일자에 일정이 존재한다면 해당 일정이 정확히 표시된다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '06:30',
+ endTime: '07:00',
+ description: '주간 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
- it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {});
+ const { user } = setup();
+
+ await user.click(screen.getByLabelText('뷰 타입 선택'));
+ await user.click(within(screen.getByLabelText('뷰 타입 선택')).getByRole('combobox'));
+ await user.click(screen.getByRole('option', { name: 'week-option' }));
+
+ const weekView = within(screen.getByTestId('week-view'));
+ expect(await weekView.findByText('팀 회의')).toBeInTheDocument();
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('회의실 A')).toBeInTheDocument();
+ });
+
+ it('월별 뷰에 일정이 없으면, 일정이 표시되지 않아야 한다.', async () => {
+ setupMockHandlerCreation([]);
+
+ setup();
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+
+ it('월별 뷰에 일정이 정확히 표시되는지 확인한다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '이사가는 날',
+ date: '2025-10-15',
+ startTime: '11:30',
+ endTime: '13:00',
+ description: '이사가는 날 날씨 좋다!',
+ location: '봉천동',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ ]);
+ setup();
+
+ const monthView = within(screen.getByTestId('month-view'));
+ expect(await monthView.findByText('이사가는 날')).toBeInTheDocument();
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('봉천동')).toBeInTheDocument();
+ });
- it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {});
+ it('달력에 1월 1일(신정)이 공휴일로 표시되는지 확인한다', async () => {
+ setupMockHandlerCreation([]);
+ const { user } = setup();
+
+ // 왜 26년 1월 1일에는 신정 표시 안해주냐...ㅠ
+ for (let i = 0; i < 9; i++) {
+ await user.click(screen.getByLabelText('Previous'));
+ }
+ const monthView = within(screen.getByTestId('month-view'));
+ expect(await monthView.findByText('신정')).toBeInTheDocument();
+ });
});
describe('검색 기능', () => {
- it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {});
+ it('검색 결과가 없으면, "검색 결과가 없습니다."가 표시되어야 한다.', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '08:30',
+ endTime: '09:00',
+ description: '회의 하기 싫어',
+ location: '회사',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-10-02',
+ startTime: '12:30',
+ endTime: '14:00',
+ description: '쌀국수 먹어야지',
+ location: '쌀국수 맛집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ ]);
+
+ const { user } = setup();
+
+ const searchInput = screen.getByPlaceholderText('검색어를 입력하세요');
+ await user.click(searchInput);
+ await user.type(searchInput, '집에 가고싶다');
+
+ expect(await screen.findByText('검색 결과가 없습니다.')).toBeInTheDocument();
+ });
+
+ it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '08:30',
+ endTime: '09:00',
+ description: '회의 하기 싫어',
+ location: '회사',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ const searchInput = screen.getByPlaceholderText('검색어를 입력하세요');
+ await user.click(searchInput);
+ await user.type(searchInput, '팀 회의');
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('팀 회의')).toBeInTheDocument();
+ });
+
+ it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '08:30',
+ endTime: '09:00',
+ description: '회의 하기 싫어',
+ location: '회사',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ title: '점심 약속',
+ date: '2025-10-02',
+ startTime: '12:30',
+ endTime: '14:00',
+ description: '쌀국수 먹어야지',
+ location: '쌀국수 맛집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ {
+ id: '3',
+ title: '야구 직관',
+ date: '2025-10-10',
+ startTime: '18:30',
+ endTime: '21:00',
+ description: 'KT 응원',
+ location: '수원KT종합경기장',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 30,
+ },
+ ]);
- it("'팀 회의'를 검색하면 해당 제목을 가진 일정이 리스트에 노출된다", async () => {});
+ const { user } = setup();
- it('검색어를 지우면 모든 일정이 다시 표시되어야 한다', async () => {});
+ const searchInput = screen.getByPlaceholderText('검색어를 입력하세요');
+ await user.click(searchInput);
+ await user.clear(searchInput);
+
+ const eventList = within(screen.getByTestId('event-list'));
+ expect(await eventList.findByText('팀 회의')).toBeInTheDocument();
+ expect(await eventList.findByText('점심 약속')).toBeInTheDocument();
+ expect(await eventList.findByText('야구 직관')).toBeInTheDocument();
+ });
});
describe('일정 충돌', () => {
- it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {});
+ it('겹치는 시간에 새 일정을 추가할 때 경고가 표시된다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '팀 회의',
+ date: '2025-10-01',
+ startTime: '08:30',
+ endTime: '09:00',
+ description: '회의 하기 싫어',
+ location: '회사',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ const { user } = setup();
+
+ await saveSchedule(user, {
+ title: '부서 회의',
+ date: '2025-10-01',
+ startTime: '08:00',
+ endTime: '10:00',
+ description: '부서 회의',
+ location: '회사',
+ category: '업무',
+ });
+
+ expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument();
+ });
- it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {});
+ it('기존 일정의 시간을 수정하여 충돌이 발생하면 경고가 노출된다', async () => {
+ setupMockHandlerUpdating();
+
+ const { user } = setup();
+
+ const editButtons = await screen.findAllByRole('button', { name: 'Edit event' });
+ await user.click(editButtons[1]);
+ await user.clear(screen.getByLabelText('시작 시간'));
+ await user.type(screen.getByLabelText('시작 시간'), '08:00');
+ await user.click(screen.getByTestId('event-submit-button'));
+
+ expect(await screen.findByText('일정 겹침 경고')).toBeInTheDocument();
+ });
});
-it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {});
+it('notificationTime을 10으로 하면 지정 시간 10분 전 알람 텍스트가 노출된다', async () => {
+ setupMockHandlerCreation([
+ {
+ id: '1',
+ title: '물 마시기',
+ date: '2025-10-01',
+ startTime: '00:10',
+ endTime: '00:50',
+ description: '물 마시기',
+ location: '집',
+ category: '개인',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ },
+ ]);
+
+ setup();
+ // 일정이 로딩되어야 notification에 Id가 추가되고 setInterval이 실행되기 때문에 추가
+ await screen.findByText('일정 로딩 완료!');
+ expect(await screen.findByText('10분 후 물 마시기 일정이 시작됩니다.')).toBeInTheDocument();
+});
diff --git a/src/__tests__/unit/easy.dateUtils.spec.ts b/src/__tests__/unit/easy.dateUtils.spec.ts
index 967bfacd..28532816 100644
--- a/src/__tests__/unit/easy.dateUtils.spec.ts
+++ b/src/__tests__/unit/easy.dateUtils.spec.ts
@@ -12,105 +12,348 @@ import {
} from '../../utils/dateUtils';
describe('getDaysInMonth', () => {
- it('1월은 31일 수를 반환한다', () => {});
-
- it('4월은 30일 일수를 반환한다', () => {});
-
- it('윤년의 2월에 대해 29일을 반환한다', () => {});
-
- it('평년의 2월에 대해 28일을 반환한다', () => {});
-
- it('유효하지 않은 월에 대해 적절히 처리한다', () => {});
+ it('1월은 31일 수를 반환한다', () => {
+ expect(getDaysInMonth(2025, 1)).toBe(31);
+ });
+
+ it('4월은 30일 일수를 반환한다', () => {
+ expect(getDaysInMonth(2025, 4)).toBe(30);
+ });
+
+ it('윤년의 2월에 대해 29일을 반환한다', () => {
+ expect(getDaysInMonth(2024, 2)).toBe(29);
+ });
+
+ it('평년의 2월에 대해 28일을 반환한다', () => {
+ expect(getDaysInMonth(2025, 2)).toBe(28);
+ });
+
+ // 불필요한 테스트 : 월이 13월인 경우는 없음 (라이브러리에서 13월은 선택할수 없음)
+ it.skip('유효하지 않은 월에 대해 적절히 처리한다', () => {
+ expect(getDaysInMonth(2025, 13)).toBe(31);
+ });
});
describe('getWeekDates', () => {
- it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {});
-
- it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {});
-
- it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {});
-
- it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {});
-
- it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {});
+ it('주중의 날짜(수요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const date = new Date('2025-08-20');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-08-17'),
+ new Date('2025-08-18'),
+ new Date('2025-08-19'),
+ new Date('2025-08-20'),
+ new Date('2025-08-21'),
+ new Date('2025-08-22'),
+ new Date('2025-08-23'),
+ ]);
+ });
+
+ it('주의 시작(월요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const date = new Date('2025-08-18');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-08-17'),
+ new Date('2025-08-18'),
+ new Date('2025-08-19'),
+ new Date('2025-08-20'),
+ new Date('2025-08-21'),
+ new Date('2025-08-22'),
+ new Date('2025-08-23'),
+ ]);
+ });
+
+ it('주의 끝(일요일)에 대해 올바른 주의 날짜들을 반환한다', () => {
+ const date = new Date('2025-08-24');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-08-24'),
+ new Date('2025-08-25'),
+ new Date('2025-08-26'),
+ new Date('2025-08-27'),
+ new Date('2025-08-28'),
+ new Date('2025-08-29'),
+ new Date('2025-08-30'),
+ ]);
+ });
+
+ it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연말)', () => {
+ const date = new Date('2025-12-31');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-12-28'),
+ new Date('2025-12-29'),
+ new Date('2025-12-30'),
+ new Date('2025-12-31'),
+ new Date('2026-01-01'),
+ new Date('2026-01-02'),
+ new Date('2026-01-03'),
+ ]);
+ });
+
+ it('연도를 넘어가는 주의 날짜를 정확히 처리한다 (연초)', () => {
+ const date = new Date('2026-01-01');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-12-28'),
+ new Date('2025-12-29'),
+ new Date('2025-12-30'),
+ new Date('2025-12-31'),
+ new Date('2026-01-01'),
+ new Date('2026-01-02'),
+ new Date('2026-01-03'),
+ ]);
+ });
+
+ it('윤년의 2월 29일을 포함한 주를 올바르게 처리한다', () => {
+ const date = new Date('2024-02-29');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2024-02-25'),
+ new Date('2024-02-26'),
+ new Date('2024-02-27'),
+ new Date('2024-02-28'),
+ new Date('2024-02-29'),
+ new Date('2024-03-01'),
+ new Date('2024-03-02'),
+ ]);
+ });
+
+ it('월의 마지막 날짜를 포함한 주를 올바르게 처리한다', () => {
+ const date = new Date('2025-08-31');
+ const weekDates = getWeekDates(date);
+
+ expect(weekDates).toEqual([
+ new Date('2025-08-31'),
+ new Date('2025-09-01'),
+ new Date('2025-09-02'),
+ new Date('2025-09-03'),
+ new Date('2025-09-04'),
+ new Date('2025-09-05'),
+ new Date('2025-09-06'),
+ ]);
+ });
});
describe('getWeeksAtMonth', () => {
- it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {});
+ it('2025년 7월 1일의 올바른 주 정보를 반환해야 한다', () => {
+ const date = new Date('2025-07-01');
+ const weeks = getWeeksAtMonth(date);
+
+ expect(weeks).toEqual([
+ [null, null, 1, 2, 3, 4, 5],
+ [6, 7, 8, 9, 10, 11, 12],
+ [13, 14, 15, 16, 17, 18, 19],
+ [20, 21, 22, 23, 24, 25, 26],
+ [27, 28, 29, 30, 31, null, null],
+ ]);
+ });
});
describe('getEventsForDay', () => {
- it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {});
-
- it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {});
-
- it('날짜가 0일 경우 빈 배열을 반환한다', () => {});
-
- it('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {});
+ it('특정 날짜(1일)에 해당하는 이벤트만 정확히 반환한다', () => {
+ const events = [
+ {
+ date: '2025-08-01',
+ },
+ {
+ date: '2025-08-02',
+ },
+ ] as Event[];
+ const eventsForDay = getEventsForDay(events, 1);
+ expect(eventsForDay).toEqual([
+ {
+ date: '2025-08-01',
+ },
+ ]);
+ });
+
+ it('해당 날짜에 이벤트가 없을 경우 빈 배열을 반환한다', () => {
+ const events = [
+ {
+ date: '2025-08-01',
+ },
+ {
+ date: '2025-08-02',
+ },
+ ] as Event[];
+ const eventsForDay = getEventsForDay(events, 3);
+ expect(eventsForDay).toEqual([]);
+ });
+
+ // 불필요한 테스트 : 날짜가 0일 경우는 없음 (라이브러리에서 0일은 선택할수 없음)
+ it.skip('날짜가 0일 경우 빈 배열을 반환한다', () => {
+ const events = [{ date: '2025-08-01' }] as Event[];
+ const eventsForDay = getEventsForDay(events, 0);
+ expect(eventsForDay).toEqual([]);
+ });
+
+ // 불필요한 테스트 : 날짜가 32일 이상인 경우는 없음 (라이브러리에서 32일은 선택할수 없음)
+ it.skip('날짜가 32일 이상인 경우 빈 배열을 반환한다', () => {
+ const events = [
+ {
+ date: '2025-08-01',
+ },
+ ] as Event[];
+ const eventsForDay = getEventsForDay(events, 32);
+ expect(eventsForDay).toEqual([]);
+ });
});
describe('formatWeek', () => {
- it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
-
- it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {});
+ it('월의 중간 날짜에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2025-08-15');
+ const week = formatWeek(date);
+ expect(week).toBe('2025년 8월 2주');
+ });
+
+ it('월의 첫 주에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2025-08-01');
+ const week = formatWeek(date);
+ expect(week).toBe('2025년 7월 5주');
+ });
+
+ it('월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2025-08-31');
+ const week = formatWeek(date);
+ expect(week).toBe('2025년 9월 1주');
+ });
+
+ it('연도가 바뀌는 주에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2025-12-31');
+ const week = formatWeek(date);
+ expect(week).toBe('2026년 1월 1주');
+ });
+
+ it('윤년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2024-02-29');
+ const week = formatWeek(date);
+ expect(week).toBe('2024년 2월 5주');
+ });
+
+ it('평년 2월의 마지막 주에 대해 올바른 주 정보를 반환한다', () => {
+ const date = new Date('2025-02-28');
+ const week = formatWeek(date);
+ expect(week).toBe('2025년 2월 4주');
+ });
});
describe('formatMonth', () => {
- it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {});
+ it("2025년 7월 10일을 '2025년 7월'로 반환한다", () => {
+ const date = new Date('2025-07-10');
+ const month = formatMonth(date);
+
+ expect(month).toBe('2025년 7월');
+ });
});
describe('isDateInRange', () => {
- it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {});
-
- it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {});
-
- it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {});
-
- it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {});
-
- it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {});
-
- it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {});
+ it('범위 내의 날짜 2025-07-10에 대해 true를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-07-10'), new Date('2025-07-01'), new Date('2025-07-31'))
+ ).toBe(true);
+ });
+
+ it('범위의 시작일 2025-07-01에 대해 true를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-07-01'), new Date('2025-07-01'), new Date('2025-07-31'))
+ ).toBe(true);
+ });
+
+ it('범위의 종료일 2025-07-31에 대해 true를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-07-31'), new Date('2025-07-01'), new Date('2025-07-31'))
+ ).toBe(true);
+ });
+
+ it('범위 이전의 날짜 2025-06-30에 대해 false를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-06-30'), new Date('2025-07-01'), new Date('2025-07-31'))
+ ).toBe(false);
+ });
+
+ it('범위 이후의 날짜 2025-08-01에 대해 false를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-08-01'), new Date('2025-07-01'), new Date('2025-07-31'))
+ ).toBe(false);
+ });
+
+ it('시작일이 종료일보다 늦은 경우 모든 날짜에 대해 false를 반환한다', () => {
+ expect(
+ isDateInRange(new Date('2025-07-31'), new Date('2025-08-01'), new Date('2025-07-01'))
+ ).toBe(false);
+ });
});
describe('fillZero', () => {
- it("5를 2자리로 변환하면 '05'를 반환한다", () => {});
-
- it("10을 2자리로 변환하면 '10'을 반환한다", () => {});
-
- it("3을 3자리로 변환하면 '003'을 반환한다", () => {});
-
- it("100을 2자리로 변환하면 '100'을 반환한다", () => {});
-
- it("0을 2자리로 변환하면 '00'을 반환한다", () => {});
-
- it("1을 5자리로 변환하면 '00001'을 반환한다", () => {});
-
- it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {});
-
- it('size 파라미터를 생략하면 기본값 2를 사용한다', () => {});
-
- it('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {});
+ it("5를 2자리로 변환하면 '05'를 반환한다", () => {
+ expect(fillZero(5)).toBe('05');
+ });
+
+ it("10을 2자리로 변환하면 '10'을 반환한다", () => {
+ expect(fillZero(10)).toBe('10');
+ });
+
+ it("3을 3자리로 변환하면 '003'을 반환한다", () => {
+ expect(fillZero(3, 3)).toBe('003');
+ });
+
+ it("100을 2자리로 변환하면 '100'을 반환한다", () => {
+ expect(fillZero(100)).toBe('100');
+ });
+
+ it("0을 2자리로 변환하면 '00'을 반환한다", () => {
+ expect(fillZero(0)).toBe('00');
+ });
+
+ it("1을 5자리로 변환하면 '00001'을 반환한다", () => {
+ expect(fillZero(1, 5)).toBe('00001');
+ });
+
+ // 3.14는 자리수가 4자리임 (.) 포함
+ it("소수점이 있는 3.14를 5자리로 변환하면 '03.14'를 반환한다", () => {
+ expect(fillZero(3.14, 5)).toBe('03.14');
+ });
+
+ // 불필요한 테스트 : fillZero 의 기본 동작은 불필요한 테스트
+ it.skip('size 파라미터를 생략하면 기본값 2를 사용한다', () => {
+ expect(fillZero(1)).toBe('01');
+ });
+
+ // 불필요한 테스트 : padStart 의 기본 동작은 불필요한 테스트
+ it.skip('value가 지정된 size보다 큰 자릿수를 가지면 원래 값을 그대로 반환한다', () => {
+ expect(fillZero(100, 2)).toBe('100');
+ });
});
describe('formatDate', () => {
- it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {});
-
- it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {});
-
- it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {});
-
- it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {});
+ it('날짜를 YYYY-MM-DD 형식으로 포맷팅한다', () => {
+ const date = new Date(2025, 7, 1);
+ const formattedDate = formatDate(date);
+ expect(formattedDate).toBe('2025-08-01');
+ });
+
+ it('day 파라미터가 제공되면 해당 일자로 포맷팅한다', () => {
+ const date = new Date(2025, 7, 1);
+ const formattedDate = formatDate(date, 10);
+ expect(formattedDate).toBe('2025-08-10');
+ });
+
+ it('월이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {
+ const date = new Date('2025-8-10');
+ const formattedDate = formatDate(date);
+ expect(formattedDate).toBe('2025-08-10');
+ });
+
+ it('일이 한 자리 수일 때 앞에 0을 붙여 포맷팅한다', () => {
+ const date = new Date('2025-12-1');
+ const formattedDate = formatDate(date);
+ expect(formattedDate).toBe('2025-12-01');
+ });
});
diff --git a/src/__tests__/unit/easy.eventOverlap.spec.ts b/src/__tests__/unit/easy.eventOverlap.spec.ts
index 5e5f6497..984b01a9 100644
--- a/src/__tests__/unit/easy.eventOverlap.spec.ts
+++ b/src/__tests__/unit/easy.eventOverlap.spec.ts
@@ -1,4 +1,4 @@
-import { Event } from '../../types';
+import { Event, EventForm } from '../../types';
import {
convertEventToDateRange,
findOverlappingEvents,
@@ -6,31 +6,156 @@ import {
parseDateTime,
} from '../../utils/eventOverlap';
describe('parseDateTime', () => {
- it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {});
+ it('2025-07-01 14:30을 정확한 Date 객체로 변환한다', () => {
+ const date = parseDateTime('2025-07-01', '14:30');
+ expect(date).toEqual(new Date('2025-07-01T14:30'));
+ });
- it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {});
+ it('잘못된 날짜 형식에 대해 Invalid Date를 반환한다', () => {
+ const date = parseDateTime('011-123-4567', '15:30');
+ expect(date).toEqual(new Date('Invalid Date'));
+ });
- it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {});
+ it('잘못된 시간 형식에 대해 Invalid Date를 반환한다', () => {
+ const date = parseDateTime('2025-07-01', '26:80');
+ expect(date).toEqual(new Date('Invalid Date'));
+ });
- it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {});
+ it('날짜 문자열이 비어있을 때 Invalid Date를 반환한다', () => {
+ const date = parseDateTime('', '14:30');
+ expect(date).toEqual(new Date('Invalid Date'));
+ });
});
describe('convertEventToDateRange', () => {
- it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {});
+ it('일반적인 이벤트를 올바른 시작 및 종료 시간을 가진 객체로 변환한다', () => {
+ const event = {
+ date: '2025-07-01',
+ startTime: '14:30',
+ endTime: '15:30',
+ } as EventForm;
- it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {});
+ expect(convertEventToDateRange(event)).toEqual({
+ start: new Date('2025-07-01T14:30'),
+ end: new Date('2025-07-01T15:30'),
+ });
+ });
- it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {});
+ it('잘못된 날짜 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {
+ const event = {
+ date: '011-123-4567',
+ startTime: '14:30',
+ endTime: '15:30',
+ } as EventForm;
+
+ expect(convertEventToDateRange(event)).toEqual({
+ start: new Date('Invalid Date'),
+ end: new Date('Invalid Date'),
+ });
+ });
+
+ it('잘못된 시간 형식의 이벤트에 대해 Invalid Date를 반환한다', () => {
+ const event = {
+ date: '2025-07-01',
+ startTime: '29:30',
+ endTime: '50:30',
+ } as EventForm;
+
+ expect(convertEventToDateRange(event)).toEqual({
+ start: new Date('Invalid Date'),
+ end: new Date('Invalid Date'),
+ });
+ });
});
describe('isOverlapping', () => {
- it('두 이벤트가 겹치는 경우 true를 반환한다', () => {});
+ it('두 이벤트가 겹치는 경우 true를 반환한다', () => {
+ const event1 = {
+ date: '2025-07-01',
+ startTime: '14:30',
+ endTime: '15:30',
+ } as EventForm;
+
+ const event2 = {
+ date: '2025-07-01',
+ startTime: '15:00',
+ endTime: '16:00',
+ } as EventForm;
+
+ expect(isOverlapping(event1, event2)).toBe(true);
+ });
+
+ it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {
+ const event1 = {
+ date: '2025-07-01',
+ startTime: '14:30',
+ endTime: '15:30',
+ } as EventForm;
- it('두 이벤트가 겹치지 않는 경우 false를 반환한다', () => {});
+ const event2 = {
+ date: '2025-07-02',
+ startTime: '15:00',
+ endTime: '16:00',
+ } as EventForm;
+
+ expect(isOverlapping(event1, event2)).toBe(false);
+ });
});
describe('findOverlappingEvents', () => {
- it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {});
+ it('새 이벤트와 겹치는 모든 이벤트를 반환한다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '14:30',
+ endTime: '15:30',
+ },
+ {
+ id: '2',
+ date: '2025-07-02',
+ startTime: '15:00',
+ endTime: '16:00',
+ },
+ ] as Event[];
+
+ const newEvent = {
+ date: '2025-07-01',
+ startTime: '15:00',
+ endTime: '15:30',
+ } as EventForm;
+
+ expect(findOverlappingEvents(newEvent, events)).toEqual([events[0]]);
+ });
+
+ it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '14:30',
+ endTime: '15:30',
+ },
+ {
+ id: '2',
+ date: '2025-07-02',
+ startTime: '15:00',
+ endTime: '16:00',
+ },
+ {
+ id: '3',
+ date: '2025-07-03',
+ startTime: '15:00',
+ endTime: '16:00',
+ },
+ ] as Event[];
+
+ const newEvent = {
+ date: '2025-08-01',
+ startTime: '15:00',
+ endTime: '16:00',
+ } as EventForm;
- it('겹치는 이벤트가 없으면 빈 배열을 반환한다', () => {});
+ expect(findOverlappingEvents(newEvent, events)).toEqual([]);
+ });
});
diff --git a/src/__tests__/unit/easy.eventUtils.spec.ts b/src/__tests__/unit/easy.eventUtils.spec.ts
index 8eef6371..e4efec7f 100644
--- a/src/__tests__/unit/easy.eventUtils.spec.ts
+++ b/src/__tests__/unit/easy.eventUtils.spec.ts
@@ -2,19 +2,183 @@ import { Event } from '../../types';
import { getFilteredEvents } from '../../utils/eventUtils';
describe('getFilteredEvents', () => {
- it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {});
+ it("검색어 '이벤트 2'에 맞는 이벤트만 반환한다", () => {
+ const events = [
+ {
+ date: '2025-07-01',
+ title: '이벤트 1',
+ description: '이벤트 1 설명',
+ location: '이벤트 1 장소',
+ },
+ {
+ date: '2025-07-01',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ ] as Event[];
- it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {});
+ const filteredEvents = getFilteredEvents(events, '이벤트 2', new Date('2025-07-01'), 'week');
+ expect(filteredEvents).toEqual([events[1]]);
+ });
- it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {});
+ it('주간 뷰에서 2025-07-01 주의 이벤트만 반환한다', () => {
+ const events = [
+ {
+ date: '2025-06-05',
+ title: '이벤트 1',
+ description: '이벤트 1 설명',
+ location: '이벤트 1 장소',
+ },
+ {
+ date: '2025-07-03',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ {
+ date: '2025-08-01',
+ title: '이벤트 3',
+ description: '이벤트 3 설명',
+ location: '이벤트 3 장소',
+ },
+ ] as Event[];
- it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {});
+ const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-01'), 'week');
+ expect(filteredEvents).toEqual([events[1]]);
+ });
- it('검색어가 없을 때 모든 이벤트를 반환한다', () => {});
+ it('월간 뷰에서 2025년 7월의 모든 이벤트를 반환한다', () => {
+ const events = [
+ {
+ date: '2025-06-01',
+ title: '이벤트 1',
+ description: '이벤트 1 설명',
+ location: '이벤트 1 장소',
+ },
+ {
+ date: '2025-07-01',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ {
+ date: '2025-08-01',
+ title: '이벤트 3',
+ description: '이벤트 3 설명',
+ location: '이벤트 3 장소',
+ },
+ ] as Event[];
- it('검색어가 대소문자를 구분하지 않고 작동한다', () => {});
+ const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-01'), 'month');
+ expect(filteredEvents).toEqual([events[1]]);
+ });
- it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {});
+ it("검색어 '이벤트'와 주간 뷰 필터링을 동시에 적용한다", () => {
+ const events = [
+ {
+ date: '2025-06-15',
+ title: '이벤트 1',
+ description: '이벤트 1 설명',
+ location: '이벤트 1 장소',
+ },
+ {
+ date: '2025-06-30',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ {
+ date: '2025-07-01',
+ title: '이벤트 3',
+ description: '이벤트 3 설명',
+ location: '이벤트 3 장소',
+ },
+ {
+ date: '2025-07-15',
+ title: '이벤트 4',
+ description: '이벤트 4 설명',
+ location: '이벤트 4 장소',
+ },
+ {
+ date: '2025-07-30',
+ title: '이벤트 5',
+ description: '이벤트 5 설명',
+ location: '이벤트 5 장소',
+ },
+ ] as Event[];
+ const filteredEvents = getFilteredEvents(events, '이벤트', new Date('2025-07-03'), 'week');
+ expect(filteredEvents).toEqual([events[1], events[2]]);
+ });
- it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {});
+ it('검색어가 없을 때 모든 이벤트를 반환한다', () => {
+ const events = [
+ {
+ date: '2025-07-01',
+ title: '이벤트 1',
+ description: '이벤트 1 설명',
+ location: '이벤트 1 장소',
+ },
+ {
+ date: '2025-07-02',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ {
+ date: '2025-07-03',
+ title: '이벤트 3',
+ description: '이벤트 3 설명',
+ location: '이벤트 3 장소',
+ },
+ ] as Event[];
+
+ const filteredEvents = getFilteredEvents(events, '', new Date('2025-07-01'), 'week');
+ expect(filteredEvents).toEqual(events);
+ });
+
+ it('검색어가 대소문자를 구분하지 않고 작동한다', () => {
+ const events = [
+ {
+ date: '2025-07-01',
+ title: 'EVENT 1',
+ description: 'EVENT 1 설명',
+ location: 'EVENT 1 장소',
+ },
+ {
+ date: '2025-07-02',
+ title: 'EVENT 2',
+ description: 'EVENT 2 설명',
+ location: 'EVENT 2 장소',
+ },
+ {
+ date: '2025-07-03',
+ title: 'EVENT 3',
+ description: 'EVENT 3 설명',
+ location: 'EVENT 3 장소',
+ },
+ ] as Event[];
+ const filteredEvents = getFilteredEvents(events, 'event', new Date('2025-07-01'), 'week');
+ expect(filteredEvents).toEqual([events[0], events[1], events[2]]);
+ });
+
+ it('월의 경계에 있는 이벤트를 올바르게 필터링한다', () => {
+ const events = [
+ {
+ date: '2025-07-31',
+ title: '이벤트 2',
+ description: '이벤트 2 설명',
+ location: '이벤트 2 장소',
+ },
+ ] as Event[];
+
+ const filteredEvents = getFilteredEvents(events, '', new Date('2025-07-01'), 'month');
+ expect(filteredEvents).toEqual([events[0]]);
+ });
+
+ it('빈 이벤트 리스트에 대해 빈 배열을 반환한다', () => {
+ const events = [] as Event[];
+ const filteredEvents = getFilteredEvents(events, '', new Date('2025-08-01'));
+ expect(filteredEvents).toEqual([]);
+ });
});
diff --git a/src/__tests__/unit/easy.fetchHolidays.spec.ts b/src/__tests__/unit/easy.fetchHolidays.spec.ts
index 013e87f0..10db5d17 100644
--- a/src/__tests__/unit/easy.fetchHolidays.spec.ts
+++ b/src/__tests__/unit/easy.fetchHolidays.spec.ts
@@ -1,8 +1,37 @@
import { fetchHolidays } from '../../apis/fetchHolidays';
describe('fetchHolidays', () => {
- it('주어진 월의 공휴일만 반환한다', () => {});
+ it('주어진 월의 공휴일만 반환한다', () => {
+ const holidays = fetchHolidays(new Date('2025-10-01'));
+ expect(holidays).toEqual({
+ '2025-10-03': '개천절',
+ '2025-10-05': '추석',
+ '2025-10-06': '추석',
+ '2025-10-07': '추석',
+ '2025-10-09': '한글날',
+ });
+ });
- it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {});
+ it('공휴일이 없는 월에 대해 빈 객체를 반환한다', () => {
+ const holidays = fetchHolidays(new Date('2025-11-01'));
+ expect(holidays).toEqual({});
+ });
- it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {});
+ it('여러 공휴일이 있는 월에 대해 모든 공휴일을 반환한다', () => {
+ const holidays = fetchHolidays(new Date('2025-01-01'));
+ expect(holidays).toEqual({
+ '2025-01-01': '신정',
+ '2025-01-29': '설날',
+ '2025-01-30': '설날',
+ '2025-01-31': '설날',
+ });
+
+ const holidays2 = fetchHolidays(new Date('2025-10-01'));
+ expect(holidays2).toEqual({
+ '2025-10-03': '개천절',
+ '2025-10-05': '추석',
+ '2025-10-06': '추석',
+ '2025-10-07': '추석',
+ '2025-10-09': '한글날',
+ });
+ });
});
diff --git a/src/__tests__/unit/easy.notificationUtils.spec.ts b/src/__tests__/unit/easy.notificationUtils.spec.ts
index 2fe10360..cfe6a60f 100644
--- a/src/__tests__/unit/easy.notificationUtils.spec.ts
+++ b/src/__tests__/unit/easy.notificationUtils.spec.ts
@@ -2,15 +2,131 @@ import { Event } from '../../types';
import { createNotificationMessage, getUpcomingEvents } from '../../utils/notificationUtils';
describe('getUpcomingEvents', () => {
- it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {});
+ it('알림 시간이 정확히 도래한 이벤트를 반환한다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ date: '2025-07-01',
+ startTime: '11:00',
+ endTime: '12:00',
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ date: '2025-07-01',
+ startTime: '12:00',
+ endTime: '13:00',
+ notificationTime: 10,
+ },
+ ] as Event[];
- it('이미 알림이 간 이벤트는 제외한다', () => {});
+ const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 10:50'), []);
+ expect(upcomingEvents).toEqual([events[1]]);
+ });
- it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {});
+ it('이미 알림이 간 이벤트는 제외한다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ date: '2025-07-01',
+ startTime: '11:00',
+ endTime: '12:00',
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ date: '2025-07-01',
+ startTime: '12:00',
+ endTime: '13:00',
+ notificationTime: 10,
+ },
+ ] as Event[];
- it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {});
+ const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 09:55'), ['1']);
+ expect(upcomingEvents).toEqual([]);
+ });
+
+ it('알림 시간이 아직 도래하지 않은 이벤트는 반환하지 않는다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ date: '2025-07-01',
+ startTime: '11:00',
+ endTime: '12:00',
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ date: '2025-07-01',
+ startTime: '12:00',
+ endTime: '13:00',
+ notificationTime: 10,
+ },
+ ] as Event[];
+
+ const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 09:00'), []);
+ expect(upcomingEvents).toEqual([]);
+ });
+
+ it('알림 시간이 지난 이벤트는 반환하지 않는다', () => {
+ const events = [
+ {
+ id: '1',
+ date: '2025-07-01',
+ startTime: '10:00',
+ endTime: '11:00',
+ notificationTime: 10,
+ },
+ {
+ id: '2',
+ date: '2025-07-01',
+ startTime: '11:00',
+ endTime: '12:00',
+ notificationTime: 10,
+ },
+ {
+ id: '3',
+ date: '2025-07-01',
+ startTime: '12:00',
+ endTime: '13:00',
+ notificationTime: 10,
+ },
+ ] as Event[];
+
+ const upcomingEvents = getUpcomingEvents(events, new Date('2025-07-01 13:30'), []);
+ expect(upcomingEvents).toEqual([]);
+ });
});
describe('createNotificationMessage', () => {
- it('올바른 알림 메시지를 생성해야 한다', () => {});
+ it('올바른 알림 메시지를 생성해야 한다', () => {
+ const event = {
+ title: '이벤트 1',
+ notificationTime: 10,
+ } as Event;
+
+ const message = createNotificationMessage(event);
+ expect(message).toBe('10분 후 이벤트 1 일정이 시작됩니다.');
+ });
});
diff --git a/src/__tests__/unit/easy.timeValidation.spec.ts b/src/__tests__/unit/easy.timeValidation.spec.ts
index 9dda1954..e15c9d58 100644
--- a/src/__tests__/unit/easy.timeValidation.spec.ts
+++ b/src/__tests__/unit/easy.timeValidation.spec.ts
@@ -1,15 +1,52 @@
import { getTimeErrorMessage } from '../../utils/timeValidation';
describe('getTimeErrorMessage >', () => {
- it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {});
+ it('시작 시간이 종료 시간보다 늦을 때 에러 메시지를 반환한다', () => {
+ const result = getTimeErrorMessage('11:00', '09:00');
+ expect(result).toEqual({
+ startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.',
+ endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.',
+ });
+ });
- it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {});
+ it('시작 시간과 종료 시간이 같을 때 에러 메시지를 반환한다', () => {
+ const result = getTimeErrorMessage('10:00', '10:00');
+ expect(result).toEqual({
+ startTimeError: '시작 시간은 종료 시간보다 빨라야 합니다.',
+ endTimeError: '종료 시간은 시작 시간보다 늦어야 합니다.',
+ });
+ });
- it('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {});
+ // 불필요한 테스트: 시작 시간이 종료 시간보다 빠른 것은 당연하다. 에러를 반환할 필요가 없다.
+ it.skip('시작 시간이 종료 시간보다 빠를 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('09:00', '10:00');
+ expect(result).toEqual({
+ startTimeError: null,
+ endTimeError: null,
+ });
+ });
- it('시작 시간이 비어있을 때 null을 반환한다', () => {});
+ it('시작 시간이 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('', '10:00');
+ expect(result).toEqual({
+ startTimeError: null,
+ endTimeError: null,
+ });
+ });
- it('종료 시간이 비어있을 때 null을 반환한다', () => {});
+ it('종료 시간이 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('10:00', '');
+ expect(result).toEqual({
+ startTimeError: null,
+ endTimeError: null,
+ });
+ });
- it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {});
+ it('시작 시간과 종료 시간이 모두 비어있을 때 null을 반환한다', () => {
+ const result = getTimeErrorMessage('', '');
+ expect(result).toEqual({
+ startTimeError: null,
+ endTimeError: null,
+ });
+ });
});
diff --git a/src/__tests__/utils.ts b/src/__tests__/utils.ts
index 8e419c87..f2c8b22f 100644
--- a/src/__tests__/utils.ts
+++ b/src/__tests__/utils.ts
@@ -1,3 +1,4 @@
+import { Event } from '../types';
import { fillZero } from '../utils/dateUtils';
export const assertDate = (date1: Date, date2: Date) => {
@@ -10,3 +11,33 @@ export const parseHM = (timestamp: number) => {
const m = fillZero(date.getMinutes());
return `${h}:${m}`;
};
+
+// 공통 테스트 데이터
+export const createMockEvent = (overrides: Partial = {}): Event => ({
+ id: '1',
+ title: '팀 회의',
+ date: '2025-01-15',
+ startTime: '09:00',
+ endTime: '10:00',
+ description: '주간 팀 미팅',
+ location: '회의실 A',
+ category: '업무',
+ repeat: { type: 'none', interval: 0 },
+ notificationTime: 10,
+ ...overrides,
+});
+
+export const createMockEvents = (): Event[] => [
+ createMockEvent(),
+ createMockEvent({
+ id: '2',
+ title: '점심 약속',
+ startTime: '12:00',
+ endTime: '13:00',
+ description: '팀원들과 점심',
+ location: '회사 근처 식당',
+ category: '개인',
+ repeat: { type: 'weekly', interval: 1, endDate: '2025-12-31' },
+ notificationTime: 30,
+ }),
+];
diff --git a/src/components/calendar/CalendarView.tsx b/src/components/calendar/CalendarView.tsx
new file mode 100644
index 00000000..aba7d9d6
--- /dev/null
+++ b/src/components/calendar/CalendarView.tsx
@@ -0,0 +1,71 @@
+import ChevronLeft from '@mui/icons-material/ChevronLeft';
+import ChevronRight from '@mui/icons-material/ChevronRight';
+import { IconButton, MenuItem, Select, Stack, Typography } from '@mui/material';
+
+import { MonthView } from './MonthView';
+import { WeekView } from './WeekView';
+import { Event } from '../../types';
+
+interface CalendarViewProps {
+ view: 'week' | 'month';
+ setView: (view: 'week' | 'month') => void;
+ currentDate: Date;
+ navigate: (direction: 'prev' | 'next') => void;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ holidays: Record;
+}
+
+export function CalendarView({
+ view,
+ setView,
+ currentDate,
+ navigate,
+ filteredEvents,
+ notifiedEvents,
+ holidays,
+}: CalendarViewProps) {
+ return (
+
+ 일정 보기
+
+
+ navigate('prev')}>
+
+
+
+ navigate('next')}>
+
+
+
+
+ {view === 'week' && (
+
+ )}
+ {view === 'month' && (
+
+ )}
+
+ );
+}
diff --git a/src/components/calendar/MonthView.tsx b/src/components/calendar/MonthView.tsx
new file mode 100644
index 00000000..e18c6c8a
--- /dev/null
+++ b/src/components/calendar/MonthView.tsx
@@ -0,0 +1,118 @@
+import Notifications from '@mui/icons-material/Notifications';
+import {
+ Box,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+import { formatDate, formatMonth, getEventsForDay, getWeeksAtMonth } from '../../utils/dateUtils';
+
+interface MonthViewProps {
+ currentDate: Date;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ holidays: Record;
+}
+
+const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
+
+export function MonthView({
+ currentDate,
+ filteredEvents,
+ notifiedEvents,
+ holidays,
+}: MonthViewProps) {
+ const weeks = getWeeksAtMonth(currentDate);
+
+ const renderEventItem = (event: Event) => {
+ const isNotified = notifiedEvents.includes(event.id);
+ return (
+
+
+ {isNotified && }
+
+ {event.title}
+
+
+
+ );
+ };
+
+ return (
+
+ {formatMonth(currentDate)}
+
+
+
+
+ {weekDays.map((day) => (
+
+ {day}
+
+ ))}
+
+
+
+ {weeks.map((week, weekIndex) => (
+
+ {week.map((day, dayIndex) => {
+ const dateString = day ? formatDate(currentDate, day) : '';
+ const holiday = holidays[dateString];
+
+ return (
+
+ {day && (
+ <>
+
+ {day}
+
+ {holiday && (
+
+ {holiday}
+
+ )}
+ {getEventsForDay(filteredEvents, day).map(renderEventItem)}
+ >
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/calendar/WeekView.tsx b/src/components/calendar/WeekView.tsx
new file mode 100644
index 00000000..02c14452
--- /dev/null
+++ b/src/components/calendar/WeekView.tsx
@@ -0,0 +1,97 @@
+import Notifications from '@mui/icons-material/Notifications';
+import {
+ Box,
+ Stack,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+import { formatWeek, getWeekDates } from '../../utils/dateUtils';
+
+interface WeekViewProps {
+ currentDate: Date;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+}
+
+const weekDays = ['일', '월', '화', '수', '목', '금', '토'];
+
+export function WeekView({ currentDate, filteredEvents, notifiedEvents }: WeekViewProps) {
+ const weekDates = getWeekDates(currentDate);
+
+ const renderEventItem = (event: Event) => {
+ const isNotified = notifiedEvents.includes(event.id);
+ return (
+
+
+ {isNotified && }
+
+ {event.title}
+
+
+
+ );
+ };
+
+ return (
+
+ {formatWeek(currentDate)}
+
+
+
+
+ {weekDays.map((day) => (
+
+ {day}
+
+ ))}
+
+
+
+
+ {weekDates.map((date) => (
+
+
+ {date.getDate()}
+
+ {filteredEvents
+ .filter((event) => new Date(event.date).toDateString() === date.toDateString())
+ .map(renderEventItem)}
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/src/components/calendar/index.ts b/src/components/calendar/index.ts
new file mode 100644
index 00000000..da421a27
--- /dev/null
+++ b/src/components/calendar/index.ts
@@ -0,0 +1,3 @@
+export { CalendarView } from './CalendarView';
+export { MonthView } from './MonthView';
+export { WeekView } from './WeekView';
diff --git a/src/components/dialog/OverlapDialog.tsx b/src/components/dialog/OverlapDialog.tsx
new file mode 100644
index 00000000..80485c8a
--- /dev/null
+++ b/src/components/dialog/OverlapDialog.tsx
@@ -0,0 +1,43 @@
+import {
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogContentText,
+ DialogTitle,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+
+interface OverlapDialogProps {
+ open: boolean;
+ onClose: () => void;
+ overlappingEvents: Event[];
+ onConfirm: () => void;
+}
+
+export function OverlapDialog({ open, onClose, overlappingEvents, onConfirm }: OverlapDialogProps) {
+ return (
+
+ );
+}
diff --git a/src/components/dialog/index.ts b/src/components/dialog/index.ts
new file mode 100644
index 00000000..09bed373
--- /dev/null
+++ b/src/components/dialog/index.ts
@@ -0,0 +1 @@
+export { OverlapDialog } from './OverlapDialog';
diff --git a/src/components/event/EventForm.tsx b/src/components/event/EventForm.tsx
new file mode 100644
index 00000000..59635d16
--- /dev/null
+++ b/src/components/event/EventForm.tsx
@@ -0,0 +1,229 @@
+import {
+ Button,
+ Checkbox,
+ FormControl,
+ FormControlLabel,
+ FormLabel,
+ MenuItem,
+ Select,
+ Stack,
+ TextField,
+ Tooltip,
+ Typography,
+} from '@mui/material';
+import React from 'react';
+
+import { Event, EventForm } from '../../types';
+import { getTimeErrorMessage } from '../../utils/timeValidation';
+
+interface FormState {
+ title: string;
+ date: string;
+ startTime: string;
+ endTime: string;
+ description: string;
+ location: string;
+ category: string;
+ isRepeating: boolean;
+ repeatType: string;
+ repeatInterval: number;
+ repeatEndDate: string;
+ notificationTime: number;
+ editingEvent: EventForm | null;
+}
+
+interface FormActions {
+ setTitle: (title: string) => void;
+ setDate: (date: string) => void;
+ setDescription: (description: string) => void;
+ setLocation: (location: string) => void;
+ setCategory: (category: string) => void;
+ setIsRepeating: (isRepeating: boolean) => void;
+ setNotificationTime: (time: number) => void;
+ handleStartTimeChange: (e: React.ChangeEvent) => void;
+ handleEndTimeChange: (e: React.ChangeEvent) => void;
+ resetForm: () => void;
+ editEvent: (event: Event) => void;
+ onSubmit: () => void;
+}
+
+interface FormValidation {
+ startTimeError: string | null;
+ endTimeError: string | null;
+}
+
+interface EventFormProps {
+ formState: FormState;
+ formActions: FormActions;
+ formValidation: FormValidation;
+}
+
+const categories = ['업무', '개인', '가족', '기타'];
+
+const notificationOptions = [
+ { value: 1, label: '1분 전' },
+ { value: 10, label: '10분 전' },
+ { value: 60, label: '1시간 전' },
+ { value: 120, label: '2시간 전' },
+ { value: 1440, label: '1일 전' },
+];
+
+export function EventFormComponent({ formState, formActions, formValidation }: EventFormProps) {
+ const {
+ title,
+ date,
+ startTime,
+ endTime,
+ description,
+ location,
+ category,
+ isRepeating,
+ notificationTime,
+ editingEvent,
+ } = formState;
+
+ const {
+ setTitle,
+ setDate,
+ setDescription,
+ setLocation,
+ setCategory,
+ setIsRepeating,
+ setNotificationTime,
+ handleStartTimeChange,
+ handleEndTimeChange,
+ onSubmit,
+ } = formActions;
+
+ const { startTimeError, endTimeError } = formValidation;
+
+ return (
+
+ {editingEvent ? '일정 수정' : '일정 추가'}
+
+
+ 제목
+ setTitle(e.target.value)}
+ />
+
+
+
+ 날짜
+ setDate(e.target.value)}
+ />
+
+
+
+
+ 시작 시간
+
+ getTimeErrorMessage(startTime, endTime)}
+ error={!!startTimeError}
+ />
+
+
+
+ 종료 시간
+
+ getTimeErrorMessage(startTime, endTime)}
+ error={!!endTimeError}
+ />
+
+
+
+
+
+ 설명
+ setDescription(e.target.value)}
+ />
+
+
+
+ 위치
+ setLocation(e.target.value)}
+ />
+
+
+
+ 카테고리
+
+
+
+
+ setIsRepeating(e.target.checked)} />
+ }
+ label="반복 일정"
+ />
+
+
+
+ 알림 설정
+
+
+
+
+
+ );
+}
diff --git a/src/components/event/EventList.tsx b/src/components/event/EventList.tsx
new file mode 100644
index 00000000..6d5149f3
--- /dev/null
+++ b/src/components/event/EventList.tsx
@@ -0,0 +1,117 @@
+import Delete from '@mui/icons-material/Delete';
+import Edit from '@mui/icons-material/Edit';
+import Notifications from '@mui/icons-material/Notifications';
+import {
+ Box,
+ FormControl,
+ FormLabel,
+ IconButton,
+ Stack,
+ TextField,
+ Typography,
+} from '@mui/material';
+
+import { Event } from '../../types';
+
+interface EventListProps {
+ searchTerm: string;
+ setSearchTerm: (term: string) => void;
+ filteredEvents: Event[];
+ notifiedEvents: string[];
+ editEvent: (event: Event) => void;
+ deleteEvent: (id: string) => void;
+}
+
+const notificationOptions = [
+ { value: 1, label: '1분 전' },
+ { value: 10, label: '10분 전' },
+ { value: 60, label: '1시간 전' },
+ { value: 120, label: '2시간 전' },
+ { value: 1440, label: '1일 전' },
+];
+
+export function EventList({
+ searchTerm,
+ setSearchTerm,
+ filteredEvents,
+ notifiedEvents,
+ editEvent,
+ deleteEvent,
+}: EventListProps) {
+ const renderEventItem = (event: Event) => {
+ const isNotified = notifiedEvents.includes(event.id);
+
+ return (
+
+
+
+
+ {isNotified && }
+
+ {event.title}
+
+
+ {event.date}
+
+ {event.startTime} - {event.endTime}
+
+ {event.description}
+ {event.location}
+ 카테고리: {event.category}
+ {event.repeat.type !== 'none' && (
+
+ 반복: {event.repeat.interval}
+ {event.repeat.type === 'daily' && '일'}
+ {event.repeat.type === 'weekly' && '주'}
+ {event.repeat.type === 'monthly' && '월'}
+ {event.repeat.type === 'yearly' && '년'}
+ 마다
+ {event.repeat.endDate && ` (종료: ${event.repeat.endDate})`}
+
+ )}
+
+ 알림:{' '}
+ {notificationOptions.find((option) => option.value === event.notificationTime)?.label}
+
+
+
+ editEvent(event)}>
+
+
+ deleteEvent(event.id)}>
+
+
+
+
+
+ );
+ };
+
+ return (
+
+
+ 일정 검색
+ setSearchTerm(e.target.value)}
+ />
+
+
+ {filteredEvents.length === 0 ? (
+ 검색 결과가 없습니다.
+ ) : (
+ filteredEvents.map(renderEventItem)
+ )}
+
+ );
+}
diff --git a/src/components/event/index.ts b/src/components/event/index.ts
new file mode 100644
index 00000000..e8c64a75
--- /dev/null
+++ b/src/components/event/index.ts
@@ -0,0 +1,2 @@
+export { EventFormComponent } from './EventForm';
+export { EventList } from './EventList';
diff --git a/src/components/index.ts b/src/components/index.ts
new file mode 100644
index 00000000..06a17793
--- /dev/null
+++ b/src/components/index.ts
@@ -0,0 +1,11 @@
+// Event related components
+export * from './event';
+
+// Calendar related components
+export * from './calendar';
+
+// Notification related components
+export * from './notification';
+
+// Dialog related components
+export * from './dialog';
diff --git a/src/components/notification/NotificationToast.tsx b/src/components/notification/NotificationToast.tsx
new file mode 100644
index 00000000..db8682ef
--- /dev/null
+++ b/src/components/notification/NotificationToast.tsx
@@ -0,0 +1,41 @@
+import Close from '@mui/icons-material/Close';
+import { Alert, AlertTitle, IconButton, Stack } from '@mui/material';
+
+interface Notification {
+ id: string;
+ message: string;
+}
+
+interface NotificationToastProps {
+ notifications: Notification[];
+ setNotifications: (updater: (prev: Notification[]) => Notification[]) => void;
+}
+
+export function NotificationToast({ notifications, setNotifications }: NotificationToastProps) {
+ if (notifications.length === 0) {
+ return null;
+ }
+
+ const handleClose = (index: number) => {
+ setNotifications((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ return (
+
+ {notifications.map((notification, index) => (
+ handleClose(index)}>
+
+
+ }
+ >
+ {notification.message}
+
+ ))}
+
+ );
+}
diff --git a/src/components/notification/index.ts b/src/components/notification/index.ts
new file mode 100644
index 00000000..04ee234e
--- /dev/null
+++ b/src/components/notification/index.ts
@@ -0,0 +1 @@
+export { NotificationToast } from './NotificationToast';
diff --git a/src/hooks/useEventOperations.ts b/src/hooks/useEventOperations.ts
index 3216cc05..93812e8a 100644
--- a/src/hooks/useEventOperations.ts
+++ b/src/hooks/useEventOperations.ts
@@ -3,10 +3,14 @@ import { useEffect, useState } from 'react';
import { Event, EventForm } from '../types';
+// 이벤트 관련 CRUD 작업을 제공하는 커스텀 훅
export const useEventOperations = (editing: boolean, onSave?: () => void) => {
+ // 이벤트 목록 상태 관리
const [events, setEvents] = useState([]);
+ // 스낵바 알림 함수
const { enqueueSnackbar } = useSnackbar();
+ // 이벤트 목록을 서버에서 불러오는 함수
const fetchEvents = async () => {
try {
const response = await fetch('/api/events');
@@ -14,23 +18,26 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => {
throw new Error('Failed to fetch events');
}
const { events } = await response.json();
- setEvents(events);
+ setEvents(events); // 상태에 이벤트 목록 저장
} catch (error) {
console.error('Error fetching events:', error);
- enqueueSnackbar('이벤트 로딩 실패', { variant: 'error' });
+ enqueueSnackbar('이벤트 로딩 실패', { variant: 'error' }); // 에러 알림
}
};
+ // 이벤트를 저장(추가/수정)하는 함수
const saveEvent = async (eventData: Event | EventForm) => {
try {
let response;
if (editing) {
+ // 수정 모드: PUT 요청
response = await fetch(`/api/events/${(eventData as Event).id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(eventData),
});
} else {
+ // 추가 모드: POST 요청
response = await fetch('/api/events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -42,17 +49,18 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => {
throw new Error('Failed to save event');
}
- await fetchEvents();
- onSave?.();
+ await fetchEvents(); // 저장 후 이벤트 목록 갱신
+ onSave?.(); // 저장 후 콜백 실행(옵션)
enqueueSnackbar(editing ? '일정이 수정되었습니다.' : '일정이 추가되었습니다.', {
variant: 'success',
- });
+ }); // 성공 알림
} catch (error) {
console.error('Error saving event:', error);
- enqueueSnackbar('일정 저장 실패', { variant: 'error' });
+ enqueueSnackbar('일정 저장 실패', { variant: 'error' }); // 에러 알림
}
};
+ // 이벤트를 삭제하는 함수
const deleteEvent = async (id: string) => {
try {
const response = await fetch(`/api/events/${id}`, { method: 'DELETE' });
@@ -61,23 +69,25 @@ export const useEventOperations = (editing: boolean, onSave?: () => void) => {
throw new Error('Failed to delete event');
}
- await fetchEvents();
- enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' });
+ await fetchEvents(); // 삭제 후 이벤트 목록 갱신
+ enqueueSnackbar('일정이 삭제되었습니다.', { variant: 'info' }); // 삭제 성공 알림
} catch (error) {
console.error('Error deleting event:', error);
- enqueueSnackbar('일정 삭제 실패', { variant: 'error' });
+ enqueueSnackbar('일정 삭제 실패', { variant: 'error' }); // 에러 알림
}
};
+ // 컴포넌트 마운트 시 초기화 함수
async function init() {
- await fetchEvents();
- enqueueSnackbar('일정 로딩 완료!', { variant: 'info' });
+ await fetchEvents(); // 이벤트 목록 불러오기
+ enqueueSnackbar('일정 로딩 완료!', { variant: 'info' }); // 로딩 완료 알림
}
+ // 컴포넌트가 처음 렌더링될 때 init 실행
useEffect(() => {
init();
- // eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
+ // 훅에서 제공하는 값 및 함수들 반환
return { events, fetchEvents, saveEvent, deleteEvent };
};
diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts
index be78512c..a4411e13 100644
--- a/src/utils/dateUtils.ts
+++ b/src/utils/dateUtils.ts
@@ -93,7 +93,7 @@ export function isDateInRange(date: Date, rangeStart: Date, rangeEnd: Date): boo
const normalizedDate = stripTime(date);
const normalizedStart = stripTime(rangeStart);
const normalizedEnd = stripTime(rangeEnd);
-
+ console.log(normalizedDate, normalizedStart, normalizedEnd);
return normalizedDate >= normalizedStart && normalizedDate <= normalizedEnd;
}