diff --git a/package-lock.json b/package-lock.json
index 9df77ef..3165483 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,11 +14,18 @@
"@types/react-dom": "^18.2.7",
"astro": "^3.0.12",
"classnames": "^2.3.2",
+ "colord": "^2.9.3",
"date-fns": "^2.29.3",
+ "date-fns-tz": "^2.0.0",
+ "nanoid": "^5.0.4",
"prettier": "^3.0.3",
"react": "^18.2.0",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
- "react-use-measure": "^2.1.1"
+ "react-use-measure": "^2.1.1",
+ "rrule": "^2.8.1",
+ "styled-components": "^6.1.1"
}
},
"node_modules/@ampproject/remapping": {
@@ -414,6 +421,17 @@
"@babel/core": "^7.0.0-0"
}
},
+ "node_modules/@babel/runtime": {
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz",
+ "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==",
+ "dependencies": {
+ "regenerator-runtime": "^0.14.0"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/template": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
@@ -460,6 +478,24 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@emotion/is-prop-valid": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
+ "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==",
+ "dependencies": {
+ "@emotion/memoize": "^0.8.1"
+ }
+ },
+ "node_modules/@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "node_modules/@emotion/unitless": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+ },
"node_modules/@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -890,6 +926,21 @@
"node": ">= 8"
}
},
+ "node_modules/@react-dnd/asap": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+ "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
+ },
+ "node_modules/@react-dnd/invariant": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+ "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
+ },
+ "node_modules/@react-dnd/shallowequal": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
+ },
"node_modules/@react-spring/animated": {
"version": "9.7.3",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz",
@@ -1082,6 +1133,11 @@
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
},
+ "node_modules/@types/stylis": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.4.tgz",
+ "integrity": "sha512-36ZrGJ8fgtBr6nwNnuJ9jXIj+bn/pF6UoqmrQT7+Y99+tFFeHHsoR54+194dHdyhPjgbeoNz3Qru0oRt0l6ASQ=="
+ },
"node_modules/@types/unist": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -1852,6 +1908,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/caniuse-lite": {
"version": "1.0.30001557",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001557.tgz",
@@ -2069,6 +2133,11 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"optional": true
},
+ "node_modules/colord": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ },
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -2109,10 +2178,28 @@
"node": ">= 8"
}
},
+ "node_modules/css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "dependencies": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
"node_modules/csstype": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
- "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"node_modules/date-fns": {
"version": "2.29.3",
@@ -2126,6 +2213,14 @@
"url": "https://opencollective.com/date-fns"
}
},
+ "node_modules/date-fns-tz": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz",
+ "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==",
+ "peerDependencies": {
+ "date-fns": ">=2.0.0"
+ }
+ },
"node_modules/debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -2218,6 +2313,16 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
+ "node_modules/dnd-core": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+ "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
+ "dependencies": {
+ "@react-dnd/asap": "^5.0.1",
+ "@react-dnd/invariant": "^4.0.1",
+ "redux": "^4.2.0"
+ }
+ },
"node_modules/dset": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.2.tgz",
@@ -2374,6 +2479,11 @@
"node": ">=0.10.0"
}
},
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"node_modules/fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -2678,6 +2788,14 @@
"url": "https://opencollective.com/unified"
}
},
+ "node_modules/hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "dependencies": {
+ "react-is": "^16.7.0"
+ }
+ },
"node_modules/html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -3926,9 +4044,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz",
+ "integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig==",
"funding": [
{
"type": "github",
@@ -3936,10 +4054,10 @@
}
],
"bin": {
- "nanoid": "bin/nanoid.cjs"
+ "nanoid": "bin/nanoid.js"
},
"engines": {
- "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ "node": "^18 || >=20"
}
},
"node_modules/napi-build-utils": {
@@ -4262,9 +4380,9 @@
}
},
"node_modules/postcss": {
- "version": "8.4.29",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
- "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
+ "version": "8.4.32",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
+ "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"funding": [
{
"type": "opencollective",
@@ -4280,7 +4398,7 @@
}
],
"dependencies": {
- "nanoid": "^3.3.6",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
@@ -4288,6 +4406,28 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
+ },
+ "node_modules/postcss/node_modules/nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
"node_modules/prebuild-install": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
@@ -4515,6 +4655,43 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-dnd": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+ "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
+ "dependencies": {
+ "@react-dnd/invariant": "^4.0.1",
+ "@react-dnd/shallowequal": "^4.0.1",
+ "dnd-core": "^16.0.1",
+ "fast-deep-equal": "^3.1.3",
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "@types/hoist-non-react-statics": ">= 3.3.1",
+ "@types/node": ">= 12",
+ "@types/react": ">= 16",
+ "react": ">= 16.14"
+ },
+ "peerDependenciesMeta": {
+ "@types/hoist-non-react-statics": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@types/react": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-dnd-html5-backend": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+ "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
+ "dependencies": {
+ "dnd-core": "^16.0.1"
+ }
+ },
"node_modules/react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -4527,6 +4704,11 @@
"react": "^18.2.0"
}
},
+ "node_modules/react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"node_modules/react-use-measure": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.1.tgz",
@@ -4563,6 +4745,19 @@
"node": ">=8.10.0"
}
},
+ "node_modules/redux": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+ "dependencies": {
+ "@babel/runtime": "^7.9.2"
+ }
+ },
+ "node_modules/regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ },
"node_modules/rehype": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz",
@@ -4819,6 +5014,14 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rrule": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
+ "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -4926,6 +5129,11 @@
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="
},
+ "node_modules/shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
+ },
"node_modules/sharp": {
"version": "0.32.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz",
@@ -5211,6 +5419,38 @@
"node": ">=0.10.0"
}
},
+ "node_modules/styled-components": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
+ "integrity": "sha512-cpZZP5RrKRIClBW5Eby4JM1wElLVP4NQrJbJ0h10TidTyJf4SIIwa3zLXOoPb4gJi8MsJ8mjq5mu2IrEhZIAcQ==",
+ "dependencies": {
+ "@emotion/is-prop-valid": "^1.2.1",
+ "@emotion/unitless": "^0.8.0",
+ "@types/stylis": "^4.0.2",
+ "css-to-react-native": "^3.2.0",
+ "csstype": "^3.1.2",
+ "postcss": "^8.4.31",
+ "shallowequal": "^1.1.0",
+ "stylis": "^4.3.0",
+ "tslib": "^2.5.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/styled-components"
+ },
+ "peerDependencies": {
+ "react": ">= 16.8.0",
+ "react-dom": ">= 16.8.0"
+ }
+ },
+ "node_modules/stylis": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz",
+ "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ=="
+ },
"node_modules/supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -5345,6 +5585,11 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ },
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@@ -6138,6 +6383,14 @@
"@babel/types": "^7.22.15"
}
},
+ "@babel/runtime": {
+ "version": "7.23.5",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.5.tgz",
+ "integrity": "sha512-NdUTHcPe4C99WxPub+K9l9tK5/lV4UXIoaHSYgzco9BCyjKAAwzdBI+wWtYqHt7LJdbo74ZjRPJgzVweq1sz0w==",
+ "requires": {
+ "regenerator-runtime": "^0.14.0"
+ }
+ },
"@babel/template": {
"version": "7.22.15",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz",
@@ -6175,6 +6428,24 @@
"to-fast-properties": "^2.0.0"
}
},
+ "@emotion/is-prop-valid": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz",
+ "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==",
+ "requires": {
+ "@emotion/memoize": "^0.8.1"
+ }
+ },
+ "@emotion/memoize": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz",
+ "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA=="
+ },
+ "@emotion/unitless": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.1.tgz",
+ "integrity": "sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ=="
+ },
"@esbuild/android-arm": {
"version": "0.18.20",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz",
@@ -6388,6 +6659,21 @@
"fastq": "^1.6.0"
}
},
+ "@react-dnd/asap": {
+ "version": "5.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-5.0.2.tgz",
+ "integrity": "sha512-WLyfoHvxhs0V9U+GTsGilGgf2QsPl6ZZ44fnv0/b8T3nQyvzxidxsg/ZltbWssbsRDlYW8UKSQMTGotuTotZ6A=="
+ },
+ "@react-dnd/invariant": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-4.0.2.tgz",
+ "integrity": "sha512-xKCTqAK/FFauOM9Ta2pswIyT3D8AQlfrYdOi/toTPEhqCuAs1v5tcJ3Y08Izh1cJ5Jchwy9SeAXmMg6zrKs2iw=="
+ },
+ "@react-dnd/shallowequal": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-4.0.2.tgz",
+ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA=="
+ },
"@react-spring/animated": {
"version": "9.7.3",
"resolved": "https://registry.npmjs.org/@react-spring/animated/-/animated-9.7.3.tgz",
@@ -6563,6 +6849,11 @@
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
"integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
},
+ "@types/stylis": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.4.tgz",
+ "integrity": "sha512-36ZrGJ8fgtBr6nwNnuJ9jXIj+bn/pF6UoqmrQT7+Y99+tFFeHHsoR54+194dHdyhPjgbeoNz3Qru0oRt0l6ASQ=="
+ },
"@types/unist": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.6.tgz",
@@ -7006,6 +7297,11 @@
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz",
"integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw=="
},
+ "camelize": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz",
+ "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ=="
+ },
"caniuse-lite": {
"version": "1.0.30001557",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001557.tgz",
@@ -7145,6 +7441,11 @@
"simple-swizzle": "^0.2.2"
}
},
+ "colord": {
+ "version": "2.9.3",
+ "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
+ "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw=="
+ },
"comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
@@ -7175,16 +7476,37 @@
"which": "^2.0.1"
}
},
+ "css-color-keywords": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
+ "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg=="
+ },
+ "css-to-react-native": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz",
+ "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==",
+ "requires": {
+ "camelize": "^1.0.0",
+ "css-color-keywords": "^1.0.0",
+ "postcss-value-parser": "^4.0.2"
+ }
+ },
"csstype": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.1.tgz",
- "integrity": "sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw=="
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
},
"date-fns": {
"version": "2.29.3",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz",
"integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA=="
},
+ "date-fns-tz": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-2.0.0.tgz",
+ "integrity": "sha512-OAtcLdB9vxSXTWHdT8b398ARImVwQMyjfYGkKD2zaGpHseG2UPHbHjXELReErZFxWdSLph3c2zOaaTyHfOhERQ==",
+ "requires": {}
+ },
"debounce": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz",
@@ -7247,6 +7569,16 @@
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="
},
+ "dnd-core": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-16.0.1.tgz",
+ "integrity": "sha512-HK294sl7tbw6F6IeuK16YSBUoorvHpY8RHO+9yFfaJyCDVb6n7PRcezrOEOa2SBCqiYpemh5Jx20ZcjKdFAVng==",
+ "requires": {
+ "@react-dnd/asap": "^5.0.1",
+ "@react-dnd/invariant": "^4.0.1",
+ "redux": "^4.2.0"
+ }
+ },
"dset": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/dset/-/dset-3.1.2.tgz",
@@ -7368,6 +7700,11 @@
"is-extendable": "^0.1.0"
}
},
+ "fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+ },
"fast-fifo": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
@@ -7597,6 +7934,14 @@
"space-separated-tokens": "^2.0.0"
}
},
+ "hoist-non-react-statics": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+ "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+ "requires": {
+ "react-is": "^16.7.0"
+ }
+ },
"html-escaper": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
@@ -8366,9 +8711,9 @@
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"nanoid": {
- "version": "3.3.6",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
- "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA=="
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.4.tgz",
+ "integrity": "sha512-vAjmBf13gsmhXSgBrtIclinISzFFy22WwCYoyilZlsrRXNIHSwgFQ1bEdjRwMT3aoadeIF6HMuDRlOxzfXV8ig=="
},
"napi-build-utils": {
"version": "1.0.2",
@@ -8594,15 +8939,27 @@
}
},
"postcss": {
- "version": "8.4.29",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz",
- "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==",
+ "version": "8.4.32",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
+ "integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"requires": {
- "nanoid": "^3.3.6",
+ "nanoid": "^3.3.7",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
+ },
+ "dependencies": {
+ "nanoid": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
+ "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g=="
+ }
}
},
+ "postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="
+ },
"prebuild-install": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz",
@@ -8765,6 +9122,26 @@
"loose-envify": "^1.1.0"
}
},
+ "react-dnd": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-16.0.1.tgz",
+ "integrity": "sha512-QeoM/i73HHu2XF9aKksIUuamHPDvRglEwdHL4jsp784BgUuWcg6mzfxT0QDdQz8Wj0qyRKx2eMg8iZtWvU4E2Q==",
+ "requires": {
+ "@react-dnd/invariant": "^4.0.1",
+ "@react-dnd/shallowequal": "^4.0.1",
+ "dnd-core": "^16.0.1",
+ "fast-deep-equal": "^3.1.3",
+ "hoist-non-react-statics": "^3.3.2"
+ }
+ },
+ "react-dnd-html5-backend": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-16.0.1.tgz",
+ "integrity": "sha512-Wu3dw5aDJmOGw8WjH1I1/yTH+vlXEL4vmjk5p+MHxP8HuHJS1lAGeIdG/hze1AvNeXWo/JgULV87LyQOr+r5jw==",
+ "requires": {
+ "dnd-core": "^16.0.1"
+ }
+ },
"react-dom": {
"version": "18.2.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz",
@@ -8774,6 +9151,11 @@
"scheduler": "^0.23.0"
}
},
+ "react-is": {
+ "version": "16.13.1",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+ },
"react-use-measure": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.1.tgz",
@@ -8800,6 +9182,19 @@
"picomatch": "^2.2.1"
}
},
+ "redux": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
+ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
+ "requires": {
+ "@babel/runtime": "^7.9.2"
+ }
+ },
+ "regenerator-runtime": {
+ "version": "0.14.0",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.0.tgz",
+ "integrity": "sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA=="
+ },
"rehype": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/rehype/-/rehype-12.0.1.tgz",
@@ -8979,6 +9374,14 @@
"fsevents": "~2.3.2"
}
},
+ "rrule": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz",
+ "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==",
+ "requires": {
+ "tslib": "^2.4.0"
+ }
+ },
"run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -9045,6 +9448,11 @@
"resolved": "https://registry.npmjs.org/server-destroy/-/server-destroy-1.0.1.tgz",
"integrity": "sha512-rb+9B5YBIEzYcD6x2VKidaa+cqYBJQKnU4oe4E3ANwRRN56yk/ua1YCJT1n21NTS8w6CcOclAKNP3PhdCXKYtQ=="
},
+ "shallowequal": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz",
+ "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ=="
+ },
"sharp": {
"version": "0.32.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.5.tgz",
@@ -9235,6 +9643,27 @@
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"optional": true
},
+ "styled-components": {
+ "version": "6.1.1",
+ "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.1.tgz",
+ "integrity": "sha512-cpZZP5RrKRIClBW5Eby4JM1wElLVP4NQrJbJ0h10TidTyJf4SIIwa3zLXOoPb4gJi8MsJ8mjq5mu2IrEhZIAcQ==",
+ "requires": {
+ "@emotion/is-prop-valid": "^1.2.1",
+ "@emotion/unitless": "^0.8.0",
+ "@types/stylis": "^4.0.2",
+ "css-to-react-native": "^3.2.0",
+ "csstype": "^3.1.2",
+ "postcss": "^8.4.31",
+ "shallowequal": "^1.1.0",
+ "stylis": "^4.3.0",
+ "tslib": "^2.5.0"
+ }
+ },
+ "stylis": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.0.tgz",
+ "integrity": "sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ=="
+ },
"supports-color": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
@@ -9335,6 +9764,11 @@
}
}
},
+ "tslib": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz",
+ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q=="
+ },
"tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
diff --git a/package.json b/package.json
index 302ef44..074feef 100644
--- a/package.json
+++ b/package.json
@@ -17,10 +17,17 @@
"@types/react-dom": "^18.2.7",
"astro": "^3.0.12",
"classnames": "^2.3.2",
+ "colord": "^2.9.3",
"date-fns": "^2.29.3",
+ "date-fns-tz": "^2.0.0",
+ "nanoid": "^5.0.4",
"prettier": "^3.0.3",
"react": "^18.2.0",
+ "react-dnd": "^16.0.1",
+ "react-dnd-html5-backend": "^16.0.1",
"react-dom": "^18.2.0",
- "react-use-measure": "^2.1.1"
+ "react-use-measure": "^2.1.1",
+ "rrule": "^2.8.1",
+ "styled-components": "^6.1.1"
}
}
diff --git a/public/icons/chevron-down.svg b/public/icons/chevron-down.svg
new file mode 100644
index 0000000..a57e6cb
--- /dev/null
+++ b/public/icons/chevron-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/drag.svg b/public/icons/drag.svg
new file mode 100644
index 0000000..19a9101
--- /dev/null
+++ b/public/icons/drag.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/calendar.svg b/public/icons/navigation/calendar.svg
new file mode 100644
index 0000000..9191a8c
--- /dev/null
+++ b/public/icons/navigation/calendar.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/help.svg b/public/icons/navigation/help.svg
new file mode 100644
index 0000000..5c46dc5
--- /dev/null
+++ b/public/icons/navigation/help.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/notes.svg b/public/icons/navigation/notes.svg
new file mode 100644
index 0000000..d558d84
--- /dev/null
+++ b/public/icons/navigation/notes.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/settings.svg b/public/icons/navigation/settings.svg
new file mode 100644
index 0000000..715a1c1
--- /dev/null
+++ b/public/icons/navigation/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/stats.svg b/public/icons/navigation/stats.svg
new file mode 100644
index 0000000..1137ecc
--- /dev/null
+++ b/public/icons/navigation/stats.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/navigation/tasks.svg b/public/icons/navigation/tasks.svg
new file mode 100644
index 0000000..867974b
--- /dev/null
+++ b/public/icons/navigation/tasks.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/play.svg b/public/icons/play.svg
new file mode 100644
index 0000000..ceb71ad
--- /dev/null
+++ b/public/icons/play.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/settings.svg b/public/icons/settings.svg
new file mode 100644
index 0000000..ac7e961
--- /dev/null
+++ b/public/icons/settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/public/icons/triangle-down.svg b/public/icons/triangle-down.svg
new file mode 100644
index 0000000..78ae21b
--- /dev/null
+++ b/public/icons/triangle-down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/components/Accordion/Accordion.module.css b/src/components/Accordion/Accordion.module.css
index 81b5ca1..c66653e 100644
--- a/src/components/Accordion/Accordion.module.css
+++ b/src/components/Accordion/Accordion.module.css
@@ -21,6 +21,16 @@
padding: 0;
}
+.icon {
+ display: inline-flex;
+ vertical-align: middle;
+ transition: transform 0.3s ease-in-out;
+}
+
+.icon.open {
+ transform: rotate(180deg);
+}
+
@media screen and (min-width: 769px) {
.demo {
display: none;
diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx
index 9757cbb..2a44c79 100644
--- a/src/components/Accordion/Accordion.tsx
+++ b/src/components/Accordion/Accordion.tsx
@@ -1,6 +1,11 @@
-import React, { useRef, type ReactNode, useLayoutEffect, useState } from "react";
+import React, {
+ useRef,
+ type ReactNode,
+ useLayoutEffect,
+ useState,
+} from "react";
import { animated, useSpring, useTransition } from "@react-spring/web";
-import useMeasure from 'react-use-measure';
+import useMeasure from "react-use-measure";
import cn from "classnames";
import styles from "./Accordion.module.css";
@@ -27,7 +32,14 @@ export const AccordionItem = (props: Props) => {
})}
>
- {title}
+
+
+ {" "}
+ {title}
{children}
@@ -59,9 +71,7 @@ export const Accordion = (props: AccordionProps) => {
onOpen={() => onIndexChange(i)}
>
{item.content}
-
- {item.demo}
-
+ {item.demo}
))}
diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx
new file mode 100644
index 0000000..6c7c7a3
--- /dev/null
+++ b/src/components/App/App.tsx
@@ -0,0 +1,607 @@
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+import styled, { ThemeProvider, useTheme } from "styled-components";
+import { nanoid } from "nanoid";
+import { WeekCalendar } from "../../components/WeekCalendar";
+import { DndProvider } from "react-dnd";
+import { HTML5Backend } from "react-dnd-html5-backend";
+import { useCalendarTheme } from "../../hooks/useCalendarTheme";
+import { Sidebar } from "./Sidebar";
+import { light } from "./theme/light";
+import { DragLayer, TASK } from "./DragLayer";
+import { colord } from "colord";
+import {
+ addDays,
+ endOfWeek,
+ format,
+ intervalToDuration,
+ isPast,
+ set,
+ startOfWeek,
+} from "date-fns";
+import { Status, type CalendarEventData, type TTask } from "./types";
+import { TaskStatus } from "../TaskStatus/TaskStatus";
+import {
+ WeekCalendarView,
+ type WeekEvent,
+ type WeekEventDates,
+ type WeekTimebox,
+} from "../WeekCalendar/types";
+import {
+ CALENDAR_TASK_BLOCK,
+ CALENDAR_TIMEBOX_BLOCK,
+} from "../WeekCalendar/constants";
+import { RRule } from "rrule";
+
+export const Root = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+const defaultProjects = {
+ Personal: {
+ id: nanoid(),
+ title: "Personal",
+ color: "#388e3c",
+ backgroundColor: "#e8f5e9",
+ },
+ Work: {
+ id: nanoid(),
+ title: "Work",
+ color: "#7b1fa2",
+ backgroundColor: "#f3e5f5",
+ },
+ Education: {
+ id: nanoid(),
+ title: "Education",
+ color: "#303f9f",
+ backgroundColor: "#e8eaf6",
+ },
+};
+
+const defaultBacklogTasks = [
+ {
+ id: nanoid(),
+ title: "Buy cat food",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Make duplicate of the key",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Find a babysitter",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Book a hotel for vacation",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Pay for the internet",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Buy a new phone",
+ project: defaultProjects["Personal"],
+ },
+ {
+ id: nanoid(),
+ title: "Send the report",
+ project: defaultProjects["Work"],
+ },
+ {
+ id: nanoid(),
+ title: "Prepare the presentation",
+ project: defaultProjects["Work"],
+ },
+ {
+ id: nanoid(),
+ title: "Write tests",
+ project: defaultProjects["Work"],
+ tasks: [
+ {
+ id: nanoid(),
+ title: "Unit tests",
+ project: defaultProjects["Work"],
+ },
+ {
+ id: nanoid(),
+ title: "Integration tests",
+ project: defaultProjects["Work"],
+ },
+ {
+ id: nanoid(),
+ title: "E2E tests",
+ project: defaultProjects["Work"],
+ },
+ ],
+ },
+ {
+ id: nanoid(),
+ title: "Refactor the monetization code",
+ project: defaultProjects["Work"],
+ },
+];
+
+const taskNames = [
+ "Complete project proposal",
+ "Conduct market research",
+ "Schedule meetings with clients",
+ "Create a social media strategy",
+ "Review and revise website content",
+ "Develop a marketing campaign",
+ "Analyze data from customer surveys",
+ "Write a business plan",
+ "Update employee training manuals",
+ "Design a new logo",
+ "Coordinate team training sessions",
+ "Generate monthly financial reports",
+ "Implement a customer loyalty program",
+ "Research new product ideas",
+ "Conduct performance evaluations",
+ "Optimize website for search engines",
+ "Plan company retreat",
+ "Prepare sales forecast",
+ "Create promotional materials",
+ "Review and respond to customer inquiries",
+ "Conduct competitor analysis",
+ "Launch new product line",
+ "Evaluate supplier contracts",
+ "Develop a content marketing strategy",
+ "Coordinate trade show participation",
+ "Improve inventory management system",
+ "Conduct staff training on new software",
+ "Organize company-wide event",
+ "Create email marketing campaign",
+ "Implement cost-cutting measures",
+ "Upgrade computer hardware",
+ "Conduct user testing on website",
+ "Develop customer relationship management system",
+ "Write and distribute press releases",
+ "Conduct customer satisfaction surveys",
+ "Plan and execute advertising campaign",
+ "Research and select new vendors",
+ "Develop sales training program",
+ "Design and implement employee wellness program",
+ "Update company website",
+ "Analyze and interpret sales data",
+ "Conduct brainstorming sessions",
+ "Develop customer personas",
+ "Negotiate contracts with suppliers",
+ "Create a customer referral program",
+ "Plan and host industry conference",
+ "Conduct market segmentation analysis",
+ "Develop customer onboarding process",
+ "Update product packaging",
+ "Analyze website traffic and user behavior",
+ "Conduct employee engagement survey",
+ "Develop online learning modules",
+ "Coordinate product launches",
+ "Implement data security measures",
+ "Create standardized procedures and policies",
+ "Research and recommend project management software",
+ "Evaluate and select advertising agencies",
+ "Develop affiliate marketing program",
+ "Design and conduct customer focus groups",
+ "Optimize social media profiles",
+ "Conduct product demonstrations",
+ "Coordinate public relations activities",
+ "Conduct risk assessments",
+ "Develop customer retention strategies",
+ "Write case studies and success stories",
+ "Evaluate and recommend CRM software",
+ "Conduct market trend analysis",
+ "Develop mobile app",
+ "Implement customer feedback system",
+ "Create employee recognition program",
+ "Update company branding",
+ "Conduct usability testing",
+ "Develop pricing strategy",
+ "Analyze financial statements",
+ "Plan and execute direct mail campaign",
+ "Set up and optimize online advertising campaigns",
+ "Conduct inventory audit",
+ "Develop customer satisfaction measurement tools",
+ "Design and conduct training workshops",
+ "Conduct focus groups for product testing",
+ "Implement project management methodology",
+ "Research and recommend email marketing software",
+ "Develop customer service training program",
+ "Create and manage social media content calendar",
+ "Analyze and optimize sales funnels",
+ "Conduct market research surveys",
+ "Develop employee performance metrics",
+ "Coordinate product recalls or returns",
+ "Implement website analytics tools",
+ "Create employee handbook",
+ "Conduct supplier negotiations",
+ "Develop marketing collateral",
+ "Update pricing information",
+ "Conduct customer churn analysis",
+ "Design and execute customer loyalty program",
+ "Analyze and optimize email marketing campaigns",
+ "Conduct competitor pricing analysis",
+ "Develop lead generation strategies",
+ "Create customer testimonials",
+ "Plan and execute product launch event",
+];
+
+const personalTaskNames = [
+ "Go for a run",
+ "Complete laundry",
+ "Buy groceries",
+ "Pay bills",
+ "Read a book",
+ "Write in journal",
+ "Organize closet",
+ "Meditate",
+ "Plan meals for the week",
+ "Call a friend",
+ "Clean the bathroom",
+ "Schedule doctor's appointment",
+ "Learn a new recipe",
+ "Update budget",
+ "Take a walk in the park",
+];
+
+let taskNamesIndex = 0;
+
+const App = () => {
+ const [date, setDate] = useState(
+ startOfWeek(new Date(), { weekStartsOn: 1 }),
+ );
+ const [events, setEvents] = useState[]>(
+ getEvents({ start: startOfWeek(new Date()), end: endOfWeek(new Date()) }),
+ );
+ const [timeBoxes, setTimeBoxes] = useState([]);
+ const [range, setRange] = useState();
+ const [backlogTasks, setBacklogTasks] = useState(defaultBacklogTasks);
+ const [timezones] = useState([
+ {
+ label: "Home",
+ name: Intl.DateTimeFormat().resolvedOptions().timeZone,
+ isMain: true,
+ },
+ ]);
+
+ useEffect(() => {
+ if (range) {
+ setTimeBoxes(getTimeboxes(range));
+ }
+ }, [range]);
+
+ const theme = useTheme();
+
+ const calendarTheme = useCalendarTheme();
+
+ const handleAddEvent = (dates: WeekEventDates, task: TTask) => {
+ setEvents([
+ ...events,
+ {
+ id: task.id,
+ data: {
+ task: {
+ ...task,
+ status: Status.IN_PROGRESS,
+ },
+ type: CALENDAR_TASK_BLOCK,
+ },
+ ...dates,
+ },
+ ]);
+
+ setBacklogTasks((backlogTasks) =>
+ backlogTasks.map((t) => {
+ if (t.id === task.id) {
+ return {
+ ...t,
+ status: Status.IN_PROGRESS,
+ };
+ } else {
+ return t;
+ }
+ }),
+ );
+
+ return Promise.resolve(true);
+ };
+
+ const handleEventAddRequest = (dates: WeekEventDates) => {
+ const projectName = (["Personal", "Work", "Education"] as const)[
+ Math.floor(getRandomArbitrary(0, 3))
+ ];
+
+ setEvents([
+ ...events,
+ {
+ id: nanoid(),
+ ...dates,
+ data: {
+ task: {
+ id: nanoid(),
+ title: taskNames[taskNamesIndex++],
+ status: Status.IN_PROGRESS,
+ project: defaultProjects[projectName],
+ },
+ type: CALENDAR_TASK_BLOCK,
+ },
+ },
+ ]);
+ };
+
+ const handleEventChange = (dates: WeekEventDates, event: WeekEvent) => {
+ setEvents(
+ events.map((e) => {
+ if (e.id === event.id) {
+ return {
+ ...e,
+ ...dates,
+ };
+ } else {
+ return e;
+ }
+ }),
+ );
+ };
+
+ const handleUpdateStatus = (taskId: string, status: Status) => {};
+
+ const renderDayInfo = useCallback((date: Date, events: WeekEvent[]) => {
+ const time = events.reduce((acc, event) => {
+ if (!event.startDateTime || !event.endDateTime) {
+ return acc;
+ }
+
+ const duration = intervalToDuration({
+ start: event.startDateTime,
+ end: event.endDateTime,
+ });
+
+ return acc + (duration.hours || 0) * 60 + (duration.minutes || 0);
+ }, 0);
+
+ const hours = Math.floor(time / 60);
+ const minutes = time % 60;
+
+ if (time === 0) {
+ return null;
+ }
+
+ return (
+
+ {hours}h {minutes}m
+
+ );
+ }, []);
+
+ const renderEvent = useCallback(
+ (event: WeekEvent, dates: WeekEventDates) => {
+ const backgroundColor = colord(
+ event.data.task?.project.backgroundColor || theme.eventBackgroundColor,
+ )
+ .alpha(event ? 0.75 : 1)
+ .toHex();
+ const color = event.data.task?.project.color || "#444";
+
+ let duration, h, m, d;
+
+ if (event.startDateTime && event.endDateTime) {
+ duration = intervalToDuration({
+ start: dates.startDateTime || event.startDateTime,
+ end: dates.endDateTime || event.endDateTime,
+ });
+
+ h = duration.hours ? `${duration.hours}h` : "";
+ m = duration.minutes ? `${duration.minutes}m` : "";
+ d = duration.days ? `${duration.days}d` : "";
+ }
+
+ const status = event.data.task ? (
+
+
+
+ ) : null;
+
+ return (
+
+
+ {status}
+ {event.data.task?.title}
+
+
+ {format((dates.startDateTime || event.startDateTime)!, "HH:mm")}
+ {" - "}
+ {format((dates.endDateTime || event.endDateTime)!, "HH:mm")} (
+ {`${d} ${h} ${m}`.trim()})
+
+
+ );
+ },
+ [],
+ );
+
+ return (
+
+
+
+
+
+ accepts={[TASK]}
+ layers={{
+ [WeekCalendarView.Week]: ,
+ }}
+ date={date}
+ colorScheme={calendarTheme}
+ events={events}
+ timeboxes={timeBoxes}
+ onRangeChange={setRange}
+ onDateChange={setDate}
+ onEventAdd={handleAddEvent}
+ view={WeekCalendarView.Week}
+ renderEvent={renderEvent}
+ onEventAddRequest={handleEventAddRequest}
+ onEventChange={handleEventChange}
+ onTimeboxChange={() => {}}
+ renderDayInfo={renderDayInfo}
+ timezones={timezones}
+ />
+
+
+ );
+};
+
+const Aside = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+function getRandomArbitrary(min: number, max: number) {
+ return Math.random() * (max - min) + min;
+}
+
+function getTimeboxes(range: Interval) {
+ const rrule = RRule.fromString("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
+ rrule.options.dtstart = startOfWeek(new Date(), { weekStartsOn: 1 });
+
+ return rrule
+ .between(new Date(range.start), new Date(range.end))
+ .map((date) => {
+ return {
+ id: nanoid(),
+ title: "Work",
+ startDateTime: set(date, { hours: 9, minutes: 0 }),
+ endDateTime: set(date, { hours: 18, minutes: 0 }),
+ data: {
+ type: CALENDAR_TIMEBOX_BLOCK,
+ },
+ backgroundColor: "#7716dd",
+ };
+ });
+}
+
+function getEvents(range: Interval) {
+ return personalTaskNames.map((title) => {
+ const projectName = (["Personal", "Work", "Education"] as const)[
+ Math.floor(getRandomArbitrary(0, 3))
+ ];
+
+ const date = addDays(range.start, getRandomArbitrary(0, 6));
+
+ const startHour = Math.floor(getRandomArbitrary(9, 18));
+ const endHour = startHour + getRandomArbitrary(1, 3);
+
+ const startDateTime = set(date, { hours: startHour, minutes: 0 });
+ const endDateTime = set(date, { hours: endHour, minutes: 0 });
+
+ return {
+ id: nanoid(),
+ title,
+ startDateTime,
+ endDateTime,
+ data: {
+ task: {
+ title,
+ project: defaultProjects[projectName],
+ status: isPast(endDateTime) ? Status.DONE : Status.IN_PROGRESS,
+ },
+ type: CALENDAR_TASK_BLOCK,
+ },
+ };
+ });
+}
+
+const AppRoot = styled.main`
+ display: flex;
+ width: 100%;
+ height: 70vh;
+ margin: 0 auto;
+ position: relative;
+ border: 1px solid #eee;
+ border-radius: 8px;
+ box-shadow: 0 0 1px rgba(0, 0, 0, 0.25);
+ overflow: hidden;
+ margin-top: 64px;
+`;
+
+const AsideRoot = styled.aside`
+ background-color: rgb(255, 255, 255);
+ width: 56px;
+ display: flex;
+ flex: 0 0 auto;
+ box-sizing: border-box;
+ flex-direction: column;
+ -webkit-box-pack: justify;
+ justify-content: space-between;
+ border-width: 0px 1px 0 0;
+ border-style: solid;
+ border-color: rgba(0, 0, 0, 0.1);
+ padding: 12px 16px;
+`;
+
+const Navigation = styled.nav`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ & img {
+ opacity: 0.6;
+ }
+`;
+
+const CalendarRoot = styled.div`
+ position: relative;
+ flex: 1 0;
+ width: calc(100% - 56px - 250px);
+`;
+
+const StatusWrapper = styled.span`
+ z-index: 3;
+`;
+
+const EventBlock = styled.div`
+ font-size: 12px;
+ height: 100%;
+ width: 100%;
+ border-radius: 4px;
+ padding: 2px 4px;
+ overflow: hidden;
+ border: 1px solid transparent;
+`;
+
+const Title = styled.div`
+ display: flex;
+ gap: 4px;
+ color: inherit;
+`;
+
+const PlannedHours = styled.div`
+ font-size: 12px;
+ text-align: right;
+ height: 100%;
+ display: flex;
+ align-items: flex-end;
+ justify-content: flex-end;
+`;
diff --git a/src/components/App/DragLayer.tsx b/src/components/App/DragLayer.tsx
new file mode 100644
index 0000000..a583af5
--- /dev/null
+++ b/src/components/App/DragLayer.tsx
@@ -0,0 +1,210 @@
+import React, { type ReactNode } from "react";
+import styled from "styled-components";
+import { useDragLayer } from "react-dnd";
+import { type DayViewLayerProps } from "../../components/WeekCalendar/types";
+import {
+ CALENDAR_EVENT_BLOCK,
+ CALENDAR_TASK_BLOCK,
+ CALENDAR_TIMEBOX_BLOCK,
+} from "../../components/WeekCalendar/constants";
+import {
+ getDnDCorrectedPosition,
+ getDnDDateFromPosition,
+ getIsAllDay,
+} from "../../components/WeekCalendar/components/views/DayView/utils";
+import { differenceInMinutes, format } from "date-fns";
+
+export const TASK = Symbol("Task");
+
+const ALLOWED_TYPES = [
+ CALENDAR_EVENT_BLOCK,
+ CALENDAR_TASK_BLOCK,
+ CALENDAR_TIMEBOX_BLOCK,
+ TASK,
+];
+
+export const DragLayer = (
+ props: Partial,
+): ReactNode => {
+ const {
+ dayWidth,
+ start,
+ startHour,
+ endHour,
+ asideWidth,
+ dateLabelHeight,
+ headerHeight,
+ hourHeight,
+ container,
+ containerRect,
+ defaultDuration = 30,
+ } = props;
+
+ const { offset, item, type, isDragging } = useDragLayer((monitor) => ({
+ item: monitor.getItem(),
+ type: monitor.getItemType(),
+ offset: monitor.getClientOffset(),
+ isDragging: monitor.isDragging(),
+ }));
+
+ if (!isDragging || !offset || !ALLOWED_TYPES.includes(type as symbol)) {
+ return null;
+ }
+
+ const x = offset.x;
+ const y = offset.y;
+
+ let duration;
+
+ const event = item;
+ const isAllDay = getIsAllDay({ y, containerRect, headerHeight });
+
+ if (
+ type === CALENDAR_EVENT_BLOCK ||
+ type === CALENDAR_TASK_BLOCK ||
+ type === CALENDAR_TIMEBOX_BLOCK
+ ) {
+ if (event.startDateTime && event.endDateTime) {
+ duration = differenceInMinutes(
+ new Date(event.endDateTime),
+ new Date(event.startDateTime),
+ );
+ } else if (event.startDate && event.endDate) {
+ if (isAllDay) {
+ duration = differenceInMinutes(
+ new Date(event.endDate),
+ new Date(event.startDate),
+ );
+ } else {
+ duration = defaultDuration;
+ }
+ }
+ } else if (type === "TASK_BLOCK") {
+ duration = event.duration || defaultDuration;
+ }
+
+ const [newX, newY] = getDnDCorrectedPosition([x, y], {
+ snapSize: [dayWidth, hourHeight / 4],
+ headerHeight: headerHeight,
+ dateLabelHeight: dateLabelHeight,
+ container: [containerRect.left + asideWidth, containerRect.top],
+ scrollTop: container.scrollTop,
+ });
+
+ const style = getItemStyle([x, y], [newX, newY], {
+ dayWidth,
+ asideWidth: asideWidth,
+ containerRect: containerRect,
+ dateLabelHeight: dateLabelHeight,
+ headerHeight: headerHeight,
+ hourHeight: isAllDay ? 100 : hourHeight,
+ duration,
+ });
+
+ const dates = getDnDDateFromPosition([newX, newY], {
+ start,
+ snapSize: [dayWidth, hourHeight / 4],
+ containerRect,
+ asideWidth,
+ headerHeight,
+ hourHeight: isAllDay ? 100 : hourHeight,
+ scrollLeft: container.scrollLeft,
+ scrollTop: container.scrollTop,
+ });
+
+ return (
+
+
+ {item.title}
+ {dates.dateTime && format(dates.dateTime, "HH:mm")}
+
+
+ );
+};
+
+const Root = styled.section`
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+`;
+
+const Task = styled.div`
+ overflow: hidden;
+ font-size: 12px;
+ box-sizing: border-box;
+ border: 1px solid ${(p) => p.theme.primaryColor};
+ border-radius: 4px;
+ background-color: ${(p) => p.theme.contentBackgroundColor};
+ padding: 2px 10px;
+`;
+
+type TransformState = "free" | "allDay" | "event";
+type Coords = [number, number];
+
+type GetItemStyleParams = {
+ containerRect: DOMRect;
+ asideWidth: number;
+ dateLabelHeight: number;
+ headerHeight: number;
+ hourHeight: number;
+ dayWidth: number;
+ duration?: number;
+};
+
+const getItemStyle = (
+ defaultPos: Coords,
+ newPos: Coords,
+ params: GetItemStyleParams,
+) => {
+ const {
+ dayWidth,
+ hourHeight,
+ asideWidth,
+ dateLabelHeight,
+ headerHeight,
+ containerRect,
+ duration = 60 / 2,
+ } = params;
+ const [x, y] = defaultPos;
+ const [newX, newY] = newPos;
+
+ let width, height, transform;
+
+ let state: TransformState = "free";
+
+ if (
+ x > containerRect.left + asideWidth &&
+ y > containerRect.top + dateLabelHeight
+ ) {
+ if (y < containerRect.top + headerHeight) {
+ state = "allDay";
+ } else {
+ state = "event";
+ }
+ }
+
+ if (state === "free") {
+ transform = `translate3d(${x}px, ${y}px, 0)`;
+ width = dayWidth;
+ height = hourHeight / 4;
+ } else if (state === "allDay") {
+ // allDayEvent
+ height = 100 / 4;
+ transform = `translate3d(${newX}px, ${
+ dateLabelHeight + containerRect.top
+ }px, 0)`;
+ width = Math.max((duration / (24 * 60)) * dayWidth, dayWidth);
+ } else {
+ // event
+ transform = `translate3d(${newX}px, ${newY}px, 0)`;
+ width = `${dayWidth}px`;
+ height = (duration * hourHeight) / 60;
+ }
+
+ return {
+ transform,
+ height,
+ width,
+ };
+};
diff --git a/src/components/App/Sidebar.tsx b/src/components/App/Sidebar.tsx
new file mode 100644
index 0000000..17e9a2e
--- /dev/null
+++ b/src/components/App/Sidebar.tsx
@@ -0,0 +1,132 @@
+import React from "react";
+import styled from "styled-components";
+import { colord } from "colord";
+import { Text } from "../Text";
+import { TasksList } from "./TasksList";
+import type { TProject, TTask } from "./types";
+import ChevronDown from "../../../public/icons/chevron-down.svg";
+import PlayIcon from '../../../public/icons/play.svg';
+import SettingsIcon from '../../../public/icons/settings.svg';
+
+type Props = {
+ tasks: TTask[];
+ projects: { [key: string]: TProject };
+};
+
+export const Sidebar = (props: Props) => {
+
+ return (
+
+
+
+ Add task
+
+
+
+
+
+
+
+
+ Click to select a task
+ 0:00
+
+
+
+ Group by:
+ Project
+
+
+
+
+ );
+};
+
+const Root = styled.aside`
+ flex-shrink: 1;
+ flex-grow: 0;
+ flex-basis: 250px;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ gap: 8px;
+ border-right: 1px solid rgb(238, 238, 238);
+ background-color: rgb(255, 255, 255);
+ padding: 10px;
+ transition: transform 0.5s ease 0s;
+ z-index: 5;
+`;
+
+const Action = styled.div`
+ display: flex;
+ gap: 4px;
+`;
+
+const ActionButton = styled.div`
+ position: relative;
+ flex-grow: 1;
+ height: 32px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background: rgb(3, 169, 244);
+ border-radius: 3px;
+
+ &:hover {
+ background: ${(p) => colord("rgb(3, 169, 244)").darken(0.025).toHex()};
+ }
+`;
+
+const TransparentButton = styled.button`
+ display: flex;
+ justify-content: center;
+ background-color: transparent;
+ border: none;
+ outline: none;
+ min-width: 32px;
+ color: #fff;
+`;
+
+const GrowTransparentButton = styled(TransparentButton)`
+ flex-grow: 1;
+`;
+
+const Line = styled.div`
+ width: 1px;
+ height: 60%;
+ background-color: #fff;
+ opacity: 30%;
+ flex-shrink: 0;
+`;
+
+const Tracker = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ height: 32px;
+ box-sizing: border-box;
+ padding: 4px 2px;
+ background-color: ${(p) => p.theme.backgroundColor};
+ border-radius: 4px;
+`;
+
+const TrackerText = styled(Text)`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-grow: 1;
+`;
+
+const SidebarSettings = styled.div`
+ display: flex;
+ align-items: center;
+ border-bottom: 1px solid #eee;
+ padding-bottom: 4px;
+`;
+
+const SidebarSettingsItem = styled.div`
+ flex-grow: 1;
+ display: flex;
+ gap: 4px;
+`;
\ No newline at end of file
diff --git a/src/components/App/TasksList.tsx b/src/components/App/TasksList.tsx
new file mode 100644
index 0000000..8dc1e5b
--- /dev/null
+++ b/src/components/App/TasksList.tsx
@@ -0,0 +1,153 @@
+import React, { useEffect } from "react";
+import styled from "styled-components";
+import { Text } from "../Text";
+import { Status, type TProject, type TTask } from "./types";
+import { TaskStatus } from "../TaskStatus/TaskStatus";
+import CaseImg from "../../../public/emoji/case.png";
+import { useDrag } from "react-dnd";
+import { getEmptyImage } from "react-dnd-html5-backend";
+import { TASK } from "./DragLayer";
+
+type TasksListProps = {
+ tasks: TTask[];
+ projects: { [key: string]: TProject };
+};
+
+export const TasksList = (props: TasksListProps) => {
+ const groups = props.tasks.reduce(
+ (groups, task) => {
+ const key = task.project.title;
+
+ if (!groups[key]) {
+ groups[key] = [];
+ }
+
+ groups[key].push(task);
+
+ return groups;
+ },
+ {} as { [key: string]: TTask[] },
+ );
+
+ return (
+
+ {Object.entries(groups).map(([key, value]) => {
+ return (
+
+ );
+ })}
+
+ );
+};
+
+type GroupProps = {
+ title: string;
+ icon: string;
+ tasks: TTask[];
+ projects: { [key: string]: TProject };
+};
+
+const Group = (props: GroupProps) => {
+ return (
+
+
+
+ {props.title}
+
+
+ {props.tasks.map((task) => {
+ return ;
+ })}
+
+
+ );
+};
+
+type TaskProps = {
+ task: TTask;
+ projects: { [key: string]: TProject };
+};
+
+const Task = (props: TaskProps) => {
+ const { task, projects } = props;
+
+ const [{ isDragging }, drag, dragPreview] = useDrag({
+ type: TASK,
+ item: task,
+ collect: (monitor) => ({
+ isDragging: monitor.isDragging(),
+ }),
+ });
+
+ useEffect(() => {
+ dragPreview(getEmptyImage());
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ {task.title}
+
+ );
+};
+
+const TasksListRoot = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ overflow-y: auto;
+`;
+
+const GroupRoot = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+`;
+
+const GroupTitle = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+`;
+
+const GroupTasks = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+`;
+
+const TaskRoot = styled.div<{ backgroundColor?: string; color?: string }>`
+ padding: 5px 10px 5px 5px;
+ border-radius: 3px;
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+ justify-content: flex-start;
+ background-color: ${(p) => p.backgroundColor};
+ color: ${(p) => p.color};
+ border: 1px solid ${(p) => p.color};
+`;
+
+const IconWrapper = styled.span`
+ display: inline-block;
+ padding-top: 1px;
+`;
+
+const DragWrapper = styled(IconWrapper)`
+ cursor: grab;
+`;
diff --git a/src/components/App/index.ts b/src/components/App/index.ts
new file mode 100644
index 0000000..5edbba4
--- /dev/null
+++ b/src/components/App/index.ts
@@ -0,0 +1 @@
+export { Root as App } from './App';
\ No newline at end of file
diff --git a/src/components/App/theme/light.ts b/src/components/App/theme/light.ts
new file mode 100644
index 0000000..0a0319a
--- /dev/null
+++ b/src/components/App/theme/light.ts
@@ -0,0 +1,18 @@
+export const light = {
+ backgroundColor: "#fafafa",
+ textColor: "#444",
+ textColor2: "#626362",
+ altTextColor: "#fff",
+ mutedColor: "#888",
+ primaryColor: "#03a9f4",
+ successColor: "#00aa00",
+ dangerColor: "#e00",
+ warningColor: "#ee8a00",
+ contentBackgroundColor: "#fff",
+ borderColor: "#eee",
+ inputBorderColor: "#bbb",
+ selectedBackgroundColor: "#e1f5fe",
+ eventBackgroundColor: "#e1f5fe",
+ eventColor: "#047ad8",
+ shadow: "0 0 2px rgb(0 0 0 / 60%)",
+};
diff --git a/src/components/App/types.ts b/src/components/App/types.ts
new file mode 100644
index 0000000..a39f70f
--- /dev/null
+++ b/src/components/App/types.ts
@@ -0,0 +1,37 @@
+
+
+export type TTask = {
+ id: string;
+ title: string;
+ project: TProject;
+ status: Status;
+}
+
+export enum Status {
+ TODO = 'TODO',
+ IN_PROGRESS = 'IN_PROGRESS',
+ DONE = 'DONE',
+ CANCELLED = 'CANCELLED'
+}
+
+export type TProject = {
+ id: string;
+ title: string;
+ backgroundColor: string;
+ color: string;
+}
+
+export type TTimeEntry = {
+ id: string;
+ taskId: string;
+ startDateTime: Date;
+ endDateTime: Date;
+ parent: TTask;
+}
+
+export type CalendarEventData = {
+ task?: TTask;
+ timeEntry?: TTimeEntry;
+ event?: Event;
+ type: symbol;
+};
\ No newline at end of file
diff --git a/src/components/Benefits.astro b/src/components/Benefits.astro
index 1d1fd17..65fc831 100644
--- a/src/components/Benefits.astro
+++ b/src/components/Benefits.astro
@@ -102,3 +102,4 @@ import { Statistics } from "./Statistics/Statistics";
+./CalendarBlock/Calendar
\ No newline at end of file
diff --git a/src/components/Demo/Demo.tsx b/src/components/Demo/Demo.tsx
new file mode 100644
index 0000000..c2aab62
--- /dev/null
+++ b/src/components/Demo/Demo.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect } from "react";
+import { App } from "../App";
+import { animated, useSpring, useSpringRef, useChain } from "@react-spring/web";
+import styled from "styled-components";
+
+export const Demo = () => {
+ const firstRef = useSpringRef();
+ const secondRef = useSpringRef();
+ const thirdRef = useSpringRef();
+
+ const heading1Styles = useSpring({
+ ref: firstRef,
+ from: { opacity: 0, transform: "translate3d(0, -40px, 0)" },
+ to: { opacity: 1, transform: "translate3d(0, 0, 0)" },
+ delay: 1000,
+ config: { duration: 1000 },
+ });
+
+ const heading2Styles = useSpring({
+ ref: secondRef,
+ from: { opacity: 0, transform: "translate3d(0, -40px, 0)" },
+ to: { opacity: 1, transform: "translate3d(0, 0, 0)" },
+ delay: 2500,
+ config: { duration: 1000 },
+ });
+
+ const heading3Styles = useSpring({
+ ref: thirdRef,
+ from: { opacity: 0, transform: "translate3d(0, -40px, 0)" },
+ to: { opacity: 1, transform: "translate3d(0, 0, 0)" },
+ delay: 2000,
+ config: { duration: 1000 },
+ });
+
+ useChain([firstRef, secondRef, thirdRef]);
+
+ return (
+
+
+ We can't get back the wasted time.
+
+
+ But we can help you waste less.
+
+
+ Week is a calendar and task manager app
+ for individuals who aspire to accomplish more.
+
+
+
+ );
+};
+
+const Heading2 = styled(animated.h2)`
+ text-align: center;
+`;
+
+const Info = styled(animated.div)`
+ text-align: center;
+`;
\ No newline at end of file
diff --git a/src/components/Features.astro b/src/components/Features.astro
index 83f4153..c8d4627 100644
--- a/src/components/Features.astro
+++ b/src/components/Features.astro
@@ -16,6 +16,10 @@ import { Tasks } from "./Tasks/Tasks";
margin-top: 256px;
}
+ h3 {
+ text-align: center;
+ }
+
@media screen and (max-width: 768px) {
main {
margin-top: 64px;
@@ -145,4 +149,4 @@ import { Tasks } from "./Tasks/Tasks";
-
+
\ No newline at end of file
diff --git a/src/components/Footer.astro b/src/components/Footer.astro
index da4e023..c8d56dc 100644
--- a/src/components/Footer.astro
+++ b/src/components/Footer.astro
@@ -73,7 +73,7 @@ import apps from './apps.json'
Week
- ©️ 2023
+ ©️ 2024
diff --git a/src/components/Note/Note.tsx b/src/components/Note/Note.tsx
index eaed588..e4edd4b 100644
--- a/src/components/Note/Note.tsx
+++ b/src/components/Note/Note.tsx
@@ -12,6 +12,7 @@ import {
import { DonutChart } from "../DonutChart/DonutChart";
import { TaskStatus } from "../TaskStatus/TaskStatus";
import styles from "./Note.module.css";
+import { Status } from "../App/types";
const noteMessage = `Need to prepare new Grafana dashboard by the end of this week. It
should display Web Vitals metrics and overall score.`;
@@ -54,7 +55,7 @@ export const Note = () => {
return (
-
+
Prepare dashboard for metrics
diff --git a/src/components/TaskStatus/TaskStatus.tsx b/src/components/TaskStatus/TaskStatus.tsx
index 4440557..37f2e16 100644
--- a/src/components/TaskStatus/TaskStatus.tsx
+++ b/src/components/TaskStatus/TaskStatus.tsx
@@ -3,22 +3,23 @@ import notStarted from './not-started.svg';
import inProgress from './in-progress.svg';
import completed from './completed.svg';
import cancelled from './cancelled.svg';
+import { Status } from '../App/types';
-export const TaskStatus = ({status}) => {
+export const TaskStatus = ({status}: {status: Status}) => {
switch (status) {
- case 'notStarted':
+ case Status.TODO:
return (
)
- case 'inProgress':
+ case Status.IN_PROGRESS:
return (
)
- case 'completed':
+ case Status.DONE:
return (
)
- case 'cancelled':
+ case Status.CANCELLED:
return (
)
diff --git a/src/components/Tasks/Tasks.tsx b/src/components/Tasks/Tasks.tsx
index b9aeb80..9b51741 100644
--- a/src/components/Tasks/Tasks.tsx
+++ b/src/components/Tasks/Tasks.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { Task } from '../Task/Task';
import styles from './Tasks.module.css';
+import { Status } from '../App/types';
const tasks = [
{
@@ -13,7 +14,7 @@ const tasks = [
)},
- status: 'notStarted',
+ status: Status.TODO,
priority: (
<>
@@ -35,7 +36,7 @@ const tasks = [
)},
- status: 'inProgress',
+ status: Status.IN_PROGRESS,
priority: (
<>
@@ -50,7 +51,7 @@ const tasks = [
id: 3,
title: "Buy light bulbs",
project: {title: 'Home', color: '#E8F5E9'},
- status: 'completed',
+ status: Status.DONE,
priority: (
<>
diff --git a/src/components/Text/Text.tsx b/src/components/Text/Text.tsx
new file mode 100644
index 0000000..2bbffd3
--- /dev/null
+++ b/src/components/Text/Text.tsx
@@ -0,0 +1,71 @@
+import React, { forwardRef, AnchorHTMLAttributes, ReactNode, HTMLAttributes } from 'react';
+import styled from 'styled-components';
+
+type CommonProps = {
+ size?: 'xs' | 'sm' | 'md' | 'lg';
+ muted?: boolean;
+ bold?: boolean;
+ interactive?: boolean;
+ inherit?: boolean;
+ link?: boolean;
+ children: ReactNode;
+};
+
+type SpanProps = {
+ as?: 'span';
+} & CommonProps & HTMLAttributes
+
+type AnchorProps = {
+ as?: 'a';
+} & CommonProps & AnchorHTMLAttributes
+
+export const Text = forwardRef((props, ref: React.Ref) => {
+ const {
+ children,
+ as = 'span',
+ ...rest
+ } = props;
+
+ return (
+ {children}
+ )
+});
+
+const Root = styled.span`
+ font-family: 'Open Sans', sans-serif;
+ text-decoration: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-start;
+ text-align: left;
+ min-height: 18px;
+ font-weight: ${p => p.bold ? 600 : 'normal'};
+ font-size: ${p => {
+ switch (p.size) {
+ case 'xs':
+ return '10px';
+ case 'sm':
+ return '12px';
+ case 'lg':
+ return '15px';
+ case 'md':
+ default:
+ return '13px';
+ }
+ }};
+ color: ${p => {
+ // p.muted ? p.theme.mutedColor : p.inherit ? 'inherit' : p.theme.textColor
+ if (p.muted) {
+ return p.theme.mutedColor;
+ }
+ if (p.inherit) {
+ return 'inherit';
+ }
+ if (p.link) {
+ return p.theme.primaryColor;
+ }
+
+ return p.theme.textColor;
+ }};
+ cursor: ${p => p.interactive ? 'pointer' : 'inherit'};
+`;
diff --git a/src/components/Text/index.ts b/src/components/Text/index.ts
new file mode 100644
index 0000000..b0c76af
--- /dev/null
+++ b/src/components/Text/index.ts
@@ -0,0 +1 @@
+export * from './Text';
diff --git a/src/components/WeekCalendar/components/WeekCalendar.tsx b/src/components/WeekCalendar/components/WeekCalendar.tsx
new file mode 100644
index 0000000..7d04d9e
--- /dev/null
+++ b/src/components/WeekCalendar/components/WeekCalendar.tsx
@@ -0,0 +1,212 @@
+import React from "react";
+import styled, { ThemeProvider } from "styled-components";
+import type {
+ IDraggable,
+ WeekCalendarColorScheme,
+ WeekEvent,
+ WeekEventDates,
+ WeekTimezone,
+ WeekTimebox,
+} from "../types";
+import { WeekCalendarView } from "../types";
+import { DayView } from "./views/DayView/DayView";
+import { type ReactElement } from "react";
+import { DAYS_COUNT_BY_VIEWS } from "./views/DayView/constants";
+
+type Props = {
+ /**
+ * Color scheme for the calendar.
+ */
+ colorScheme?: WeekCalendarColorScheme;
+ /**
+ * Currently selected date of the calendar.
+ */
+ date: Date;
+ /**
+ * Currently selected view of the calendar.
+ */
+ view: WeekCalendarView;
+ /**
+ * Height of one hour in pixels.
+ */
+ hourHeight?: number;
+ /**
+ * Drag'n'drop layers
+ */
+ layers: Partial>;
+ /**
+ * List of accepted drop targets.
+ */
+ accepts: (string | symbol)[];
+ /**
+ * Events to display.
+ */
+ events: WeekEvent[];
+ /**
+ * Timeboxes to display.
+ */
+ timeboxes: WeekTimebox[];
+ /**
+ * Timezones to display.
+ */
+ timezones: WeekTimezone[];
+ /**
+ * Renders an event with a custom styling.
+ *
+ * @param event Event to render.
+ * @param params Event's start and end dates.
+ */
+ renderEvent: (
+ event: WeekEvent,
+ params: { startDateTime: Date; endDateTime: Date },
+ ) => React.ReactNode;
+
+ /**
+ * Any additional info to display in the day header.
+ *
+ * @param date Date of the day..
+ */
+ renderDayInfo?(date: Date, events: WeekEvent[]): React.ReactNode;
+
+ /**
+ * Called when user scrolls calendar to a different date.
+ *
+ * @param date New date.
+ */
+ onDateChange(date: Date): void;
+
+ /**
+ * Called when user changes calendar's visible range.
+ *
+ * @param range New range.
+ */
+ onRangeChange(range: Interval): void;
+
+ /**
+ * Called when user adds a new event.
+ *
+ * @param dates New dates of the event.
+ * @param event The changed event.
+ */
+ onEventAdd(dates: WeekEventDates, event: TDroppable): void;
+
+ /**
+ * Called when user requests to add a new event.
+ *
+ * @param dates Dates of the new event.
+ */
+ onEventAddRequest(dates: WeekEventDates): void;
+
+ /**
+ * Called when user changes event dates.
+ *
+ * @param dates New dates of the event.
+ * @param event The changed event.
+ */
+ onEventChange(dates: WeekEventDates, event: WeekEvent): void;
+
+ /**
+ * Called when user changes timebox dates.
+ *
+ * @param dates New dates of the timebox.
+ * @param timebox The changed timebox.
+ */
+ onTimeboxChange(dates: WeekEventDates, timebox: WeekTimebox): void;
+
+ /**
+ * Called when user clicks on an event.
+ *
+ * @param event WeekEvent that was clicked.
+ */
+ onEventSelect?(event: WeekEvent): void;
+
+ /**
+ * Called when user tries to open context menu on an event.
+ *
+ * @param event WeekEvent that was clicked
+ */
+ onEventContextMenu?(event: WeekEvent, e: React.MouseEvent): void;
+
+ /**
+ * Called when user tries to open context menu on a timebox.
+ *
+ * @param timebox WeekTimebox that was clicked
+ */
+ onTimeboxContextMenu?(timebox: WeekTimebox, e: React.MouseEvent): void;
+};
+
+/**
+ * Main WeekCalendar component. It is a wrapper for all other components.
+ */
+export const WeekCalendar = (
+ props: Props,
+): JSX.Element => {
+ const {
+ events,
+ timeboxes,
+ colorScheme,
+ date,
+ view,
+ timezones,
+ layers,
+ accepts,
+ hourHeight,
+ renderEvent,
+ renderDayInfo,
+ onDateChange,
+ onRangeChange,
+ onEventAdd,
+ onEventAddRequest,
+ onEventChange,
+ onTimeboxChange,
+ onEventSelect,
+ onEventContextMenu,
+ onTimeboxContextMenu,
+ } = props;
+
+ const renderContent = () => {
+ switch (view) {
+ case WeekCalendarView.Day:
+ case WeekCalendarView.Week:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {renderContent()}
+
+ );
+};
+
+const Root = styled.div`
+ flex: 1;
+ overflow: hidden;
+ display: flex;
+ height: 100%;
+ flex-direction: column;
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/AllDayEventBlock.tsx b/src/components/WeekCalendar/components/views/DayView/AllDayEventBlock.tsx
new file mode 100644
index 0000000..3244541
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/AllDayEventBlock.tsx
@@ -0,0 +1,248 @@
+import React, { type ReactNode, useEffect, useRef, useState } from "react";
+import type { IDraggable, WeekEvent, WeekEventDates } from "../../../types";
+import type { CSSProperties } from "react";
+import { useDrag } from "react-dnd";
+import { getEmptyImage } from "react-dnd-html5-backend";
+import {
+ addDays,
+ differenceInDays,
+ endOfDay,
+ startOfDay,
+ subDays,
+} from "date-fns";
+import styled from "styled-components";
+import {
+ getAllDayDateFromPosition,
+ getCorrectedPosition,
+ getDateFromPosition,
+} from "./utils";
+import { EVENTS_PADDING_RIGHT } from "./constants";
+
+type ResizingState = "left" | "right" | false;
+
+type Props = {
+ event: WeekEvent;
+ style?: CSSProperties;
+ start: Date;
+ left: number;
+ length: number;
+ dayWidth: number;
+ hourHeight: number;
+ asideWidth: number;
+ container: HTMLDivElement;
+ containerRect: DOMRect;
+ headerHeight: number;
+ renderEvent(event: WeekEvent, params: WeekEventDates): React.ReactNode;
+ onEventChange(dates: WeekEventDates, event: WeekEvent): void;
+};
+
+export const AllDayEventBlock = (
+ props: Props,
+): JSX.Element => {
+ const {
+ renderEvent,
+ event: { startDate, endDate, ...event },
+ start,
+ dayWidth,
+ container,
+ containerRect,
+ headerHeight,
+ asideWidth,
+ onEventChange,
+ } = props;
+
+ const [isResizing, setResizing] = useState(false);
+ const [left, setLeft] = useState(props.left);
+ const [length, setLength] = useState(props.length);
+ const datesRef = useRef<[Date, Date]>([startDate!, endDate!]);
+
+ useEffect(() => {
+ setLeft(props.left);
+ setLength(props.length);
+ }, [props.left, props.length]);
+
+ const [{ isDragging }, drag, preview] = useDrag({
+ type: event.data.type,
+ item: event,
+ collect: (monitor) => ({
+ isDragging: Boolean(monitor.isDragging()),
+ }),
+ });
+
+ useEffect(() => {
+ preview(getEmptyImage());
+ }, []);
+
+ useEffect(() => {
+ const handleResize = (event: MouseEvent) => {
+ if (!isResizing) {
+ return;
+ }
+
+ const [x, y] = getCorrectedPosition([event.x, event.y], {
+ snapSize: [dayWidth, 100 / 4],
+ containerRect,
+ headerHeight,
+ asideWidth,
+ direction: isResizing,
+ });
+
+ const newDate = getAllDayDateFromPosition([x + container.scrollLeft, y], {
+ start,
+ headerHeight,
+ dayWidth,
+ asideWidth,
+ hourHeight: 100,
+ timeFrom: 0,
+ timeTo: 24,
+ });
+
+ if (isResizing === "right") {
+ const diffLength = differenceInDays(newDate, endDate) + 1;
+ const oldLength = props.length + 1;
+
+ setLength(oldLength + diffLength);
+ datesRef.current = [startDate!, newDate];
+ } else if (isResizing === "left") {
+ const diffLength = differenceInDays(startDate, newDate);
+ const oldLength = props.length + 1;
+
+ setLeft(x + container.scrollLeft);
+ setLength(oldLength + diffLength);
+ datesRef.current = [newDate, endDate!];
+ }
+ };
+
+ if (isResizing) {
+ document.addEventListener("mousemove", handleResize);
+ document.addEventListener("mouseup", handleStopResizing);
+ }
+
+ return () => {
+ document.removeEventListener("mousemove", handleResize);
+ document.removeEventListener("mouseup", handleStopResizing);
+ };
+ }, [
+ isResizing,
+ containerRect,
+ asideWidth,
+ headerHeight,
+ dayWidth,
+ left,
+ length,
+ ]);
+
+ const handleStartResizingLeft = (
+ event: React.MouseEvent,
+ ) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing("left");
+ };
+
+ const handleStartResizingRight = (
+ event: React.MouseEvent,
+ ) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing("right");
+ };
+
+ const handleStopResizing = (ev) => {
+ ev.stopPropagation();
+ ev.preventDefault();
+
+ if (!isResizing) {
+ return;
+ }
+
+ onEventChange(
+ {
+ startDate: startOfDay(datesRef.current[0]),
+ endDate: endOfDay(datesRef.current[1]),
+ },
+ event,
+ );
+ };
+
+ return (
+ {
+ setResizing(false);
+ // if (!isResizing) {
+ // onEventSelect(event);
+ // }
+ }}
+ isDragging={isDragging}
+ style={{
+ ...props.style,
+ height: 25,
+ left: isResizing ? left : props.left,
+ width: isResizing
+ ? `${length * dayWidth - EVENTS_PADDING_RIGHT}px`
+ : `${(props.length + 1) * dayWidth - EVENTS_PADDING_RIGHT}px`,
+ }}
+ ref={drag}
+ >
+ {renderEvent(event, {
+ startDate: isResizing === "left" ? datesRef.current[0] : startDate!,
+ endDate: isResizing === "right" ? datesRef.current[1] : endDate!,
+ })}
+
+
+
+
+
+
+
+ );
+};
+
+const ResizeHandlerContainer = styled.div`
+ position: absolute;
+ height: 100%;
+ top: 0;
+ bottom: 0;
+ width: 8px;
+ display: flex;
+ justify-content: center;
+ cursor: ew-resize;
+`;
+
+const ResizeHandlerContainerLeft = styled(ResizeHandlerContainer)`
+ left: 0;
+ padding-left: 5px;
+`;
+
+const ResizeHandlerContainerRight = styled(ResizeHandlerContainer)`
+ right: 0;
+ padding-right: 5px;
+`;
+
+const ResizeHandler = styled.span`
+ position: absolute;
+ display: inline-block;
+ width: 3px;
+ background-color: #fff;
+ border-radius: 3px;
+ height: calc(100% - 20%);
+ top: 10%;
+ opacity: 0;
+ transition: all 0.35s;
+`;
+
+const Root = styled.div<{ isDragging: boolean }>`
+ user-select: none;
+ position: absolute;
+ overflow: hidden;
+ background: ${(p) => p.theme.contentBackgroundColor};
+ color: ${(p) => p.theme.defaultEventTextColor};
+ border-radius: 4px;
+ margin: 0px;
+ z-index: 3;
+ opacity: ${(p) => (p.isDragging ? 0.5 : 1)};
+
+ &:hover ${ResizeHandler} {
+ opacity: 0.3;
+ }
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/CurrentTime.tsx b/src/components/WeekCalendar/components/views/DayView/CurrentTime.tsx
new file mode 100644
index 0000000..56eb685
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/CurrentTime.tsx
@@ -0,0 +1,97 @@
+import React, { useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+import { getPositionFromDate } from './utils';
+import { formatInTimeZone as formatTz } from 'date-fns-tz';
+import type { WeekTimezone } from '../../../types';
+
+type Props = {
+ startDate: Date;
+ asideWidth: number;
+ dayWidth: number;
+ headerHeight: number;
+ hourHeight: number;
+ startHour: number;
+ timeFormat: 12 | 24;
+ timezones: WeekTimezone[];
+};
+
+export const CurrentTime = (props: Props): JSX.Element => {
+ const {
+ startDate,
+ dayWidth,
+ timeFormat,
+ asideWidth,
+ headerHeight,
+ hourHeight,
+ startHour,
+ timezones = [],
+ } = props;
+
+ const [now, setNow] = useState(-1);
+ const intervalRef = useRef();
+
+ useEffect(() => {
+ const handleUpdate = () => {
+ const date = new Date();
+ const [, now] = getPositionFromDate(date, {
+ startDate,
+ dayWidth,
+ headerHeight,
+ startHour,
+ hourHeight,
+ });
+
+ setNow(now);
+ };
+
+ handleUpdate();
+
+ intervalRef.current = setInterval(handleUpdate, 1000);
+
+ return () => {
+ setNow(-1);
+ clearInterval(intervalRef.current);
+ };
+ }, [startDate, dayWidth, hourHeight]);
+
+ return (
+
+ {timezones.map((timezone) => (
+
+ {formatTz(new Date(), timezone.name, timeFormat === 12 ? 'h:mm aaa' : 'HH:mm', {})}
+
+ ))}
+
+ );
+};
+
+const Now = styled.div`
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ font-size: 12px;
+ position: absolute;
+ justify-content: space-between;
+ z-index: 5;
+ background: ${(p) => p.theme.weekendTextColor};
+ color: #fff;
+ padding: 2px 4px;
+ right: 0;
+ transform: translateY(-50%);
+ border-radius: 3px;
+
+ & > span {
+ color: #fff;
+ flex-grow: 1;
+ display: flex;
+ justify-content: center;
+ }
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/DayView.tsx b/src/components/WeekCalendar/components/views/DayView/DayView.tsx
new file mode 100644
index 0000000..afcda4b
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/DayView.tsx
@@ -0,0 +1,1148 @@
+import React, {
+ memo,
+ useEffect,
+ useRef,
+ useState,
+ useLayoutEffect,
+ type ReactElement,
+ type MouseEvent,
+} from "react";
+import styled from "styled-components";
+import {
+ addDays,
+ addMinutes,
+ differenceInDays,
+ differenceInMinutes,
+ format,
+ isToday,
+ set,
+ startOfDay,
+ subDays,
+} from "date-fns";
+import { formatInTimeZone as formatTz } from "date-fns-tz";
+import { eachHourOfInterval, isSameDay, isWeekend, setHours } from "date-fns";
+import { useDayLayout } from "./useDayLayout";
+import {
+ ASIDE_WIDTH,
+ DATE_FORMAT,
+ DATE_LABEL_HEIGHT,
+ DAYS_DIFF,
+ DAYS_THRESHOLD,
+ EVENTS_PADDING_RIGHT,
+} from "./constants";
+import {
+ getCorrectedPosition,
+ getDateFromPosition,
+ getDnDDateFromPosition,
+ getPositionOfEvent,
+ getRange,
+} from "./utils";
+import type {
+ IDraggable,
+ WeekEvent,
+ WeekEventDates,
+ WeekTimezone,
+ WeekTimebox,
+} from "../../../types";
+import { EventBlock } from "./EventBlock";
+import { useDrop } from "react-dnd";
+import {
+ CALENDAR_EVENT_BLOCK,
+ CALENDAR_TASK_BLOCK,
+ CALENDAR_TIMEBOX_BLOCK,
+} from "../../../constants";
+import { useAllDayLayout } from "./useAllDayLayout";
+import { AllDayEventBlock } from "./AllDayEventBlock";
+import { useTimeboxesLayout } from "./useTimeboxesLayout";
+import { TimeboxBlock } from "./TimeboxBlock";
+import { Timeline } from "./Timeline";
+import { CurrentTime } from "./CurrentTime";
+import type { NewAllDayEvent, NewEvent } from "./types";
+
+type Props = {
+ /**
+ * Currently selected date of the calendar.
+ */
+ date: Date;
+
+ /**
+ * Events to display.
+ */
+ events: WeekEvent[];
+
+ /**
+ * Timeboxes to display.
+ */
+ timeboxes: WeekTimebox[];
+
+ /**
+ * Timezones to display.
+ */
+ timezones: WeekTimezone[];
+
+ /**
+ * Edit mode of the calendar.
+ */
+ mode?: "events" | "timeboxes";
+
+ /**
+ * List of accepted drop targets
+ */
+ accepts?: (string | symbol)[];
+
+ /**
+ * Drag'n'drop layer for displaying dragging events
+ */
+ layer?: ReactElement;
+
+ /**
+ * Amount of days to display.
+ */
+ daysCount: number;
+
+ /**
+ * Hour from which to start displaying calendar grid.
+ */
+ startHour?: number;
+
+ /**
+ * Hour at which to end displaying calendar grid.
+ */
+ endHour?: number;
+
+ /**
+ * Height of an hour in pixels.
+ */
+ hourHeight?: number;
+
+ /**
+ * Calls to render an event with a custom styling.
+ *
+ * @param event Event to render.
+ * @param params Actual value of event start and end dates.
+ */
+ renderEvent(
+ event: WeekEvent,
+ params: { startDateTime: Date; endDateTime: Date },
+ ): React.ReactNode;
+
+ /**
+ * Any additional info to display in the day header.
+ *
+ * @param date Date of the day..
+ */
+ renderDayInfo?(date: Date, events: WeekEvent[]): React.ReactNode;
+
+ /**
+ * Called when user scrolls calendar to a different date.
+ *
+ * @param date New date.
+ */
+ onDateChange(date: Date): void;
+
+ /**
+ * Called when user changes calendar's visible range.
+ *
+ * @param range New range.
+ */
+ onRangeChange(range: Interval): void;
+
+ /**
+ * Called when user adds a new event.
+ *
+ * @param dates New dates of the event.
+ * @param event The changed event.
+ */
+ onEventAdd(dates: WeekEventDates, event: TDroppable): void;
+
+ /**
+ * Called when user requests to add a new event.
+ *
+ * @param dates Dates of the new event.
+ */
+ onEventAddRequest(dates: WeekEventDates): void;
+
+ /**
+ * Called when user changes event dates.
+ *
+ * @param dates New dates of the event.
+ * @param event The changed event.
+ */
+ onEventChange(dates: WeekEventDates, event: WeekEvent): void;
+
+ /**
+ * Called when user changes timebox dates.
+ *
+ * @param dates New dates of the timebox.
+ * @param timebox The changed timebox.
+ */
+ onTimeboxChange(dates: WeekEventDates, timebox: WeekTimebox): void;
+
+ /**
+ * Called when user clicks on an event.
+ *
+ * @param event WeekEvent that was clicked.
+ */
+ onEventSelect?(event: WeekEvent): void;
+
+ /**
+ * Called when user tries to open context menu on an event.
+ *
+ * @param event WeekEvent that was clicked
+ */
+ onEventContextMenu?(event: WeekEvent, e: React.MouseEvent): void;
+
+ /**
+ * Called when user tries to open context menu on a timebox.
+ *
+ * @param timebox WeekTimebox that was clicked
+ */
+ onTimeboxContextMenu?(timebox: WeekTimebox, e: React.MouseEvent): void;
+};
+
+export const DayView = (
+ props: Props,
+): JSX.Element => {
+ const {
+ date,
+ events = [],
+ timeboxes = [],
+ timezones = [],
+ accepts = [],
+ mode = "events",
+ layer,
+ daysCount,
+ startHour = 0,
+ endHour = 23,
+ hourHeight = 100,
+ renderEvent,
+ renderDayInfo = () => null,
+ onDateChange,
+ onRangeChange,
+ onEventAdd,
+ onEventChange,
+ onEventAddRequest,
+ onTimeboxChange,
+ onEventSelect,
+ onEventContextMenu,
+ onTimeboxContextMenu,
+ } = props;
+
+ const isFirstRender = useRef(true);
+
+ const rootRef = useRef(null);
+ const bodyRef = useRef(null);
+ const rootRectRef = useRef(null);
+
+ const edges = useRef({
+ left: 0,
+ right: 0,
+ });
+ const direction = useRef(0);
+
+ const asideWidth = ASIDE_WIDTH * timezones.length;
+ const [dayWidth, setDayWidth] = useState(0);
+ const [headerHeight] = useState(DATE_LABEL_HEIGHT + (100 / 4) * 2.5);
+ const [range, setRange] = useState(getRange(date));
+
+ const [newEvent, setNewEvent] = useState(null);
+ const [newAllDayEvent, setNewAllDayEvent] = useState(
+ null,
+ );
+
+ const hours = eachHourOfInterval({
+ start: setHours(date, startHour),
+ end: setHours(date, endHour),
+ });
+
+ const eventsMap = useDayLayout(events);
+ const allDayEvents = useAllDayLayout(events);
+ const timeboxesMap = useTimeboxesLayout(timeboxes);
+
+ const [, dropRef] = useDrop({
+ accept: [
+ ...accepts,
+ CALENDAR_EVENT_BLOCK,
+ CALENDAR_TASK_BLOCK,
+ CALENDAR_TIMEBOX_BLOCK,
+ ],
+ collect: (monitor) => ({
+ type: monitor.getItemType(),
+ }),
+ drop: (item, monitor) => {
+ const type = monitor.getItemType();
+ const coords = monitor.getClientOffset();
+
+ if (!coords || !rootRectRef.current || !rootRef.current) {
+ return null;
+ }
+
+ const event = item as WeekEvent;
+ let duration = 30;
+ let dates;
+
+ const dateInfo = getDnDDateFromPosition([coords.x, coords.y], {
+ start: range[0],
+ snapSize: [dayWidth, hourHeight / 4],
+ scrollLeft: rootRef.current.scrollLeft,
+ scrollTop: rootRef.current.scrollTop,
+ containerRect: rootRectRef.current,
+ asideWidth,
+ hourHeight: hourHeight,
+ headerHeight,
+ });
+
+ if (
+ type === CALENDAR_EVENT_BLOCK ||
+ type === CALENDAR_TASK_BLOCK ||
+ type === CALENDAR_TIMEBOX_BLOCK ||
+ accepts.includes(type)
+ ) {
+ if (!dateInfo.isAllDay) {
+ if (event.endDateTime) {
+ duration = differenceInMinutes(
+ new Date(event.endDateTime),
+ new Date(event.startDateTime),
+ );
+ } else {
+ duration = 60 / 2;
+ }
+ } else {
+ if (event.endDate) {
+ duration = differenceInMinutes(
+ new Date(event.endDate),
+ new Date(event.startDate),
+ );
+ } else {
+ duration = 60 * 24;
+ }
+ }
+ }
+
+ if (dateInfo.date) {
+ dates = {
+ startDate: dateInfo.date,
+ endDate: addMinutes(dateInfo.date, duration),
+ startDateTime: null,
+ endDateTime: null,
+ };
+ } else {
+ dates = {
+ startDate: null,
+ endDate: null,
+ startDateTime: dateInfo.dateTime,
+ endDateTime: addMinutes(dateInfo.dateTime, duration),
+ };
+ }
+
+ if (type === CALENDAR_TASK_BLOCK || type === CALENDAR_EVENT_BLOCK) {
+ onEventChange(dates, item as WeekEvent);
+ } else if (type === CALENDAR_TIMEBOX_BLOCK) {
+ onTimeboxChange(dates, item as WeekTimebox);
+ } else {
+ onEventAdd(dates, item as TDroppable);
+ }
+ },
+ });
+
+ useEffect(() => {
+ if (rootRef.current) {
+ if (direction.current === 0) {
+ const index = range.findIndex((d) => isSameDay(d, date));
+ rootRef.current.scrollLeft = dayWidth * index;
+ rootRef.current.scrollTop = 800;
+ } else if (direction.current === -1) {
+ rootRef.current.scrollLeft = edges.current.left + DAYS_DIFF * dayWidth;
+ } else if (direction.current === 1) {
+ rootRef.current.scrollLeft = edges.current.right - DAYS_DIFF * dayWidth;
+ }
+
+ direction.current = 0;
+ }
+ }, [range, dayWidth]);
+
+ const handleResize = (element) => {
+ const rect = element?.getBoundingClientRect();
+
+ if (rect) {
+ const dayWidth = (rect.width - asideWidth) / daysCount;
+
+ if (rootRef.current) {
+ rootRectRef.current = rootRef.current.getBoundingClientRect();
+ }
+
+ edges.current = {
+ left: DAYS_THRESHOLD * dayWidth,
+ right: (range.length - DAYS_THRESHOLD * 2) * dayWidth,
+ };
+
+ setDayWidth(dayWidth);
+ }
+ };
+
+ useEffect(() => {
+ const resize = () => {
+ handleResize(bodyRef.current);
+ };
+
+ const observer = new ResizeObserver(resize);
+ observer.observe(rootRef.current);
+
+ return () => {
+ observer.disconnect();
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ const newRange = getRange(date);
+
+ onRangeChange({
+ start: newRange[0],
+ end: newRange[newRange.length - 1],
+ });
+ setRange(newRange);
+ }, [date]);
+
+ const renderEvents = (date: Date) => {
+ const key = format(date, DATE_FORMAT);
+ const events = eventsMap.get(key) || [];
+
+ return events.map((eventData) => {
+ const position = getPositionOfEvent(
+ {
+ start: eventData.startDateTime,
+ end: eventData.endDateTime,
+ },
+ {
+ startTime: set(date, {
+ hours: startHour,
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0,
+ }),
+ hourHeight,
+ },
+ );
+
+ const eventWidth = (dayWidth - EVENTS_PADDING_RIGHT) * eventData.width;
+ const colWidth = (dayWidth - EVENTS_PADDING_RIGHT) * eventData.colWidth;
+
+ return (
+
+ );
+ });
+ };
+
+ const renderAllDayEvents = () => {
+ return allDayEvents.map((eventData) => {
+ const diff = differenceInDays(eventData.event.startDate!, range[0]);
+ const length =
+ differenceInDays(eventData.event.endDate!, eventData.event.startDate!) -
+ 1;
+
+ return (
+
+ );
+ });
+ };
+
+ const renderDay = (date: Date, index: number) => {
+ const style = {
+ width: `${dayWidth}px`,
+ left: index * dayWidth + asideWidth,
+ };
+
+ return (
+
+ {hours.map((hour) => (
+
+ ))}
+ {renderEvents(date)}
+ {renderTimeboxes(date)}
+
+ );
+ };
+
+ const renderAllDay = (date, index) => {
+ const style = {
+ width: `${dayWidth}px`,
+ height: headerHeight - DATE_LABEL_HEIGHT,
+ left: index * dayWidth,
+ };
+
+ return (
+
+ );
+ };
+
+ const renderNewEvent = () => {
+ if (!newEvent) {
+ return null;
+ }
+
+ return (
+
+ {format(newEvent.dates[0], "HH:mm")} -{" "}
+ {format(newEvent.dates[1], "HH:mm")}
+
+ );
+ };
+
+ const renderNewAllDayEvent = () => {
+ if (!newAllDayEvent) {
+ return null;
+ }
+
+ const left = dayWidth * differenceInDays(newAllDayEvent.start, range[0]);
+
+ return (
+
+ );
+ };
+
+ const renderTimeboxes = (date: Date) => {
+ const key = format(date, DATE_FORMAT);
+ const timeboxes = timeboxesMap.get(key) || [];
+
+ return timeboxes.map((timebox) => {
+ const position = getPositionOfEvent(
+ {
+ start: new Date(timebox.startDateTime),
+ end: new Date(timebox.endDateTime),
+ },
+ {
+ startTime: startOfDay(date),
+ hourHeight,
+ },
+ );
+
+ return (
+
+ );
+ });
+ };
+
+ const handleStartEventCreate = (event: MouseEvent) => {
+ if (event.button !== 0) {
+ return;
+ }
+
+ if (!rootRectRef.current) {
+ return;
+ }
+
+ const [x, y] = getCorrectedPosition(
+ [event.clientX, event.clientY + (rootRef.current?.scrollTop || 0)],
+ {
+ snapSize: [dayWidth, hourHeight / 4],
+ containerRect: rootRectRef.current,
+ headerHeight,
+ asideWidth,
+ direction: "top",
+ },
+ );
+
+ const date = getDateFromPosition([x, y], {
+ start: range[0],
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ });
+
+ const newX = x + (rootRef.current?.scrollLeft || 0) + asideWidth;
+ const newY = y - headerHeight;
+
+ setNewEvent({
+ start: [newX, newY],
+ end: [newX, newY + hourHeight / 4],
+ dates: [date, addMinutes(date, 15)],
+ state: "started",
+ });
+ };
+
+ const handleContinueEventCreate = (event: MouseEvent) => {
+ if (
+ newEvent === null ||
+ newEvent.state === "ended" ||
+ !rootRectRef.current
+ ) {
+ return;
+ }
+
+ const [x, y] = getCorrectedPosition(
+ [event.clientX, event.clientY + (rootRef.current?.scrollTop || 0)],
+ {
+ snapSize: [dayWidth, hourHeight / 4],
+ containerRect: rootRectRef.current,
+ headerHeight,
+ asideWidth,
+ direction: "top",
+ },
+ );
+
+ const date = getDateFromPosition([x, y], {
+ start: range[0],
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ });
+
+ const newX = x + (rootRef.current?.scrollLeft || 0) + asideWidth;
+ const newY = Math.max(y, newEvent.start[1] + hourHeight / 4) - headerHeight;
+
+ if (newEvent.end[0] !== newX || newEvent.end[1] !== newY) {
+ setNewEvent({
+ ...newEvent,
+ end: [newX, newY],
+ dates: [newEvent.dates[0], date],
+ state: "started",
+ });
+ }
+ };
+
+ const handleEndEventCreate = (event: MouseEvent) => {
+ if (newEvent === null || !rootRectRef.current) {
+ return;
+ }
+
+ setNewEvent({
+ ...newEvent,
+ state: "ended",
+ });
+
+ const startDateTime = getDateFromPosition(
+ [newEvent.start[0], newEvent.start[1] + headerHeight],
+ {
+ start: range[0],
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ },
+ );
+
+ const endDateTime = getDateFromPosition(
+ [newEvent.end[0], newEvent.end[1] + headerHeight],
+ {
+ start: range[0],
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ },
+ );
+
+ onEventAddRequest({ startDateTime, endDateTime });
+ setNewEvent(null);
+ };
+
+ const handleStartAllDayEventCreate = (event: MouseEvent) => {
+ const { date } = getDnDDateFromPosition([event.pageX, 0], {
+ start: range[0],
+ snapSize: [dayWidth, hourHeight / 4],
+ scrollLeft: rootRef.current.scrollLeft,
+ scrollTop: rootRef.current.scrollTop,
+ containerRect: rootRectRef.current,
+ asideWidth,
+ hourHeight,
+ headerHeight,
+ });
+
+ setNewAllDayEvent({
+ start: date,
+ end: addDays(date, 1),
+ state: "started",
+ });
+ };
+
+ const handleContinueAllDayEventCreate = (event: MouseEvent) => {
+ if (!newAllDayEvent) {
+ return;
+ }
+
+ const { date } = getDnDDateFromPosition([event.pageX, 0], {
+ start: range[0],
+ snapSize: [dayWidth, hourHeight / 4],
+ scrollLeft: rootRef.current.scrollLeft,
+ scrollTop: rootRef.current.scrollTop,
+ containerRect: rootRectRef.current,
+ asideWidth,
+ hourHeight,
+ headerHeight,
+ });
+
+ const dates = [newAllDayEvent.start, addDays(date, 1)].sort((d1, d2) => {
+ return d1.getTime() - d2.getTime();
+ });
+
+ setNewAllDayEvent({
+ ...newAllDayEvent,
+ start: dates[0],
+ end: dates[1],
+ });
+ };
+
+ const handleEndAllDayEventCreate = (event: MouseEvent) => {
+ if (!newAllDayEvent) {
+ return;
+ }
+
+ onEventAddRequest({
+ startDate: newAllDayEvent.start,
+ endDate: newAllDayEvent.end,
+ });
+ setNewAllDayEvent(null);
+ };
+
+ const handleRefBody = (element: HTMLDivElement) => {
+ if (!element) {
+ return;
+ }
+
+ bodyRef.current = element;
+
+ handleResize(element);
+ };
+
+ const handleRefRoot = (element: HTMLDivElement) => {
+ dropRef(element);
+ rootRef.current = element;
+ rootRectRef.current = element?.getBoundingClientRect();
+ const index = range.findIndex((d) => isSameDay(d, date));
+
+ if (isFirstRender.current) {
+ rootRef.current.scrollLeft = dayWidth * index;
+ isFirstRender.current = false;
+ }
+ };
+
+ const handleReachLeft = () => {
+ const newDate = subDays(date, DAYS_DIFF);
+ direction.current = -1;
+
+ onDateChange(newDate);
+ };
+
+ const handleReachRight = () => {
+ const newDate = addDays(date, DAYS_DIFF);
+ direction.current = 1;
+
+ onDateChange(newDate);
+ };
+
+ const handleScroll = () => {
+ const scrollLeft = rootRef.current?.scrollLeft || 0;
+
+ if (scrollLeft <= edges.current.left) {
+ handleReachLeft();
+ } else if (scrollLeft >= edges.current.right) {
+ handleReachRight();
+ }
+ };
+
+ return (
+ <>
+ {layer &&
+ React.cloneElement(layer, {
+ start: range[0],
+ startHour,
+ endHour,
+ dayWidth,
+ hourHeight,
+ headerHeight,
+ asideWidth,
+ daysContainer: bodyRef.current,
+ dateLabelHeight: DATE_LABEL_HEIGHT,
+ container: rootRef.current,
+ containerRect: rootRectRef.current,
+ })}
+
+
+
+
+ {timezones.map((timezone) => (
+ {timezone.label}
+ ))}
+
+ All day
+
+
+
+ {range.map((date) => (
+
+
+
+ {format(date, "EEE")}
+
+
+ {format(date, "d")}
+
+
+
+ {renderDayInfo(
+ date,
+ (eventsMap.get(format(date, DATE_FORMAT)) || []).map(
+ (e) => e.event,
+ ),
+ )}
+
+
+ ))}
+
+
+ {range.map(renderAllDay)}
+ {renderAllDayEvents()}
+ {renderNewAllDayEvent()}
+
+
+
+
+
+
+ event.stopPropagation()}
+ >
+ {hours.map((hour) => (
+
+
+ {timezones.map((timezone) => (
+
+ {formatTz(
+ set(hour, {
+ minutes: 0,
+ seconds: 0,
+ milliseconds: 0,
+ }),
+ timezone.name,
+ "HH:mm",
+ {},
+ )}
+
+ ))}
+
+
+ ))}
+
+
+
+ {range.map(renderDay)}
+ {renderNewEvent()}
+
+
+
+
+ >
+ );
+};
+
+const Root = styled.div`
+ overflow: scroll;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+ height: 100%;
+ overscroll-behavior: auto;
+ scroll-snap-type: x proximity;
+ border: 1px solid ${(p) => p.theme.delimiter};
+ user-select: none;
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ &::-webkit-scrollbar {
+ display: none;
+ }
+`;
+
+const Container = memo(styled.div`
+ position: relative;
+ display: flex;
+ flex: 1;
+ width: 100%;
+ height: 100%;
+`);
+
+const Body = styled.div`
+ position: relative;
+ display: flex;
+ flex-shrink: 0;
+ width: 100%;
+ height: 100%;
+`;
+
+const Day = styled.div`
+ display: flex;
+ flex-direction: column;
+ position: absolute;
+ top: 0;
+ left: 0;
+ box-sizing: border-box;
+ scroll-snap-align: start;
+ scroll-snap-stop: always;
+`;
+
+const Hour = styled.div<{ $isWeekend?: boolean; $hourHeight: number }>`
+ position: relative;
+ height: ${(p) => p.$hourHeight}px;
+ width: 100%;
+ box-sizing: border-box;
+
+ border-width: 1px 1px 0 0;
+ border-style: solid;
+ border-color: ${(p) => p.theme.delimiter};
+ background-color: ${(p) =>
+ p.$isWeekend ? p.theme.weekendBackgroundColor : p.theme.dayBackgroundColor};
+`;
+
+const Aside = styled.aside`
+ position: sticky;
+ left: -1px;
+ bottom: 0;
+ border-left: 1px solid ${(p) => p.theme.delimiter};
+ background-color: ${(p) => p.theme.dayBackgroundColor};
+ z-index: 5;
+`;
+
+const Header = styled.header`
+ position: sticky;
+ display: flex;
+ top: 0;
+ margin-left: -1px;
+ background-color: ${(p) => p.theme.dayBackgroundColor};
+ border-bottom: 1px solid ${(p) => p.theme.delimiter};
+ z-index: 6;
+`;
+
+const AllDayLabel = styled.div`
+ font-size: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ color: ${(p) => p.theme.dayTextColor};
+`;
+
+const AllDayContainer = styled.div`
+ position: relative;
+ display: flex;
+ overflow-y: scroll;
+`;
+
+const AllDay = styled.div<{ $isWeekend?: boolean }>`
+ position: sticky;
+ top: 0;
+ box-sizing: border-box;
+ border-width: 0 0 0 1px;
+ border-style: solid;
+ border-color: ${(p) => p.theme.delimiter};
+ background-color: ${(p) =>
+ p.$isWeekend ? p.theme.weekendBackgroundColor : p.theme.dayBackgroundColor};
+`;
+
+const Timezones = styled.div`
+ display: flex;
+ justify-content: space-around;
+ align-items: center;
+`;
+
+const Timezone = styled.div`
+ font-size: 12px;
+ color: ${(p) => p.theme.dayTextColor};
+`;
+
+const Corner = styled.div`
+ flex-shrink: 0;
+ display: flex;
+ flex-direction: column;
+ background-color: ${(p) => p.theme.dayBackgroundColor};
+ position: sticky;
+ left: -1px;
+ border-right: 1px solid ${(p) => p.theme.delimiter};
+ border-bottom: 1px solid ${(p) => p.theme.delimiter};
+ z-index: 4;
+
+ & ${Timezones} {
+ border-bottom: 1px solid ${(p) => p.theme.delimiter};
+ }
+`;
+
+const Dates = styled.div`
+ position: sticky;
+ display: flex;
+`;
+
+const HeaderContent = styled.div`
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ position: relative;
+`;
+
+const Days = styled.div``;
+
+const HeaderDate = styled.div<{ $isToday?: boolean }>`
+ display: flex;
+ height: ${DATE_LABEL_HEIGHT}px;
+ box-sizing: border-box;
+ padding: 4px;
+ border-left: 1px solid ${(p) => p.theme.delimiter};
+ text-align: left;
+ & > div {
+ flex: 1 0;
+ }
+
+ ${(p) =>
+ p.$isToday
+ ? `border-bottom: 2px solid ${p.theme.weekendTextColor}`
+ : `border-bottom: 1px solid ${p.theme.delimiter}`}
+`;
+
+const DayOfWeek = styled.div<{ $isWeekend?: boolean }>`
+ font-size: 12px;
+ color: ${(p) =>
+ p.$isWeekend ? p.theme.weekendTextColor : p.theme.dayTextColor};
+`;
+
+const DateNumber = styled.div<{ $isToday?: boolean }>`
+ font-size: 20px;
+ color: ${(p) =>
+ p.$isToday ? p.theme.weekendTextColor : p.theme.dayTextColor};
+`;
+
+const BlankEvent = styled.div`
+ position: absolute;
+ font-size: 12px;
+ color: ${(p) => p.theme.dayTextColor};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: ${(p) => p.theme.eventBackgroundColor};
+ z-index: 5;
+ border-radius: 4px;
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/EmptyBlock.tsx b/src/components/WeekCalendar/components/views/DayView/EmptyBlock.tsx
new file mode 100644
index 0000000..e5b45f3
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/EmptyBlock.tsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import styled from 'styled-components';
+
+export const EmptyBlock = (): JSX.Element => {
+
+ return ;
+}
+
+const Root = styled.div`
+ position: absolute;
+ background-color: rgba(0, 0, 0, 0.4);
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/EventBlock.tsx b/src/components/WeekCalendar/components/views/DayView/EventBlock.tsx
new file mode 100644
index 0000000..ed4d776
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/EventBlock.tsx
@@ -0,0 +1,278 @@
+import React, { useRef, useState } from 'react';
+import styled from 'styled-components';
+import type { IDraggable, WeekEvent, WeekEventDates } from '../../../types';
+import { useEffect } from 'react';
+import { getCorrectedPosition, getDateFromPosition } from './utils';
+import { addMinutes, max, min, subMinutes } from 'date-fns';
+import { useDrag } from 'react-dnd';
+import { getEmptyImage } from 'react-dnd-html5-backend';
+
+type Props = {
+ event: WeekEvent;
+ editable?: boolean;
+ style?: React.CSSProperties;
+ top: number;
+ height: number;
+ startDate: Date;
+ dayWidth: number;
+ hourHeight: number;
+ container: HTMLDivElement;
+ containerRect: DOMRect;
+ headerHeight: number;
+ asideWidth: number;
+ startHour: number;
+ endHour: number;
+ onEventSelect(event: WeekEvent): void;
+ onEventChange(dates: WeekEventDates, event: WeekEvent): void;
+ onEventContextMenu(event: WeekEvent, e: React.MouseEvent): void;
+ renderEvent: (
+ event: WeekEvent,
+ params: {
+ startDateTime: Date;
+ endDateTime: Date;
+ },
+ ) => React.ReactNode;
+};
+
+type ResizingState = 'top' | 'bottom' | false;
+
+export const EventBlock = (props: Props): JSX.Element => {
+ const {
+ event,
+ editable = true,
+ startDate,
+ container,
+ containerRect,
+ headerHeight,
+ asideWidth,
+ hourHeight,
+ dayWidth,
+ startHour,
+ endHour,
+ style,
+ onEventSelect,
+ onEventChange,
+ onEventContextMenu,
+ renderEvent,
+ } = props;
+
+ const resizingPos = useRef<[number, number]>();
+ const [currentDates, setCurrentDates] = useState<[Date, Date]>([null, null]);
+
+ const [isResizing, setResizing] = useState(false);
+ const [top, setTop] = useState(props.top || 0);
+ const [height, setHeight] = useState(props.height || 0);
+
+ const [{ isDragging }, dragRef, preview] = useDrag({
+ type: event.data.type,
+ item: event,
+ collect: (monitor) => ({
+ isDragging: Boolean(monitor.isDragging()),
+ }),
+ // canDrag: () => event.isEditable && editable,
+ });
+
+ useEffect(() => {
+ preview(getEmptyImage());
+ }, []);
+
+ useEffect(() => {
+ const handleResize = (event: MouseEvent) => {
+ if (!isResizing) {
+ return;
+ }
+
+ const [x, y] = getCorrectedPosition([event.x, event.y + (container?.scrollTop || 0)], {
+ snapSize: [dayWidth, hourHeight / 4],
+ containerRect: containerRect,
+ headerHeight,
+ asideWidth,
+ direction: isResizing,
+ });
+
+ const date = getDateFromPosition([x + container.scrollLeft + asideWidth, y], {
+ start: startDate,
+ headerHeight,
+ dayWidth,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ });
+
+ if (isResizing === 'bottom') {
+ setHeight(Math.max(y - props.top - headerHeight, hourHeight / 4));
+ setCurrentDates([currentDates[0], date]);
+ } else if (isResizing === 'top') {
+ const newHeight = props.top - y + headerHeight + props.height;
+
+ if (newHeight > hourHeight / 4) {
+ setTop(y - headerHeight);
+ }
+ setHeight(Math.max(newHeight, hourHeight / 4));
+ setCurrentDates([date, currentDates[1]]);
+ }
+ resizingPos.current = [x, y];
+ };
+
+ if (isResizing) {
+ document.addEventListener('mousemove', handleResize);
+ document.addEventListener('mouseup', handleStopResizing);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleResize);
+ document.removeEventListener('mouseup', handleStopResizing);
+ };
+ }, [dayWidth, hourHeight, containerRect, container, headerHeight, asideWidth, isResizing]);
+
+ const handleStartResizingTop = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing('top');
+
+ return false;
+ };
+
+ const handleStartResizingBottom = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing('bottom');
+
+ return false;
+ };
+
+ const handleStopResizing = (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+
+ if (resizingPos.current && container) {
+ const [x, y] = resizingPos.current;
+
+ const date = getDateFromPosition([x + container.scrollLeft + asideWidth, y], {
+ start: startDate,
+ headerHeight,
+ dayWidth,
+ hourHeight,
+ asideWidth,
+ timeFrom: startHour,
+ timeTo: endHour,
+ });
+
+ if (isResizing === 'bottom') {
+ onEventChange(
+ {
+ startDateTime: event.startDateTime,
+ endDateTime: max([date, addMinutes(event.startDateTime, 15)]),
+ },
+ event,
+ );
+ } else if (isResizing === 'top') {
+ onEventChange(
+ {
+ startDateTime: min([date, subMinutes(event.endDateTime, 15)]),
+ endDateTime: event.endDateTime,
+ },
+ event,
+ );
+ }
+ }
+
+ setCurrentDates([null, null]);
+ setResizing(false);
+ };
+
+ const handleContextMenu = (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+
+ onEventContextMenu(event, e);
+ };
+
+ return (
+ {
+ e.stopPropagation();
+
+ return false;
+ }}
+ onContextMenu={handleContextMenu}
+ >
+
+
+
+ {
+ if (editable && !isResizing) {
+ onEventSelect(event);
+ }
+ }}
+ ref={dragRef}
+ />
+
+
+
+ {renderEvent(props.event, {
+ startDateTime: currentDates[0],
+ endDateTime: currentDates[1],
+ })}
+
+ );
+};
+
+const ResizeHandler = styled.span`
+ display: inline-block;
+ height: 3px;
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 3px;
+ width: 100%;
+ opacity: 0;
+ transition: all 0.35s;
+`;
+
+const Root = styled.div<{ $dragging?: boolean; $editable?: boolean }>`
+ position: absolute;
+ z-index: 4;
+ border: 1px solid transparent;
+ overflow: hidden;
+ box-sizing: border-box;
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &:hover ${ResizeHandler} {
+ opacity: 0.7;
+ }
+`;
+
+const DragHandler = styled.div`
+ inset: 2px 0;
+ position: absolute;
+ z-index: 1;
+`;
+
+const ResizeHandlerContainer = styled.div`
+ left: 25%;
+ width: 50%;
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ cursor: ns-resize;
+ z-index: 3;
+`;
+
+const ResizeHandlerContainerTop = styled(ResizeHandlerContainer)`
+ top: 0;
+ padding-top: 1px;
+`;
+
+const ResizeHandlerContainerBottom = styled(ResizeHandlerContainer)`
+ bottom: 0;
+ padding-bottom: 1px;
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/TimeboxBlock.tsx b/src/components/WeekCalendar/components/views/DayView/TimeboxBlock.tsx
new file mode 100644
index 0000000..e9c5380
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/TimeboxBlock.tsx
@@ -0,0 +1,158 @@
+import React, { useEffect, useRef, useState } from "react";
+import { useDrag } from "react-dnd";
+import { CALENDAR_TIMEBOX_BLOCK } from "../../../constants";
+import type { WeekTimebox } from "../../../types";
+import { colord } from "colord";
+import styled, { useTheme } from "styled-components";
+import { getEmptyImage } from "react-dnd-html5-backend";
+
+type ResizingState = "top" | "bottom" | false;
+
+type Props = {
+ top: number;
+ height: number;
+ timebox: WeekTimebox;
+ editable: boolean;
+ start: Date;
+ dayWidth: number;
+ hourHeight: number;
+ headerHeight: number;
+ asideWidth: number;
+ containerRect: DOMRect;
+ container: HTMLDivElement | null;
+};
+
+export const TimeboxBlock = (props: Props): JSX.Element => {
+ const { timebox, dayWidth, editable } = props;
+ const { isLight } = useTheme();
+
+ const [isResizing, setResizing] = useState(false);
+ const [top, setTop] = useState(props.top || 0);
+ const [height, setHeight] = useState(props.height || 0);
+ const [currentDates, setCurrentDates] = useState<[Date, Date]>([null, null]);
+ const resizingPos = useRef<[number, number]>();
+
+ const [, drag, preview] = useDrag({
+ type: CALENDAR_TIMEBOX_BLOCK,
+ item: timebox,
+ canDrag: () => editable,
+ });
+
+ useEffect(() => {
+ preview(getEmptyImage());
+ }, []);
+
+ const opacity = isLight ? (editable ? 0.75 : 0.55) : editable ? 0.25 : 0.15;
+
+ const background =
+ "repeating-linear-gradient(45deg," +
+ `${colord(timebox.backgroundColor).alpha(opacity).toRgbString()}, ` +
+ `${colord(timebox.backgroundColor).alpha(opacity).toRgbString()} 4px, ` +
+ `${colord(timebox.backgroundColor).alpha(0.01).toRgbString()} 4px, ` +
+ `${colord(timebox.backgroundColor).alpha(0.01).toRgbString()} 10px` +
+ ")";
+
+ const handleStartResizingTop = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing("top");
+
+ return false;
+ };
+
+ const handleStartResizingBottom = (event: React.MouseEvent) => {
+ event.stopPropagation();
+ event.preventDefault();
+ setResizing("bottom");
+
+ return false;
+ };
+
+ return (
+
+
+ {timebox.title}
+
+ {editable && (
+ <>
+
+
+
+
+
+
+ >
+ )}
+
+
+ );
+};
+
+const ResizeHandlerContainer = styled.div`
+ left: 25%;
+ width: 50%;
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ cursor: ns-resize;
+ z-index: 1;
+`;
+
+const ResizeHandlerContainerTop = styled(ResizeHandlerContainer)`
+ top: 0;
+ padding-top: 2px;
+`;
+
+const ResizeHandlerContainerBottom = styled(ResizeHandlerContainer)`
+ bottom: 0;
+ padding-bottom: 2px;
+`;
+
+const ResizeHandler = styled.span`
+ display: inline-block;
+ height: 3px;
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 3px;
+ width: 100%;
+ opacity: 0;
+ transition: all 0.35s;
+`;
+
+const Root = styled.div`
+ position: absolute;
+ font-size: 12px;
+ box-sizing: border-box;
+ padding: 2px;
+ z-index: 3;
+`;
+
+const Content = styled.div`
+ height: 100%;
+ position: relative;
+ z-index: 1;
+`;
+
+const Background = styled.div`
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: start;
+ justify-content: start;
+ padding: 4px;
+ border-radius: 4px;
+ box-sizing: border-box;
+`;
+
+const DragHandler = styled.div<{ draggable: boolean }>`
+ position: absolute;
+ cursor: ${(p) => (p.draggable ? "grab" : "default")};
+ inset: 0;
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/Timeline.tsx b/src/components/WeekCalendar/components/views/DayView/Timeline.tsx
new file mode 100644
index 0000000..597cf49
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/Timeline.tsx
@@ -0,0 +1,117 @@
+import React, { CSSProperties, useEffect, useRef, useState } from 'react';
+import styled from 'styled-components';
+import { getPositionFromDate } from './utils';
+import { format } from 'date-fns';
+
+type Props = {
+ startDate: Date;
+ style: CSSProperties;
+ asideWidth: number;
+ startHour: number;
+ dayWidth: number;
+ headerHeight: number;
+ hourHeight: number;
+}
+
+export const Timeline = (props: Props): JSX.Element => {
+ const {
+ startDate,
+ style,
+ startHour,
+ asideWidth,
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ } = props;
+
+ const [now, setNow] = useState(-1);
+ const [day, setDay] = useState(0);
+ const intervalRef = useRef();
+
+ useEffect(() => {
+ const handleUpdate = () => {
+ const date = new Date();
+ const [nowDay, now] = getPositionFromDate(date, {
+ startDate,
+ dayWidth,
+ headerHeight,
+ hourHeight,
+ startHour,
+ });
+
+ setNow(now);
+ setDay(nowDay);
+ };
+
+ handleUpdate();
+
+ intervalRef.current = setInterval(handleUpdate, 1000);
+
+ return () => {
+ clearInterval(intervalRef.current);
+ };
+ }, [startDate, dayWidth, hourHeight]);
+
+ return (
+ <>
+
+ {day !== -1 && (
+
+ )}
+ >
+ );
+}
+
+const NowLine = styled.div<{ position: number; time: string }>`
+ position: absolute;
+ top: ${(p) => p.position}px;
+ height: 1px;
+ border-bottom: 1px dashed ${(p) => p.theme.weekendTextColor};
+ left: 0;
+ right: 0;
+ z-index: 5;
+
+ &:before {
+ content: '${(p) => p.time}';
+ font-size: 12px;
+ transform: translateY(-50%) translateY(-4px) translateX(-10px) translateX(-100%);
+ display: inline-block;
+ color: #fff;
+ background: ${(p) => p.theme.weekendTextColor};
+ padding: 0 2px;
+ border-radius: 3px;
+ }
+`;
+
+const NowDayLine = styled.div<{ top: number; width: number; }>`
+ position: absolute;
+ top: ${(p) => p.top}px;
+ height: 1px;
+ width: ${(p) => p.width}px;
+ border-bottom: 2px solid ${(p) => p.theme.weekendTextColor};
+ transform: translateY(-0.5px);
+ z-index: 5;
+
+ &:before {
+ position: absolute;
+ content: '';
+ height: 8px;
+ width: 8px;
+ border-radius: 50%;
+ background: ${(p) => p.theme.weekendTextColor};
+ transform: translate3d(-4px, -2px, 0);
+ }
+`;
diff --git a/src/components/WeekCalendar/components/views/DayView/constants.ts b/src/components/WeekCalendar/components/views/DayView/constants.ts
new file mode 100644
index 0000000..49d38d6
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/constants.ts
@@ -0,0 +1,37 @@
+import { WeekCalendarView } from "../../../types";
+
+/**
+ * Amount of extra days to render before and after the current date.
+ */
+export const DAYS_DIFF = 14;
+
+/**
+ * Amount of days to scroll from the current date to start rendering
+ * new days.
+ */
+export const DAYS_THRESHOLD = 7;
+
+/**
+ * Width of the calendar's time sidebar.
+ */
+export const ASIDE_WIDTH = 60;
+
+/**
+ * Height of the calendar's date label.
+ */
+export const DATE_LABEL_HEIGHT = 50;
+
+/**
+ * Date format used for inner purposes only.
+ */
+export const DATE_FORMAT = 'dd.MM.yyyy';
+
+/**
+ * Padding between events and the day end line.
+ */
+export const EVENTS_PADDING_RIGHT = 10;
+
+export const DAYS_COUNT_BY_VIEWS = {
+ [WeekCalendarView.Day]: 1,
+ [WeekCalendarView.Week]: 7,
+}
diff --git a/src/components/WeekCalendar/components/views/DayView/types.ts b/src/components/WeekCalendar/components/views/DayView/types.ts
new file mode 100644
index 0000000..50e4450
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/types.ts
@@ -0,0 +1,13 @@
+type Coords = [number, number];
+
+export type NewEvent = {
+ start: Coords;
+ end: Coords;
+ state: 'started' | 'ended';
+};
+
+export type NewAllDayEvent = {
+ start: Date;
+ end: Date;
+ state: 'started' | 'ended';
+};
diff --git a/src/components/WeekCalendar/components/views/DayView/useAllDayLayout.ts b/src/components/WeekCalendar/components/views/DayView/useAllDayLayout.ts
new file mode 100644
index 0000000..55f6759
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/useAllDayLayout.ts
@@ -0,0 +1,73 @@
+import { areIntervalsOverlapping, differenceInCalendarDays, endOfDay } from 'date-fns';
+import { useMemo } from 'react';
+import type { WeekEvent } from '../../../types';
+
+type AllDayEventData = {
+ event: WeekEvent;
+ width: number;
+ offset: number;
+};
+
+export const useAllDayLayout = (events: WeekEvent[]) => {
+ return useMemo(() => {
+ const allEvents: AllDayEventData[] = [];
+
+ events
+ .filter((event) => event.startDate && event.endDate && event.startDate < event.endDate)
+ .forEach((event) => {
+ allEvents.push({
+ event,
+ width: differenceInCalendarDays(event.endDate!, event.startDate!) + 1,
+ offset: 0,
+ });
+ });
+
+ const rows: AllDayEventData[][] = [[]];
+ let rowIndex = 0;
+
+ allEvents
+ .sort((a, b) => {
+ return b.width - a.width;
+ })
+ .forEach((eventData) => {
+ let added = false;
+
+ while (!added) {
+ const row = rows[rowIndex] || [];
+
+ if (
+ row.some((e) =>
+ areIntervalsOverlapping(
+ {
+ start: eventData.event.startDate!,
+ end: eventData.event.endDate!,
+ },
+ {
+ start: e.event.startDate!,
+ end: e.event.endDate!,
+ },
+ ),
+ )
+ ) {
+ rowIndex++;
+ } else {
+ row.push(eventData);
+ rows[rowIndex] = row;
+
+ rowIndex = 0;
+ added = true;
+ }
+ }
+ });
+
+ console.log();
+
+ rows.forEach((row, index) => {
+ row.forEach((eventData) => {
+ eventData.offset = index;
+ });
+ });
+
+ return allEvents;
+ }, [events]);
+};
diff --git a/src/components/WeekCalendar/components/views/DayView/useDayLayout.ts b/src/components/WeekCalendar/components/views/DayView/useDayLayout.ts
new file mode 100644
index 0000000..c2b3338
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/useDayLayout.ts
@@ -0,0 +1,193 @@
+import {
+ areIntervalsOverlapping,
+ eachDayOfInterval,
+ endOfDay,
+ format,
+ isBefore,
+ startOfDay,
+} from 'date-fns';
+import { useMemo } from 'react';
+import { DATE_FORMAT } from './constants';
+import type { WeekEvent } from '../../../types';
+
+export type EventData = {
+ startDateTime: Date;
+ endDateTime: Date;
+ offset: number;
+ width: number;
+ event: WeekEvent;
+ overlaps: EventData[];
+ colWidth: number;
+};
+
+/**
+ * Hook for calculating layout of events for DayView
+ *
+ * @param events Array of WeekEvents
+ * @returns Map of EventData arrays, where key is date in DATE_FORMAT format
+ */
+export const useDayLayout = (
+ events: WeekEvent[],
+): Map[]> => {
+ return useMemo(() => {
+ const result = new Map[]>();
+
+ events
+ .filter(
+ (event) =>
+ event.startDateTime &&
+ event.endDateTime &&
+ isBefore(event.startDateTime, event.endDateTime),
+ )
+ .sort((b, a) => {
+ return (
+ (a.startDate?.getTime() || a.startDateTime!.getTime()) -
+ (b.startDate?.getTime() || b.startDateTime!.getTime())
+ );
+ })
+ .forEach((event) => {
+ if (event.startDateTime && event.endDateTime) {
+ const days = eachDayOfInterval({
+ start: event.startDateTime,
+ end: event.endDateTime,
+ });
+
+ days.forEach((day, index) => {
+ const key = format(day, DATE_FORMAT);
+ const l = days.length;
+ let startDateTime;
+ let endDateTime;
+
+ if (index === 0) {
+ startDateTime = event.startDateTime;
+ } else {
+ startDateTime = startOfDay(day);
+ }
+
+ if (index === l - 1) {
+ endDateTime = event.endDateTime;
+ } else {
+ endDateTime = endOfDay(day);
+ }
+
+ const dateEvents = result.get(key) || [];
+
+ dateEvents.push({
+ event,
+ startDateTime,
+ endDateTime,
+ offset: -1,
+ width: 1,
+ overlaps: [],
+ colWidth: 0,
+ });
+
+ result.set(key, dateEvents);
+ });
+ }
+ });
+
+ // Filling up array of overlapping events
+ result.forEach((day) => {
+ day.forEach((eventDataMain) => {
+ day.forEach((eventDataSecond) => {
+ try {
+ if (
+ eventDataMain !== eventDataSecond &&
+ areIntervalsOverlapping(
+ {
+ start: eventDataMain.event.startDateTime!,
+ end: eventDataMain.event.endDateTime!,
+ },
+ {
+ start: eventDataSecond.event.startDateTime!,
+ end: eventDataSecond.event.endDateTime!,
+ },
+ )
+ ) {
+ eventDataMain.overlaps.push(eventDataSecond);
+ }
+ } catch {
+ // Do nothing
+ }
+ });
+ });
+
+ const cols: EventData[][] = [];
+ let colIndex = 0;
+
+ day.forEach((eventDataMain) => {
+ let col = cols[colIndex] || [];
+ let added = false;
+
+ while (!added) {
+ if (
+ col.some((d) => {
+ try {
+ return areIntervalsOverlapping(
+ {
+ start: eventDataMain.event.startDateTime!,
+ end: eventDataMain.event.endDateTime!,
+ },
+ {
+ start: d.event.startDateTime!,
+ end: d.event.endDateTime!,
+ },
+ );
+ } catch (error) {
+ return false;
+ }
+ })
+ ) {
+ colIndex++;
+ col = cols[colIndex] || [];
+ } else {
+ col.push(eventDataMain);
+ cols[colIndex] = col;
+ colIndex = 0;
+ added = true;
+ }
+ }
+ });
+
+ cols.forEach((col, index) => {
+ col.forEach((d) => {
+ d.offset = index;
+ d.width = 1 / cols.length;
+ });
+ });
+
+ day.forEach((eventDataMain) => {
+ eventDataMain.colWidth = 1 / cols.length;
+
+ for (let colIndex = eventDataMain.offset + 1; colIndex < cols.length; colIndex++) {
+ const col = cols[colIndex];
+ if (
+ !col.some((d) => {
+ try {
+ return areIntervalsOverlapping(
+ {
+ start: eventDataMain.event.startDateTime!,
+ end: eventDataMain.event.endDateTime!,
+ },
+ {
+ start: d.event.startDateTime!,
+ end: d.event.endDateTime!,
+ },
+ );
+ } catch (error) {
+ return false;
+ }
+ })
+ ) {
+ eventDataMain.width += 1 / cols.length;
+ } else {
+ break;
+ }
+ }
+ });
+ });
+
+ return result;
+ }, [events]);
+};
diff --git a/src/components/WeekCalendar/components/views/DayView/useTimeboxesLayout.ts b/src/components/WeekCalendar/components/views/DayView/useTimeboxesLayout.ts
new file mode 100644
index 0000000..fa0df9d
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/useTimeboxesLayout.ts
@@ -0,0 +1,31 @@
+import { useMemo } from "react";
+import { eachDayOfInterval, endOfDay, format, startOfDay } from "date-fns";
+import { DATE_FORMAT } from "./constants";
+import type { WeekTimebox } from "../../../types";
+
+export const useTimeboxesLayout = (timeboxes: WeekTimebox[] = []): Map => {
+ return useMemo(() => {
+ const map = new Map();
+
+ timeboxes.forEach((timebox) => {
+ const days = eachDayOfInterval({
+ start: new Date(timebox.startDateTime),
+ end: new Date(timebox.endDateTime),
+ });
+
+ days.forEach((day, index) => {
+ const key = format(day, DATE_FORMAT);
+ const data = map.get(key) || [];
+
+ data.push({
+ ...timebox,
+ startDateTime: index === 0 ? timebox.startDateTime : startOfDay(day),
+ endDateTime: index === days.length - 1 ? timebox.endDateTime : endOfDay(day),
+ });
+ map.set(key, data);
+ })
+ });
+
+ return map;
+ }, [timeboxes]);
+};
diff --git a/src/components/WeekCalendar/components/views/DayView/utils.ts b/src/components/WeekCalendar/components/views/DayView/utils.ts
new file mode 100644
index 0000000..e247381
--- /dev/null
+++ b/src/components/WeekCalendar/components/views/DayView/utils.ts
@@ -0,0 +1,246 @@
+import {
+ addDays,
+ addMinutes,
+ differenceInDays,
+ differenceInMinutes,
+ eachDayOfInterval,
+ startOfDay,
+ subDays,
+} from 'date-fns';
+import { DAYS_DIFF } from './constants';
+
+/**
+ * Get the range of dates to render.
+ *
+ * @param date Date from which to get the range.
+ * @returns Array of the dates to render.
+ */
+export const getRange = (date: Date): Date[] => {
+ return eachDayOfInterval({
+ start: subDays(date, DAYS_DIFF),
+ end: addDays(date, DAYS_DIFF * 2),
+ });
+};
+
+type Position = {
+ top: number;
+ height: number;
+};
+
+type GetPositionOfEventOptions = {
+ startTime: Date;
+ hourHeight: number;
+};
+
+/**
+ * Calculate absolute position of the event.
+ *
+ * @param eventDate start and end date of the event
+ * @param opts params for calculating position
+ * @returns Position of the event
+ */
+export const getPositionOfEvent = (
+ eventDate: { start: Date; end: Date },
+ opts: GetPositionOfEventOptions,
+): Position => {
+ const { startTime, hourHeight } = opts;
+
+ const diffMinsFromStart = differenceInMinutes(startTime, startOfDay(startTime));
+
+ const minuteHeight = hourHeight / 60;
+ const minutes = differenceInMinutes(eventDate.end, eventDate.start);
+
+ return {
+ top:
+ differenceInMinutes(eventDate.start, startOfDay(eventDate.end)) * minuteHeight -
+ diffMinsFromStart * minuteHeight,
+ height: minutes * minuteHeight,
+ };
+};
+
+type GetCorrectedPositionParams = {
+ snapSize: [number, number];
+ containerRect: DOMRect;
+ headerHeight: number;
+ asideWidth: number;
+ direction: 'top' | 'bottom' | 'left' | 'right';
+};
+
+type Coords = [number, number];
+
+/**
+ * Corrects position according to the params.
+ *
+ * @param coords Original coordinates of the event
+ * @param params
+ * @returns
+ */
+export const getCorrectedPosition = (
+ coords: Coords,
+ params: GetCorrectedPositionParams,
+): Coords => {
+ const [x, y] = coords;
+ const {
+ snapSize: [snapX, snapY],
+ containerRect,
+ headerHeight,
+ asideWidth,
+ direction,
+ } = params;
+
+ const tempX = x - containerRect.left - asideWidth;
+ const tempY = y - containerRect.top - headerHeight;
+
+ const roundFunctionForY = direction === 'top' ? Math.floor : Math.ceil;
+
+ const newX = Math.floor(tempX / snapX) * snapX;
+ const newY = roundFunctionForY(tempY / snapY) * snapY + headerHeight;
+
+ return [newX, newY];
+};
+
+type GetDateFromPositionParams = {
+ start: Date;
+ headerHeight: number;
+ dayWidth: number;
+ asideWidth: number;
+ hourHeight: number;
+ timeFrom: number;
+ timeTo: number;
+};
+
+/**
+ * Calculate date from the position.
+ *
+ * @param coords Coords of the position
+ * @param params Calendar properties to calculate the date
+ * @returns Date from the position
+ */
+export const getDateFromPosition = (coords: Coords, params: GetDateFromPositionParams): Date => {
+ const [x, y] = coords;
+ const { start, headerHeight, dayWidth, hourHeight, asideWidth, timeFrom } = params;
+
+ const minuteHeight = hourHeight / 60;
+ const date = addDays(start, Math.round((x - asideWidth) / dayWidth));
+ const mins = Math.round((y - headerHeight) / minuteHeight + timeFrom * 60);
+
+ return addMinutes(date, mins);
+};
+
+export const getAllDayDateFromPosition = (
+ coords: Coords,
+ params: GetDateFromPositionParams,
+): Date => {
+ const [x] = coords;
+ const { start, dayWidth, asideWidth } = params;
+
+ return addDays(start, Math.round((x - asideWidth) / dayWidth) + 1);
+};
+
+type GetDnDDateTimeFromPositionParams = {
+ start: Date;
+ containerRect: DOMRect;
+ snapSize: [number, number];
+ scrollLeft: number;
+ scrollTop: number;
+ asideWidth: number;
+ headerHeight: number;
+ hourHeight: number;
+};
+
+export const getDnDDateFromPosition = (
+ coords: Coords,
+ params: GetDnDDateTimeFromPositionParams,
+) => {
+ const [x, y] = coords;
+ const {
+ start,
+ snapSize: [snapX, snapY],
+ containerRect,
+ asideWidth,
+ scrollLeft,
+ scrollTop,
+ headerHeight,
+ hourHeight,
+ } = params;
+
+ const isAllDay = getIsAllDay({ y, containerRect, headerHeight });
+
+ let date = addDays(start, Math.floor((x + scrollLeft - containerRect.left - asideWidth) / snapX));
+
+ if (!isAllDay) {
+ const diff = Math.floor((y - containerRect.top - headerHeight + scrollTop) / snapY) * snapY;
+ const minuteHeight = hourHeight / 60;
+
+ date = addMinutes(date, diff / minuteHeight);
+ } else {
+ date = startOfDay(date);
+ }
+
+ return isAllDay ? { date, isAllDay } : { dateTime: date, isAllDay };
+};
+
+type GetIsAllDayParams = {
+ y: number;
+ containerRect: DOMRect;
+ headerHeight: number;
+};
+
+/**
+ * Returns true is dragged event is over all day block.
+ *
+ * @param params data required to calculate the position
+ */
+export const getIsAllDay = (params: GetIsAllDayParams): boolean => {
+ const { y, containerRect, headerHeight } = params;
+
+ return y < containerRect.top + headerHeight;
+};
+
+type GetDnDCorrectedPositionParams = {
+ snapSize: [number, number];
+ container: [number, number];
+ dateLabelHeight: number;
+ headerHeight: number;
+ scrollTop: number;
+};
+
+export const getDnDCorrectedPosition = (coords: Coords, params: GetDnDCorrectedPositionParams) => {
+ let [x, y] = coords;
+ const {
+ snapSize: [snapX, snapY],
+ container: [containerX, containerY],
+ headerHeight,
+ scrollTop,
+ } = params;
+
+ x -= containerX;
+ y -= containerY + headerHeight;
+
+ x = Math.floor(x / snapX) * snapX + containerX;
+ y = Math.floor((y + scrollTop) / snapY) * snapY - scrollTop + containerY + headerHeight;
+
+ return [x, y];
+};
+
+type GetPositionFromDateParams = {
+ startDate: Date;
+ dayWidth: number;
+ hourHeight: number;
+ startHour: number;
+ headerHeight: number;
+};
+
+export const getPositionFromDate = (
+ date: Date,
+ params: GetPositionFromDateParams,
+): [number, number] => {
+ const { startDate, dayWidth, hourHeight, startHour, headerHeight } = params;
+
+ const minuteHeight = hourHeight / 60;
+ const minutes = date.getHours() * 60 + date.getMinutes();
+
+ const daysDiff = differenceInDays(date, startDate);
+
+ return [daysDiff * dayWidth, hourHeight * (minutes - startHour * 60) / 60];
+};
diff --git a/src/components/WeekCalendar/constants.ts b/src/components/WeekCalendar/constants.ts
new file mode 100644
index 0000000..bea074e
--- /dev/null
+++ b/src/components/WeekCalendar/constants.ts
@@ -0,0 +1,18 @@
+import { type WeekCalendarColorScheme } from "./types";
+
+export const defaultColorScheme: WeekCalendarColorScheme = {
+ dayBackgroundColor: "#fff",
+ weekendBackgroundColor: "#fff",
+ dayTextColor: "#444",
+ weekendTextColor: "#e00",
+ mutedTextColor: "#ccc",
+ delimiter: "#e5e5e5",
+ weekDelimiter: "#ccc",
+ eventBorderRadius: "0.5rem",
+ defaultEventBackgroundColor: "#000",
+ defaultEventBackgroundTextColor: "#fff",
+};
+
+export const CALENDAR_EVENT_BLOCK = Symbol("CALENDAR_EVENT_BLOCK");
+export const CALENDAR_TASK_BLOCK = Symbol("CALENDAR_TASK_BLOCK");
+export const CALENDAR_TIMEBOX_BLOCK = Symbol("CALENDAR_TIMEBOX_BLOCK");
diff --git a/src/components/WeekCalendar/index.ts b/src/components/WeekCalendar/index.ts
new file mode 100644
index 0000000..8737e3e
--- /dev/null
+++ b/src/components/WeekCalendar/index.ts
@@ -0,0 +1 @@
+export { WeekCalendar } from './components/WeekCalendar';
diff --git a/src/components/WeekCalendar/stories/DayViewLayer.tsx b/src/components/WeekCalendar/stories/DayViewLayer.tsx
new file mode 100644
index 0000000..39af76e
--- /dev/null
+++ b/src/components/WeekCalendar/stories/DayViewLayer.tsx
@@ -0,0 +1,180 @@
+import React from 'react';
+import styled from 'styled-components';
+import { useDragLayer } from 'react-dnd';
+import { DayViewLayerProps } from '../types';
+import { CALENDAR_EVENT_BLOCK, CALENDAR_TASK_BLOCK, CALENDAR_TIMEBOX_BLOCK } from '../constants';
+import { getDnDCorrectedPosition, getDnDDateFromPosition, getIsAllDay } from '../components/views/DayView/utils';
+import { differenceInMinutes, format } from 'date-fns';
+
+export const TASK = Symbol('Task');
+
+const ALLOWED_TYPES = [CALENDAR_EVENT_BLOCK, CALENDAR_TASK_BLOCK, CALENDAR_TIMEBOX_BLOCK, TASK];
+
+export const DayViewLayer = (props: Partial): JSX.Element => {
+ const {
+ dayWidth,
+ start,
+ startHour,
+ endHour,
+ asideWidth,
+ dateLabelHeight,
+ headerHeight,
+ hourHeight,
+ container,
+ containerRect,
+ defaultDuration = 30,
+ } = props;
+
+ const { offset, item, type, isDragging } = useDragLayer((monitor) => ({
+ item: monitor.getItem(),
+ type: monitor.getItemType(),
+ offset: monitor.getClientOffset(),
+ isDragging: monitor.isDragging(),
+ }));
+
+ if (!isDragging || !offset || !ALLOWED_TYPES.includes(type as symbol)) {
+ return null;
+ }
+
+ const x = offset.x;
+ const y = offset.y;
+
+ let duration;
+
+ const event = item;
+ const isAllDay = getIsAllDay({ y, containerRect, headerHeight });
+
+ if (
+ type === CALENDAR_EVENT_BLOCK ||
+ type === CALENDAR_TASK_BLOCK ||
+ type === CALENDAR_TIMEBOX_BLOCK
+ ) {
+ if (event.startDateTime && event.endDateTime) {
+ duration = differenceInMinutes(new Date(event.endDateTime), new Date(event.startDateTime));
+ } else if (event.startDate && event.endDate) {
+ if (isAllDay) {
+ duration = differenceInMinutes(new Date(event.endDate), new Date(event.startDate));
+ } else {
+ duration = defaultDuration;
+ }
+ }
+ } else if (type === 'TASK_BLOCK') {
+ duration = event.duration || defaultDuration;
+ }
+
+ const [newX, newY] = getDnDCorrectedPosition([x, y], {
+ snapSize: [dayWidth, hourHeight / 4],
+ headerHeight: headerHeight,
+ dateLabelHeight: dateLabelHeight,
+ container: [containerRect.left + asideWidth, containerRect.top],
+ scrollTop: container.scrollTop,
+ });
+
+ const style = getItemStyle([x, y], [newX, newY], {
+ dayWidth,
+ asideWidth: asideWidth,
+ containerRect: containerRect,
+ dateLabelHeight: dateLabelHeight,
+ headerHeight: headerHeight,
+ hourHeight: isAllDay ? 100 : hourHeight,
+ duration,
+ });
+
+ const dates = getDnDDateFromPosition([newX, newY], {
+ start,
+ snapSize: [dayWidth, hourHeight / 4],
+ containerRect,
+ asideWidth,
+ headerHeight,
+ hourHeight: isAllDay ? 100 : hourHeight,
+ scrollLeft: container.scrollLeft,
+ scrollTop: container.scrollTop,
+ });
+
+ return (
+
+
+ {item.title}
+ {dates.dateTime && format(dates.dateTime, 'HH:mm')}
+
+
+ );
+};
+
+const Root = styled.section`
+ pointer-events: none;
+ position: fixed;
+ inset: 0;
+ z-index: 9999;
+`;
+
+const Task = styled.div`
+ overflow: hidden;
+ font-size: 12px;
+ box-sizing: border-box;
+ border: 1px solid ${p => p.theme.primaryColor};
+ border-radius: 4px;
+ background-color: ${p => p.theme.contentBackgroundColor};
+ padding: 2px 10px;
+`;
+
+type TransformState = 'free' | 'allDay' | 'event';
+type Coords = [number, number];
+
+type GetItemStyleParams = {
+ containerRect: DOMRect;
+ asideWidth: number;
+ dateLabelHeight: number;
+ headerHeight: number;
+ hourHeight: number;
+ dayWidth: number;
+ duration?: number;
+};
+
+const getItemStyle = (defaultPos: Coords, newPos: Coords, params: GetItemStyleParams) => {
+ const {
+ dayWidth,
+ hourHeight,
+ asideWidth,
+ dateLabelHeight,
+ headerHeight,
+ containerRect,
+ duration = 60 / 2,
+ } = params;
+ const [x, y] = defaultPos;
+ const [newX, newY] = newPos;
+
+ let width, height, transform;
+
+ let state: TransformState = 'free';
+
+ if (x > containerRect.left + asideWidth && y > containerRect.top + dateLabelHeight) {
+ if (y < containerRect.top + headerHeight) {
+ state = 'allDay';
+ } else {
+ state = 'event';
+ }
+ }
+
+ if (state === 'free') {
+ transform = `translate3d(${x}px, ${y}px, 0)`;
+ width = dayWidth;
+ height = hourHeight / 4;
+ } else if (state === 'allDay') {
+ // allDayEvent
+ height = 100 / 4;
+ transform = `translate3d(${newX}px, ${dateLabelHeight + containerRect.top}px, 0)`;
+ width = Math.max((duration / (24 * 60)) * dayWidth, dayWidth);
+ } else {
+ // event
+ transform = `translate3d(${newX}px, ${newY}px, 0)`;
+ width = `${dayWidth}px`;
+ height = (duration * hourHeight) / 60;
+ }
+
+ return {
+ transform,
+ height,
+ width,
+ };
+};
diff --git a/src/components/WeekCalendar/stories/WeekCalendar.stories.tsx b/src/components/WeekCalendar/stories/WeekCalendar.stories.tsx
new file mode 100644
index 0000000..c40ac27
--- /dev/null
+++ b/src/components/WeekCalendar/stories/WeekCalendar.stories.tsx
@@ -0,0 +1,136 @@
+import React, { useState } from 'react';
+import type { Meta } from '@storybook/react';
+import styled from 'styled-components';
+import { HTML5Backend } from 'react-dnd-html5-backend';
+import { WeekCalendar } from '../components/WeekCalendar';
+import { CALENDAR_TASK_BLOCK, defaultColorScheme } from '../constants';
+import { WeekCalendarView, WeekEvent, WeekEventDates, WeekTimebox } from '../types';
+import { EventData, defaultEvents, defaultTimeboxes, defaultTimezones } from './data';
+import { format } from 'date-fns';
+import { useCallback } from 'react';
+import { DndProvider } from 'react-dnd';
+import { DayViewLayer } from './DayViewLayer';
+import { useMemo } from 'react';
+
+const meta: Meta = {
+ component: WeekCalendar,
+};
+
+export default meta;
+
+export const Primary = {
+ name: '7-day view',
+ render: (): JSX.Element => {
+ const [date, setDate] = useState(new Date());
+ const [events, setEvents] = useState[]>(defaultEvents);
+ const [timeboxes, setTimeboxes] = useState(defaultTimeboxes);
+
+ const renderEvent = useCallback((event: WeekEvent, params: WeekEventDates) => {
+ const dates =
+ event.startDateTime && event.endDateTime ? (
+
+ {format(params.startDateTime || event.startDateTime, 'HH:mm')}
+ -
+ {format(params.endDateTime || event.endDateTime, 'HH:mm')}
+
+ ) : null;
+
+ return (
+
+ {event.data.title}
+ {dates}
+
+ );
+ }, []);
+
+ const handleChangeEvent = useCallback((dates: WeekEventDates, event: WeekEvent) => {
+ setEvents((prev) =>
+ prev.map((e) => {
+ if (e.id === event.id) {
+ return {
+ ...e,
+ startDate: dates.startDate,
+ endDate: dates.endDate,
+ startDateTime: dates.startDateTime,
+ endDateTime: dates.endDateTime,
+ };
+ }
+
+ return e;
+ }),
+ );
+ }, []);
+
+ const handleAddNewEvent = useCallback((dates: WeekEventDates) => {
+ setEvents((prev) => [
+ ...prev,
+ {
+ id: Math.random().toString(),
+ startDate: dates.startDate,
+ endDate: dates.endDate,
+ startDateTime: dates.startDateTime,
+ endDateTime: dates.endDateTime,
+ data: {
+ title: 'New event',
+ type: CALENDAR_TASK_BLOCK,
+ },
+ },
+ ]);
+ }, []);
+
+ const layers = useMemo(
+ () => ({
+ [WeekCalendarView.Week]: ,
+ }),
+ [],
+ );
+
+ return (
+ <>
+
+
+
+ setDate(new Date(2024, 2, 23))}>23 march
+ setDate(new Date())}>Today
+ setDate(new Date(2024, 11, 16))}>16 december
+
+ null}
+ />
+
+
+ >
+ );
+ },
+};
+
+const Root = styled.div`
+ width: 100%;
+ height: 500px;
+`;
+
+const EventBlock = styled.div`
+ font-size: 12px;
+ background-color: ${(p) => p.theme.eventBackgroundColor};
+ height: 100%;
+ width: 100%;
+ border-radius: 4px;
+ padding: 2px 4px;
+ overflow: hidden;
+`;
+
+const EventTime = styled.div`
+ font-size: 10px;
+`;
diff --git a/src/components/WeekCalendar/stories/data.ts b/src/components/WeekCalendar/stories/data.ts
new file mode 100644
index 0000000..1fee666
--- /dev/null
+++ b/src/components/WeekCalendar/stories/data.ts
@@ -0,0 +1,60 @@
+import { set } from 'date-fns';
+import { WeekEvent, WeekTimezone, WeekTimebox } from '../types';
+import { CALENDAR_EVENT_BLOCK } from '../constants';
+
+export type EventData = {
+ title: string;
+ type: symbol;
+};
+
+export const defaultTimezones: WeekTimezone[] = [
+ {
+ label: '🏠',
+ name: 'Europe/Madrid',
+ isMain: true,
+ },
+ {
+ label: '🏢',
+ name: 'Europe/Moscow',
+ },
+];
+
+export const defaultEvents: WeekEvent[] = [
+ {
+ id: '1',
+ startDateTime: set(new Date(), { hours: 10, minutes: 15 }),
+ endDateTime: set(new Date(), { hours: 11, minutes: 15 }),
+ data: {
+ title: 'Event 1',
+ type: CALENDAR_EVENT_BLOCK,
+ },
+ },
+ {
+ id: '3',
+ startDateTime: set(new Date(), { hours: 0, minutes: 0 }),
+ endDateTime: set(new Date(), { hours: 3, minutes: 0 }),
+ data: {
+ title: 'Event 3',
+ type: CALENDAR_EVENT_BLOCK,
+ },
+ },
+ {
+ id: '2',
+ startDate: set(new Date(), { hours: 0, minutes: 0 }),
+ endDate: set(new Date(), { hours: 23, minutes: 59 }),
+ data: {
+ title: 'Event 2',
+ type: CALENDAR_EVENT_BLOCK,
+ },
+ }
+];
+
+export const defaultTimeboxes: WeekTimebox[] = [
+ {
+ id: '1',
+ title: 'Timebox 1',
+ startDateTime: set(new Date(), { hours: 8, minutes: 0 }),
+ endDateTime: set(new Date(), { hours: 18, minutes: 0 }),
+ backgroundColor: '#274ce9',
+ },
+];
diff --git a/src/components/WeekCalendar/types.ts b/src/components/WeekCalendar/types.ts
new file mode 100644
index 0000000..2e008a2
--- /dev/null
+++ b/src/components/WeekCalendar/types.ts
@@ -0,0 +1,69 @@
+export interface IDraggable {
+ type: symbol;
+}
+
+export enum WeekCalendarView {
+ Day = 'Day',
+ Days3 = 'Days3',
+ WorkWeek = 'WorkWeek',
+ Week = 'Week',
+ Month = 'Month',
+}
+
+export type WeekTimezone = {
+ label: string;
+ name: string;
+ isMain?: boolean;
+}
+
+export type WeekCalendarColorScheme = {
+ dayBackgroundColor: string;
+ weekendBackgroundColor: string;
+ dayTextColor: string;
+ weekendTextColor: string;
+ mutedTextColor: string;
+ delimiter: string;
+ weekDelimiter: string;
+ eventBorderRadius: string;
+ defaultEventBackgroundColor: string;
+ defaultEventBackgroundTextColor: string;
+};
+
+export type WeekEventDates = {
+ startDate?: Date;
+ endDate?: Date;
+ startDateTime?: Date;
+ endDateTime?: Date;
+};
+
+export type WeekEvent = {
+ id: string;
+ startDate?: Date;
+ endDate?: Date;
+ startDateTime?: Date;
+ endDateTime?: Date;
+ data: TData;
+};
+
+export type WeekTimebox = {
+ id: string;
+ title: string;
+ startDateTime: Date;
+ endDateTime: Date;
+ backgroundColor: string;
+}
+
+export type DayViewLayerProps = {
+ dayWidth: number;
+ start: Date;
+ startHour: number;
+ endHour: number;
+ asideWidth: number;
+ dateLabelHeight: number;
+ headerHeight: number;
+ hourHeight: number;
+ container: HTMLDivElement;
+ containerRect: DOMRect;
+ defaultDuration?: number;
+};
+
diff --git a/src/hooks/useCalendarRange.ts b/src/hooks/useCalendarRange.ts
new file mode 100644
index 0000000..e06017e
--- /dev/null
+++ b/src/hooks/useCalendarRange.ts
@@ -0,0 +1,98 @@
+import { useCallback, useEffect, useState } from 'react';
+import {
+ addDays,
+ endOfDay,
+ endOfWeek,
+ startOfDay,
+ startOfWeek,
+} from 'date-fns';
+import type { Interval } from 'date-fns';
+import { CalendarView } from '../types/calendar';
+import type { DateMutator } from '../types/calendar';
+
+export const useCalendarRange = (
+ startDate: Date,
+ view: CalendarView,
+ weekStart: 0 | 1,
+) => {
+ const [date, setDate] = useState(startDate);
+ const [range, setRange] = useState(
+ getRangeForView(date, view, weekStart),
+ );
+
+ useEffect(() => {
+ setRange(getRangeForView(date, view, weekStart));
+ }, [date, view, weekStart]);
+
+ const prev = useCallback(() => {
+ if (!view || !date) return;
+
+ const mutator = DateMutators[view];
+ setDate(mutator(date, -1, weekStart));
+ }, [view, date]);
+
+ const next = useCallback(() => {
+ if (!view || !date) return;
+
+ const mutator = DateMutators[view];
+ setDate(mutator(date, 1, weekStart));
+ }, [view, date]);
+
+ const today = useCallback(() => {
+ const mutator = DateMutators[view];
+ setDate(mutator(date, 0, weekStart));
+ }, [date]);
+
+ return {
+ date,
+ range,
+ today,
+ next,
+ prev,
+ setDate,
+ };
+};
+
+const getRangeForView = (
+ date: Date,
+ view: CalendarView,
+ weekStart: 0 | 1,
+): Interval => {
+ switch (view) {
+ case CalendarView.Days3:
+ return {
+ start: startOfDay(date),
+ end: endOfDay(addDays(date, 2)),
+ };
+ case CalendarView.Week: {
+ return {
+ start: date,
+ end: addDays(date, 6),
+ };
+ }
+ case CalendarView.Day:
+ return {
+ start: startOfDay(date),
+ end: endOfDay(date),
+ };
+ default:
+ throw new Error('There is no such View');
+ }
+};
+
+const DateMutators: Record = {
+ [CalendarView.Day]: (date, direction) =>
+ direction === 0 ? new Date() : addDays(date, 1 * direction),
+ [CalendarView.Days3]: (date, direction) =>
+ direction === 0 ? new Date() : addDays(date, 3 * direction),
+ [CalendarView.WorkWeek]: (date) => {
+ return date;
+ },
+ [CalendarView.Week]: (date, direction, weekStart) =>
+ direction === 0
+ ? startOfWeek(new Date(), { weekStartsOn: weekStart })
+ : startOfWeek(addDays(date, 7 * direction), { weekStartsOn: weekStart }),
+ [CalendarView.Month]: (date) => {
+ return date;
+ },
+};
diff --git a/src/hooks/useCalendarTheme.ts b/src/hooks/useCalendarTheme.ts
new file mode 100644
index 0000000..5caaf56
--- /dev/null
+++ b/src/hooks/useCalendarTheme.ts
@@ -0,0 +1,60 @@
+import { useMemo } from "react"
+
+const light = {
+ backgroundColor: "#fafafa",
+ textColor: "#444",
+ textColor2: "#626362",
+ altTextColor: "#fff",
+ mutedColor: "#888",
+ primaryColor: "#03a9f4",
+ successColor: "#00aa00",
+ dangerColor: "#e00",
+ warningColor: "#ee8a00",
+ contentBackgroundColor: "#fff",
+ borderColor: "#eee",
+ inputBorderColor: "#bbb",
+ selectedBackgroundColor: "#e1f5fe",
+ eventBackgroundColor: "#e1f5fe",
+ eventColor: "#047ad8",
+ shadow: "0 0 2px rgb(0 0 0 / 60%)",
+};
+
+
+export const dark = {
+ backgroundColor: "#474e5b",
+ textColor: "#eee",
+ textColor2: "#e3e3e3",
+ altTextColor: "#fff",
+ mutedColor: "#c4c6c9",
+ primaryColor: "#03a9f4",
+ successColor: "#00aa00",
+ dangerColor: "#f26464",
+ warningColor: "#ee8a00",
+ contentBackgroundColor: "#4b525f",
+ borderColor: "#5d5d5d",
+ inputBorderColor: "#6c6c6c",
+ selectedBackgroundColor: "#363e48",
+ eventBackgroundColor: "#e1f5fe",
+ eventColor: "#047ad8",
+ shadow: "0 0 2px rgb(0 0 0 / 60%)",
+};
+
+export const useCalendarTheme = () => {
+
+ const theme = light;
+
+ return useMemo(() => {
+ return {
+ dayBackgroundColor: theme.contentBackgroundColor,
+ weekendBackgroundColor: theme.backgroundColor,
+ delimiter: theme.borderColor,
+ weekendTextColor: theme.dangerColor,
+ eventBorderRadius: "4px",
+ weekDelimiter: theme.primaryColor,
+ dayTextColor: theme.textColor,
+ mutedTextColor: theme.mutedColor,
+ defaultEventBackgroundColor: theme.eventBackgroundColor,
+ defaultEventBackgroundTextColor: "#444",
+ };
+ }, []);
+}
\ No newline at end of file
diff --git a/src/hooks/useDebouncedCallback.ts b/src/hooks/useDebouncedCallback.ts
new file mode 100644
index 0000000..d3c4bb2
--- /dev/null
+++ b/src/hooks/useDebouncedCallback.ts
@@ -0,0 +1,31 @@
+import {useEffect, useRef} from 'react';
+
+export function useDebouncedCallback(
+ callback: (...args: A) => void,
+ wait: number
+) {
+ const argsRef = useRef ();
+ const timeout = useRef>();
+
+ function cleanup() {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ }
+ }
+
+ useEffect(() => cleanup, []);
+
+ return function debouncedCallback(
+ ...args: A
+ ) {
+ argsRef.current = args;
+
+ cleanup();
+
+ timeout.current = setTimeout(() => {
+ if (argsRef.current) {
+ callback(...argsRef.current);
+ }
+ }, wait);
+ };
+}
diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro
index f1369e8..8e821e4 100644
--- a/src/layouts/BaseLayout.astro
+++ b/src/layouts/BaseLayout.astro
@@ -15,7 +15,6 @@ import Footer from '../components/Footer.astro';