diff --git a/package.json b/package.json index 9f30638..6c3b7d8 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "type": "module", "scripts": { "dev": "vite", + "predeploy": "npm run build", + "deploy": "gh-pages -d dist", "test": "vitest", "test:ui": "vitest --ui", "test:coverage": "vitest run --coverage", @@ -39,6 +41,7 @@ "eslint": "^8.57.0", "eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-refresh": "^0.4.7", + "gh-pages": "^6.3.0", "tsx": "^4.17.0", "typescript": "^5.4.5", "vite": "npm:rolldown-vite@latest", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 704a7bc..7839582 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: eslint-plugin-react-refresh: specifier: ^0.4.7 version: 0.4.20(eslint@8.57.1) + gh-pages: + specifier: ^6.3.0 + version: 6.3.0 tsx: specifier: ^4.17.0 version: 4.19.4 @@ -1243,6 +1246,9 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1316,6 +1322,13 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + commander@13.1.0: + resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} + engines: {node: '>=18'} + + commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1399,6 +1412,9 @@ packages: eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + email-addresses@5.0.0: + resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -1441,6 +1457,10 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1529,13 +1549,29 @@ packages: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + find-root@1.1.0: resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1585,6 +1621,10 @@ packages: framesync@6.1.2: resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + fs-extra@11.3.3: + resolution: {integrity: sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg==} + engines: {node: '>=14.14'} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1618,6 +1658,11 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} + gh-pages@6.3.0: + resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==} + engines: {node: '>=10'} + hasBin: true + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -1650,6 +1695,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -1784,6 +1832,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsonfile@6.2.0: + resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -1858,6 +1909,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -1897,6 +1952,10 @@ packages: magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} @@ -1985,14 +2044,26 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + p-locate@5.0.0: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} @@ -2051,6 +2122,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + postcss@8.5.4: resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} @@ -2233,6 +2308,10 @@ packages: scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + semver@7.7.2: resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} engines: {node: '>=10'} @@ -2309,6 +2388,10 @@ packages: strip-literal@3.0.0: resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==} + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -2375,6 +2458,10 @@ packages: resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} engines: {node: '>=16'} + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-api-utils@1.4.3: resolution: {integrity: sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==} engines: {node: '>=16'} @@ -2416,6 +2503,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3609,6 +3700,8 @@ snapshots: assertion-error@2.0.1: {} + async@3.2.6: {} + asynckit@0.4.0: {} axios@1.11.0: @@ -3689,6 +3782,10 @@ snapshots: dependencies: delayed-stream: 1.0.0 + commander@13.1.0: {} + + commondir@1.0.1: {} + concat-map@0.0.1: {} convert-source-map@1.9.0: {} @@ -3755,6 +3852,8 @@ snapshots: eastasianwidth@0.2.0: {} + email-addresses@5.0.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -3836,6 +3935,8 @@ snapshots: escalade@3.2.0: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): @@ -3948,12 +4049,31 @@ snapshots: dependencies: flat-cache: 3.2.0 + filename-reserved-regex@2.0.0: {} + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 + find-cache-dir@3.3.2: + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + find-root@1.1.0: {} + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4000,6 +4120,12 @@ snapshots: dependencies: tslib: 2.4.0 + fs-extra@11.3.3: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.0 + universalify: 2.0.1 + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4035,6 +4161,16 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + gh-pages@6.3.0: + dependencies: + async: 3.2.6 + commander: 13.1.0 + email-addresses: 5.0.0 + filenamify: 4.3.0 + find-cache-dir: 3.3.2 + fs-extra: 11.3.3 + globby: 11.1.0 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -4078,6 +4214,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + graphemer@1.4.0: {} graphql@16.11.0: {} @@ -4187,6 +4325,12 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsonfile@6.2.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -4243,6 +4387,10 @@ snapshots: lines-and-columns@1.2.4: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -4279,6 +4427,10 @@ snapshots: '@babel/types': 7.27.6 source-map-js: 1.2.1 + make-dir@3.1.0: + dependencies: + semver: 6.3.1 + make-dir@4.0.0: dependencies: semver: 7.7.2 @@ -4368,14 +4520,24 @@ snapshots: outvariant@1.4.3: {} + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + p-limit@3.1.0: dependencies: yocto-queue: 0.1.0 + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + p-locate@5.0.0: dependencies: p-limit: 3.1.0 + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} parent-module@1.0.1: @@ -4418,6 +4580,10 @@ snapshots: picomatch@4.0.3: {} + pkg-dir@4.2.0: + dependencies: + find-up: 4.1.0 + postcss@8.5.4: dependencies: nanoid: 3.3.11 @@ -4606,6 +4772,8 @@ snapshots: scheduler@0.26.0: {} + semver@6.3.1: {} + semver@7.7.2: {} shebang-command@2.0.0: @@ -4668,6 +4836,10 @@ snapshots: dependencies: js-tokens: 9.0.1 + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + stylis@4.2.0: {} supports-color@7.2.0: @@ -4719,6 +4891,10 @@ snapshots: dependencies: tldts: 7.0.12 + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-api-utils@1.4.3(typescript@5.8.3): dependencies: typescript: 5.8.3 @@ -4748,6 +4924,8 @@ snapshots: undici-types@6.21.0: {} + universalify@2.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..5eb4495 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,28 +1,63 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; +import React, { + createContext, + PropsWithChildren, + useContext, + useMemo, + useState, +} from "react"; import { Schedule } from "./types.ts"; import dummyScheduleMap from "./dummyScheduleMap.ts"; -interface ScheduleContextType { - schedulesMap: Record; - setSchedulesMap: React.Dispatch>>; -} +// State와 Dispatch를 분리하여 불필요한 리렌더링 방지 +const ScheduleStateContext = createContext< + Record | undefined +>(undefined); +const ScheduleDispatchContext = createContext< + React.Dispatch>> | undefined +>(undefined); -const ScheduleContext = createContext(undefined); +// 전체 상태를 구독 (schedulesMap 변경 시 리렌더링됨) +export const useScheduleState = () => { + const context = useContext(ScheduleStateContext); + if (context === undefined) { + throw new Error("useScheduleState must be used within a ScheduleProvider"); + } + return context; +}; -export const useScheduleContext = () => { - const context = useContext(ScheduleContext); +// dispatch만 구독 (상태 변경 시 리렌더링 안 됨) +export const useScheduleDispatch = () => { + const context = useContext(ScheduleDispatchContext); if (context === undefined) { - throw new Error('useSchedule must be used within a ScheduleProvider'); + throw new Error( + "useScheduleDispatch must be used within a ScheduleProvider" + ); } return context; }; +// 특정 테이블의 스케줄만 구독 (해당 테이블 변경 시에만 리렌더링) +export const useTableSchedules = (tableId: string) => { + const schedulesMap = useScheduleState(); + return useMemo(() => schedulesMap[tableId] || [], [schedulesMap, tableId]); +}; + +// 하위 호환성을 위한 기존 훅 (deprecated) +export const useScheduleContext = () => { + const schedulesMap = useScheduleState(); + const setSchedulesMap = useScheduleDispatch(); + return { schedulesMap, setSchedulesMap }; +}; + export const ScheduleProvider = ({ children }: PropsWithChildren) => { - const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + const [schedulesMap, setSchedulesMap] = + useState>(dummyScheduleMap); return ( - - {children} - + + + {children} + + ); }; diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx index ca15f52..261fe2e 100644 --- a/src/ScheduleDndProvider.tsx +++ b/src/ScheduleDndProvider.tsx @@ -1,7 +1,13 @@ -import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { PropsWithChildren } from "react"; +import { + DndContext, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { PropsWithChildren, useCallback } from "react"; import { CellSize, DAY_LABELS } from "./constants.ts"; -import { useScheduleContext } from "./ScheduleContext.tsx"; +import { useScheduleDispatch } from "./ScheduleContext.tsx"; function createSnapModifier(): Modifier { return ({ transform, containerNodeRect, draggingNodeRect }) => { @@ -17,19 +23,30 @@ function createSnapModifier(): Modifier { const maxX = containerRight - right; const maxY = containerBottom - bottom; - - return ({ + return { ...transform, - x: Math.min(Math.max(Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, minX), maxX), - y: Math.min(Math.max(Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, minY), maxY), - }) + x: Math.min( + Math.max( + Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + minX + ), + maxX + ), + y: Math.min( + Math.max( + Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + minY + ), + maxY + ), + }; }; } -const modifiers = [createSnapModifier()] +const modifiers = [createSnapModifier()]; export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const setSchedulesMap = useScheduleDispatch(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -38,33 +55,47 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) { }) ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const handleDragEnd = (event: any) => { - const { active, delta } = event; - const { x, y } = delta; - const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; - const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) - const moveDayIndex = Math.floor(x / 80); - const moveTimeIndex = Math.floor(y / 30); + const handleDragEnd = useCallback( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (event: any) => { + const { active, delta } = event; + const { x, y } = delta; + const [tableId, index] = active.id.split(":"); + + setSchedulesMap((prevSchedulesMap) => { + const schedule = prevSchedulesMap[tableId][index]; + const nowDayIndex = DAY_LABELS.indexOf( + schedule.day as (typeof DAY_LABELS)[number] + ); + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { - if (targetIndex !== Number(index)) { - return { ...targetSchedule } - } return { - ...targetSchedule, - day: DAY_LABELS[nowDayIndex + moveDayIndex], - range: targetSchedule.range.map(time => time + moveTimeIndex), - } - }) - }) - }; + ...prevSchedulesMap, + [tableId]: prevSchedulesMap[tableId].map( + (targetSchedule, targetIndex) => { + if (targetIndex !== Number(index)) { + return { ...targetSchedule }; + } + return { + ...targetSchedule, + day: DAY_LABELS[nowDayIndex + moveDayIndex], + range: targetSchedule.range.map((time) => time + moveTimeIndex), + }; + } + ), + }; + }); + }, + [setSchedulesMap] + ); return ( - + {children} ); diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx index ea17b6a..2a5cb10 100644 --- a/src/ScheduleTable.tsx +++ b/src/ScheduleTable.tsx @@ -17,7 +17,7 @@ import { Schedule } from "./types.ts"; import { fill2, parseHnM } from "./utils.ts"; import { useDndContext, useDraggable } from "@dnd-kit/core"; import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; +import { ComponentProps, Fragment, memo, useCallback, useMemo } from "react"; interface Props { tableId: string; @@ -38,31 +38,52 @@ const TIMES = [ .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), ] as const; -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { - - const getColor = (lectureId: string): string => { - const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; - const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; +// DnD Context를 구독하는 래퍼 컴포넌트 (드래그 시 이 컴포넌트만 리렌더링) +const ScheduleTableWrapper = memo(({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + console.log(`[ScheduleTableWrapper ${tableId}] 렌더링`); const dndContext = useDndContext(); - const getActiveTableId = () => { + // 활성 테이블 ID 계산 + const activeTableId = useMemo(() => { const activeId = dndContext.active?.id; if (activeId) { return String(activeId).split(":")[0]; } return null; - } + }, [dndContext.active?.id]); - const activeTableId = getActiveTableId(); + const isActive = activeTableId === tableId; return ( + + + ); +}); + +const ScheduleTable = memo(({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + console.log(`[ScheduleTable ${tableId}] 렌더링`); + + // 강의 색상 반환 함수 (메모이제이션) + const getColor = useCallback((lectureId: string): string => { + const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; + const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; + return colors[lectures.indexOf(lectureId) % colors.length]; + }, [schedules]); + + return ( + ); -}; +}); -const DraggableSchedule = ({ +const DraggableSchedule = memo(({ id, data, bg, @@ -135,6 +156,8 @@ const DraggableSchedule = ({ }: { id: string; data: Schedule } & ComponentProps & { onDeleteButtonClick: () => void }) => { + console.log(`[DraggableSchedule ${id}] 렌더링`); + const { day, range, room, lecture } = data; const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); @@ -175,6 +198,6 @@ const DraggableSchedule = ({ ); -} +}); -export default ScheduleTable; +export default ScheduleTableWrapper; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx index 44dbd7a..92d410d 100644 --- a/src/ScheduleTables.tsx +++ b/src/ScheduleTables.tsx @@ -1,10 +1,90 @@ import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; import ScheduleTable from "./ScheduleTable.tsx"; -import { useScheduleContext } from "./ScheduleContext.tsx"; +import { useScheduleContext, useScheduleDispatch, useTableSchedules } from "./ScheduleContext.tsx"; import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; +import { memo, useCallback, useState } from "react"; + +// 개별 시간표 아이템 컴포넌트 (메모이제이션으로 불필요한 리렌더링 방지) +const ScheduleTableItem = memo(({ + tableId, + index, + disabledRemoveButton, + onSearchInfoChange, + onDuplicate, + onRemove +}: { + tableId: string; + index: number; + disabledRemoveButton: boolean; + onSearchInfoChange: (info: { tableId: string; day?: string; time?: number } | null) => void; + onDuplicate: (targetId: string) => void; + onRemove: (targetId: string) => void; +}) => { + console.log(`[ScheduleTableItem ${index + 1}] 렌더링`); + + // 특정 테이블의 스케줄만 구독 (해당 테이블 변경 시에만 리렌더링) + const schedules = useTableSchedules(tableId); + + // dispatch만 구독하여 상태 변경 시 리렌더링 방지 + const setSchedulesMap = useScheduleDispatch(); + + // 각 테이블별 콜백을 useCallback으로 메모이제이션 + const handleScheduleTimeClick = useCallback((timeInfo: { day: string; time: number }) => { + onSearchInfoChange({ tableId, ...timeInfo }); + }, [tableId, onSearchInfoChange]); + + const handleDeleteButtonClick = useCallback(({ day, time }: { day: string; time: number }) => { + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter( + (schedule) => + schedule.day !== day || !schedule.range.includes(time) + ), + })); + }, [tableId, setSchedulesMap]); + + return ( + + + + 시간표 {index + 1} + + + + + + + + + + ); +}); + +export const ScheduleTables = memo(() => { + console.log("[ScheduleTables] 렌더링"); -export const ScheduleTables = () => { const { schedulesMap, setSchedulesMap } = useScheduleContext(); const [searchInfo, setSearchInfo] = useState<{ tableId: string; @@ -14,48 +94,48 @@ export const ScheduleTables = () => { const disabledRemoveButton = Object.keys(schedulesMap).length === 1; - const duplicate = (targetId: string) => { - setSchedulesMap(prev => ({ + // 시간표 복제 함수 (메모이제이션) + const duplicate = useCallback((targetId: string) => { + setSchedulesMap((prev) => ({ ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; + [`schedule-${Date.now()}`]: [...prev[targetId]], + })); + }, [setSchedulesMap]); - const remove = (targetId: string) => { - setSchedulesMap(prev => { + // 시간표 삭제 함수 (메모이제이션) + const remove = useCallback((targetId: string) => { + setSchedulesMap((prev) => { delete prev[targetId]; return { ...prev }; - }) - }; + }); + }, [setSchedulesMap]); + + // 검색 정보 변경 함수 (메모이제이션) + const handleSearchInfoChange = useCallback((info: { tableId: string; day?: string; time?: number } | null) => { + setSearchInfo(info); + }, []); return ( <> - {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( - - - 시간표 {index + 1} - - - - - - - setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} - /> - + {Object.keys(schedulesMap).map((tableId, index) => ( + ))} - setSearchInfo(null)}/> + {searchInfo && ( + setSearchInfo(null)} + /> + )} ); -} +}); diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..c696b9e 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Button, @@ -29,7 +29,7 @@ import { VStack, Wrap, } from "@chakra-ui/react"; -import { useScheduleContext } from "./ScheduleContext.tsx"; +import { useScheduleDispatch } from "./ScheduleContext.tsx"; import { Lecture } from "./types.ts"; import { parseSchedule } from "./utils.ts"; import axios from "axios"; @@ -45,12 +45,12 @@ interface Props { } interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; } const TIME_SLOTS = [ @@ -82,99 +82,210 @@ const TIME_SLOTS = [ const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +// 클로저를 이용한 캐싱 함수 +const createCachedFetcher = (fetcher: () => Promise) => { + let cache: T | null = null; + let promise: Promise | null = null; -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); + return async () => { + // 이미 캐시된 데이터가 있으면 반환 + if (cache) { + return cache; + } + + // 진행 중인 요청이 있으면 해당 Promise 반환 + if (promise) { + return promise; + } + + // 새로운 요청 시작 + promise = fetcher(); + cache = await promise; + promise = null; + + return cache; + }; +}; + +// 캐싱된 fetcher 생성 +const cachedFetchMajors = createCachedFetcher(() => + axios.get("/schedules-majors.json") +); +const cachedFetchLiberalArts = createCachedFetcher(() => + axios.get("/schedules-liberal-arts.json") +); + +// 개선된 API 호출: 병렬 실행 + 캐싱 +const fetchAllLectures = async () => { + // Promise.all로 병렬 실행 (await를 제거하여 병렬 실행) + // 캐싱 덕분에 실제 네트워크 요청은 2번만 발생 + const results = await Promise.all([ + (console.log("API Call 1", performance.now()), cachedFetchMajors()), + (console.log("API Call 2", performance.now()), cachedFetchLiberalArts()), + (console.log("API Call 3", performance.now()), cachedFetchMajors()), + (console.log("API Call 4", performance.now()), cachedFetchLiberalArts()), + (console.log("API Call 5", performance.now()), cachedFetchMajors()), + (console.log("API Call 6", performance.now()), cachedFetchLiberalArts()), + ]); + + // 두 응답의 데이터를 합쳐서 반환 + return [...results[0].data, ...results[1].data]; +}; + +// 강의 목록 행 컴포넌트 (메모이제이션으로 불필요한 리렌더링 방지) +const LectureRow = memo(({ lecture, onAdd }: { + lecture: Lecture; + onAdd: (lecture: Lecture) => void; +}) => { + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + + ); +}); + +// 전공 체크박스 컴포넌트 (메모이제이션으로 불필요한 리렌더링 방지) +const MajorCheckbox = memo(({ major }: { major: string }) => ( + + + {major.replace(/

/gi, " ")} + + +)); + +// 시간 체크박스 컴포넌트 (메모이제이션으로 불필요한 리렌더링 방지) +const TimeSlotCheckbox = memo(({ id, label }: { id: number; label: string }) => ( + + + {id}교시({label}) + + +)); // TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); + // dispatch만 구독하여 상태 변경 시 리렌더링 방지 + const setSchedulesMap = useScheduleDispatch(); const loaderWrapperRef = useRef(null); const loaderRef = useRef(null); const [lectures, setLectures] = useState([]); const [page, setPage] = useState(1); const [searchOptions, setSearchOptions] = useState({ - query: '', + query: "", grades: [], days: [], times: [], majors: [], }); - const getFilteredLectures = () => { - const { query = '', credits, grades, days, times, majors } = searchOptions; + const filteredLectures = useMemo(() => { + const { query = "", credits, grades, days, times, majors } = searchOptions; return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) + .filter( + (lecture) => + lecture.title.toLowerCase().includes(query.toLowerCase()) || + lecture.id.toLowerCase().includes(query.toLowerCase()) ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { + .filter( + (lecture) => grades.length === 0 || grades.includes(lecture.grade) + ) + .filter( + (lecture) => majors.length === 0 || majors.includes(lecture.major) + ) + .filter( + (lecture) => !credits || lecture.credits.startsWith(String(credits)) + ) + .filter((lecture) => { if (days.length === 0) { return true; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => days.includes(s.day)); }) - .filter(lecture => { + .filter((lecture) => { if (times.length === 0) { return true; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => + s.range.some((time) => times.includes(time)) + ); }); - } - - const filteredLectures = getFilteredLectures(); + }, [lectures, searchOptions]); const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { + // 전공 목록 메모이제이션 (lectures가 변경될 때만 재계산) + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); + + const changeSearchOption = ( + field: keyof SearchOption, + value: SearchOption[typeof field] + ) => { setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); + setSearchOptions({ ...searchOptions, [field]: value }); loaderWrapperRef.current?.scrollTo(0, 0); }; - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; + // 강의 추가 함수 메모이제이션 (의존성이 변경될 때만 함수 재생성) + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; - const { tableId } = searchInfo; + const { tableId } = searchInfo; - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); - onClose(); - }; + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); useEffect(() => { const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + console.log("API 호출 시작: ", start); + fetchAllLectures().then((lectures) => { const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) + console.log("모든 API 호출 완료 ", end); + console.log("API 호출에 걸린 시간(ms): ", end - start); + setLectures(lectures); + }); }, []); useEffect(() => { @@ -186,9 +297,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { } const observer = new IntersectionObserver( - entries => { + (entries) => { if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); } }, { threshold: 0, root: $loaderWrapper } @@ -200,20 +311,20 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { }, [lastPage]); useEffect(() => { - setSearchOptions(prev => ({ + setSearchOptions((prev) => ({ ...prev, days: searchInfo?.day ? [searchInfo.day] : [], times: searchInfo?.time ? [searchInfo.time] : [], - })) + })); setPage(1); }, [searchInfo]); return ( - + 수업 검색 - + @@ -222,7 +333,7 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { changeSearchOption('query', e.target.value)} + onChange={(e) => changeSearchOption("query", e.target.value)} /> @@ -230,7 +341,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { 학점