├── .eslintignore ├── .eslintrc ├── .gitignore ├── .husky └── pre-commit ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── development.md ├── index.html ├── logo.svg ├── package.json ├── readme.md ├── src ├── App.tsx ├── assets │ └── icons │ │ ├── index.ts │ │ ├── svgs │ │ ├── add.svg │ │ ├── arrow-down.svg │ │ ├── arrow-left.svg │ │ ├── arrow-right.svg │ │ ├── arrow-up.svg │ │ ├── calendar-free.svg │ │ ├── calendar-warning.svg │ │ ├── close.svg │ │ ├── default-avatar.svg │ │ ├── filter.svg │ │ ├── moon.svg │ │ ├── search.svg │ │ ├── subtract.svg │ │ └── sun.svg │ │ └── types.ts ├── components │ ├── Calendar │ │ ├── Calendar.tsx │ │ ├── Grid │ │ │ ├── Grid.tsx │ │ │ ├── index.ts │ │ │ ├── styles.ts │ │ │ └── types.ts │ │ ├── Header │ │ │ ├── Header.tsx │ │ │ ├── Topbar │ │ │ │ ├── Topbar.tsx │ │ │ │ ├── index.ts │ │ │ │ ├── styles.ts │ │ │ │ └── types.ts │ │ │ ├── index.ts │ │ │ ├── styles.ts │ │ │ └── types.ts │ │ ├── index.ts │ │ ├── styles.ts │ │ └── types.ts │ ├── ConfigPanel │ │ ├── ConfigPanel.tsx │ │ ├── index.ts │ │ ├── styles.ts │ │ └── types.ts │ ├── EmptyBox │ │ ├── EmptyBox.tsx │ │ ├── empty-box.svg │ │ ├── index.tsx │ │ └── styles.ts │ ├── Icon │ │ ├── Icon.tsx │ │ ├── index.ts │ │ └── types.ts │ ├── IconButton │ │ ├── IconButton.tsx │ │ ├── index.ts │ │ ├── styles.ts │ │ └── types.ts │ ├── LeftColumn │ │ ├── LeftColumn.tsx │ │ ├── LeftColumnItem │ │ │ ├── LeftColumnItem.tsx │ │ │ ├── index.tsx │ │ │ ├── styles.ts │ │ │ └── types.ts │ │ ├── index.tsx │ │ ├── styles.ts │ │ └── types.ts │ ├── Loader │ │ ├── Loader.tsx │ │ ├── index.tsx │ │ ├── styles.ts │ │ └── types.ts │ ├── PaginationButton │ │ ├── PaginationButton.tsx │ │ ├── index.ts │ │ ├── styles.ts │ │ └── types.ts │ ├── Scheduler │ │ ├── Scheduler.tsx │ │ ├── styles.ts │ │ └── types.ts │ ├── Tiles │ │ ├── Tile │ │ │ ├── Tile.tsx │ │ │ ├── index.tsx │ │ │ ├── styles.ts │ │ │ └── types.ts │ │ ├── Tiles.tsx │ │ ├── index.tsx │ │ └── types.ts │ ├── Toggle │ │ ├── Toggle.tsx │ │ ├── index.tsx │ │ ├── styles.ts │ │ └── types.ts │ ├── Tooltip │ │ ├── Tooltip.tsx │ │ ├── index.ts │ │ ├── styles.ts │ │ └── types.ts │ └── index.tsx ├── constants.ts ├── context │ ├── CalendarProvider │ │ ├── CalendarProvider.tsx │ │ ├── calendarContext.tsx │ │ ├── index.ts │ │ └── types.ts │ └── LocaleProvider │ │ ├── LocaleProvider.tsx │ │ ├── index.ts │ │ ├── localeContext.tsx │ │ ├── locales.ts │ │ └── types.ts ├── hooks │ ├── types.ts │ └── usePagination.ts ├── index.ts ├── locales │ ├── de.ts │ ├── en.ts │ ├── index.ts │ ├── lt.ts │ └── pl.ts ├── main.tsx ├── mock │ └── appMock.ts ├── styled-components.d.ts ├── styles.css ├── styles.ts ├── types │ ├── global.ts │ └── guards.ts ├── utils │ ├── dates.ts │ ├── drawDashedLine.ts │ ├── drawGrid │ │ ├── drawCell.ts │ │ ├── drawGrid.ts │ │ ├── drawHourlyView.ts │ │ ├── drawMonthlyView.ts │ │ └── drawYearlyView.ts │ ├── drawHeader │ │ ├── drawHeader.ts │ │ └── drawRows │ │ │ ├── DrawZoom2MonthsOnTop.ts │ │ │ ├── drawDaysOnBottom.ts │ │ │ ├── drawMonthsInMiddle.ts │ │ │ ├── drawMonthsOnTop.ts │ │ │ ├── drawWeeksInMiddle.ts │ │ │ ├── drawWeeksOnBottom.ts │ │ │ ├── drawYearsOnTop.ts │ │ │ ├── drawZoom2DaysInMiddle.ts │ │ │ └── drawZoom2HoursOnBottom.ts │ ├── drawRow.ts │ ├── getBoxFillStyle.ts │ ├── getCanvasWidth.ts │ ├── getCols.ts │ ├── getDatesRange.ts │ ├── getDayOccupancy.ts │ ├── getDuration.ts │ ├── getHourOccupancy.ts │ ├── getOccupancy.ts │ ├── getProjectsOnGrid.ts │ ├── getTextStyle.ts │ ├── getTileProperties.ts │ ├── getTileTextColor.ts │ ├── getTileXAndWidth.ts │ ├── getTimeOccupancy.ts │ ├── getTooltipData.ts │ ├── getTotalHoursAndMinutes.ts │ ├── getTotalRowsPerPage.ts │ ├── getWeekOccupancy.ts │ ├── resizeCanvas.ts │ ├── setProjectsInRows.ts │ └── splitToPages.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json ├── vite.config.d.ts ├── vite.config.ts └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:react/recommended", 9 | "plugin:react-hooks/recommended", 10 | "plugin:import/recommended", 11 | "plugin:@typescript-eslint/recommended", 12 | "plugin:prettier/recommended" 13 | ], 14 | "overrides": [], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": "latest", 21 | "sourceType": "module" 22 | }, 23 | "plugins": ["react", "@typescript-eslint", "filenames"], 24 | "rules": { 25 | "react/react-in-jsx-scope": "off", 26 | "eqeqeq": ["error", "always"], 27 | "@typescript-eslint/no-empty-function": "off", 28 | "import/order": [ 29 | "error", 30 | { 31 | "groups": ["builtin", "external", "internal", "parent", "sibling"] 32 | } 33 | ], 34 | "prettier/prettier": [ 35 | "error", 36 | { 37 | "endOfLine": "auto" 38 | } 39 | ], 40 | "react-hooks/rules-of-hooks": "error", 41 | "react-hooks/exhaustive-deps": "warn", 42 | "react/prop-types": "off", 43 | "semi": [2, "always"], 44 | "quotes": [ 45 | 2, 46 | "double", 47 | { 48 | "avoidEscape": true, 49 | "allowTemplateLiterals": true 50 | } 51 | ], 52 | "filenames/match-exported": 2, 53 | "import/no-internal-modules": [ 54 | "error", 55 | { 56 | "forbid": ["@/components/**"] 57 | } 58 | ] 59 | }, 60 | "settings": { 61 | "import/resolver": { 62 | "typescript": {}, 63 | "node": { 64 | "extensions": [".js", ".jsx", ".ts", ".tsx"] 65 | } 66 | }, 67 | "react": { 68 | "pragma": "React", 69 | "version": "detect" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node artifact and build files 2 | node_modules/ 3 | dist/ 4 | 5 | # Log files 6 | *.log 7 | 8 | # IDEs 9 | .vscode 10 | .idea/ 11 | 12 | # OS 13 | .DS_Store 14 | Thumbs.db 15 | 16 | # Reports 17 | stats.html 18 | 19 | # Typescript 20 | *.tsbuildinfo 21 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | npx tsc -b 6 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 18 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Ignore artifacts: 2 | dist 3 | 4 | # Ignore all HTML files: 5 | *.html -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "tabWidth": 2, 4 | "printWidth": 100, 5 | "singleQuote": false, 6 | "trailingComma": "none", 7 | "jsxBracketSameLine": true 8 | } 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release v0.2.1 2 | 3 | - added example of scheduler data filtering in readme.md 4 | 5 | # Release v0.2 6 | 7 | - added `showTooltip` property to scheduler's config object for showing / hiding tooltip when hovering over the tiles 8 | - added `translations` property to scheduler's config object which allow user to add a custom translations 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ## Prepare system image 2 | FROM node:18.16-alpine3.16 3 | WORKDIR /app 4 | 5 | ## Copy source files 6 | COPY src ./src 7 | COPY package.json yarn.lock tsconfig.json tsconfig.node.json index.html vite.config.ts ./ 8 | 9 | ## Install dependencies 10 | RUN yarn install --frozen-lockfile 11 | 12 | ## Expose port 13 | EXPOSE 5173 14 | 15 | ## Run 16 | CMD yarn run dev -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Bitnoise 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /development.md: -------------------------------------------------------------------------------- 1 | # Contribution 2 | 3 | ### Development Setup 4 | 5 | ##### Pre-requirements 6 | 7 | - **Node version**: please check `.nvmrc` file for required node version 8 | 9 | ##### Local setup 10 | 11 | To set up the project locally for development and testing, please follow these steps: 12 | 13 | 1. Clone the repository: `git clone git@github.com:Bitnoise/react-scheduler.git`. 14 | 2. Install the dependencies: `yarn install`, depending on your package manager. 15 | 3. Start the development server: `yarn dev`. 16 | 4. Open http://localhost:5173 in your web browser. 17 | 18 | ### Project structure 19 | 20 | #### General: 21 | 22 | ``` 23 | . 24 | ├── src 25 | │   ├── assets 26 | │   ├── components 27 | │ | ├── ExampleComponent 28 | │ | ├── AnotherComponent 29 | | | └── index.ts 30 | │   ├── constants.ts 31 | │   ├── context 32 | │   ├── locales 33 | │   ├── types 34 | │   ├── utils 35 | ``` 36 | 37 | - **assets** - folder that consists all of the svgs and images used within app 38 | - **components** - folder that has all React components used within app 39 | - **_ExampleComponent_** - folder with component files, written in camelCase convention 40 | - **_index.ts_** - file that consists exports of all components e.g. 41 | ``` 42 | export { default as ExampleComponent } from "./ExampleComponent" 43 | ``` 44 | - **constants** - all constants that are globally used and should not change during usage of app, e.g.: height and width of cell, width of single tile. 45 | - **context** - folder that consists CalendarProvider and LocaleProvider 46 | - **locales** - folder that consists files with translations (currently en / pl / de / lt) 47 | - **types** - folder that consists all global types and type guards 48 | - **utils** - folder that consists all utility functions used within app (e.g. drawing all the grid, data parsers etc.) 49 | 50 | #### Example of component folder structure: 51 | 52 | ``` 53 | ExampleComponent 54 | ├── ExampleComponent.tsx 55 | ├── index.ts 56 | ├── styles.ts 57 | ├── types.ts 58 | ``` 59 | 60 | Each component should consist of the following files: 61 | 62 | - **_[ComponentName].tsx_** - .tsx file named after component name, written in camelCase convention 63 | - **_index.ts_** - file that exports component e.g.: 64 | ``` 65 | export { default } from "./ExampleComponent"; 66 | ``` 67 | - **_styles.ts_** - optional file that consists all styling of the component 68 | - **_types.ts_** - optional file that consists all types of component 69 | 70 | ### Code Style and Guidelines 71 | 72 | 1. Commits should meet [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) rules 73 | 2. Project uses `eslint` and `prettier` for code linting and styling. 74 | 3. Both `husky` and `lint-staged` are used to ensure that code meets code style and guidelines 75 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Scheduler 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@bitnoi.se/react-scheduler", 3 | "version": "0.3.0", 4 | "type": "module", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Bitnoise/react-scheduler" 9 | }, 10 | "keywords": [ 11 | "scheduler", 12 | "gantt", 13 | "gantt chart", 14 | "react", 15 | "timeline", 16 | "calendar" 17 | ], 18 | "author": { 19 | "name": "Bitnoise", 20 | "url": "https://scheduler.bitnoise.pl/" 21 | }, 22 | "scripts": { 23 | "dev": "vite", 24 | "build": "tsc && vite build", 25 | "preview": "vite preview", 26 | "lint": "eslint .", 27 | "lint:fix": "eslint --fix .", 28 | "format": "prettier --write .", 29 | "typecheck": "tsc -b", 30 | "prepare": "husky install" 31 | }, 32 | "main": "dist/index.umd.js", 33 | "modules": "dist/index.js", 34 | "types": "dist/index.d.ts", 35 | "exports": { 36 | ".": { 37 | "import": "./dist/index.js", 38 | "require": "./dist/index.umd.cjs" 39 | }, 40 | "./dist/style.css": { 41 | "import": "./dist/style.css", 42 | "require": "./dist/style.css" 43 | } 44 | }, 45 | "files": [ 46 | "dist" 47 | ], 48 | "publishConfig": { 49 | "access": "public" 50 | }, 51 | "lint-staged": { 52 | "**/*.{ts,tsx}": [ 53 | "yarn run lint" 54 | ] 55 | }, 56 | "dependencies": { 57 | "dayjs": "1.11.7", 58 | "lodash.debounce": "4.0.8", 59 | "path": "0.12.7", 60 | "react": "18.3.1", 61 | "react-dom": "18.3.1", 62 | "styled-components": "5.3.8", 63 | "styled-normalize": "8.0.7" 64 | }, 65 | "devDependencies": { 66 | "@faker-js/faker": "7.6.0", 67 | "@types/datejs": "0.0.32", 68 | "@types/lodash.debounce": "4.0.7", 69 | "@types/node": "18.15.11", 70 | "@types/react": "18.0.27", 71 | "@types/react-dom": "18.0.10", 72 | "@types/styled-components": "5.1.26", 73 | "@typescript-eslint/eslint-plugin": "5.57.1", 74 | "@typescript-eslint/parser": "5.57.1", 75 | "@vitejs/plugin-react": "3.1.0", 76 | "babel-plugin-styled-components": "2.0.7", 77 | "eslint": "8.37.0", 78 | "eslint-config-prettier": "8.8.0", 79 | "eslint-import-resolver-typescript": "3.5.5", 80 | "eslint-plugin-filenames": "1.3.2", 81 | "eslint-plugin-import": "2.27.5", 82 | "eslint-plugin-prettier": "4.2.1", 83 | "eslint-plugin-react": "7.32.2", 84 | "eslint-plugin-react-hooks": "4.6.0", 85 | "husky": "8.0.0", 86 | "lint-staged": "13.2.0", 87 | "prettier": "2.8.7", 88 | "rollup-plugin-visualizer": "5.9.0", 89 | "typescript": "4.9.3", 90 | "vite": "4.1.0", 91 | "vite-plugin-dts": "2.2.0", 92 | "vite-plugin-svgr": "2.4.0" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 | @bitnoise/react-scheduler 3 |
4 |

5 | ✨ https://scheduler.bitnoise.pl/ ✨ 6 |
7 | Open sourced, typescript oriented, light-weight, and ultra fast React Component for creating gantt charts. 8 |

9 |
10 | Youtube Tutorial 11 |   •   12 | npm 13 |   •   14 | Report an issue 15 |
16 |
17 |
18 | 19 |
20 |

NEWSLETTER

21 |

22 | If you want to stay updated with Scheduler updates and news from the technical world, sign up for our newsletter. We don't spam (we send emails once a month), we don't run a sales newsletter, and we respect your time. 23 |

24 |

25 | See for yourself: NEWSLETTER 26 |

27 |
28 |
29 | 30 | ### Installation 31 | 32 | ```bash 33 | # yarn 34 | yarn add '@bitnoi.se/react-scheduler' 35 | # npm 36 | npm install '@bitnoi.se/react-scheduler' 37 | ``` 38 | 39 | ### Example usage 40 | 41 | 1. import required styles for scheduler 42 | 43 | ```ts 44 | import "@bitnoi.se/react-scheduler/dist/style.css"; 45 | ``` 46 | 47 | 2. Import Scheduler component into your project 48 | 49 | ```ts 50 | import { Scheduler, SchedulerData } from "@bitnoi.se/react-scheduler"; 51 | import dayjs from "dayjs"; 52 | 53 | default export function Component() { 54 | const [filterButtonState, setFilterButtonState] = useState(0); 55 | 56 | const [range, setRange] = useState({ 57 | startDate: new Date(), 58 | endDate: new Date() 59 | }); 60 | 61 | const handleRangeChange = useCallback((range) => { 62 | setRange(range); 63 | }, []); 64 | 65 | // Filtering events that are included in current date range 66 | // Example can be also found on video https://youtu.be/9oy4rTVEfBQ?t=118&si=52BGKSIYz6bTZ7fx 67 | // and in the react-scheduler repo App.tsx file https://github.com/Bitnoise/react-scheduler/blob/master/src/App.tsx 68 | const filteredMockedSchedulerData = mockedSchedulerData.map((person) => ({ 69 | ...person, 70 | data: person.data.filter( 71 | (project) => 72 | // we use "dayjs" for date calculations, but feel free to use library of your choice 73 | dayjs(project.startDate).isBetween(range.startDate, range.endDate) || 74 | dayjs(project.endDate).isBetween(range.startDate, range.endDate) || 75 | (dayjs(project.startDate).isBefore(range.startDate, "day") && 76 | dayjs(project.endDate).isAfter(range.endDate, "day")) 77 | ) 78 | })) 79 | 80 | return ( 81 |
82 | console.log(clickedResource)} 87 | onItemClick={(item) => console.log(item)} 88 | onFilterData={() => { 89 | // Some filtering logic... 90 | setFilterButtonState(1); 91 | }} 92 | onClearFilterData={() => { 93 | // Some clearing filters logic... 94 | setFilterButtonState(0) 95 | }} 96 | config={{ 97 | zoom: 0, 98 | filterButtonState, 99 | }} 100 | /> 101 |
102 | ); 103 | } 104 | 105 | const mockedSchedulerData: SchedulerData = [ 106 | { 107 | id: "070ac5b5-8369-4cd2-8ba2-0a209130cc60", 108 | label: { 109 | icon: "https://picsum.photos/24", 110 | title: "Joe Doe", 111 | subtitle: "Frontend Developer" 112 | }, 113 | data: [ 114 | { 115 | id: "8b71a8a5-33dd-4fc8-9caa-b4a584ba3762", 116 | startDate: new Date("2023-04-13T15:31:24.272Z"), 117 | endDate: new Date("2023-08-28T10:28:22.649Z"), 118 | occupancy: 3600, 119 | title: "Project A", 120 | subtitle: "Subtitle A", 121 | description: "array indexing Salad West Account", 122 | bgColor: "rgb(254,165,177)" 123 | }, 124 | { 125 | id: "22fbe237-6344-4c8e-affb-64a1750f33bd", 126 | startDate: new Date("2023-10-07T08:16:31.123Z"), 127 | endDate: new Date("2023-11-15T21:55:23.582Z"), 128 | occupancy: 2852, 129 | title: "Project B", 130 | subtitle: "Subtitle B", 131 | description: "Tuna Home pascal IP drive", 132 | bgColor: "rgb(254,165,177)" 133 | }, 134 | { 135 | id: "3601c1cd-f4b5-46bc-8564-8c983919e3f5", 136 | startDate: new Date("2023-03-30T22:25:14.377Z"), 137 | endDate: new Date("2023-09-01T07:20:50.526Z"), 138 | occupancy: 1800, 139 | title: "Project C", 140 | subtitle: "Subtitle C", 141 | bgColor: "rgb(254,165,177)" 142 | }, 143 | { 144 | id: "b088e4ac-9911-426f-aef3-843d75e714c2", 145 | startDate: new Date("2023-10-28T10:08:22.986Z"), 146 | endDate: new Date("2023-10-30T12:30:30.150Z"), 147 | occupancy: 11111, 148 | title: "Project D", 149 | subtitle: "Subtitle D", 150 | description: "Garden heavy an software Metal", 151 | bgColor: "rgb(254,165,177)" 152 | } 153 | ] 154 | } 155 | ]; 156 | 157 | ``` 158 | 159 | 3. If some problems occur, please see our troubleshooting section below. 160 | 161 | ### Scheduler API 162 | 163 | ##### Scheduler Component Props 164 | 165 | | Property Name | Type | Arguments | Description | 166 | | ----------------- | ---------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | 167 | | isLoading | `boolean` | - | shows loading indicators on scheduler | 168 | | onRangeChange | `function` | updated `startDate` and `endDate` | runs whenever user reaches end of currently rendered canvas | 169 | | onTileClick | `function` | clicked resource data | detects resource click | 170 | | onItemClick | `function` | clicked left column item data | detects item click on left column | 171 | | onFilterData | `function` | - | callback firing when filter button was clicked | 172 | | onClearFilterData | `function` | - | callback firing when clear filters button was clicked (clearing button is visible **only** when filterButtonState is set to `>0`) | 173 | | config | `Config` | - | object with scheduler config properties | 174 | 175 | ##### Scheduler Config Object 176 | 177 | --- 178 | 179 | | Property Name | Type | Default | Description | 180 | | ------------------------------------ | ------------------ | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 181 | | zoom | `0` or `1` or `2` | 0 | `0` - display grid divided into weeks `1` - display grid divided into days `2` - display grid divided into hours | 182 | | filterButtonState | `number` | 0 | `< 0` - hides filter button, `0` - state for when filters were not set, `> 0` - state for when some filters were set (allows to also handle `onClearFilterData` event) | 183 | | maxRecordsPerPage | `number` | 50 | number of items from `SchedulerData` visible per page | 184 | | lang | `en`, `lt` or `pl` | en | scheduler's language | 185 | | includeTakenHoursOnWeekendsInDayView | `boolean` | `false` | show weekends as taken when given resource is longer than a week | 186 | | showTooltip | `boolean` | `true` | show tooltip when hovering over tiles | 187 | | translations | `LocaleType[]` | `undefined` | option to add specific langs translations | 188 | | showThemeToggle | `boolean` | `false` | show toggle button to switch between light/dark mode | 189 | | defaultTheme | `light` or `dark` | `light` | scheduler's default theme | 190 | 191 | #### Translation object example 192 | 193 | ```ts 194 | import enDayjsTranslations from "dayjs/locale/en"; 195 | 196 | const langs: LocaleType[] = [ 197 | { 198 | id: "en", 199 | lang: { 200 | feelingEmpty: "I feel so empty...", 201 | free: "Free", 202 | loadNext: "Next", 203 | loadPrevious: "Previous", 204 | over: "over", 205 | taken: "Taken", 206 | topbar: { 207 | filters: "Filters", 208 | next: "next", 209 | prev: "prev", 210 | today: "Today", 211 | view: "View" 212 | }, 213 | search: "search", 214 | week: "week" 215 | }, 216 | translateCode: "en-EN", 217 | dayjsTranslations: enDayjsTranslations 218 | } 219 | ]; 220 | 221 | ; 228 | ``` 229 | 230 | #### Scheduler LocaleType Object 231 | 232 | | Property Name | Type | Description | 233 | | ----------------- | -------------------------- | ---------------------------------- | 234 | | id | `string` | key is needed for selecting lang | 235 | | lang | `Translation` | object with translations | 236 | | translateCode | `string` | code that is saved in localStorage | 237 | | dayjsTranslations | `string ILocale undefined` | object with translation from dayjs | 238 | 239 | #### Scheduler Translation Object 240 | 241 | | Property Name | Type | 242 | | ------------- | -------- | 243 | | feelingEmpty | `string` | 244 | | free | `string` | 245 | | loadNext | `string` | 246 | | loadPrevious | `string` | 247 | | over | `string` | 248 | | taken | `string` | 249 | | search | `string` | 250 | | week | `string` | 251 | | topbar | `Topbar` | 252 | 253 | ##### Scheduler Topbar Object 254 | 255 | | Property Name | Type | 256 | | ------------- | -------- | 257 | | filters | `string` | 258 | | next | `string` | 259 | | prev | `string` | 260 | | today | `string` | 261 | | view | `string` | 262 | 263 | ##### Scheduler Data 264 | 265 | array of chart rows with shape of 266 | | Property Name | Type | Description | 267 | | -------- | --------------------- | -------------------------------- | 268 | | id | `string` | unique row id | 269 | | label | `SchedulerRowLabel` | row's label, `e.g person's name, surname, icon` | 270 | | data | `Array` | array of `resources` | 271 | 272 | ##### Left Colum Item Data 273 | 274 | data that is accessible as argument of `onItemClick` callback 275 | | Property Name | Type | Description | 276 | | -------- | --------------------- | -------------------------------- | 277 | | id | `string` | unique row id | 278 | | label | `SchedulerRowLabel` | row's label, `e.g person's name, surname, icon` | 279 | 280 | ##### Resource Item 281 | 282 | item that will be visible on the grid as tile and that will be accessible as argument of `onTileClick` event 283 | | Property Name | Type | Description | 284 | | ----------- | ----------------- | ------------------------------------------------------------------------------------------------------- | 285 | | id | `string` | unique resource id | 286 | | title | `string` | resource title that will be displayed on resource tile | 287 | | subtitle | `string (optional)` | resource subtitle that will be displayed on resource tile | 288 | | description | `string (optional)` | resource description that will be displayed on resource tile | 289 | | startDate | `Date` | date for calculating start position for resource | 290 | | endDate | `Date` | date for calculating end position for resource | 291 | | occupancy | `number` | number of seconds resource takes up for given row that will be visible on resource tooltip when hovered | 292 | | bgColor | `string (optional)` | tile color | 293 | 294 | ### Troubleshooting 295 | 296 | - For using Scheduler with RemixJS make sure to add `@bitnoi.se/react-scheduler` to `serverDependenciesToBundle` in `remix.config.js` like so: 297 | 298 | ```js 299 | // remix.config.js 300 | /** @type {import('@remix-run/dev').AppConfig} */ 301 | module.exports = { 302 | // ... 303 | serverDependenciesToBundle: [..., "@bitnoi.se/react-scheduler"], 304 | }; 305 | ``` 306 | 307 | - When using with NextJS (app router) Scheduler needs to be wrapped with component with `use client` 308 | 309 | ```ts 310 | "use client" 311 | import { Scheduler, SchedulerProps } from "@bitnoi.se/react-scheduler"; 312 | 313 | default export function SchedulerClient(props: SchedulerProps) { 314 | return ; 315 | } 316 | 317 | ``` 318 | 319 | - When using with NextJS (pages router) it needs to be imported using `dynamic`: 320 | 321 | ```ts 322 | import dynamic from "next/dynamic"; 323 | const Scheduler = dynamic(() => import("@bitnoi.se/react-scheduler").then((mod) => mod.Scheduler), { 324 | ssr: false 325 | }); 326 | ``` 327 | 328 | - How to customize Scheduler dimensions 329 | 330 | Scheduler is position absolutely to take all available space. If you want to have fixed dimensions wrap Scheduler inside a div with position set to relative. 331 | 332 | Example using styled components: 333 | 334 | ```ts 335 | export const StyledSchedulerFrame = styled.div` 336 | position: relative; 337 | height: 40vh; 338 | width: 40vw; 339 | `; 340 | 341 | 342 | 343 | 344 | ``` 345 | 346 | ### Known Issues 347 | 348 | 1. No responsiveness 349 | 2. Slower performance on Firefox when working with big set of data due to Firefox being slower working with canvas 350 | 351 | ### How to contribute 352 | 353 | - **Reporting Issues**: If you come across any bugs, glitches, or have any suggestions for improvements, please [open an issue](https://github.com/Bitnoise/react-scheduler/issues) on our GitHub repository. Provide as much detail as possible, including steps to reproduce the issue. 354 | - **Suggesting Enhancements**: If you have ideas for new features or enhancements, we would love to hear them! You can [open an issue](https://github.com/Bitnoise/react-scheduler/issues) on our GitHub repository and clearly describe your suggestion. 355 | - **Submitting Pull Requests**: If you have developed a fix or a new feature that you would like to contribute, you can submit a pull request. Here's a quick overview of the process: 356 | - Clone the repository and create your own branch: `git checkout -b feat/your-branch-name`. 357 | - Implement your changes, following the **code style and guidelines** from [development.md](development.md). 358 | - Test your changes to ensure they work as expected. 359 | - Commit your changes and push to your forked repository. 360 | - Open a pull request against our main repository's `master` branch. 361 | - add at least 1 reviewer 362 | - link correct issue 363 | 364 | ### Contact 365 | 366 | If you have any questions or need further assistance, feel free to reach out to us at [scheduler@bitnoi.se](mailto:scheduler@bitnoi.se). We appreciate your contributions and thank you for helping us improve this project! 367 | 368 | ### License 369 | 370 | MIT Licensed. Copyright (c) Bitnoise 2023. 371 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import { createMockData } from "./mock/appMock"; 4 | import { ParsedDatesRange } from "./utils/getDatesRange"; 5 | import { ConfigFormValues, SchedulerProjectData } from "./types/global"; 6 | import ConfigPanel from "./components/ConfigPanel"; 7 | import { StyledSchedulerFrame } from "./styles"; 8 | import { Scheduler } from "."; 9 | 10 | function App() { 11 | const [values, setValues] = useState({ 12 | peopleCount: 15, 13 | projectsPerYear: 5, 14 | yearsCovered: 0, 15 | startDate: undefined, 16 | maxRecordsPerPage: 50, 17 | isFullscreen: true 18 | }); 19 | 20 | const { peopleCount, projectsPerYear, yearsCovered, isFullscreen, maxRecordsPerPage } = values; 21 | 22 | const mocked = useMemo( 23 | () => createMockData(+peopleCount, +yearsCovered, +projectsPerYear), 24 | [peopleCount, projectsPerYear, yearsCovered] 25 | ); 26 | 27 | const [range, setRange] = useState({ 28 | startDate: new Date(), 29 | endDate: new Date() 30 | }); 31 | 32 | const handleRangeChange = useCallback((range: ParsedDatesRange) => { 33 | setRange(range); 34 | }, []); 35 | 36 | const filteredData = useMemo( 37 | () => 38 | mocked.map((person) => ({ 39 | ...person, 40 | data: person.data.filter( 41 | (project) => 42 | dayjs(project.startDate).isBetween(range.startDate, range.endDate) || 43 | dayjs(project.endDate).isBetween(range.startDate, range.endDate) || 44 | (dayjs(project.startDate).isBefore(range.startDate, "day") && 45 | dayjs(project.endDate).isAfter(range.endDate, "day")) 46 | ) 47 | })), 48 | [mocked, range.endDate, range.startDate] 49 | ); 50 | 51 | const handleFilterData = () => console.log(`Filters button was clicked.`); 52 | 53 | const handleTileClick = (data: SchedulerProjectData) => 54 | console.log( 55 | `Item ${data.title} - ${data.subtitle} was clicked. \n==============\nStart date: ${data.startDate} \n==============\nEnd date: ${data.endDate}\n==============\nOccupancy: ${data.occupancy}` 56 | ); 57 | 58 | return ( 59 | <> 60 | 61 | {isFullscreen ? ( 62 | console.log("clicked: ", data)} 71 | /> 72 | ) : ( 73 | 74 | console.log("clicked: ", data)} 82 | /> 83 | 84 | )} 85 | 86 | ); 87 | } 88 | 89 | export default App; 90 | -------------------------------------------------------------------------------- /src/assets/icons/index.ts: -------------------------------------------------------------------------------- 1 | import { ReactComponent as add } from "./svgs/add.svg"; 2 | import { ReactComponent as subtract } from "./svgs/subtract.svg"; 3 | import { ReactComponent as filter } from "./svgs/filter.svg"; 4 | import { ReactComponent as arrowLeft } from "./svgs/arrow-left.svg"; 5 | import { ReactComponent as arrowRight } from "./svgs/arrow-right.svg"; 6 | import { ReactComponent as defaultAvatar } from "./svgs/default-avatar.svg"; 7 | import { ReactComponent as calendarWarning } from "./svgs/calendar-warning.svg"; 8 | import { ReactComponent as calendarFree } from "./svgs/calendar-free.svg"; 9 | import { ReactComponent as arrowUp } from "./svgs/arrow-up.svg"; 10 | import { ReactComponent as arrowDown } from "./svgs/arrow-down.svg"; 11 | import { ReactComponent as search } from "./svgs/search.svg"; 12 | import { ReactComponent as close } from "./svgs/close.svg"; 13 | import { ReactComponent as moon } from "./svgs/moon.svg"; 14 | import { ReactComponent as sun } from "./svgs/sun.svg"; 15 | import { Icon, IconsNames } from "./types"; 16 | 17 | const icons: { [key in IconsNames]: Icon } = { 18 | add, 19 | subtract, 20 | filter, 21 | arrowLeft, 22 | arrowRight, 23 | defaultAvatar, 24 | calendarWarning, 25 | calendarFree, 26 | arrowDown, 27 | arrowUp, 28 | search, 29 | close, 30 | moon, 31 | sun 32 | }; 33 | 34 | export default icons; 35 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/arrow-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/arrow-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/arrow-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/arrow-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/calendar-free.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/calendar-warning.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/close.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/default-avatar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/filter.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/moon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/search.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/subtract.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/assets/icons/svgs/sun.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /src/assets/icons/types.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentProps, FunctionComponent } from "react"; 2 | 3 | export type IconsNames = 4 | | "add" 5 | | "subtract" 6 | | "filter" 7 | | "arrowLeft" 8 | | "arrowRight" 9 | | "defaultAvatar" 10 | | "calendarWarning" 11 | | "calendarFree" 12 | | "arrowUp" 13 | | "arrowDown" 14 | | "search" 15 | | "close" 16 | | "moon" 17 | | "sun"; 18 | 19 | export type Icon = FunctionComponent & { title?: string }>; 20 | -------------------------------------------------------------------------------- /src/components/Calendar/Calendar.tsx: -------------------------------------------------------------------------------- 1 | import { ChangeEvent, FC, useCallback, useEffect, useRef, useState } from "react"; 2 | import debounce from "lodash.debounce"; 3 | import { useCalendar } from "@/context/CalendarProvider"; 4 | import { Day, SchedulerData, SchedulerProjectData, TooltipData, ZoomLevel } from "@/types/global"; 5 | import { getTooltipData } from "@/utils/getTooltipData"; 6 | import { usePagination } from "@/hooks/usePagination"; 7 | import EmptyBox from "../EmptyBox"; 8 | import { Grid, Header, LeftColumn, Tooltip } from ".."; 9 | import { CalendarProps } from "./types"; 10 | import { StyledOuterWrapper, StyledInnerWrapper, StyledEmptyBoxWrapper } from "./styles"; 11 | 12 | const initialTooltipData: TooltipData = { 13 | coords: { x: 0, y: 0 }, 14 | resourceIndex: 0, 15 | disposition: { 16 | taken: { hours: 0, minutes: 0 }, 17 | free: { hours: 0, minutes: 0 }, 18 | overtime: { hours: 0, minutes: 0 } 19 | } 20 | }; 21 | 22 | export const Calendar: FC = ({ 23 | data, 24 | onTileClick, 25 | onItemClick, 26 | toggleTheme, 27 | topBarWidth 28 | }) => { 29 | const [tooltipData, setTooltipData] = useState(initialTooltipData); 30 | const [filteredData, setFilteredData] = useState(data); 31 | const [isVisible, setIsVisible] = useState(false); 32 | const [searchPhrase, setSearchPhrase] = useState(""); 33 | const { 34 | zoom, 35 | startDate, 36 | config: { includeTakenHoursOnWeekendsInDayView, showTooltip, showThemeToggle } 37 | } = useCalendar(); 38 | const gridRef = useRef(null); 39 | const { 40 | page, 41 | projectsPerPerson, 42 | totalRowsPerPage, 43 | rowsPerItem, 44 | currentPageNum, 45 | pagesAmount, 46 | next, 47 | previous, 48 | reset 49 | } = usePagination(filteredData); 50 | const debouncedHandleMouseOver = useRef( 51 | debounce( 52 | ( 53 | e: MouseEvent, 54 | startDate: Day, 55 | rowsPerItem: number[], 56 | projectsPerPerson: SchedulerProjectData[][][], 57 | zoom: ZoomLevel 58 | ) => { 59 | if (!gridRef.current) return; 60 | const { left, top } = gridRef.current.getBoundingClientRect(); 61 | const tooltipCoords = { x: e.clientX - left, y: e.clientY - top }; 62 | const { 63 | coords: { x, y }, 64 | resourceIndex, 65 | disposition 66 | } = getTooltipData( 67 | startDate, 68 | tooltipCoords, 69 | rowsPerItem, 70 | projectsPerPerson, 71 | zoom, 72 | includeTakenHoursOnWeekendsInDayView 73 | ); 74 | setTooltipData({ coords: { x, y }, resourceIndex, disposition }); 75 | setIsVisible(true); 76 | }, 77 | 300 78 | ) 79 | ); 80 | const debouncedFilterData = useRef( 81 | debounce((dataToFilter: SchedulerData, enteredSearchPhrase: string) => { 82 | reset(); 83 | setFilteredData( 84 | dataToFilter.filter((item) => 85 | item.label.title.toLowerCase().includes(enteredSearchPhrase.toLowerCase()) 86 | ) 87 | ); 88 | }, 500) 89 | ); 90 | 91 | const handleSearch = (event: ChangeEvent) => { 92 | const phrase = event.target.value; 93 | setSearchPhrase(phrase); 94 | debouncedFilterData.current.cancel(); 95 | debouncedFilterData.current(data, phrase); 96 | }; 97 | 98 | const handleMouseLeave = useCallback(() => { 99 | debouncedHandleMouseOver.current.cancel(); 100 | setIsVisible(false); 101 | setTooltipData(initialTooltipData); 102 | }, []); 103 | 104 | useEffect(() => { 105 | const handleMouseOver = (e: MouseEvent) => 106 | debouncedHandleMouseOver.current(e, startDate, rowsPerItem, projectsPerPerson, zoom); 107 | const gridArea = gridRef.current; 108 | 109 | if (!gridArea) return; 110 | 111 | gridArea.addEventListener("mousemove", handleMouseOver); 112 | gridArea.addEventListener("mouseleave", handleMouseLeave); 113 | 114 | return () => { 115 | gridArea.removeEventListener("mousemove", handleMouseOver); 116 | gridArea.removeEventListener("mouseleave", handleMouseLeave); 117 | }; 118 | }, [debouncedHandleMouseOver, handleMouseLeave, projectsPerPerson, rowsPerItem, startDate, zoom]); 119 | 120 | useEffect(() => { 121 | if (searchPhrase) return; 122 | 123 | setFilteredData(data); 124 | }, [data, searchPhrase]); 125 | 126 | return ( 127 | 128 | 139 | 140 |
146 | {data.length ? ( 147 | 154 | ) : ( 155 | 156 | 157 | 158 | )} 159 | {showTooltip && isVisible && tooltipData?.resourceIndex > -1 && ( 160 | 161 | )} 162 | 163 | 164 | ); 165 | }; 166 | 167 | export default Calendar; 168 | -------------------------------------------------------------------------------- /src/components/Calendar/Grid/Grid.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, useCallback, useEffect, useRef } from "react"; 2 | import { useTheme } from "styled-components"; 3 | import { drawGrid } from "@/utils/drawGrid/drawGrid"; 4 | import { boxHeight, canvasWrapperId, leftColumnWidth, outsideWrapperId } from "@/constants"; 5 | import { Loader, Tiles } from "@/components"; 6 | import { useCalendar } from "@/context/CalendarProvider"; 7 | import { resizeCanvas } from "@/utils/resizeCanvas"; 8 | import { getCanvasWidth } from "@/utils/getCanvasWidth"; 9 | import { GridProps } from "./types"; 10 | import { StyledCanvas, StyledInnerWrapper, StyledSpan, StyledWrapper } from "./styles"; 11 | 12 | const Grid = forwardRef(function Grid( 13 | { zoom, rows, data, onTileClick }, 14 | ref 15 | ) { 16 | const { handleScrollNext, handleScrollPrev, date, isLoading, cols, startDate } = useCalendar(); 17 | const canvasRef = useRef(null); 18 | const refRight = useRef(null); 19 | const refLeft = useRef(null); 20 | 21 | const theme = useTheme(); 22 | 23 | const handleResize = useCallback( 24 | (ctx: CanvasRenderingContext2D) => { 25 | const width = getCanvasWidth(); 26 | const height = rows * boxHeight + 1; 27 | resizeCanvas(ctx, width, height); 28 | drawGrid(ctx, zoom, rows, cols, startDate, theme); 29 | }, 30 | [cols, startDate, rows, zoom, theme] 31 | ); 32 | 33 | useEffect(() => { 34 | if (!canvasRef.current) return; 35 | const ctx = canvasRef.current.getContext("2d"); 36 | if (!ctx) return; 37 | 38 | const onResize = () => handleResize(ctx); 39 | 40 | window.addEventListener("resize", onResize); 41 | 42 | return () => window.removeEventListener("resize", onResize); 43 | }, [handleResize]); 44 | 45 | useEffect(() => { 46 | const canvas = canvasRef.current; 47 | if (!canvas) return; 48 | canvas.style.letterSpacing = "1px"; 49 | const ctx = canvas.getContext("2d"); 50 | if (!ctx) return; 51 | 52 | handleResize(ctx); 53 | }, [date, rows, zoom, handleResize]); 54 | 55 | useEffect(() => { 56 | if (!refRight.current) return; 57 | const observerRight = new IntersectionObserver( 58 | (e) => (e[0].isIntersecting ? handleScrollNext() : null), 59 | { root: document.getElementById(outsideWrapperId) } 60 | ); 61 | observerRight.observe(refRight.current); 62 | 63 | return () => observerRight.disconnect(); 64 | }, [handleScrollNext]); 65 | 66 | useEffect(() => { 67 | if (!refLeft.current) return; 68 | const observerLeft = new IntersectionObserver( 69 | (e) => (e[0].isIntersecting ? handleScrollPrev() : null), 70 | { 71 | root: document.getElementById(outsideWrapperId), 72 | rootMargin: `0px 0px 0px -${leftColumnWidth}px` 73 | } 74 | ); 75 | observerLeft.observe(refLeft.current); 76 | 77 | return () => observerLeft.disconnect(); 78 | }, [handleScrollPrev]); 79 | 80 | return ( 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | ); 92 | }); 93 | 94 | export default Grid; 95 | -------------------------------------------------------------------------------- /src/components/Calendar/Grid/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Grid"; 2 | -------------------------------------------------------------------------------- /src/components/Calendar/Grid/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { StyledSpanProps } from "./types"; 3 | 4 | export const StyledWrapper = styled.div` 5 | height: calc(100vh - headerHeight); 6 | `; 7 | 8 | export const StyledInnerWrapper = styled.div` 9 | position: relative; 10 | `; 11 | 12 | export const StyledCanvas = styled.canvas``; 13 | export const StyledCanvasHeader = styled.canvas``; 14 | 15 | export const StyledSpan = styled.span` 16 | width: 1px; 17 | height: 100%; 18 | position: absolute; 19 | top: 0; 20 | left: ${({ position }) => (position === "left" ? 0 : "auto")}; 21 | right: ${({ position }) => (position === "right" ? 0 : "auto")}; 22 | `; 23 | -------------------------------------------------------------------------------- /src/components/Calendar/Grid/types.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedSchedulerData, SchedulerProjectData } from "@/types/global"; 2 | 3 | export type GridProps = { 4 | zoom: number; 5 | rows: number; 6 | data: PaginatedSchedulerData; 7 | onTileClick?: (data: SchedulerProjectData) => void; 8 | }; 9 | 10 | export type StyledSpanProps = { 11 | position: "left" | "right"; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback, useEffect, useRef } from "react"; 2 | import { useTheme } from "styled-components"; 3 | import { headerHeight, canvasHeaderWrapperId, zoom2HeaderHeight } from "@/constants"; 4 | import { useCalendar } from "@/context/CalendarProvider"; 5 | import { useLanguage } from "@/context/LocaleProvider"; 6 | import { drawHeader } from "@/utils/drawHeader/drawHeader"; 7 | import { resizeCanvas } from "@/utils/resizeCanvas"; 8 | import { getCanvasWidth } from "@/utils/getCanvasWidth"; 9 | import { HeaderProps } from "./types"; 10 | import { StyledCanvas, StyledOuterWrapper, StyledWrapper } from "./styles"; 11 | import Topbar from "./Topbar"; 12 | 13 | const Header: FC = ({ zoom, topBarWidth, showThemeToggle, toggleTheme }) => { 14 | const { week } = useLanguage(); 15 | const { date, cols, dayOfYear, startDate } = useCalendar(); 16 | const canvasRef = useRef(null); 17 | 18 | const theme = useTheme(); 19 | 20 | const handleResize = useCallback( 21 | (ctx: CanvasRenderingContext2D) => { 22 | const width = getCanvasWidth(); 23 | const currentHeaderHeight = zoom === 2 ? zoom2HeaderHeight : headerHeight; 24 | const height = currentHeaderHeight + 1; 25 | resizeCanvas(ctx, width, height); 26 | 27 | drawHeader(ctx, zoom, cols, startDate, week, dayOfYear, theme); 28 | }, 29 | [cols, dayOfYear, startDate, week, zoom, theme] 30 | ); 31 | 32 | useEffect(() => { 33 | if (!canvasRef.current) return; 34 | const ctx = canvasRef.current.getContext("2d"); 35 | if (!ctx) return; 36 | const onResize = () => handleResize(ctx); 37 | window.addEventListener("resize", onResize); 38 | 39 | return () => window.removeEventListener("resize", onResize); 40 | }, [handleResize]); 41 | 42 | useEffect(() => { 43 | const canvas = canvasRef.current; 44 | if (!canvas) return; 45 | canvas.style.letterSpacing = "1px"; 46 | const ctx = canvas.getContext("2d"); 47 | if (!ctx) return; 48 | 49 | handleResize(ctx); 50 | }, [date, zoom, handleResize]); 51 | 52 | return ( 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | }; 61 | 62 | export default Header; 63 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/Topbar/Topbar.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "styled-components"; 2 | import { FC, MouseEventHandler } from "react"; 3 | import { Icon, IconButton, Toggle } from "@/components"; 4 | import { useCalendar } from "@/context/CalendarProvider"; 5 | import { useLanguage } from "@/context/LocaleProvider"; 6 | import { 7 | NavigationWrapper, 8 | Wrapper, 9 | NavBtn, 10 | Today, 11 | Zoom, 12 | Filters, 13 | OptionsContainer 14 | } from "./styles"; 15 | import { TopbarProps } from "./types"; 16 | 17 | const Topbar: FC = ({ width, showThemeToggle, toggleTheme }) => { 18 | const { topbar } = useLanguage(); 19 | const { 20 | data, 21 | config, 22 | handleGoNext, 23 | handleGoPrev, 24 | handleGoToday, 25 | zoomIn, 26 | zoomOut, 27 | isNextZoom, 28 | isPrevZoom, 29 | handleFilterData, 30 | onClearFilterData 31 | } = useCalendar(); 32 | const { colors } = useTheme(); 33 | const { filterButtonState = -1 } = config; 34 | 35 | const handleClearFilters: MouseEventHandler = (event) => { 36 | event.stopPropagation(); 37 | onClearFilterData?.(); 38 | }; 39 | 40 | return ( 41 | 42 | 43 | {filterButtonState >= 0 && ( 44 | 50 | {topbar.filters} 51 | {!!filterButtonState && ( 52 | 53 | 54 | 55 | )} 56 | 57 | )} 58 | 59 | 60 | 61 | 62 | {topbar.prev} 63 | 64 | {topbar.today} 65 | 66 | {topbar.next} 67 | 68 | 69 | 70 | 71 | {showThemeToggle && } 72 | 73 | {topbar.view} 74 | 81 | 88 | 89 | 90 | 91 | ); 92 | }; 93 | export default Topbar; 94 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/Topbar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Topbar"; 2 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/Topbar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { leftColumnWidth } from "@/constants"; 3 | import { TopbarProps } from "./types"; 4 | 5 | const resetBtnStyles = ` 6 | background: none; 7 | outline: none; 8 | border: none; 9 | font-size: 100%; 10 | line-height: 1.15 11 | margin: 0 12 | `; 13 | 14 | export const Wrapper = styled.div` 15 | width: calc(${({ width }) => width}px - ${leftColumnWidth}px); 16 | position: sticky; 17 | top: 0; 18 | left: ${leftColumnWidth}px; 19 | display: flex; 20 | justify-content: space-between; 21 | align-items: center; 22 | height: ${({ theme }) => theme.navHeight}; 23 | padding: 0.625rem 1rem; 24 | background-color: ${({ theme }) => theme.colors.background}; 25 | z-index: 3; 26 | `; 27 | 28 | export const NavigationWrapper = styled.div` 29 | display: flex; 30 | gap: 1.875rem; 31 | `; 32 | 33 | export const NavBtn = styled.button` 34 | ${resetBtnStyles}; 35 | display: flex; 36 | align-items: center; 37 | gap: 0.25rem; 38 | font-size: 0.875rem; 39 | font-weight: 400; 40 | color: ${({ theme }) => theme.colors.textPrimary}; 41 | :not(:disabled) { 42 | cursor: pointer; 43 | } 44 | `; 45 | 46 | export const Today = styled.button` 47 | ${resetBtnStyles}; 48 | position: relative; 49 | font-weight: 600; 50 | cursor: pointer; 51 | line-height: 1.5rem; 52 | color: ${({ theme }) => theme.colors.textPrimary}; 53 | 54 | &::before, 55 | &::after { 56 | content: ""; 57 | position: absolute; 58 | width: 1px; 59 | height: 1.5rem; 60 | background-color: ${({ theme }) => theme.colors.textPrimary}; 61 | } 62 | &::before { 63 | left: -1.125rem; 64 | } 65 | &::after { 66 | right: -1.125rem; 67 | } 68 | `; 69 | 70 | export const Zoom = styled.div` 71 | display: flex; 72 | align-items: center; 73 | gap: 0.5rem; 74 | font-weight: 600; 75 | font-size: 0.875rem; 76 | color: ${({ theme }) => theme.colors.textPrimary}; 77 | `; 78 | 79 | export const Filters = styled.div` 80 | display: flex; 81 | `; 82 | 83 | export const OptionsContainer = styled.div` 84 | display: flex; 85 | align-items: "center"; 86 | gap: 1.25rem; 87 | `; 88 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/Topbar/types.ts: -------------------------------------------------------------------------------- 1 | export type TopbarProps = { 2 | width: number; 3 | showThemeToggle?: boolean; 4 | toggleTheme?: () => void; 5 | }; 6 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Header"; 2 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { headerHeight } from "@/constants"; 3 | 4 | export const StyledOuterWrapper = styled.div` 5 | position: sticky; 6 | top: 0; 7 | z-index: 1; 8 | `; 9 | 10 | export const StyledWrapper = styled.div` 11 | height: ${headerHeight}px; 12 | display: block; 13 | `; 14 | 15 | export const StyledCanvas = styled.canvas``; 16 | -------------------------------------------------------------------------------- /src/components/Calendar/Header/types.ts: -------------------------------------------------------------------------------- 1 | export type HeaderProps = { 2 | zoom: number; 3 | topBarWidth: number; 4 | showThemeToggle?: boolean; 5 | toggleTheme?: () => void; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/Calendar/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Calendar"; 2 | -------------------------------------------------------------------------------- /src/components/Calendar/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { leftColumnWidth } from "@/constants"; 3 | 4 | export const StyledOuterWrapper = styled.div` 5 | position: relative; 6 | display: flex; 7 | `; 8 | 9 | export const StyledInnerWrapper = styled.div` 10 | position: relative; 11 | margin-left: ${leftColumnWidth}; 12 | display: flex; 13 | flex-direction: column; 14 | contain: paint; 15 | `; 16 | 17 | export const StyledEmptyBoxWrapper = styled.div<{ width: number }>` 18 | width: calc(${({ width }) => width}px - ${leftColumnWidth}px); 19 | position: sticky; 20 | top: 0; 21 | height: 100%; 22 | left: ${leftColumnWidth}px; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | `; 27 | -------------------------------------------------------------------------------- /src/components/Calendar/types.ts: -------------------------------------------------------------------------------- 1 | import { SchedulerData, SchedulerItemClickData, SchedulerProjectData } from "@/types/global"; 2 | 3 | export type CalendarProps = { 4 | data: SchedulerData; 5 | topBarWidth: number; 6 | onTileClick?: (data: SchedulerProjectData) => void; 7 | onItemClick?: (data: SchedulerItemClickData) => void; 8 | toggleTheme?: () => void; 9 | }; 10 | 11 | export type StyledSpanProps = { 12 | position: "left" | "right"; 13 | }; 14 | 15 | export type ProjectsData = [projectsPerPerson: SchedulerProjectData[][][], rowsPerPerson: number[]]; 16 | -------------------------------------------------------------------------------- /src/components/ConfigPanel/ConfigPanel.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import { ConfigFormValues } from "@/types/global"; 4 | import { formFieldsIds } from "@/constants"; 5 | import { 6 | StyledButton, 7 | StyledCheckbox, 8 | StyledForm, 9 | StyledInnerWrapper, 10 | StyledInput, 11 | StyledLabel, 12 | StyledWrapper 13 | } from "./styles"; 14 | import { ConfigPanelProps } from "./types"; 15 | 16 | const ConfigPanel: FC = ({ values, onSubmit }) => { 17 | const [inputValues, setInputValues] = useState(values); 18 | const [isExpanded, setIsExpanded] = useState(false); 19 | 20 | const handleChange = (event: React.ChangeEvent) => { 21 | const { name, value, type, checked } = event.target; 22 | const inputValue = type === "checkbox" ? checked : value; 23 | 24 | setInputValues((prev) => ({ 25 | ...prev, 26 | [name]: +inputValue 27 | })); 28 | }; 29 | 30 | const handleSubmit = (event: React.FormEvent) => { 31 | event.preventDefault(); 32 | onSubmit(inputValues); 33 | }; 34 | 35 | const handleDateChange = (event: React.ChangeEvent) => { 36 | const { value } = event.target; 37 | 38 | setInputValues((prev) => ({ 39 | ...prev, 40 | startDate: value ? dayjs(value).format("YYYY-MM-DD") : undefined 41 | })); 42 | }; 43 | 44 | return ( 45 | setIsExpanded(false)} isExpanded={isExpanded}> 46 | 47 | 48 | People count: 49 | 58 | 59 | 60 | Projects per year: 61 | 70 | 71 | 72 | Years covered: 73 | 82 | 83 | 84 | Starting date 85 | 92 | 93 | 94 | Records/page: 95 | 104 | 105 | 106 | Fullscreen: 107 | 114 | 115 | {isExpanded ? ( 116 | Change 117 | ) : ( 118 | setIsExpanded(true)} type="button"> 119 | Expand config panel 120 | 121 | )} 122 | 123 | 124 | ); 125 | }; 126 | 127 | export default ConfigPanel; 128 | -------------------------------------------------------------------------------- /src/components/ConfigPanel/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./ConfigPanel"; 2 | -------------------------------------------------------------------------------- /src/components/ConfigPanel/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | type WrapperProps = { 4 | isExpanded: boolean; 5 | }; 6 | 7 | export const StyledWrapper = styled.div` 8 | box-sizing: border-box; 9 | font-family: Inter; 10 | padding: 0 0.5rem; 11 | height: 125px; 12 | position: fixed; 13 | top: ${({ isExpanded }) => (isExpanded ? 0 : "-129px")}; 14 | display: flex; 15 | flex-direction: column; 16 | background-color: white; 17 | z-index: 999; 18 | `; 19 | 20 | export const StyledInnerWrapper = styled.div` 21 | width: 100%; 22 | margin-top: 2px; 23 | height: 1.5rem; 24 | display: flex; 25 | align-items: center; 26 | justify-content: space-between; 27 | letter-spacing: 0.5px; 28 | background-color: white; 29 | `; 30 | 31 | export const StyledLabel = styled.label` 32 | font-size: 14px; 33 | `; 34 | 35 | export const StyledInput = styled.input` 36 | width: 45px; 37 | height: 18px; 38 | font-size: 14px; 39 | border: 1px solid #0a11eb; 40 | border-radius: 4px; 41 | background-color: white; 42 | outline: none; 43 | `; 44 | 45 | export const StyledCheckbox = styled.input` 46 | height: 18px; 47 | width: 18px; 48 | `; 49 | 50 | export const StyledButton = styled.button` 51 | width: 100%; 52 | font-size: 14px; 53 | outline: none; 54 | background-color: #fff; 55 | border: 1px solid #0a11eb; 56 | border-radius: 4px; 57 | color: #0a11eb; 58 | cursor: pointer; 59 | &:hover { 60 | background-color: #c9e5ff; 61 | } 62 | `; 63 | 64 | export const StyledForm = styled.form` 65 | background-color: rgba(255, 255, 255, 0.75); 66 | `; 67 | -------------------------------------------------------------------------------- /src/components/ConfigPanel/types.ts: -------------------------------------------------------------------------------- 1 | import { ConfigFormValues } from "@/types/global"; 2 | 3 | export type ConfigPanelProps = { 4 | values: ConfigFormValues; 5 | onSubmit: (values: ConfigFormValues) => void; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/EmptyBox/EmptyBox.tsx: -------------------------------------------------------------------------------- 1 | import { useLanguage } from "@/context/LocaleProvider"; 2 | import { ReactComponent as EmptyBoxSvg } from "./empty-box.svg"; 3 | import { StyledText, StyledWrapper } from "./styles"; 4 | const EmptyBox = () => { 5 | const { feelingEmpty } = useLanguage(); 6 | return ( 7 | 8 | 9 | {feelingEmpty} 10 | 11 | ); 12 | }; 13 | 14 | export default EmptyBox; 15 | -------------------------------------------------------------------------------- /src/components/EmptyBox/empty-box.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/EmptyBox/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./EmptyBox"; 2 | -------------------------------------------------------------------------------- /src/components/EmptyBox/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const StyledWrapper = styled.div` 4 | height: 440px; 5 | width: 514px; 6 | position: relative; 7 | `; 8 | 9 | export const StyledText = styled.p` 10 | position: absolute; 11 | top: 75%; 12 | left: 50%; 13 | transform: translateX(-50%); 14 | font-size: 20px; 15 | letter-spacing: 1px; 16 | line-height: 1px; 17 | color: ${({ theme }) => theme.colors.textPrimary}; 18 | `; 19 | -------------------------------------------------------------------------------- /src/components/Icon/Icon.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "styled-components"; 2 | import icons from "@/assets/icons"; 3 | import { IconProps } from "./types"; 4 | 5 | const Icon = ({ iconName, width, height, fill, className }: IconProps) => { 6 | const { colors } = useTheme(); 7 | 8 | const IconComponent = icons[iconName]; 9 | 10 | if (!IconComponent) return null; 11 | 12 | return ( 13 | 20 | ); 21 | }; 22 | 23 | export default Icon; 24 | -------------------------------------------------------------------------------- /src/components/Icon/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Icon"; 2 | export type { IconProps } from "./types"; 3 | -------------------------------------------------------------------------------- /src/components/Icon/types.ts: -------------------------------------------------------------------------------- 1 | import icons from "@/assets/icons"; 2 | 3 | export type IconProps = { 4 | iconName: keyof typeof icons; 5 | fill?: string; 6 | width?: string; 7 | height?: string; 8 | className?: string; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/IconButton/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import { useTheme } from "styled-components"; 2 | import { Icon } from "@/components"; 3 | import { ButtonWrapper } from "./styles"; 4 | import { IconButtonProps } from "./types"; 5 | 6 | const IconButton = ({ 7 | iconName, 8 | width, 9 | height, 10 | fill, 11 | className, 12 | onClick, 13 | children, 14 | isFullRounded, 15 | isDisabled, 16 | variant = "outlined" 17 | }: IconButtonProps) => { 18 | const { colors } = useTheme(); 19 | 20 | return ( 21 | 27 | 34 | {children} 35 | 36 | ); 37 | }; 38 | export default IconButton; 39 | -------------------------------------------------------------------------------- /src/components/IconButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./IconButton"; 2 | -------------------------------------------------------------------------------- /src/components/IconButton/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Theme } from "@/styles"; 3 | import { IconButtonVariant } from "./types"; 4 | 5 | type ButtonWrapperProps = { 6 | isFullRounded?: boolean; 7 | hasChildren?: boolean; 8 | disabled?: boolean; 9 | variant: IconButtonVariant; 10 | }; 11 | 12 | const variantStyles = (theme: Theme, variant: IconButtonVariant, disabled?: boolean) => 13 | ({ 14 | outlined: { 15 | color: disabled ? theme.colors.disabled : theme.colors.accent, 16 | border: `1px solid ${disabled ? theme.colors.disabled : theme.colors.accent}`, 17 | background: "transparent" 18 | }, 19 | filled: { 20 | color: disabled ? theme.colors.primary : theme.colors.textSecondary, 21 | background: disabled ? theme.colors.disabled : theme.colors.accent, 22 | border: "1px solid transparent" 23 | } 24 | }[variant]); 25 | 26 | export const ButtonWrapper = styled.button` 27 | outline: none; 28 | display: flex; 29 | align-items: center; 30 | justify-content: center; 31 | min-width: 24px; 32 | min-height: 24px; 33 | border-radius: ${({ isFullRounded }) => (isFullRounded ? "50%" : "4px")}; 34 | cursor: ${({ disabled }) => (disabled ? "auto" : "pointer")}; 35 | font-size: 14px; 36 | gap: 4px; 37 | padding: ${({ hasChildren }) => (hasChildren ? "0 10px" : "0")}; 38 | transition: 0.5s ease; 39 | ${({ theme, variant, disabled }) => variantStyles(theme, variant, disabled)} 40 | `; 41 | -------------------------------------------------------------------------------- /src/components/IconButton/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { IconProps } from "../Icon"; 3 | 4 | export type IconButtonVariant = "outlined" | "filled"; 5 | 6 | export type IconButtonProps = { 7 | onClick?: () => void; 8 | children?: ReactNode; 9 | isFullRounded?: boolean; 10 | isDisabled?: boolean; 11 | variant?: IconButtonVariant; 12 | } & IconProps; 13 | -------------------------------------------------------------------------------- /src/components/LeftColumn/LeftColumn.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useState } from "react"; 2 | import { useLanguage } from "@/context/LocaleProvider"; 3 | import Icon from "../Icon"; 4 | import PaginationButton from "../PaginationButton/PaginationButton"; 5 | import { StyledInput, StyledInputWrapper, StyledLeftColumnHeader, StyledWrapper } from "./styles"; 6 | import { LeftColumnProps } from "./types"; 7 | import LeftColumnItem from "./LeftColumnItem/LeftColumnItem"; 8 | 9 | const LeftColumn: FC = ({ 10 | data, 11 | rows, 12 | onLoadNext, 13 | onLoadPrevious, 14 | pageNum, 15 | pagesAmount, 16 | searchInputValue, 17 | onSearchInputChange, 18 | onItemClick 19 | }) => { 20 | const [isInputFocused, setIsInputFocused] = useState(false); 21 | const { search } = useLanguage(); 22 | 23 | const toggleFocus = () => setIsInputFocused((prev) => !prev); 24 | 25 | return ( 26 | 27 | 28 | 29 | 36 | 37 | 38 | } 43 | pageNum={pageNum} 44 | pagesAmount={pagesAmount} 45 | /> 46 | 47 | {data.map((item, index) => ( 48 | 55 | ))} 56 | } 61 | pageNum={pageNum} 62 | pagesAmount={pagesAmount} 63 | /> 64 | 65 | ); 66 | }; 67 | 68 | export default LeftColumn; 69 | -------------------------------------------------------------------------------- /src/components/LeftColumn/LeftColumnItem/LeftColumnItem.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { Icon } from "@/components"; 3 | import { 4 | StyledImage, 5 | StyledImageWrapper, 6 | StyledInnerWrapper, 7 | StyledText, 8 | StyledTextWrapper, 9 | StyledWrapper 10 | } from "./styles"; 11 | import { LeftColumnItemProps } from "./types"; 12 | 13 | const LeftColumnItem: FC = ({ id, item, rows, onItemClick }) => { 14 | return ( 15 | onItemClick?.({ id, label: item })}> 20 | 21 | 22 | {item.icon ? ( 23 | 24 | ) : ( 25 | 26 | )} 27 | 28 | 29 | {item.title} 30 | {item.subtitle} 31 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export default LeftColumnItem; 38 | -------------------------------------------------------------------------------- /src/components/LeftColumn/LeftColumnItem/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./LeftColumnItem"; 2 | -------------------------------------------------------------------------------- /src/components/LeftColumn/LeftColumnItem/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { boxHeight } from "@/constants"; 3 | import { StyledLeftColumnItemWrapperProps, StyledTextProps } from "./types"; 4 | 5 | export const StyledWrapper = styled.div` 6 | display: flex; 7 | align-items: ${({ rows }) => (rows > 1 ? "start" : "center")}; 8 | padding: 0.813rem 0 0.813rem 1rem; 9 | width: 100%; 10 | min-height: ${boxHeight}px; 11 | height: calc(${boxHeight}px * ${({ rows }) => rows}); 12 | border-top: 1px solid ${({ theme }) => theme.colors.border}; 13 | transition: 0.5s ease; 14 | cursor: ${({ clickable }) => (clickable ? "pointer" : "auto")}; 15 | &:hover { 16 | background-color: ${({ theme }) => theme.colors.hover}; 17 | } 18 | `; 19 | 20 | export const StyledInnerWrapper = styled.div` 21 | display: flex; 22 | align-items: center; 23 | `; 24 | 25 | export const StyledImageWrapper = styled.div` 26 | margin-right: 0.5rem; 27 | width: 1.5rem; 28 | height: 1.5rem; 29 | border-radius: 50%; 30 | overflow: hidden; 31 | flex-shrink: 0; 32 | `; 33 | export const StyledImage = styled.img` 34 | object-fit: cover; 35 | height: 100%; 36 | width: 100%; 37 | `; 38 | export const StyledTextWrapper = styled.div` 39 | display: flex; 40 | flex-direction: column; 41 | flex: 1 0 0; 42 | `; 43 | export const StyledText = styled.p` 44 | margin: 0; 45 | padding: 0; 46 | font-size: ${({ isMain }) => (isMain ? 0.75 + "rem" : 0.625 + "rem")}; 47 | letter-spacing: ${({ isMain }) => (isMain ? 1 + "px" : 0.5 + "px")}; 48 | line-height: ${({ isMain }) => (isMain ? 1.125 + "rem" : 0.75 + "rem")}; 49 | color: ${({ isMain, theme }) => (isMain ? theme.colors.textPrimary : theme.colors.placeholder)}; 50 | text-overflow: ellipsis; 51 | display: inline-block; 52 | max-width: 144px; 53 | width: 100%; 54 | white-space: nowrap; 55 | overflow: hidden; 56 | `; 57 | -------------------------------------------------------------------------------- /src/components/LeftColumn/LeftColumnItem/types.ts: -------------------------------------------------------------------------------- 1 | import { SchedulerItemClickData, SchedulerRowLabel } from "@/types/global"; 2 | 3 | export type LeftColumnItemProps = { 4 | id: string; 5 | item: SchedulerRowLabel; 6 | rows: number; 7 | onItemClick?: (data: SchedulerItemClickData) => void; 8 | }; 9 | 10 | export type StyledTextProps = { 11 | isMain?: boolean; 12 | }; 13 | 14 | export type StyledLeftColumnItemWrapperProps = { 15 | rows: number; 16 | clickable: boolean; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/LeftColumn/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./LeftColumn"; 2 | -------------------------------------------------------------------------------- /src/components/LeftColumn/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { leftColumnWidth } from "@/constants"; 3 | import { StyledInputWrapperProps } from "./types"; 4 | 5 | export const StyledWrapper = styled.div` 6 | min-width: ${leftColumnWidth + "px"}; 7 | max-width: ${leftColumnWidth + "px"}; 8 | min-height: 100vh; 9 | position: sticky; 10 | left: 0; 11 | background-color: ${({ theme }) => theme.colors.background}; 12 | box-shadow: 0px 4px 15px rgba(39, 55, 75, 0.16); 13 | z-index: 2; 14 | `; 15 | 16 | export const StyledLeftColumnHeader = styled.div` 17 | padding-bottom: 4px; 18 | position: sticky; 19 | top: 0; 20 | height: 124px; 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: end; 24 | width: ${leftColumnWidth}px; 25 | background-color: ${({ theme }) => theme.colors.background}; 26 | z-index: 3; 27 | `; 28 | 29 | export const StyledInput = styled.input` 30 | height: 100%; 31 | width: calc(100% - 44px); 32 | background-color: transparent; 33 | color: ${({ theme }) => theme.colors.textPrimary}; 34 | padding: 7px 0 7px 12px; 35 | border: 0; 36 | outline: none; 37 | &::placeholder { 38 | color: ${({ theme }) => theme.colors.placeholder}; 39 | } 40 | `; 41 | 42 | export const StyledInputWrapper = styled.div` 43 | margin-left: 10px; 44 | height: 36px; 45 | width: calc(100% - 20px); //20px = 10px margin each side 46 | background-color: ${({ theme }) => theme.colors.primary}; 47 | border: 1px solid 48 | ${({ theme, isFocused }) => (isFocused ? theme.colors.accent : theme.colors.border)}; 49 | border-radius: 4px; 50 | display: flex; 51 | justify-content: space-between; 52 | align-items: center; 53 | 54 | svg { 55 | margin-left: auto; 56 | margin-right: 12px; 57 | height: 24px; 58 | width: 24px; 59 | } 60 | `; 61 | -------------------------------------------------------------------------------- /src/components/LeftColumn/types.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedSchedulerData, SchedulerItemClickData } from "@/types/global"; 2 | 3 | export type LeftColumnProps = { 4 | data: PaginatedSchedulerData; 5 | rows: number[]; 6 | pageNum: number; 7 | pagesAmount: number; 8 | onLoadNext: () => void; 9 | onLoadPrevious: () => void; 10 | searchInputValue: string; 11 | onSearchInputChange: React.ChangeEventHandler; 12 | onItemClick?: (data: SchedulerItemClickData) => void; 13 | }; 14 | 15 | export type StyledInputWrapperProps = { 16 | isFocused: boolean; 17 | }; 18 | -------------------------------------------------------------------------------- /src/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { LoaderProps } from "./types"; 3 | import { StyledWalker, StyledWrapper } from "./styles"; 4 | 5 | const Loader: FC = ({ isLoading, position }) => { 6 | return isLoading ? ( 7 | 8 | 9 | 10 | ) : null; 11 | }; 12 | 13 | export default Loader; 14 | -------------------------------------------------------------------------------- /src/components/Loader/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Loader"; 2 | -------------------------------------------------------------------------------- /src/components/Loader/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | import { StyledWrapperProps } from "./types"; 3 | 4 | export const StyledWrapper = styled.div` 5 | width: 388px; 6 | height: 100%; 7 | position: absolute; 8 | top: 0; 9 | left: ${({ position }) => (position === "left" ? 0 : "auto")}; 10 | right: ${({ position }) => (position === "right" ? 0 : "auto")}; 11 | background-color: ${({ theme }) => theme.colors.secondary}; 12 | opacity: 0.7; 13 | overflow: hidden; 14 | z-index: 1; 15 | `; 16 | 17 | const move = keyframes` 18 | from{ 19 | left: -100%; 20 | } 21 | to{ 22 | left: 100%; 23 | }`; 24 | 25 | export const StyledWalker = styled.div` 26 | width: inherit; 27 | height: 100%; 28 | position: absolute; 29 | background: linear-gradient(90deg, #e6f3ff 1%, #9ec4e7 50%, #e6f3ff 100%); 30 | animation: ${move} 1s infinite; 31 | `; 32 | -------------------------------------------------------------------------------- /src/components/Loader/types.ts: -------------------------------------------------------------------------------- 1 | export interface StyledWrapperProps { 2 | position: "left" | "right"; 3 | } 4 | 5 | export interface LoaderProps extends StyledWrapperProps { 6 | isLoading: boolean; 7 | } 8 | -------------------------------------------------------------------------------- /src/components/PaginationButton/PaginationButton.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useLanguage } from "@/context/LocaleProvider"; 3 | import { PaginationButtonProps } from "./types"; 4 | import { StyledButton, StyledIconWrapper, StyledText, StyledWrapper } from "./styles"; 5 | 6 | const PaginationButton: FC = ({ 7 | intent, 8 | onClick, 9 | icon, 10 | isVisible, 11 | pageNum, 12 | pagesAmount 13 | }) => { 14 | const { loadNext, loadPrevious } = useLanguage(); 15 | 16 | const buttonText = 17 | intent === "next" 18 | ? `${loadNext} ${pageNum + 2}/${pagesAmount}` 19 | : `${loadPrevious} ${pageNum}/${pagesAmount}`; 20 | 21 | return ( 22 | 23 | 24 | {icon && {icon}} 25 | {buttonText} 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default PaginationButton; 32 | -------------------------------------------------------------------------------- /src/components/PaginationButton/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./PaginationButton"; 2 | -------------------------------------------------------------------------------- /src/components/PaginationButton/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { marginPaddingReset } from "@/styles"; 3 | import { PaginationButtonProps, StyledPaginationButton } from "./types"; 4 | 5 | export const StyledWrapper = styled.div>` 6 | padding: 4px 11px 0; 7 | width: 100%; 8 | border-top: ${({ intent, theme }) => 9 | intent === "next" ? `1px solid ${theme.colors.border}` : "none"}; 10 | `; 11 | 12 | export const StyledButton = styled.button` 13 | margin-top: 0px; 14 | padding: 0; 15 | width: 100%; 16 | display: flex; 17 | align-items: center; 18 | background-color: transparent; 19 | border: 1px solid ${({ theme }) => theme.colors.accent}; 20 | border-radius: 4px; 21 | font-size: 14px; 22 | color: ${({ theme }) => theme.colors.accent}; 23 | line-height: 150%; 24 | letter-spacing: 1px; 25 | cursor: pointer; 26 | opacity: ${({ isVisible }) => (isVisible ? "1" : "0")}; 27 | pointer-events: ${({ isVisible }) => (isVisible ? "auto" : "none")}; 28 | &:hover { 29 | transition: 0.5s ease; 30 | background-color: ${({ theme }) => theme.colors.hover}; 31 | } 32 | `; 33 | 34 | export const StyledIconWrapper = styled.div` 35 | position: absolute; 36 | max-height: 16px; 37 | margin: 0 4px 0 10px; 38 | display: flex; 39 | align-items: center; 40 | justify-content: center; 41 | `; 42 | 43 | export const StyledText = styled.p` 44 | ${marginPaddingReset} 45 | margin-left: 14px; 46 | width: 100%; 47 | text-align: center; 48 | `; 49 | -------------------------------------------------------------------------------- /src/components/PaginationButton/types.ts: -------------------------------------------------------------------------------- 1 | export type PaginationButtonProps = { 2 | intent: "previous" | "next"; 3 | isVisible: boolean; 4 | onClick: () => void; 5 | pageNum: number; 6 | pagesAmount: number; 7 | icon?: React.ReactNode; 8 | }; 9 | 10 | export type StyledPaginationButton = { 11 | isVisible: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Scheduler/Scheduler.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "styled-components"; 2 | import { useEffect, useMemo, useRef, useState } from "react"; 3 | import dayjs from "dayjs"; 4 | import { Calendar } from "@/components"; 5 | import CalendarProvider from "@/context/CalendarProvider"; 6 | import LocaleProvider from "@/context/LocaleProvider"; 7 | import { darkTheme, GlobalStyle, theme } from "@/styles"; 8 | import { Config } from "@/types/global"; 9 | import { outsideWrapperId } from "@/constants"; 10 | import { SchedulerProps } from "./types"; 11 | import { StyledInnerWrapper, StyledOutsideWrapper } from "./styles"; 12 | 13 | const Scheduler = ({ 14 | data, 15 | config, 16 | startDate, 17 | onRangeChange, 18 | onTileClick, 19 | onFilterData, 20 | onClearFilterData, 21 | onItemClick, 22 | isLoading 23 | }: SchedulerProps) => { 24 | const appConfig: Config = useMemo( 25 | () => ({ 26 | zoom: 0, 27 | filterButtonState: 1, 28 | includeTakenHoursOnWeekendsInDayView: false, 29 | showTooltip: true, 30 | translations: undefined, 31 | ...config 32 | }), 33 | [config] 34 | ); 35 | 36 | const outsideWrapperRef = useRef(null); 37 | const [topBarWidth, setTopBarWidth] = useState(outsideWrapperRef.current?.clientWidth); 38 | const defaultStartDate = useMemo(() => dayjs(startDate), [startDate]); 39 | const [themeMode, setThemeMode] = useState<"light" | "dark">(appConfig.defaultTheme ?? "light"); 40 | const toggleTheme = () => { 41 | themeMode === "light" ? setThemeMode("dark") : setThemeMode("light"); 42 | }; 43 | 44 | const currentTheme = themeMode === "light" ? theme : darkTheme; 45 | const customColors = appConfig.theme ? appConfig.theme[currentTheme.mode] : {}; 46 | const mergedTheme = { 47 | ...currentTheme, 48 | colors: { 49 | ...currentTheme.colors, 50 | ...customColors 51 | } 52 | }; 53 | 54 | useEffect(() => { 55 | const handleResize = () => { 56 | if (outsideWrapperRef.current) { 57 | setTopBarWidth(outsideWrapperRef.current.clientWidth); 58 | } 59 | }; 60 | 61 | handleResize(); 62 | 63 | window.addEventListener("resize", handleResize); 64 | 65 | return () => window.removeEventListener("resize", handleResize); 66 | }, []); 67 | 68 | if (!outsideWrapperRef.current) null; 69 | return ( 70 | <> 71 | 72 | 73 | 74 | 82 | 86 | 87 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | ); 101 | }; 102 | 103 | export default Scheduler; 104 | -------------------------------------------------------------------------------- /src/components/Scheduler/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { StyledOutsideWrapperProps } from "./types"; 3 | 4 | export const StyledOutsideWrapper = styled.div` 5 | position: absolute; 6 | top: 0; 7 | bottom: 0; 8 | left: 0; 9 | right: 0; 10 | display: flex; 11 | overflow-x: ${({ showScroll }) => (showScroll ? "scroll" : "hidden")}; 12 | background-color: ${({ theme }) => theme.colors.gridBackground}; 13 | `; 14 | export const StyledInnerWrapper = styled.div` 15 | position: relative; 16 | `; 17 | -------------------------------------------------------------------------------- /src/components/Scheduler/types.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Config, 3 | SchedulerData, 4 | SchedulerItemClickData, 5 | SchedulerProjectData 6 | } from "@/types/global"; 7 | import { ParsedDatesRange } from "@/utils/getDatesRange"; 8 | 9 | export type SchedulerProps = { 10 | data: SchedulerData; 11 | isLoading?: boolean; 12 | config?: Config; 13 | startDate?: string; 14 | onRangeChange?: (range: ParsedDatesRange) => void; 15 | onTileClick?: (data: SchedulerProjectData) => void; 16 | onFilterData?: () => void; 17 | onClearFilterData?: () => void; 18 | onItemClick?: (data: SchedulerItemClickData) => void; 19 | }; 20 | 21 | export type StyledOutsideWrapperProps = { 22 | showScroll: boolean; 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/Tiles/Tile/Tile.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useTheme } from "styled-components"; 3 | import { useCalendar } from "@/context/CalendarProvider"; 4 | import { getDatesRange } from "@/utils/getDatesRange"; 5 | import { getTileProperties } from "@/utils/getTileProperties"; 6 | import { getTileTextColor } from "@/utils/getTileTextColor"; 7 | import { 8 | StyledDescription, 9 | StyledStickyWrapper, 10 | StyledText, 11 | StyledTextWrapper, 12 | StyledTileWrapper 13 | } from "./styles"; 14 | import { TileProps } from "./types"; 15 | 16 | const Tile: FC = ({ row, data, zoom, onTileClick }) => { 17 | const { date } = useCalendar(); 18 | const datesRange = getDatesRange(date, zoom); 19 | const { y, x, width } = getTileProperties( 20 | row, 21 | datesRange.startDate, 22 | datesRange.endDate, 23 | data.startDate, 24 | data.endDate, 25 | zoom 26 | ); 27 | 28 | const { colors } = useTheme(); 29 | 30 | return ( 31 | onTileClick?.(data)}> 40 | 41 | 42 | {data.title} 43 | {data.subtitle} 44 | {data.description} 45 | 46 | 47 | 48 | ); 49 | }; 50 | 51 | export default Tile; 52 | -------------------------------------------------------------------------------- /src/components/Tiles/Tile/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Tile"; 2 | -------------------------------------------------------------------------------- /src/components/Tiles/Tile/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { leftColumnWidth, tileHeight } from "@/constants"; 3 | import { marginPaddingReset, truncate } from "@/styles"; 4 | import { StyledTextProps } from "./types"; 5 | 6 | export const StyledTileWrapper = styled.button` 7 | ${marginPaddingReset} 8 | height: ${tileHeight}px; 9 | position: absolute; 10 | outline: none; 11 | border: none; 12 | border-radius: 4px; 13 | text-align: left; 14 | color: ${({ theme }) => theme.colors.textPrimary}; 15 | width: 100%; 16 | cursor: pointer; 17 | `; 18 | 19 | export const StyledTextWrapper = styled.div` 20 | margin: 10px 16px; 21 | position: relative; 22 | display: flex; 23 | font-size: 10px; 24 | letter-spacing: 0.5px; 25 | line-height: 12px; 26 | `; 27 | 28 | export const StyledText = styled.p` 29 | ${marginPaddingReset} 30 | ${truncate} 31 | display: inline; 32 | font-weight: ${({ bold }) => (bold ? "600" : "400")}; 33 | &:first-child { 34 | &::after { 35 | content: "|"; 36 | margin: 0 3px; 37 | } 38 | } 39 | `; 40 | 41 | export const StyledDescription = styled.p` 42 | ${marginPaddingReset} 43 | ${truncate} 44 | `; 45 | 46 | export const StyledStickyWrapper = styled.div` 47 | position: sticky; 48 | left: ${leftColumnWidth + 16}px; 49 | overflow: hidden; 50 | `; 51 | -------------------------------------------------------------------------------- /src/components/Tiles/Tile/types.ts: -------------------------------------------------------------------------------- 1 | import { SchedulerProjectData } from "@/types/global"; 2 | 3 | export type TileProps = { 4 | row: number; 5 | data: SchedulerProjectData; 6 | zoom: number; 7 | onTileClick?: (data: SchedulerProjectData) => void; 8 | }; 9 | 10 | export type StyledTextProps = { 11 | bold?: boolean; 12 | }; 13 | -------------------------------------------------------------------------------- /src/components/Tiles/Tiles.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useCallback } from "react"; 2 | import { Tile } from ".."; 3 | import { PlacedTiles, TilesProps } from "./types"; 4 | 5 | const Tiles: FC = ({ data, zoom, onTileClick }) => { 6 | const placeTiles = useCallback((): PlacedTiles => { 7 | let rows = 0; 8 | return data 9 | .map((person, personIndex) => { 10 | if (personIndex > 0) { 11 | rows += Math.max(data[personIndex - 1].data.length, 1); 12 | } 13 | return person.data.map((projectsPerRow, rowIndex) => 14 | projectsPerRow.map((project) => ( 15 | 22 | )) 23 | ); 24 | }) 25 | .flat(2); 26 | }, [data, onTileClick, zoom]); 27 | 28 | return <>{placeTiles()}; 29 | }; 30 | 31 | export default Tiles; 32 | -------------------------------------------------------------------------------- /src/components/Tiles/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Tiles"; 2 | -------------------------------------------------------------------------------- /src/components/Tiles/types.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedSchedulerData, SchedulerProjectData } from "@/types/global"; 2 | 3 | export type TilesProps = { 4 | zoom: number; 5 | data: PaginatedSchedulerData; 6 | onTileClick?: (data: SchedulerProjectData) => void; 7 | }; 8 | 9 | export type PlacedTiles = JSX.Element[]; 10 | -------------------------------------------------------------------------------- /src/components/Toggle/Toggle.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from "react"; 2 | import { useTheme } from "styled-components"; 3 | import Icon from "../Icon"; 4 | import { ToggleProps } from "./types"; 5 | import { ToggleContainer, ToggleCircle, IconContainer } from "./styles"; 6 | 7 | const Toggle: FC = ({ toggleTheme }) => { 8 | const theme = useTheme(); 9 | 10 | return ( 11 | 12 | 13 | 14 | {theme.mode === "light" ? ( 15 | 16 | ) : ( 17 | 18 | )} 19 | 20 | 21 | ); 22 | }; 23 | 24 | export default Toggle; 25 | -------------------------------------------------------------------------------- /src/components/Toggle/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from "./Toggle"; 2 | -------------------------------------------------------------------------------- /src/components/Toggle/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const ToggleContainer = styled.div` 4 | display: flex; 5 | align-items: center; 6 | cursor: pointer; 7 | width: 60px; 8 | height: 26px; 9 | background-color: ${({ theme }) => theme.colors.secondary}; 10 | border-radius: 30px; 11 | position: relative; 12 | transition: background-color 0.3s ease; 13 | `; 14 | 15 | export const ToggleCircle = styled.div` 16 | width: 20px; 17 | height: 20px; 18 | background-color: ${({ theme }) => theme.colors.button}; 19 | border-radius: 50%; 20 | position: absolute; 21 | top: 3px; 22 | left: ${({ theme }) => (theme.mode === "light" ? "4px" : "34px")}; 23 | transition: left 0.3s ease; 24 | `; 25 | 26 | export const IconContainer = styled.div` 27 | position: absolute; 28 | top: 5px; 29 | left: ${({ theme }) => (theme.mode === "light" ? "38px" : "4px")}; 30 | transition: left 0.3s ease; 31 | `; 32 | -------------------------------------------------------------------------------- /src/components/Toggle/types.ts: -------------------------------------------------------------------------------- 1 | export type ToggleProps = { 2 | toggleTheme?: () => void; 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useLayoutEffect, useRef } from "react"; 2 | import { dayWidth, weekWidth, zoom2ColumnWidth } from "@/constants"; 3 | import { useLanguage } from "@/context/LocaleProvider"; 4 | import Icon from "../Icon"; 5 | import { 6 | StyledContentWrapper, 7 | StyledInnerWrapper, 8 | StyledOvertimeWarning, 9 | StyledText, 10 | StyledTextWrapper, 11 | StyledTooltipBeak, 12 | StyledTooltipContent, 13 | StyledTooltipWrapper 14 | } from "./styles"; 15 | import { TooltipProps } from "./types"; 16 | 17 | const Tooltip: FC = ({ tooltipData, zoom }) => { 18 | const { taken, free, over } = useLanguage(); 19 | 20 | const { coords, disposition } = tooltipData; 21 | const tooltipRef = useRef(null); 22 | let width = weekWidth; 23 | switch (zoom) { 24 | case 0: 25 | width = weekWidth; 26 | break; 27 | case 1: 28 | width = dayWidth; 29 | break; 30 | case 2: 31 | width = zoom2ColumnWidth; 32 | break; 33 | } 34 | 35 | useLayoutEffect(() => { 36 | // re calculate tooltip width before repaint 37 | if (!tooltipRef.current) return; 38 | 39 | const { width: tooltipWidth } = tooltipRef.current.getBoundingClientRect(); 40 | 41 | let xOffset; 42 | switch (zoom) { 43 | case 2: 44 | xOffset = tooltipWidth / 2 + width; 45 | break; 46 | default: 47 | xOffset = tooltipWidth / 2 + width / 2; 48 | break; 49 | } 50 | tooltipRef.current.style.left = `${coords.x - xOffset}px`; 51 | tooltipRef.current.style.top = `${coords.y + 8}px`; 52 | 53 | // disposition.overtime affects tooltip's width, thus it's needed to recalculate it's coords whenever overtime changes 54 | }, [coords.x, width, disposition.overtime, coords.y, zoom]); 55 | 56 | return ( 57 | 58 | 59 | 60 | 61 | 62 | 63 | {`${taken}: ${disposition.taken.hours}h ${disposition.taken.minutes}m`} 64 | {(disposition.overtime.hours > 0 || disposition.overtime.minutes > 0) && ( 65 | <> 66 |  {"-"}  67 | {`${disposition.overtime.hours}h ${disposition.overtime.minutes}m ${over}`} 68 | 69 | )} 70 | 71 | 72 | 73 | 74 | 75 | {`${free}: ${disposition.free.hours}h ${disposition.free.minutes}m`} 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | }; 84 | 85 | export default Tooltip; 86 | -------------------------------------------------------------------------------- /src/components/Tooltip/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from "./Tooltip"; 2 | -------------------------------------------------------------------------------- /src/components/Tooltip/styles.ts: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { marginPaddingReset } from "@/styles"; 3 | 4 | export const StyledTooltipWrapper = styled.div` 5 | padding: 8px 16px; 6 | position: absolute; 7 | background-color: ${({ theme }) => theme.colors.tooltip}; 8 | border-radius: 8px; 9 | z-index: 3; 10 | transition: all 0.25s; 11 | transition-timing-function: ease-out; 12 | pointer-events: none; 13 | `; 14 | 15 | export const StyledTooltipContent = styled.div` 16 | width: 100%; 17 | `; 18 | export const StyledTooltipBeak = styled.div` 19 | position: absolute; 20 | width: 0; 21 | height: 0; 22 | top: 100%; 23 | left: 50%; 24 | transform: translateX(-50%); 25 | border-left: 14px solid transparent; 26 | border-right: 14px solid transparent; 27 | border-top: 14px solid ${({ theme }) => theme.colors.tooltip}; 28 | `; 29 | 30 | export const StyledContentWrapper = styled.div``; 31 | export const StyledInnerWrapper = styled.div` 32 | display: flex; 33 | align-items: center; 34 | &:first-child { 35 | margin-bottom: 8px; 36 | } 37 | `; 38 | export const StyledTextWrapper = styled.div` 39 | ${marginPaddingReset} 40 | display: flex; 41 | align-items: center; 42 | font-size: 10px; 43 | color: ${({ theme }) => theme.colors.textSecondary}; 44 | line-height: 12px; 45 | letter-spacing: 0.5px; 46 | `; 47 | 48 | export const StyledText = styled.p` 49 | ${marginPaddingReset} 50 | margin-left: 4px; 51 | color: ${({ theme }) => theme.colors.textSecondary}; 52 | `; 53 | 54 | export const StyledOvertimeWarning = styled.span` 55 | font-size: 10px; 56 | font-weight: 600; 57 | color: ${({ theme }) => theme.colors.warning}; 58 | `; 59 | -------------------------------------------------------------------------------- /src/components/Tooltip/types.ts: -------------------------------------------------------------------------------- 1 | import { TooltipData } from "@/types/global"; 2 | 3 | export type TooltipProps = { 4 | tooltipData: TooltipData; 5 | zoom: number; 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as Grid } from "./Calendar/Grid"; 2 | export { default as Calendar } from "./Calendar"; 3 | export { default as Topbar } from "./Calendar/Header/Topbar"; 4 | export { default as Icon } from "./Icon"; 5 | export { default as IconButton } from "./IconButton"; 6 | export { default as Scheduler } from "./Scheduler/Scheduler"; 7 | export { default as LeftColumn } from "./LeftColumn/LeftColumn"; 8 | export { default as LeftColumnItem } from "./LeftColumn/LeftColumnItem"; 9 | export { default as Loader } from "./Loader"; 10 | export { default as Header } from "./Calendar/Header"; 11 | export { default as Tile } from "./Tiles/Tile"; 12 | export { default as Tiles } from "./Tiles"; 13 | export { default as ConfigPanel } from "./ConfigPanel"; 14 | export { default as Tooltip } from "./Tooltip"; 15 | export { default as PaginationButton } from "./PaginationButton"; 16 | export { default as Toggle } from "./Toggle"; 17 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import { prefixId } from "./styles"; 2 | 3 | export const dayWidth = 50; 4 | export const headerMonthHeight = 24; 5 | export const headerWeekHeight = 16; 6 | export const headerDayHeight = 40; 7 | export const headerHeight = headerDayHeight + headerWeekHeight + headerMonthHeight; 8 | export const weekWidth = 84; 9 | export const boxHeight = 56; 10 | export const leftColumnWidth = 196; 11 | export const singleDayWidth = 12; 12 | export const zoom2ColumnWidth = 50; 13 | export const zoom2HeaderTopRowHeight = 24; 14 | export const zoom2HeaderMiddleRowHeight = 16; 15 | export const zoom2HeaderBottomRowHeight = 40; 16 | export const zoom2HeaderHeight = 17 | zoom2HeaderTopRowHeight + zoom2HeaderMiddleRowHeight + zoom2HeaderBottomRowHeight; 18 | export const zoom2ButtonJump = 1; 19 | export const weeksInYear = 52; 20 | export const navHeight = 44; 21 | export const fonts = { 22 | topRow: "600 14px Inter", 23 | middleRow: "400 10px Inter", 24 | bottomRow: { 25 | name: "600 14px Inter", 26 | number: "600 10px Inter" 27 | } 28 | }; 29 | export const screenWidthMultiplier = 3; 30 | export const dayNameYoffset = 1.6; 31 | export const dayNumYOffset = 4.5; 32 | export const monthsInYear = 12; 33 | export const hoursInDay = 24; 34 | export const canvasHeaderWrapperId = "reactSchedulerCanvasHeaderWrapper"; 35 | export const canvasWrapperId = "reactSchedulerCanvasWrapper"; 36 | export const outsideWrapperId = prefixId; 37 | export const tileYOffset = 4; 38 | export const tileHeight = 48; 39 | export const formFieldsIds = { 40 | peopleCount: "peopleCount", 41 | projectsPerYear: "projectsPerYear", 42 | yearsCovered: "yearsCovered", 43 | startDate: "startDate", 44 | maxRecordsPerPage: "maxRecordsPerPage", 45 | isFullscreen: "isFullscreen" 46 | }; 47 | export const businessDays = 5; 48 | export const maxHoursPerWeek = 40; 49 | export const maxHoursPerDay = 8; 50 | export const topRowTextYPos = headerMonthHeight / 2 + 2; 51 | export const middleRowTextYPos = headerWeekHeight / 2 + headerMonthHeight + 1; 52 | export const buttonWeeksJump = 2; 53 | export const minutesInHour = 60; 54 | -------------------------------------------------------------------------------- /src/context/CalendarProvider/CalendarProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import weekOfYear from "dayjs/plugin/weekOfYear"; 4 | import dayOfYear from "dayjs/plugin/dayOfYear"; 5 | import isoWeek from "dayjs/plugin/isoWeek"; 6 | import isBetween from "dayjs/plugin/isBetween"; 7 | import duration from "dayjs/plugin/duration"; 8 | import debounce from "lodash.debounce"; 9 | import { Coords, ZoomLevel, allZoomLevel } from "@/types/global"; 10 | import { isAvailableZoom } from "@/types/guards"; 11 | import { getDatesRange, getParsedDatesRange } from "@/utils/getDatesRange"; 12 | import { parseDay } from "@/utils/dates"; 13 | import { getCols, getVisibleCols } from "@/utils/getCols"; 14 | import { 15 | buttonWeeksJump, 16 | hoursInDay, 17 | outsideWrapperId, 18 | screenWidthMultiplier, 19 | zoom2ButtonJump 20 | } from "@/constants"; 21 | import { getCanvasWidth } from "@/utils/getCanvasWidth"; 22 | import { calendarContext } from "./calendarContext"; 23 | import { CalendarProviderProps } from "./types"; 24 | dayjs.extend(weekOfYear); 25 | dayjs.extend(dayOfYear); 26 | dayjs.extend(isoWeek); 27 | dayjs.extend(isBetween); 28 | dayjs.extend(duration); 29 | 30 | type Direction = "back" | "forward" | "middle"; 31 | 32 | const CalendarProvider = ({ 33 | data, 34 | children, 35 | isLoading, 36 | config, 37 | defaultStartDate = dayjs(), 38 | onRangeChange, 39 | onFilterData, 40 | onClearFilterData 41 | }: CalendarProviderProps) => { 42 | const { zoom: configZoom, maxRecordsPerPage = 50 } = config; 43 | const [zoom, setZoom] = useState(configZoom); 44 | const [date, setDate] = useState(dayjs()); 45 | const [isInitialized, setIsInitialized] = useState(false); 46 | const [cols, setCols] = useState(getCols(zoom)); 47 | const isNextZoom = allZoomLevel[zoom] !== allZoomLevel[allZoomLevel.length - 1]; 48 | const isPrevZoom = zoom !== 0; 49 | const range = useMemo(() => getParsedDatesRange(date, zoom), [date, zoom]); 50 | const startDate = getDatesRange(date, zoom).startDate; 51 | const dayOfYear = dayjs(startDate).dayOfYear(); 52 | const parsedStartDate = parseDay(startDate); 53 | const outsideWrapper = useRef(null); 54 | const [tilesCoords, setTilesCoords] = useState([{ x: 0, y: 0 }]); 55 | 56 | const moveHorizontalScroll = useCallback( 57 | (direction: Direction, behavior: ScrollBehavior = "auto") => { 58 | const canvasWidth = getCanvasWidth(); 59 | switch (direction) { 60 | case "back": 61 | return outsideWrapper.current?.scrollTo({ 62 | behavior, 63 | left: canvasWidth / 3 64 | }); 65 | 66 | case "forward": 67 | return outsideWrapper.current?.scrollTo({ 68 | behavior, 69 | left: canvasWidth / 3 70 | }); 71 | 72 | case "middle": { 73 | const leftOffset = canvasWidth / screenWidthMultiplier / 4; // 1/4 of component's width 74 | return outsideWrapper.current?.scrollTo({ 75 | behavior, 76 | left: canvasWidth / 2 - leftOffset 77 | }); 78 | } 79 | 80 | default: 81 | return outsideWrapper.current?.scrollTo({ 82 | behavior, 83 | left: canvasWidth / 2 84 | }); 85 | } 86 | }, 87 | [] 88 | ); 89 | 90 | const updateTilesCoords = (coords: Coords[]) => { 91 | setTilesCoords(coords); 92 | }; 93 | 94 | const loadMore = useCallback( 95 | (direction: Direction) => { 96 | const cols = getVisibleCols(zoom); 97 | let offset: number; 98 | switch (zoom) { 99 | case 0: 100 | offset = cols * 7; 101 | break; 102 | case 1: 103 | offset = cols; 104 | break; 105 | case 2: 106 | offset = Math.ceil(cols / hoursInDay); 107 | break; 108 | } 109 | const load = debounce(() => { 110 | switch (direction) { 111 | case "back": 112 | setDate((prev) => prev.subtract(offset, "days")); 113 | break; 114 | case "forward": 115 | setDate((prev) => prev.add(offset, "days")); 116 | break; 117 | case "middle": 118 | setDate(dayjs()); 119 | break; 120 | } 121 | onRangeChange?.(range); 122 | }, 300); 123 | load(); 124 | }, 125 | [onRangeChange, range, zoom] 126 | ); 127 | 128 | useEffect(() => { 129 | outsideWrapper.current = document.getElementById(outsideWrapperId); 130 | setCols(getCols(zoom)); 131 | }, [zoom]); 132 | 133 | useEffect(() => { 134 | const handleResize = () => setCols(getCols(zoom)); 135 | window.addEventListener("resize", handleResize); 136 | return () => window.removeEventListener("resize", handleResize); 137 | }, [zoom]); 138 | 139 | useEffect(() => { 140 | onRangeChange?.(range); 141 | }, [onRangeChange, range]); 142 | 143 | useEffect(() => { 144 | // when defaultStartDate changes repaint grid 145 | setIsInitialized(false); 146 | }, [defaultStartDate]); 147 | 148 | useEffect(() => { 149 | if (isInitialized) return; 150 | 151 | moveHorizontalScroll("middle"); 152 | setIsInitialized(true); 153 | setDate(defaultStartDate); 154 | }, [defaultStartDate, isInitialized, moveHorizontalScroll]); 155 | 156 | const handleGoNext = () => { 157 | if (isLoading) return; 158 | 159 | setDate((prev) => 160 | zoom === 2 ? prev.add(zoom2ButtonJump, "hours") : prev.add(buttonWeeksJump, "weeks") 161 | ); 162 | onRangeChange?.(range); 163 | }; 164 | 165 | const handleScrollNext = useCallback(() => { 166 | if (isLoading) return; 167 | 168 | loadMore("forward"); 169 | debounce(() => { 170 | moveHorizontalScroll("forward"); 171 | }, 300)(); 172 | }, [isLoading, loadMore, moveHorizontalScroll]); 173 | 174 | const handleGoPrev = () => { 175 | if (isLoading) return; 176 | 177 | setDate((prev) => 178 | zoom === 2 ? prev.subtract(zoom2ButtonJump, "hours") : prev.subtract(buttonWeeksJump, "weeks") 179 | ); 180 | onRangeChange?.(range); 181 | }; 182 | 183 | const handleScrollPrev = useCallback(() => { 184 | if (!isInitialized || isLoading) return; 185 | loadMore("back"); 186 | debounce(() => { 187 | moveHorizontalScroll("back"); 188 | }, 300)(); 189 | }, [isInitialized, isLoading, loadMore, moveHorizontalScroll]); 190 | 191 | const handleGoToday = useCallback(() => { 192 | if (isLoading) return; 193 | 194 | loadMore("middle"); 195 | debounce(() => { 196 | moveHorizontalScroll("middle", "smooth"); 197 | }, 300)(); 198 | }, [isLoading, loadMore, moveHorizontalScroll]); 199 | 200 | const zoomIn = () => changeZoom(zoom + 1); 201 | 202 | const zoomOut = () => changeZoom(zoom - 1); 203 | 204 | const changeZoom = (zoomLevel: number) => { 205 | if (!isAvailableZoom(zoomLevel)) return; 206 | setZoom(zoomLevel); 207 | setCols(getCols(zoomLevel)); 208 | onRangeChange?.(range); 209 | }; 210 | 211 | const handleFilterData = () => onFilterData?.(); 212 | 213 | const { Provider } = calendarContext; 214 | 215 | return ( 216 | 241 | {children} 242 | 243 | ); 244 | }; 245 | 246 | const useCalendar = () => useContext(calendarContext); 247 | 248 | export default CalendarProvider; 249 | export { useCalendar }; 250 | -------------------------------------------------------------------------------- /src/context/CalendarProvider/calendarContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import dayjs from "dayjs"; 3 | import { CalendarContextType } from "./types"; 4 | 5 | export const calendarContext = createContext({ 6 | handleGoNext: () => {}, 7 | handleScrollNext: () => {}, 8 | handleGoPrev: () => {}, 9 | handleScrollPrev: () => {}, 10 | handleGoToday: () => {}, 11 | zoomIn: () => {}, 12 | zoomOut: () => {}, 13 | handleFilterData: () => {}, 14 | updateTilesCoords: () => {}, 15 | tilesCoords: [], 16 | zoom: 0, 17 | isNextZoom: false, 18 | isPrevZoom: false, 19 | date: dayjs(), 20 | isLoading: false, 21 | cols: 0, 22 | startDate: { 23 | hour: 0, 24 | dayName: "", 25 | dayOfMonth: 0, 26 | weekOfYear: 0, 27 | month: 0, 28 | monthName: "", 29 | isCurrentDay: false, 30 | isBusinessDay: false, 31 | year: 0 32 | }, 33 | dayOfYear: 0, 34 | recordsThreshold: 0, 35 | config: { 36 | zoom: 0 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /src/context/CalendarProvider/index.ts: -------------------------------------------------------------------------------- 1 | export { default, useCalendar } from "./CalendarProvider"; 2 | -------------------------------------------------------------------------------- /src/context/CalendarProvider/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import dayjs from "dayjs"; 3 | import { Config, Coords, Day, SchedulerData, ZoomLevel } from "@/types/global"; 4 | import { ParsedDatesRange } from "@/utils/getDatesRange"; 5 | 6 | export type CalendarContextType = { 7 | handleGoNext: () => void; 8 | handleScrollNext: () => void; 9 | handleGoPrev: () => void; 10 | handleScrollPrev: () => void; 11 | handleGoToday: () => void; 12 | zoomIn: () => void; 13 | zoomOut: () => void; 14 | handleFilterData: () => void; 15 | updateTilesCoords: (coords: Coords[]) => void; 16 | onClearFilterData?: () => void; 17 | data?: SchedulerData; 18 | tilesCoords: Coords[]; 19 | zoom: ZoomLevel; 20 | isNextZoom: boolean; 21 | isPrevZoom: boolean; 22 | date: dayjs.Dayjs; 23 | isLoading: boolean; 24 | cols: number; 25 | startDate: Day; 26 | dayOfYear: number; 27 | recordsThreshold: number; 28 | config: Config; 29 | }; 30 | 31 | export type CalendarProviderProps = { 32 | children: ReactNode; 33 | isLoading: boolean; 34 | defaultStartDate?: dayjs.Dayjs; 35 | data?: SchedulerData; 36 | config: Config; 37 | onRangeChange?: (range: ParsedDatesRange) => void; 38 | onFilterData?: () => void; 39 | onClearFilterData?: () => void; 40 | }; 41 | -------------------------------------------------------------------------------- /src/context/LocaleProvider/LocaleProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useContext, useEffect, useState } from "react"; 2 | import dayjs from "dayjs"; 3 | import { localeContext } from "./localeContext"; 4 | import { locales } from "./locales"; 5 | import { LocaleProviderProps, LocaleType } from "./types"; 6 | 7 | const LocaleProvider = ({ children, lang, translations }: LocaleProviderProps) => { 8 | const [localLang, setLocalLang] = useState("en"); 9 | const localesData = locales.getLocales(); 10 | 11 | const findLocale = useCallback(() => { 12 | const locale = localesData.find((l) => { 13 | return l.id === localLang; 14 | }); 15 | 16 | if (typeof locale?.dayjsTranslations === "object") { 17 | dayjs.locale(locale.dayjsTranslations); 18 | } 19 | 20 | return locale || localesData[0]; 21 | }, [localLang, localesData]); 22 | 23 | const [currentLocale, setCurrentLocale] = useState(findLocale()); 24 | 25 | const saveCurrentLocale = (locale: LocaleType) => { 26 | localStorage.setItem("locale", locale.translateCode); 27 | setCurrentLocale(locale); 28 | }; 29 | 30 | useEffect(() => { 31 | translations?.forEach((translation) => { 32 | const localeData = localesData.find((el) => el.id === translation.id); 33 | if (!localeData) { 34 | locales.addLocales(translation); 35 | } 36 | }); 37 | }, [localesData, translations]); 38 | 39 | useEffect(() => { 40 | const localeId = localStorage.getItem("locale"); 41 | const language = lang ?? localeId ?? "en"; 42 | localStorage.setItem("locale", language); 43 | setLocalLang(language); 44 | setCurrentLocale(findLocale()); 45 | }, [findLocale, lang]); 46 | 47 | const { Provider } = localeContext; 48 | 49 | return ( 50 | 51 | {children} 52 | 53 | ); 54 | }; 55 | 56 | const useLanguage = () => { 57 | const context = useContext(localeContext); 58 | return context.currentLocale.lang; 59 | }; 60 | 61 | const useLocales = () => { 62 | const context = useContext(localeContext); 63 | return context.localesData; 64 | }; 65 | 66 | const useSetLocale = () => { 67 | const context = useContext(localeContext); 68 | return context.setCurrentLocale; 69 | }; 70 | 71 | export default LocaleProvider; 72 | export { useLanguage, useLocales, useSetLocale }; 73 | -------------------------------------------------------------------------------- /src/context/LocaleProvider/index.ts: -------------------------------------------------------------------------------- 1 | export { default, useLanguage, useLocales, useSetLocale } from "./LocaleProvider"; 2 | -------------------------------------------------------------------------------- /src/context/LocaleProvider/localeContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext } from "react"; 2 | import { locales } from "./locales"; 3 | import { LocaleContextType } from "./types"; 4 | 5 | export const localeContext = createContext({ 6 | localesData: locales.getLocales(), 7 | currentLocale: locales.getLocales()[0], 8 | setCurrentLocale: () => {} 9 | }); 10 | -------------------------------------------------------------------------------- /src/context/LocaleProvider/locales.ts: -------------------------------------------------------------------------------- 1 | import enDayjsTranslations from "dayjs/locale/en"; 2 | import plDayjsTranslations from "dayjs/locale/pl"; 3 | import deDayjsTranslations from "dayjs/locale/de"; 4 | import ltDayjsTranslations from "dayjs/locale/lt"; 5 | import { en, pl, de, lt } from "@/locales"; 6 | import { LocaleType } from "./types"; 7 | 8 | export const localesData: LocaleType[] = [ 9 | { 10 | id: "en", 11 | lang: en, 12 | translateCode: "en-GB", 13 | dayjsTranslations: enDayjsTranslations 14 | }, 15 | { 16 | id: "pl", 17 | lang: pl, 18 | translateCode: "pl-PL", 19 | dayjsTranslations: plDayjsTranslations 20 | }, 21 | { 22 | id: "lt", 23 | lang: lt, 24 | translateCode: "lt-LT", 25 | dayjsTranslations: ltDayjsTranslations 26 | }, 27 | { 28 | id: "de", 29 | lang: de, 30 | translateCode: "de-DE", 31 | dayjsTranslations: deDayjsTranslations 32 | } 33 | ]; 34 | 35 | class Locales { 36 | public locales: LocaleType[] = localesData; 37 | 38 | getLocales() { 39 | return this.locales; 40 | } 41 | 42 | addLocales(locale: LocaleType) { 43 | this.locales.push(locale); 44 | } 45 | } 46 | 47 | const locales = new Locales(); 48 | 49 | export { locales }; 50 | -------------------------------------------------------------------------------- /src/context/LocaleProvider/types.ts: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { LangCodes } from "@/types/global"; 3 | 4 | export type LocaleContextType = { 5 | currentLocale: LocaleType; 6 | localesData: LocaleType[]; 7 | setCurrentLocale: (locale: LocaleType) => void; 8 | }; 9 | 10 | export type LocaleProviderProps = { 11 | children: ReactNode; 12 | lang?: LangCodes | string; 13 | translations?: LocaleType[]; 14 | }; 15 | 16 | export type Topbar = { 17 | filters: string; 18 | next: string; 19 | prev: string; 20 | today: string; 21 | view: string; 22 | }; 23 | 24 | export type Translation = { 25 | feelingEmpty: string; 26 | free: string; 27 | loadNext: string; 28 | loadPrevious: string; 29 | over: string; 30 | taken: string; 31 | topbar: Topbar; 32 | search: string; 33 | week: string; 34 | }; 35 | 36 | export type LocaleType = { 37 | id: string; 38 | lang: Translation; 39 | translateCode: string; 40 | dayjsTranslations: string | ILocale | undefined; 41 | }; 42 | -------------------------------------------------------------------------------- /src/hooks/types.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedSchedulerData, SchedulerProjectData } from "@/types/global"; 2 | 3 | export type UsePaginationData = { 4 | /** 5 | * Represents paginated data on current page 6 | */ 7 | page: PaginatedSchedulerData; 8 | /** 9 | * Current page number 10 | */ 11 | currentPageNum: number; 12 | /** 13 | * Total amount of pages 14 | */ 15 | pagesAmount: number; 16 | /** 17 | * Sorted resources per item. 18 | */ 19 | projectsPerPerson: SchedulerProjectData[][][]; 20 | /** 21 | * Amount of rows per item 22 | */ 23 | rowsPerItem: number[]; 24 | /** 25 | * Total amount of rows displayed on current page 26 | */ 27 | totalRowsPerPage: number; 28 | /** 29 | * Callback function to load next page 30 | */ 31 | next: () => void; 32 | /** 33 | * Callback function to load previous page 34 | */ 35 | previous: () => void; 36 | 37 | /** 38 | * Jumps to first page 39 | */ 40 | reset: () => void; 41 | }; 42 | -------------------------------------------------------------------------------- /src/hooks/usePagination.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import { SchedulerData } from "@/types/global"; 3 | import { splitToPages } from "@/utils/splitToPages"; 4 | import { projectsOnGrid } from "@/utils/getProjectsOnGrid"; 5 | import { getTotalRowsPerPage } from "@/utils/getTotalRowsPerPage"; 6 | import { useCalendar } from "@/context/CalendarProvider"; 7 | import { outsideWrapperId } from "@/constants"; 8 | import { UsePaginationData } from "./types"; 9 | 10 | export const usePagination = (data: SchedulerData): UsePaginationData => { 11 | const { recordsThreshold } = useCalendar(); 12 | const [startIndex, setStartIndex] = useState(0); 13 | const [pageNum, setPage] = useState(0); 14 | const outsideWrapper = useRef(null); 15 | 16 | useEffect(() => { 17 | outsideWrapper.current = document.getElementById(outsideWrapperId); 18 | }, []); 19 | 20 | const { projectsPerPerson, rowsPerPerson } = useMemo(() => projectsOnGrid(data), [data]); 21 | 22 | const pages = useMemo( 23 | () => splitToPages(data, projectsPerPerson, rowsPerPerson, recordsThreshold), 24 | [data, projectsPerPerson, recordsThreshold, rowsPerPerson] 25 | ); 26 | 27 | const next = useCallback(() => { 28 | if (pages[pageNum].length && outsideWrapper.current) { 29 | outsideWrapper.current.scroll({ top: 0 }); 30 | setStartIndex((prev) => prev + pages[Math.max(pageNum, 0)].length); 31 | setPage((prev) => Math.min(prev + 1, pages.length - 1)); 32 | window.scroll({ top: 0 }); 33 | } 34 | }, [pageNum, pages]); 35 | 36 | const previous = useCallback(() => { 37 | if (pages[pageNum].length) { 38 | setStartIndex((prev) => Math.max(prev - pages[pageNum - 1].length, 0)); 39 | setPage((prev) => Math.max(prev - 1, 0)); 40 | } 41 | }, [pageNum, pages]); 42 | 43 | const reset = useCallback(() => { 44 | setStartIndex(0); 45 | setPage(0); 46 | }, []); 47 | 48 | const end = startIndex + pages[pageNum].length; 49 | 50 | const rowsPerItem = useMemo( 51 | () => rowsPerPerson.slice(startIndex, end), 52 | [end, rowsPerPerson, startIndex] 53 | ); 54 | 55 | const projectsPerPage = useMemo( 56 | () => projectsPerPerson.slice(startIndex, end), 57 | [end, projectsPerPerson, startIndex] 58 | ); 59 | 60 | return { 61 | page: pages[pageNum], 62 | currentPageNum: pageNum, 63 | pagesAmount: pages.length, 64 | projectsPerPerson: projectsPerPage, 65 | rowsPerItem, 66 | totalRowsPerPage: getTotalRowsPerPage(pages[pageNum]), 67 | next, 68 | previous, 69 | reset 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Scheduler } from "./components"; 2 | import "./styles.css"; 3 | export type { SchedulerProps } from "./components/Scheduler/types"; 4 | export type { SchedulerData, SchedulerProjectData, ZoomLevel, Config } from "./types/global"; 5 | 6 | export { Scheduler }; 7 | -------------------------------------------------------------------------------- /src/locales/de.ts: -------------------------------------------------------------------------------- 1 | export const de = { 2 | feelingEmpty: "Keine Ergebnisse...", 3 | free: "Frei", 4 | loadNext: "Weiter", 5 | loadPrevious: "Zurück", 6 | over: "über", 7 | taken: "Gebucht", 8 | topbar: { 9 | filters: "Filter", 10 | next: "vor", 11 | prev: "zurück", 12 | today: "Heute", 13 | view: "Ansicht" 14 | }, 15 | search: "Suche", 16 | week: "Woche" 17 | }; 18 | -------------------------------------------------------------------------------- /src/locales/en.ts: -------------------------------------------------------------------------------- 1 | export const en = { 2 | feelingEmpty: "I feel so empty...", 3 | free: "Free", 4 | loadNext: "Next", 5 | loadPrevious: "Previous", 6 | over: "over", 7 | taken: "Taken", 8 | topbar: { 9 | filters: "Filters", 10 | next: "next", 11 | prev: "prev", 12 | today: "Today", 13 | view: "View" 14 | }, 15 | search: "search", 16 | week: "week" 17 | }; 18 | -------------------------------------------------------------------------------- /src/locales/index.ts: -------------------------------------------------------------------------------- 1 | export { pl } from "./pl"; 2 | export { en } from "./en"; 3 | export { de } from "./de"; 4 | export { lt } from "./lt"; 5 | -------------------------------------------------------------------------------- /src/locales/lt.ts: -------------------------------------------------------------------------------- 1 | export const lt = { 2 | feelingEmpty: "Jaučiuosi toks tuščias...", 3 | free: "Laisva", 4 | loadNext: "Kitas", 5 | loadPrevious: "Ankstesnis", 6 | over: "virš", 7 | taken: "Užimta", 8 | topbar: { 9 | filters: "Filtras", 10 | next: "kitas", 11 | prev: "ankstesnis", 12 | today: "Šiandien", 13 | view: "Rodinys" 14 | }, 15 | search: "ieškoti", 16 | week: "savaitė" 17 | }; 18 | -------------------------------------------------------------------------------- /src/locales/pl.ts: -------------------------------------------------------------------------------- 1 | export const pl = { 2 | feelingEmpty: "Czuję się taki pusty...", 3 | free: "Wolne", 4 | loadNext: "Następne", 5 | loadPrevious: "Poprzednie", 6 | over: "ponad", 7 | taken: "Zajęte", 8 | topbar: { 9 | filters: "Filtry", 10 | next: "następny", 11 | prev: "poprzedni", 12 | today: "Dziś", 13 | view: "Widok" 14 | }, 15 | search: "szukaj", 16 | week: "tydzień" 17 | }; 18 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | 5 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 6 | 7 | 8 | 9 | ); 10 | -------------------------------------------------------------------------------- /src/mock/appMock.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | import dayjs from "dayjs"; 3 | import { SchedulerData, SchedulerProjectData } from "@/types/global"; 4 | import { ParsedDatesRange } from "@/utils/getDatesRange"; 5 | 6 | const secondsInWorkDay = 28800; 7 | 8 | export const mockedOnRangeChange = (range: ParsedDatesRange, data: SchedulerData) => { 9 | console.log("Mocked on range change has been triggered. New range: ", range, data); 10 | }; 11 | 12 | const getRandomWords = (amount?: number) => 13 | amount ? faker.random.words(amount) : faker.random.word(); 14 | 15 | const getRandomDates = (year: number) => { 16 | const startDate = faker.date.between(new Date(year, 0, 1), new Date(year + 1, 0, 1)); 17 | const endDate = faker.date.between( 18 | startDate, 19 | new Date(year + Math.ceil(Math.random() * 4), 0, 1) 20 | ); 21 | 22 | return { startDate, endDate }; 23 | }; 24 | 25 | export const generateProjects = ( 26 | years: number, 27 | maxProjectsPerYear: number, 28 | amountOfDscWords = 5, 29 | title: string 30 | ): SchedulerProjectData[] => { 31 | const startYear = dayjs() 32 | .subtract(Math.floor(years / 2), "years") 33 | .get("year"); 34 | 35 | const endYear = dayjs() 36 | .add(Math.floor(years / 2), "years") 37 | .get("year"); 38 | 39 | const data = []; 40 | const bgColor = `rgb(${Math.ceil(Math.random() * 255)},${Math.ceil( 41 | Math.random() * 200 42 | )},${Math.ceil(Math.random() * 200)})`; 43 | 44 | for (let yearIndex = startYear; yearIndex <= endYear; yearIndex++) { 45 | const projectsPerYear = Math.ceil(Math.random() * maxProjectsPerYear); 46 | 47 | for (let projectIndex = 0; projectIndex < projectsPerYear; projectIndex++) { 48 | const { startDate, endDate } = getRandomDates(yearIndex); 49 | data.push({ 50 | id: faker.datatype.uuid(), 51 | startDate, 52 | endDate, 53 | occupancy: Math.ceil(Math.random() * secondsInWorkDay), 54 | title, 55 | subtitle: getRandomWords(), 56 | description: getRandomWords(amountOfDscWords), 57 | bgColor 58 | }); 59 | } 60 | } 61 | return data; 62 | }; 63 | 64 | export const createMockData = ( 65 | amountOfPeople: number, 66 | years: number, 67 | maxProjectsPerYear: number, 68 | amountOfDscWords = 5 69 | ): SchedulerData => { 70 | const schedulerData: SchedulerData = []; 71 | for (let i = 0; i < amountOfPeople; i++) { 72 | const title = getRandomWords(2); 73 | const data: SchedulerProjectData[] = generateProjects( 74 | years, 75 | maxProjectsPerYear, 76 | amountOfDscWords, 77 | title 78 | ); 79 | 80 | const item = { 81 | id: faker.datatype.uuid(), 82 | label: { 83 | icon: "https://picsum.photos/24", 84 | title, 85 | subtitle: getRandomWords() 86 | }, 87 | data 88 | }; 89 | schedulerData.push(item); 90 | } 91 | return schedulerData; 92 | }; 93 | -------------------------------------------------------------------------------- /src/styled-components.d.ts: -------------------------------------------------------------------------------- 1 | import "styled-components"; 2 | import type { Theme } from "./styles"; 3 | 4 | declare module "styled-components" { 5 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 6 | export interface DefaultTheme extends Theme {} 7 | } 8 | -------------------------------------------------------------------------------- /src/styles.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap"); 2 | -------------------------------------------------------------------------------- /src/styles.ts: -------------------------------------------------------------------------------- 1 | import styled, { createGlobalStyle, type DefaultTheme } from "styled-components"; 2 | 3 | export const prefixId = "reactSchedulerOutsideWrapper"; 4 | 5 | export const GlobalStyle = createGlobalStyle` 6 | 7 | #${prefixId} { 8 | font-family: 'Inter', sans-serif; 9 | box-sizing: border-box; 10 | line-height: 1.15; 11 | -webkit-text-size-adjust: 100%; 12 | margin: 0; 13 | } 14 | 15 | #${prefixId} *, 16 | #${prefixId} *:before, 17 | #${prefixId} *:after { 18 | box-sizing: inherit; 19 | font-family: inherit; 20 | line-height: inherit; 21 | } 22 | `; 23 | 24 | export type ColorType = 25 | | "background" 26 | | "gridBackground" 27 | | "primary" 28 | | "secondary" 29 | | "tertiary" 30 | | "textPrimary" 31 | | "textSecondary" 32 | | "accent" 33 | | "disabled" 34 | | "border" 35 | | "placeholder" 36 | | "warning" 37 | | "button" 38 | | "tooltip" 39 | | "defaultTile" 40 | | "hover"; 41 | 42 | export type Theme = { 43 | colors: Record; 44 | navHeight: string; 45 | mode: "light" | "dark"; 46 | }; 47 | 48 | export const theme: DefaultTheme = { 49 | mode: "light", 50 | navHeight: "44px", 51 | colors: { 52 | background: "#FFFFFF", 53 | gridBackground: "#FFFFFF", 54 | 55 | primary: "#F8F8FD", 56 | secondary: "#E6F3FF", 57 | tertiary: "#C9E5FF", 58 | 59 | textPrimary: "#1C222F", 60 | textSecondary: "#FFFFFF", 61 | placeholder: "#777777", 62 | 63 | button: "#FFFFFF", 64 | border: "#D2D2D2", 65 | tooltip: "#3B3C5F", 66 | hover: "#E6F3FF", 67 | disabled: "#777777", 68 | warning: "#EF4444", 69 | 70 | defaultTile: "#728DE2", 71 | 72 | accent: "#0A11EB" 73 | } 74 | }; 75 | 76 | export const darkTheme: Theme = { 77 | mode: "dark", 78 | navHeight: "44px", 79 | colors: { 80 | background: "#161B22", 81 | gridBackground: "#1E252E", 82 | 83 | primary: "#303b49", 84 | secondary: "#444e5b", 85 | tertiary: "#6E757F", 86 | 87 | textPrimary: "#DADCE0", 88 | textSecondary: "#EAEBED", 89 | placeholder: "#bbbbbb", 90 | 91 | button: "#60676f", 92 | border: "#2C333A", 93 | hover: "#303439", 94 | tooltip: "#3B3C5F", 95 | disabled: "#38414a", 96 | warning: "#FF4C4C", 97 | 98 | defaultTile: "#728DE2", 99 | 100 | accent: "#1798c2" 101 | } 102 | }; 103 | 104 | export const marginPaddingReset = ` 105 | margin: 0; 106 | padding: 0; 107 | `; 108 | 109 | export const truncate = ` 110 | overflow: hidden; 111 | text-overflow: ellipsis; 112 | white-space: nowrap; 113 | `; 114 | 115 | export const StyledSchedulerFrame = styled.div` 116 | margin: 10rem 10rem; 117 | position: relative; 118 | width: 40vw; 119 | height: 40vh; 120 | `; 121 | -------------------------------------------------------------------------------- /src/types/global.ts: -------------------------------------------------------------------------------- 1 | import { LocaleType } from "@/context/LocaleProvider/types"; 2 | import { ColorType } from "@/styles"; 3 | 4 | export const allZoomLevel = [0, 1, 2] as const; 5 | 6 | export type FilterButtonState = -1 | 0 | 1; 7 | 8 | type ZoomLevelTuple = typeof allZoomLevel; 9 | 10 | export type ZoomLevel = ZoomLevelTuple[number]; 11 | 12 | export type LangCodes = "en" | "pl" | "de" | "lt"; 13 | 14 | export type Config = { 15 | zoom: ZoomLevel; 16 | /** 17 | * Dictates filter button behavior 18 | * - `< 0` - filter button is hidden 19 | * - `0` - filter button is visible, no filter had been applied 20 | * - `> 0` - filter button visible - filters had been applied 21 | */ 22 | filterButtonState?: number; 23 | /** 24 | * Language code: "en" | "pl" | "de" 25 | */ 26 | lang?: LangCodes | string; 27 | isFiltersButtonVisible?: boolean; 28 | maxRecordsPerPage?: number; 29 | /** 30 | * property for changing behavior of showing tooltip hours 31 | * true - will show taken hours same as business days 32 | * false - will always show 0 taken hours on weekends in day view 33 | * @default false 34 | */ 35 | includeTakenHoursOnWeekendsInDayView?: boolean; 36 | 37 | /** 38 | * show tooltip when hovering over tiles items 39 | * @default true 40 | */ 41 | showTooltip?: boolean; 42 | translations?: LocaleType[]; 43 | /** 44 | * show toggle button for changing theme (light/dark) 45 | */ 46 | showThemeToggle?: boolean; 47 | /** 48 | * default theme (light/dark) 49 | * when theme toggle is displayed - this is a default value of the toggle 50 | * @default "light" 51 | */ 52 | defaultTheme?: "light" | "dark"; 53 | theme?: Theme; 54 | }; 55 | 56 | export type Theme = { 57 | light?: Partial>; 58 | dark?: Partial>; 59 | }; 60 | 61 | export type SchedulerData = SchedulerRow[]; 62 | 63 | export type SchedulerRow = { 64 | id: string; 65 | label: SchedulerRowLabel; 66 | data: SchedulerProjectData[]; 67 | }; 68 | 69 | export type SchedulerItemClickData = Omit; 70 | 71 | export type PaginatedSchedulerData = PaginatedSchedulerRow[]; 72 | 73 | export type PaginatedSchedulerRow = { 74 | id: string; 75 | label: SchedulerRowLabel; 76 | data: SchedulerProjectData[][]; 77 | }; 78 | 79 | export type SchedulerRowLabel = { 80 | icon: string; 81 | title: string; 82 | subtitle: string; 83 | }; 84 | export type SchedulerProjectData = { 85 | /** 86 | * Unique Id of item 87 | */ 88 | id: string; 89 | /** 90 | * Represents start date of from which tile will render 91 | */ 92 | startDate: Date; 93 | /** 94 | * Represents end date to which tile will render 95 | */ 96 | endDate: Date; 97 | /** 98 | * Indicates how much time is spent per day. Given in seconds and converted by Scheduler to hours/minutes 99 | */ 100 | occupancy: number; 101 | /** 102 | * Title of item 103 | */ 104 | title: string; 105 | /** 106 | * Subtitle of item. Optional 107 | */ 108 | subtitle?: string; 109 | /** 110 | * Short description displayed on tile. Optional 111 | */ 112 | description?: string; 113 | /** 114 | * Background color of the tile, given in rgb color model. If not given, default color (rgb(114, 141,226 )) is set. Optional 115 | */ 116 | bgColor?: string; 117 | }; 118 | 119 | export type Day = { 120 | hour: number; 121 | dayName: string; 122 | dayOfMonth: number; 123 | weekOfYear: number; 124 | month: number; 125 | monthName: string; 126 | isBusinessDay: boolean; 127 | isCurrentDay: boolean; 128 | year: number; 129 | }; 130 | 131 | export type TextAndBoxStyleConfig = { 132 | isCurrent: boolean; 133 | isBusinessDay?: boolean; 134 | variant?: "yearView" | "bottomRow"; 135 | }; 136 | 137 | type BottomRowText = { 138 | y: number; 139 | label: string; 140 | font: string; 141 | color: string; 142 | }; 143 | 144 | export type DrawRowConfig = { 145 | ctx: CanvasRenderingContext2D; 146 | x: number; 147 | y: number; 148 | width: number; 149 | height: number; 150 | textYPos?: number; 151 | label?: string; 152 | font?: string; 153 | isBottomRow?: boolean; 154 | fillStyle?: string; 155 | topText?: BottomRowText; 156 | bottomText?: BottomRowText; 157 | labelBetweenCells?: boolean; 158 | }; 159 | 160 | export type TileProperties = { 161 | x: number; 162 | y: number; 163 | width: number; 164 | }; 165 | 166 | export type ConfigFormValues = { 167 | peopleCount: number; 168 | projectsPerYear: number; 169 | yearsCovered: number; 170 | maxRecordsPerPage: number; 171 | isFullscreen: boolean; 172 | startDate?: string; 173 | }; 174 | 175 | export type Coords = { 176 | x: number; 177 | y: number; 178 | }; 179 | 180 | export type TimeUnits = { 181 | hours: number; 182 | minutes: number; 183 | }; 184 | 185 | export type OccupancyData = { 186 | taken: TimeUnits; 187 | free: TimeUnits; 188 | overtime: TimeUnits; 189 | }; 190 | 191 | export type TooltipData = { 192 | coords: Coords; 193 | resourceIndex: number; 194 | disposition: OccupancyData; 195 | }; 196 | -------------------------------------------------------------------------------- /src/types/guards.ts: -------------------------------------------------------------------------------- 1 | import { allZoomLevel, ZoomLevel } from "./global"; 2 | 3 | export const isAvailableZoom = (value: number): value is ZoomLevel => { 4 | return allZoomLevel.includes(value as ZoomLevel); 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/dates.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | 4 | export const daysInYear = (year: number) => 5 | (year % 4 === 0 && year % 100 > 0) || year % 400 === 0 ? 366 : 365; 6 | 7 | export const getIsBusinessDay = (date: dayjs.Dayjs) => { 8 | const day = date.day(); 9 | return day !== 0 && day !== 6; 10 | }; 11 | 12 | export const getDaysInMonths = (date: Day, iterator: number) => 13 | dayjs(`${date.year}-${date.month + 1}-${date.dayOfMonth}`) 14 | .add(iterator, "months") 15 | .daysInMonth(); 16 | 17 | export const parseDay = (data: dayjs.Dayjs): Day => { 18 | return { 19 | hour: data.hour(), 20 | dayName: data.format("ddd"), 21 | dayOfMonth: data.date(), 22 | weekOfYear: data.isoWeek(), 23 | month: data.month(), 24 | monthName: data.format("MMMM"), 25 | isBusinessDay: getIsBusinessDay(data), 26 | isCurrentDay: data.isSame(dayjs(), "day"), 27 | year: parseInt(data.format("YYYY")) 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /src/utils/drawDashedLine.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@/styles"; 2 | 3 | export const drawDashedLine = ( 4 | ctx: CanvasRenderingContext2D, 5 | startPos: number, 6 | lineLength: number, 7 | theme: Theme 8 | ) => { 9 | ctx.setLineDash([5, 5]); 10 | ctx.strokeStyle = theme.colors.border; 11 | ctx.moveTo(startPos + 0.5, 0.5); 12 | ctx.lineTo(startPos + 0.5, lineLength + 0.5); 13 | ctx.stroke(); 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/drawGrid/drawCell.ts: -------------------------------------------------------------------------------- 1 | import { boxHeight } from "@/constants"; 2 | import { Theme } from "@/styles"; 3 | 4 | export const drawCell = ( 5 | ctx: CanvasRenderingContext2D, 6 | x: number, 7 | y: number, 8 | width: number, 9 | isBusinessDay: boolean, 10 | isCurrentDay: boolean, 11 | theme: Theme 12 | ) => { 13 | ctx.strokeStyle = theme.colors.border; 14 | if (isCurrentDay) { 15 | ctx.fillStyle = theme.colors.secondary; 16 | } else if (isBusinessDay) { 17 | ctx.fillStyle = "transparent"; 18 | } else { 19 | ctx.fillStyle = theme.colors.primary; 20 | } 21 | ctx.beginPath(); 22 | ctx.setLineDash([]); 23 | ctx.fillRect(x, y, width, boxHeight); 24 | ctx.strokeRect(x + 0.5, y + 0.5, width, boxHeight); 25 | }; 26 | -------------------------------------------------------------------------------- /src/utils/drawGrid/drawGrid.ts: -------------------------------------------------------------------------------- 1 | import { Day } from "@/types/global"; 2 | import { canvasWrapperId } from "@/constants"; 3 | import { Theme } from "@/styles"; 4 | import { drawMonthlyView } from "./drawMonthlyView"; 5 | import { drawYearlyView } from "./drawYearlyView"; 6 | import { drawHourlyView } from "./drawHourlyView"; 7 | 8 | export const drawGrid = ( 9 | ctx: CanvasRenderingContext2D, 10 | zoom: number, 11 | rows: number, 12 | cols: number, 13 | parsedStartDate: Day, 14 | theme: Theme 15 | ) => { 16 | ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height); 17 | const canvasWrapper = document.getElementById(canvasWrapperId); 18 | if (!canvasWrapper) return; 19 | 20 | switch (zoom) { 21 | case 0: 22 | drawYearlyView(ctx, rows, cols, parsedStartDate, theme); 23 | break; 24 | case 1: 25 | drawMonthlyView(ctx, rows, cols, parsedStartDate, theme); 26 | break; 27 | case 2: 28 | drawHourlyView(ctx, rows, cols, parsedStartDate, theme); 29 | break; 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /src/utils/drawGrid/drawHourlyView.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | import { Theme } from "@/styles"; 4 | import { boxHeight, zoom2ColumnWidth } from "@/constants"; 5 | import { getIsBusinessDay } from "../dates"; 6 | import { drawCell } from "./drawCell"; 7 | 8 | export const drawHourlyView = ( 9 | ctx: CanvasRenderingContext2D, 10 | rows: number, 11 | cols: number, 12 | startDate: Day, 13 | theme: Theme 14 | ) => { 15 | const date = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth + 1}`); 16 | for (let i = 0; i < rows; i++) { 17 | for (let j = 0; j <= cols; j++) { 18 | let hour; 19 | 20 | if (j === Math.floor(cols / 2)) { 21 | hour = dayjs(); 22 | } else if (j > Math.floor(cols / 2)) { 23 | // next hours 24 | hour = dayjs().add(j - Math.floor(cols / 2), "hours"); 25 | } else { 26 | // previous hours 27 | hour = dayjs().subtract(Math.floor(cols / 2) - i, "hours"); 28 | } 29 | 30 | const isCurrentHour = date.isSame(dayjs(), "day") && hour.isSame(dayjs(), "hour"); 31 | 32 | drawCell( 33 | ctx, 34 | j * zoom2ColumnWidth + zoom2ColumnWidth / 2 - 0.5, // -0.5 to make borders better aligned with hour axis 35 | i * boxHeight, 36 | zoom2ColumnWidth, 37 | getIsBusinessDay(hour), 38 | isCurrentHour, 39 | theme 40 | ); 41 | } 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /src/utils/drawGrid/drawMonthlyView.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | import { boxHeight, dayWidth } from "@/constants"; 4 | import { Theme } from "@/styles"; 5 | import { getIsBusinessDay } from "../dates"; 6 | import { drawCell } from "./drawCell"; 7 | 8 | export const drawMonthlyView = ( 9 | ctx: CanvasRenderingContext2D, 10 | rows: number, 11 | cols: number, 12 | startDate: Day, 13 | theme: Theme 14 | ) => { 15 | for (let i = 0; i < rows; i++) { 16 | for (let y = 0; y <= cols; y++) { 17 | const date = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`).add( 18 | y, 19 | "days" 20 | ); 21 | 22 | const isCurrentDay = date.isSame(dayjs(), "day"); 23 | 24 | drawCell( 25 | ctx, 26 | y * dayWidth, 27 | i * boxHeight, 28 | dayWidth, 29 | getIsBusinessDay(date), 30 | isCurrentDay, 31 | theme 32 | ); 33 | } 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/drawGrid/drawYearlyView.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { boxHeight, singleDayWidth, weekWidth } from "@/constants"; 3 | import { Day } from "@/types/global"; 4 | import { Theme } from "@/styles"; 5 | import { drawDashedLine } from "../drawDashedLine"; 6 | import { getDaysInMonths } from "../dates"; 7 | import { drawCell } from "./drawCell"; 8 | 9 | export const drawYearlyView = ( 10 | ctx: CanvasRenderingContext2D, 11 | rows: number, 12 | cols: number, 13 | startDate: Day, 14 | theme: Theme 15 | ) => { 16 | let xPos = 0; 17 | let startPos = -(startDate.dayOfMonth - 1) * singleDayWidth; 18 | 19 | for (let i = 0; i <= cols; i++) { 20 | const week = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`).add( 21 | i, 22 | "weeks" 23 | ); 24 | 25 | const isCurrWeek = week.isSame(dayjs(), "week"); 26 | 27 | for (let y = 0; y < rows; y++) { 28 | drawCell(ctx, xPos, y * boxHeight, weekWidth, true, isCurrWeek, theme); 29 | } 30 | 31 | xPos += weekWidth; 32 | } 33 | 34 | for (let i = 0; i < cols; i++) { 35 | const width = getDaysInMonths(startDate, i) * singleDayWidth; 36 | drawDashedLine(ctx, startPos, rows * boxHeight, theme); 37 | startPos += width; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawHeader.ts: -------------------------------------------------------------------------------- 1 | import { Day } from "@/types/global"; 2 | import { Theme } from "@/styles"; 3 | import { drawDaysOnBottom } from "./drawRows/drawDaysOnBottom"; 4 | import { drawMonthsInMiddle } from "./drawRows/drawMonthsInMiddle"; 5 | import { drawMonthsOnTop } from "./drawRows/drawMonthsOnTop"; 6 | import { drawWeeksInMiddle } from "./drawRows/drawWeeksInMiddle"; 7 | import { drawWeeksOnBottom } from "./drawRows/drawWeeksOnBottom"; 8 | import { drawYearsOnTop } from "./drawRows/drawYearsOnTop"; 9 | import { drawZoom2DaysInMiddle } from "./drawRows/drawZoom2DaysInMiddle"; 10 | import { drawZoom2MonthsOnTop } from "./drawRows/DrawZoom2MonthsOnTop"; 11 | import { drawZoom2HoursOnBottom } from "./drawRows/drawZoom2HoursOnBottom"; 12 | 13 | export const drawHeader = ( 14 | ctx: CanvasRenderingContext2D, 15 | zoom: number, 16 | cols: number, 17 | startDate: Day, 18 | weekLabel: string, 19 | dayOfYear: number, 20 | theme: Theme 21 | ) => { 22 | switch (zoom) { 23 | case 0: 24 | drawYearsOnTop(ctx, startDate, dayOfYear, theme); 25 | drawMonthsInMiddle(ctx, cols, startDate, theme); 26 | drawWeeksOnBottom(ctx, cols, startDate, weekLabel, theme); 27 | break; 28 | case 1: 29 | drawMonthsOnTop(ctx, startDate, theme); 30 | drawWeeksInMiddle(ctx, startDate, weekLabel, theme); 31 | drawDaysOnBottom(ctx, cols, startDate, theme); 32 | break; 33 | case 2: 34 | drawZoom2MonthsOnTop(ctx, cols, startDate, theme); 35 | drawZoom2DaysInMiddle(ctx, cols, startDate, theme); 36 | drawZoom2HoursOnBottom(ctx, cols, startDate, theme); 37 | break; 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/DrawZoom2MonthsOnTop.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { 3 | fonts, 4 | hoursInDay, 5 | topRowTextYPos, 6 | zoom2ColumnWidth, 7 | zoom2HeaderTopRowHeight 8 | } from "@/constants"; 9 | import { Day } from "@/types/global"; 10 | import { Theme } from "@/styles"; 11 | import { drawRow } from "../../drawRow"; 12 | 13 | export const drawZoom2MonthsOnTop = ( 14 | ctx: CanvasRenderingContext2D, 15 | cols: number, 16 | startDate: Day, 17 | theme: Theme 18 | ) => { 19 | const daysInRange = Math.ceil(cols / hoursInDay); 20 | const startDay = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`); 21 | const endDate = startDay.add(daysInRange - 1, "days"); 22 | const startMonth = startDay.month(); 23 | const endMonth = endDate.add(1, "day").month(); 24 | const monthsInRange = startMonth === endMonth ? 1 : 2; 25 | 26 | let xPos = 0.5 * zoom2ColumnWidth; 27 | 28 | for (let i = 0; i < monthsInRange; i++) { 29 | const startDateHour = dayjs( 30 | `${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}T${startDate.hour}:00:00` 31 | ); 32 | const firstDayOfAMonth = dayjs(`${startDate.year}-${startDate.month + i + 1}-01T:23:59:59`); 33 | const lastDayOfAMonth = firstDayOfAMonth.endOf("month"); 34 | const monthLabel = lastDayOfAMonth.format("MMMM").toUpperCase(); 35 | 36 | const diff = lastDayOfAMonth.diff(startDateHour, "hour") + 1; 37 | 38 | const width = i === 0 ? diff * zoom2ColumnWidth : cols * zoom2ColumnWidth; 39 | 40 | drawRow( 41 | { 42 | ctx, 43 | x: xPos, 44 | y: 0, 45 | width, 46 | height: zoom2HeaderTopRowHeight, 47 | textYPos: topRowTextYPos, 48 | label: monthLabel, 49 | font: fonts.topRow 50 | }, 51 | theme 52 | ); 53 | xPos += width; 54 | } 55 | }; 56 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawDaysOnBottom.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | import { 4 | dayNameYoffset, 5 | dayNumYOffset, 6 | dayWidth, 7 | fonts, 8 | headerDayHeight, 9 | headerHeight, 10 | headerMonthHeight, 11 | headerWeekHeight 12 | } from "@/constants"; 13 | import { parseDay } from "@/utils/dates"; 14 | import { Theme } from "@/styles"; 15 | import { drawRow } from "../../drawRow"; 16 | import { getBoxFillStyle } from "../../getBoxFillStyle"; 17 | import { getTextStyle } from "../../getTextStyle"; 18 | 19 | export const drawDaysOnBottom = ( 20 | ctx: CanvasRenderingContext2D, 21 | cols: number, 22 | startDate: Day, 23 | theme: Theme 24 | ) => { 25 | const dayNameYPos = headerHeight - headerDayHeight / dayNameYoffset; 26 | const dayNumYPos = headerHeight - headerDayHeight / dayNumYOffset; 27 | const yPos = headerMonthHeight + headerWeekHeight; 28 | let xPos = 0; 29 | 30 | for (let i = 0; i < cols; i++) { 31 | const day = parseDay( 32 | dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`).add(i, "days") 33 | ); 34 | drawRow( 35 | { 36 | ctx, 37 | x: xPos, 38 | y: yPos, 39 | width: dayWidth, 40 | height: headerDayHeight, 41 | isBottomRow: true, 42 | fillStyle: getBoxFillStyle( 43 | { 44 | isCurrent: day.isCurrentDay, 45 | isBusinessDay: day.isBusinessDay 46 | }, 47 | theme 48 | ), 49 | topText: { 50 | y: dayNameYPos, 51 | label: day.dayName.toUpperCase(), 52 | font: fonts.bottomRow.name, 53 | color: getTextStyle( 54 | { isCurrent: day.isCurrentDay, isBusinessDay: day.isBusinessDay }, 55 | theme 56 | ) 57 | }, 58 | bottomText: { 59 | y: dayNumYPos, 60 | label: `${day.dayOfMonth}`, 61 | font: fonts.bottomRow.number, 62 | color: getTextStyle( 63 | { 64 | isCurrent: day.isCurrentDay, 65 | isBusinessDay: day.isBusinessDay, 66 | variant: "bottomRow" 67 | }, 68 | theme 69 | ) 70 | } 71 | }, 72 | theme 73 | ); 74 | 75 | xPos += dayWidth; 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawMonthsInMiddle.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { 3 | fonts, 4 | headerMonthHeight, 5 | headerWeekHeight, 6 | middleRowTextYPos, 7 | monthsInYear, 8 | singleDayWidth 9 | } from "@/constants"; 10 | import { Day } from "@/types/global"; 11 | import { getDaysInMonths } from "@/utils/dates"; 12 | import { Theme } from "@/styles"; 13 | import { drawRow } from "../../drawRow"; 14 | 15 | export const drawMonthsInMiddle = ( 16 | ctx: CanvasRenderingContext2D, 17 | cols: number, 18 | startDate: Day, 19 | theme: Theme 20 | ) => { 21 | let xPos = -(startDate.dayOfMonth - 1) * singleDayWidth; 22 | const yPos = headerMonthHeight; 23 | const monthIndex = startDate.month; 24 | let index = monthIndex; 25 | 26 | for (let i = 0; i < cols; i++) { 27 | if (index >= monthsInYear) index = 0; 28 | const width = getDaysInMonths(startDate, i) * singleDayWidth; 29 | 30 | drawRow( 31 | { 32 | ctx, 33 | x: xPos, 34 | y: yPos, 35 | width, 36 | height: headerWeekHeight, 37 | textYPos: middleRowTextYPos, 38 | label: dayjs().month(index).format("MMMM").toUpperCase(), 39 | font: fonts.bottomRow.number 40 | }, 41 | theme 42 | ); 43 | xPos += width; 44 | index++; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawMonthsOnTop.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { dayWidth, fonts, headerMonthHeight, monthsInYear, topRowTextYPos } from "@/constants"; 3 | import { Day } from "@/types/global"; 4 | import { Theme } from "@/styles"; 5 | import { drawRow } from "../../drawRow"; 6 | 7 | export const drawMonthsOnTop = (ctx: CanvasRenderingContext2D, startDate: Day, theme: Theme) => { 8 | const yPos = 0; 9 | let xPos = 0; 10 | let width = 0; 11 | let yearIndex = 0; 12 | let startMonthIndex = dayjs( 13 | `${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}` 14 | ).month(); 15 | xPos = -startDate.dayOfMonth * dayWidth + dayWidth; 16 | 17 | for (let i = 0; i < monthsInYear; i++) { 18 | if (startMonthIndex > monthsInYear - 1) { 19 | startMonthIndex = 0; 20 | yearIndex++; 21 | } 22 | const dayInMonth = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`) 23 | .add(i, "months") 24 | .daysInMonth(); 25 | 26 | width = dayInMonth * dayWidth; 27 | 28 | drawRow( 29 | { 30 | ctx, 31 | x: xPos, 32 | y: yPos, 33 | width, 34 | height: headerMonthHeight, 35 | textYPos: topRowTextYPos, 36 | label: 37 | dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`) 38 | .month(startMonthIndex) 39 | .format("MMMM") 40 | .toUpperCase() + 41 | ` ${dayjs(`${startDate.year + yearIndex}-${startDate.month + 1}-${startDate.dayOfMonth}`) 42 | .month(startMonthIndex) 43 | .format("YYYY")}`, 44 | font: fonts.topRow 45 | }, 46 | theme 47 | ); 48 | 49 | xPos += width; 50 | startMonthIndex++; 51 | } 52 | }; 53 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawWeeksInMiddle.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | import { 4 | dayWidth, 5 | fonts, 6 | headerMonthHeight, 7 | headerWeekHeight, 8 | middleRowTextYPos, 9 | weeksInYear 10 | } from "@/constants"; 11 | import { drawRow } from "@/utils/drawRow"; 12 | import { Theme } from "@/styles"; 13 | 14 | export const drawWeeksInMiddle = ( 15 | ctx: CanvasRenderingContext2D, 16 | startDate: Day, 17 | weekLabel: string, 18 | theme: Theme 19 | ) => { 20 | const width = 7 * dayWidth; 21 | const yPos = headerMonthHeight; 22 | 23 | const weeksThreshold = ctx.canvas.width / width + width; 24 | const startWeek = startDate.weekOfYear; 25 | let xPos = 0; 26 | 27 | for (let i = 0; i < weeksThreshold; i++) { 28 | const day = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`).day(); 29 | let weekIndex = (startWeek + i) % weeksInYear; 30 | 31 | if (weekIndex <= 0) { 32 | weekIndex += weeksInYear; 33 | } 34 | 35 | if (day !== 1 && i === 0) xPos = -day * dayWidth + dayWidth; 36 | 37 | drawRow( 38 | { 39 | ctx, 40 | x: xPos, 41 | y: yPos, 42 | width, 43 | height: headerWeekHeight, 44 | textYPos: middleRowTextYPos, 45 | label: `${weekLabel.toUpperCase()} ${weekIndex}`, 46 | font: fonts.middleRow 47 | }, 48 | theme 49 | ); 50 | 51 | xPos += width; 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawWeeksOnBottom.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { Day } from "@/types/global"; 3 | import { 4 | fonts, 5 | headerDayHeight, 6 | headerHeight, 7 | headerMonthHeight, 8 | headerWeekHeight, 9 | weekWidth 10 | } from "@/constants"; 11 | import { Theme } from "@/styles"; 12 | import { getBoxFillStyle } from "@/utils/getBoxFillStyle"; 13 | import { getTextStyle } from "@/utils/getTextStyle"; 14 | import { drawRow } from "../../drawRow"; 15 | 16 | export const drawWeeksOnBottom = ( 17 | ctx: CanvasRenderingContext2D, 18 | cols: number, 19 | startDate: Day, 20 | weekLabel: string, 21 | theme: Theme 22 | ) => { 23 | const dayNameYPos = headerHeight - headerDayHeight / 1.6; 24 | const dayNumYPos = headerHeight - headerDayHeight / 4.5; 25 | const yPos = headerMonthHeight + headerWeekHeight; 26 | let xPos = 0; 27 | 28 | for (let i = 0; i < cols; i++) { 29 | const week = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`).add( 30 | i, 31 | "weeks" 32 | ); 33 | 34 | const isCurrWeek = week.isSame(dayjs(), "week"); 35 | drawRow( 36 | { 37 | ctx, 38 | x: xPos, 39 | y: yPos, 40 | width: weekWidth, 41 | height: headerDayHeight, 42 | isBottomRow: true, 43 | fillStyle: getBoxFillStyle({ isCurrent: isCurrWeek, variant: "yearView" }, theme), 44 | topText: { 45 | y: dayNameYPos, 46 | label: week.isoWeek().toString(), 47 | font: fonts.bottomRow.name, 48 | color: getTextStyle({ isCurrent: isCurrWeek }, theme) 49 | }, 50 | bottomText: { 51 | y: dayNumYPos, 52 | label: weekLabel.toUpperCase(), 53 | font: fonts.middleRow, 54 | color: theme.colors.placeholder 55 | } 56 | }, 57 | theme 58 | ); 59 | 60 | xPos += weekWidth; 61 | } 62 | }; 63 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawYearsOnTop.ts: -------------------------------------------------------------------------------- 1 | import { fonts, headerMonthHeight, singleDayWidth, topRowTextYPos } from "@/constants"; 2 | import { Theme } from "@/styles"; 3 | import { Day } from "@/types/global"; 4 | import { daysInYear } from "@/utils/dates"; 5 | import { drawRow } from "@/utils/drawRow"; 6 | export const drawYearsOnTop = ( 7 | ctx: CanvasRenderingContext2D, 8 | startDate: Day, 9 | dayOfYear: number, 10 | theme: Theme 11 | ) => { 12 | const yPos = 0; 13 | const year = startDate.year; 14 | const canvasWidth = ctx.canvas.width * 2; 15 | let xPos = 0; 16 | let index = 0; 17 | let width = (daysInYear(year) - dayOfYear + 1) * singleDayWidth; 18 | let totalWidthOfElements = 0; 19 | 20 | while (xPos + totalWidthOfElements <= canvasWidth) { 21 | if (index > 0) { 22 | width = daysInYear(year + index) * singleDayWidth; 23 | } 24 | 25 | if (totalWidthOfElements + width > canvasWidth && index > 0) { 26 | width = Math.ceil((canvasWidth - totalWidthOfElements) / singleDayWidth) * singleDayWidth; 27 | } 28 | 29 | drawRow( 30 | { 31 | ctx, 32 | x: xPos, 33 | y: yPos, 34 | width, 35 | height: headerMonthHeight, 36 | textYPos: topRowTextYPos, 37 | label: (year + index).toString(), 38 | font: fonts.topRow 39 | }, 40 | theme 41 | ); 42 | 43 | xPos += width; 44 | totalWidthOfElements += width; 45 | index++; 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawZoom2DaysInMiddle.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { 3 | fonts, 4 | hoursInDay, 5 | zoom2ColumnWidth, 6 | zoom2HeaderMiddleRowHeight, 7 | zoom2HeaderTopRowHeight 8 | } from "@/constants"; 9 | import { Day } from "@/types/global"; 10 | import { Theme } from "@/styles"; 11 | import { drawRow } from "../../drawRow"; 12 | 13 | export const drawZoom2DaysInMiddle = ( 14 | ctx: CanvasRenderingContext2D, 15 | cols: number, 16 | startDate: Day, 17 | theme: Theme 18 | ) => { 19 | const daysInRange = Math.floor(cols / hoursInDay) + 2; 20 | 21 | const width = hoursInDay * zoom2ColumnWidth; 22 | 23 | const startDateHour = dayjs( 24 | `${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}T${startDate.hour}:00:00` 25 | ); 26 | 27 | const xPosOffset = -startDateHour.hour() * zoom2ColumnWidth; 28 | let xPos = xPosOffset + 0.5 * zoom2ColumnWidth; 29 | 30 | for (let i = 0; i < daysInRange; i++) { 31 | const dayLabel = dayjs(`${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}`) 32 | .add(i, "day") 33 | .format("dddd DD.MM.YYYY") 34 | .toUpperCase(); 35 | 36 | drawRow( 37 | { 38 | ctx, 39 | x: xPos, 40 | y: zoom2HeaderTopRowHeight, 41 | width, 42 | height: zoom2HeaderMiddleRowHeight, 43 | textYPos: zoom2HeaderTopRowHeight + zoom2HeaderMiddleRowHeight / 2 + 2, 44 | label: dayLabel, 45 | font: fonts.bottomRow.number 46 | }, 47 | theme 48 | ); 49 | xPos += width; 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/utils/drawHeader/drawRows/drawZoom2HoursOnBottom.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { 3 | fonts, 4 | zoom2ColumnWidth, 5 | zoom2HeaderBottomRowHeight, 6 | zoom2HeaderTopRowHeight, 7 | zoom2HeaderMiddleRowHeight 8 | } from "@/constants"; 9 | import { Day } from "@/types/global"; 10 | import { Theme } from "@/styles"; 11 | import { drawRow } from "../../drawRow"; 12 | 13 | export const drawZoom2HoursOnBottom = ( 14 | ctx: CanvasRenderingContext2D, 15 | cols: number, 16 | startDate: Day, 17 | theme: Theme 18 | ) => { 19 | let xPos = 0; 20 | const yPos = zoom2HeaderTopRowHeight + zoom2HeaderMiddleRowHeight; 21 | 22 | const startDateHour = dayjs( 23 | `${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}T${startDate.hour}:00:00` 24 | ); 25 | const width = zoom2ColumnWidth; 26 | 27 | for (let i = 0; i < cols; i++) { 28 | const hourLabel = startDateHour.add(i, "hours").format("HH:00").toUpperCase(); 29 | 30 | drawRow( 31 | { 32 | ctx, 33 | x: xPos, 34 | y: yPos, 35 | width, 36 | height: zoom2HeaderBottomRowHeight, 37 | label: hourLabel, 38 | font: fonts.bottomRow.number, 39 | textYPos: 40 | zoom2HeaderTopRowHeight + zoom2HeaderMiddleRowHeight + zoom2HeaderBottomRowHeight / 2 + 2, 41 | labelBetweenCells: true 42 | }, 43 | theme 44 | ); 45 | 46 | xPos += zoom2ColumnWidth; 47 | } 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/drawRow.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@/styles"; 2 | import { DrawRowConfig } from "@/types/global"; 3 | 4 | export const drawRow = (config: DrawRowConfig, theme: Theme) => { 5 | const { 6 | ctx, 7 | x, 8 | y, 9 | width, 10 | height, 11 | textYPos, 12 | label, 13 | font, 14 | isBottomRow, 15 | fillStyle, 16 | topText, 17 | bottomText, 18 | labelBetweenCells 19 | } = config; 20 | 21 | ctx.beginPath(); 22 | ctx.strokeStyle = theme.colors.border; 23 | ctx.setLineDash([]); 24 | 25 | if (label && font && textYPos) { 26 | ctx.fillStyle = theme.colors.gridBackground; 27 | ctx.fillRect(x, y, width, height); 28 | 29 | if (labelBetweenCells) { 30 | ctx.moveTo(x, y); 31 | ctx.lineTo(x + width, y); 32 | ctx.stroke(); 33 | 34 | ctx.moveTo(x, y + height); 35 | ctx.lineTo(x + width, y + height); 36 | ctx.stroke(); 37 | 38 | ctx.moveTo(x + width / 2, y + height); 39 | ctx.lineTo(x + width / 2, y + height - 5); 40 | ctx.stroke(); 41 | } else { 42 | ctx.strokeRect(x + 0.5, y + 0.5, width, height); 43 | } 44 | 45 | ctx.font = font; 46 | 47 | const textXPos = x + width / 2 - ctx.measureText(label).width / 2; 48 | ctx.textBaseline = "middle"; 49 | ctx.fillStyle = theme.colors.placeholder; 50 | ctx.fillText(label, textXPos, textYPos); 51 | } 52 | if (isBottomRow && fillStyle && topText && bottomText) { 53 | ctx.fillStyle = fillStyle; 54 | ctx.fillRect(x, y, width, height); 55 | ctx.strokeRect(x + 0.5, y + 0.5, width, height); 56 | 57 | ctx.font = topText.font; 58 | 59 | const dayNameXPos = x + width / 2 - ctx.measureText(topText.label).width / 2; 60 | 61 | ctx.fillStyle = topText.color; 62 | ctx.fillText(topText.label, dayNameXPos, topText.y); 63 | 64 | ctx.font = bottomText.font; 65 | 66 | const dayNumXPos = x + width / 2 - ctx.measureText(bottomText.label).width / 2; 67 | 68 | ctx.fillStyle = bottomText.color; 69 | ctx.fillText(bottomText.label, dayNumXPos, bottomText.y); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /src/utils/getBoxFillStyle.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@/styles"; 2 | import { TextAndBoxStyleConfig } from "@/types/global"; 3 | 4 | export const getBoxFillStyle = (config: TextAndBoxStyleConfig, theme: Theme) => { 5 | const { isCurrent, isBusinessDay, variant } = config; 6 | if (variant === "yearView") 7 | return isCurrent ? theme.colors.tertiary : theme.colors.gridBackground; 8 | if (isCurrent) return theme.colors.secondary; 9 | if (!isBusinessDay) return theme.colors.secondary; 10 | 11 | return theme.colors.primary; 12 | }; 13 | -------------------------------------------------------------------------------- /src/utils/getCanvasWidth.ts: -------------------------------------------------------------------------------- 1 | import { outsideWrapperId, leftColumnWidth, screenWidthMultiplier } from "@/constants"; 2 | 3 | export const getCanvasWidth = () => { 4 | const wrapperWidth = document.getElementById(outsideWrapperId)?.clientWidth || 0; 5 | const width = (wrapperWidth - leftColumnWidth) * screenWidthMultiplier; 6 | return width; 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/getCols.ts: -------------------------------------------------------------------------------- 1 | import { 2 | weekWidth, 3 | dayWidth, 4 | outsideWrapperId, 5 | leftColumnWidth, 6 | screenWidthMultiplier, 7 | zoom2ColumnWidth 8 | } from "@/constants"; 9 | 10 | export const getCols = (zoom: number) => { 11 | const wrapperWidth = document.getElementById(outsideWrapperId)?.clientWidth || 0; 12 | const componentWidth = wrapperWidth - leftColumnWidth; 13 | 14 | switch (zoom) { 15 | case 1: 16 | return Math.ceil(componentWidth / dayWidth) * screenWidthMultiplier; 17 | case 2: 18 | return Math.ceil(componentWidth / zoom2ColumnWidth) * screenWidthMultiplier; 19 | default: 20 | return Math.ceil(componentWidth / weekWidth) * screenWidthMultiplier; 21 | } 22 | }; 23 | 24 | export const getVisibleCols = (zoom: number) => getCols(zoom) / screenWidthMultiplier; 25 | -------------------------------------------------------------------------------- /src/utils/getDatesRange.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { getCols } from "./getCols"; 3 | 4 | export type DatesRange = { 5 | startDate: dayjs.Dayjs; 6 | endDate: dayjs.Dayjs; 7 | }; 8 | 9 | export type ParsedDatesRange = { 10 | startDate: Date; 11 | endDate: Date; 12 | }; 13 | 14 | export const getDatesRange = (date: dayjs.Dayjs, zoom: number): DatesRange => { 15 | const colsOffset = getCols(zoom) / 2; 16 | 17 | let startDate; 18 | switch (zoom) { 19 | case 1: 20 | startDate = date.subtract(colsOffset, "days"); 21 | break; 22 | case 2: 23 | startDate = date.subtract(colsOffset, "hours"); 24 | break; 25 | default: 26 | startDate = date.subtract(colsOffset, "weeks"); 27 | break; 28 | } 29 | 30 | let endDate; 31 | switch (zoom) { 32 | case 1: 33 | endDate = date.add(colsOffset, "days"); 34 | break; 35 | case 2: 36 | endDate = date.add(colsOffset, "hours"); 37 | break; 38 | default: 39 | endDate = date.add(colsOffset, "weeks"); 40 | break; 41 | } 42 | 43 | return { 44 | startDate, 45 | endDate 46 | }; 47 | }; 48 | 49 | export const getParsedDatesRange = (date: dayjs.Dayjs, zoom: number): ParsedDatesRange => { 50 | const dates = getDatesRange(date, zoom); 51 | 52 | return { 53 | startDate: dates.startDate.toDate(), 54 | endDate: dates.endDate.toDate() 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /src/utils/getDayOccupancy.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { OccupancyData, SchedulerProjectData, TimeUnits } from "@/types/global"; 3 | import { getDuration } from "./getDuration"; 4 | import { getTotalHoursAndMinutes } from "./getTotalHoursAndMinutes"; 5 | import { getTimeOccupancy } from "./getTimeOccupancy"; 6 | 7 | export const getDayOccupancy = ( 8 | occupancy: SchedulerProjectData[], 9 | focusedDate: dayjs.Dayjs, 10 | zoom: number, 11 | includeTakenHoursOnWeekendsInDayView: boolean 12 | ): OccupancyData => { 13 | const focusedDayNum = focusedDate.isoWeekday(); 14 | const getHoursAndMinutes: TimeUnits[] = occupancy.map((item) => { 15 | const { hours: itemHours, minutes: itemMinutes } = getDuration(item.occupancy); 16 | 17 | // if config was set to include free weekends max day num is 5 - friday else is 7 - whole week 18 | if (focusedDayNum <= (includeTakenHoursOnWeekendsInDayView ? 7 : 5)) { 19 | return { hours: itemHours, minutes: itemMinutes }; 20 | } 21 | return { hours: 0, minutes: 0 }; 22 | }); 23 | 24 | const { hours: totalHours, minutes: totalMinutes } = getTotalHoursAndMinutes(getHoursAndMinutes); 25 | const { free, overtime } = getTimeOccupancy({ hours: totalHours, minutes: totalMinutes }, zoom); 26 | 27 | return { 28 | taken: { hours: Math.max(0, totalHours), minutes: Math.max(0, totalMinutes) }, 29 | free, 30 | overtime 31 | }; 32 | }; 33 | -------------------------------------------------------------------------------- /src/utils/getDuration.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { TimeUnits } from "@/types/global"; 3 | 4 | export const getDuration = (seconds: number): TimeUnits => { 5 | const duration = dayjs.duration(seconds, "seconds"); 6 | const itemHours = duration.hours(); 7 | const itemMinutes = duration.minutes(); 8 | 9 | return { hours: itemHours, minutes: itemMinutes }; 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/getHourOccupancy.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { OccupancyData, SchedulerProjectData } from "@/types/global"; 3 | import { minutesInHour } from "@/constants"; 4 | 5 | export const getHourOccupancy = ( 6 | occupancy: SchedulerProjectData[], 7 | focusedDate: dayjs.Dayjs 8 | ): OccupancyData => { 9 | let minutes = 0; 10 | occupancy.forEach((item) => { 11 | const startHour = dayjs(item.startDate).hour(); 12 | const endHour = dayjs(item.endDate).hour(); 13 | const tooltipHour = focusedDate.hour(); 14 | const endMinutes = dayjs(item.endDate).minute(); 15 | const startMinutes = dayjs(item.startDate).minute(); 16 | if (startHour < tooltipHour && endHour > tooltipHour) { 17 | // Tooltip hour is contained in the event 18 | minutes += minutesInHour; 19 | } else if (startHour === tooltipHour && endHour === tooltipHour && startMinutes && endMinutes) { 20 | // Event is contained in the tooltip hour 21 | minutes += endMinutes ? endMinutes - startMinutes : minutesInHour - startMinutes; 22 | } else if (startHour === tooltipHour && endHour >= tooltipHour) { 23 | // Event start is contained in the tooltip hour 24 | minutes += startMinutes ? minutesInHour - startMinutes : minutesInHour; 25 | } else if (endHour === tooltipHour && endMinutes) { 26 | // Event end is contained in the tooltip hour 27 | minutes += endMinutes; 28 | } 29 | }); 30 | 31 | const takenHours = Math.floor(minutes / minutesInHour); 32 | const takenMinutes = minutes % minutesInHour; 33 | const freeHours = takenHours || takenMinutes ? 0 : 1; 34 | const freeMinutes = takenHours ? 0 : takenMinutes ? minutesInHour - takenMinutes : 0; 35 | 36 | return { 37 | taken: { hours: takenHours, minutes: takenMinutes }, 38 | free: { hours: freeHours, minutes: freeMinutes }, 39 | overtime: { hours: 0, minutes: 0 } 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /src/utils/getOccupancy.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { SchedulerProjectData, OccupancyData, ZoomLevel } from "@/types/global"; 3 | import { getWeekOccupancy } from "./getWeekOccupancy"; 4 | import { getDayOccupancy } from "./getDayOccupancy"; 5 | import { getHourOccupancy } from "./getHourOccupancy"; 6 | 7 | export const getOccupancy = ( 8 | resource: SchedulerProjectData[][], 9 | resourceIndex: number, 10 | focusedDate: dayjs.Dayjs, 11 | zoom: ZoomLevel, 12 | includeTakenHoursOnWeekendsInDayView = false 13 | ): OccupancyData => { 14 | if (resourceIndex < 0) 15 | return { 16 | taken: { hours: 0, minutes: 0 }, 17 | free: { hours: 0, minutes: 0 }, 18 | overtime: { hours: 0, minutes: 0 } 19 | }; 20 | 21 | const occupancy = resource.flat(2).filter((item) => { 22 | if (zoom === 1) { 23 | return dayjs(focusedDate).isBetween(item.startDate, item.endDate, "day", "[]"); 24 | } else if (zoom === 2) { 25 | return dayjs(focusedDate).isBetween(item.startDate, item.endDate, "hour", "[]"); 26 | } else { 27 | return ( 28 | dayjs(item.startDate).isBetween( 29 | dayjs(focusedDate), 30 | dayjs(focusedDate).add(6, "days"), 31 | "day", 32 | "[]" 33 | ) || dayjs(focusedDate).isBetween(dayjs(item.startDate), dayjs(item.endDate), "day", "[]") 34 | ); 35 | } 36 | }); 37 | 38 | switch (zoom) { 39 | case 1: 40 | return getDayOccupancy(occupancy, focusedDate, zoom, includeTakenHoursOnWeekendsInDayView); 41 | case 2: 42 | return getHourOccupancy(occupancy, focusedDate); 43 | default: 44 | return getWeekOccupancy(occupancy, focusedDate, zoom); 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /src/utils/getProjectsOnGrid.ts: -------------------------------------------------------------------------------- 1 | import { SchedulerData, SchedulerProjectData } from "@/types/global"; 2 | import { setProjectsInRows } from "./setProjectsInRows"; 3 | 4 | type ProjectsData = [projectsPerPerson: SchedulerProjectData[][][], rowsPerPerson: number[]]; 5 | 6 | export const projectsOnGrid = (data: SchedulerData) => { 7 | const initialProjectsData: ProjectsData = [[], []]; 8 | const [projectsPerPerson, rowsPerPerson] = data.reduce((acc, curr) => { 9 | const projectsInRows = setProjectsInRows(curr.data); 10 | acc[0].push(projectsInRows); 11 | acc[1].push(Math.max(projectsInRows.length, 1)); 12 | return acc; 13 | }, initialProjectsData); 14 | return { projectsPerPerson, rowsPerPerson }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/utils/getTextStyle.ts: -------------------------------------------------------------------------------- 1 | import { Theme } from "@/styles"; 2 | import { TextAndBoxStyleConfig } from "@/types/global"; 3 | 4 | export const getTextStyle = (config: TextAndBoxStyleConfig, theme: Theme) => { 5 | const { isCurrent, isBusinessDay, variant } = config; 6 | if (isCurrent) return variant === "bottomRow" ? theme.colors.placeholder : theme.colors.accent; 7 | if (isBusinessDay) 8 | return variant === "bottomRow" ? theme.colors.placeholder : theme.colors.textPrimary; 9 | 10 | return theme.colors.placeholder; 11 | }; 12 | -------------------------------------------------------------------------------- /src/utils/getTileProperties.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { boxHeight, tileYOffset } from "@/constants"; 3 | import { TileProperties } from "@/types/global"; 4 | import { getTileXAndWidth } from "./getTileXAndWidth"; 5 | 6 | export const getTileProperties = ( 7 | row: number, 8 | startDate: dayjs.Dayjs, 9 | endDate: dayjs.Dayjs, 10 | resourceStartDate: Date, 11 | resourceEndDate: Date, 12 | zoom: number 13 | ): TileProperties => { 14 | const y = row * boxHeight + tileYOffset; 15 | const rangeStartHour = startDate.hour(); 16 | const rangeEndHour = endDate.hour(); 17 | let parsedResourceStartDate; 18 | let parsedResourceEndDate; 19 | let parsedStartDate; 20 | let parsedEndDate; 21 | 22 | switch (zoom) { 23 | case 2: { 24 | parsedResourceStartDate = dayjs(resourceStartDate); 25 | parsedResourceEndDate = dayjs(resourceEndDate); 26 | parsedStartDate = dayjs(startDate).hour(rangeStartHour).minute(0); 27 | parsedEndDate = dayjs(endDate).hour(rangeEndHour).minute(0); 28 | break; 29 | } 30 | default: { 31 | parsedResourceStartDate = dayjs(resourceStartDate).hour(0).minute(0); 32 | parsedResourceEndDate = dayjs(resourceEndDate).hour(23).minute(59); 33 | parsedStartDate = startDate; 34 | parsedEndDate = endDate; 35 | break; 36 | } 37 | } 38 | 39 | return { 40 | ...getTileXAndWidth( 41 | { startDate: parsedResourceStartDate, endDate: parsedResourceEndDate }, 42 | { startDate: parsedStartDate, endDate: parsedEndDate }, 43 | zoom 44 | ), 45 | y 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/utils/getTileTextColor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * calculates whether or not text on given hex color should be black or white 3 | * based on WCAG 2.0 recommendations 4 | * @param hexColor - color of the background on which font will be placed 5 | * @returns font color that matches contrast recommended by WCAG 2.0 (black or white) 6 | * @example 7 | * // returns "black" 8 | * getTextColor("#FFFFFF") 9 | * @example 10 | * // returns "white" 11 | * getTextColor("#000000"); 12 | */ 13 | export const getTileTextColor = (hexColor: string) => { 14 | if (!hexColor) return "white"; 15 | 16 | // convert hex to rgb values 17 | const rgb = []; 18 | for (let i = 1; i < 6; i += 2) { 19 | rgb.push(parseInt(hexColor.slice(i, i + 2), 16) / 255); 20 | } 21 | 22 | /* 23 | relative luminance calculated based on requirements form: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance 24 | */ 25 | const sRGBValues = rgb.map((val) => 26 | val <= 0.03928 ? val / 12.92 : Math.pow((val + 0.055) / 1.055, 2.4) 27 | ); 28 | 29 | const relativeLuminance = 30 | 0.2126 * sRGBValues[0] + 0.7152 * sRGBValues[1] + 0.0722 * sRGBValues[2]; 31 | 32 | return relativeLuminance > 0.5 ? "black" : "white"; 33 | }; 34 | -------------------------------------------------------------------------------- /src/utils/getTileXAndWidth.ts: -------------------------------------------------------------------------------- 1 | import { dayWidth, minutesInHour, singleDayWidth, zoom2ColumnWidth } from "@/constants"; 2 | import { DatesRange } from "./getDatesRange"; 3 | 4 | export const getTileXAndWidth = (item: DatesRange, range: DatesRange, zoom: number) => { 5 | let cellWidth: number; 6 | switch (zoom) { 7 | case 0: 8 | cellWidth = singleDayWidth; 9 | break; 10 | case 2: 11 | cellWidth = zoom2ColumnWidth; 12 | break; 13 | default: 14 | cellWidth = dayWidth; 15 | } 16 | const getX = () => { 17 | let position; 18 | switch (zoom) { 19 | case 2: 20 | position = 21 | (item.startDate.diff(range.startDate, "minute") / minutesInHour + 1) * cellWidth - 22 | cellWidth / 2; 23 | break; 24 | default: { 25 | position = (item.startDate.diff(range.startDate, "day") + 1) * cellWidth; 26 | } 27 | } 28 | return Math.max(0, position); 29 | }; 30 | 31 | if (item.startDate.isAfter(range.startDate) && item.endDate.isBefore(range.endDate)) { 32 | let width; 33 | switch (zoom) { 34 | case 2: 35 | width = (item.endDate.diff(item.startDate, "minute") / minutesInHour) * cellWidth; 36 | break; 37 | default: 38 | width = item.endDate.diff(item.startDate, "day") * cellWidth + cellWidth; 39 | } 40 | 41 | return { x: getX(), width }; 42 | } 43 | 44 | if (item.startDate.isBefore(range.startDate) && item.endDate.isBefore(range.endDate)) { 45 | let width; 46 | switch (zoom) { 47 | case 2: 48 | width = 49 | (item.endDate.diff(range.startDate, "minute") / minutesInHour) * cellWidth + 50 | 0.5 * cellWidth; 51 | break; 52 | default: 53 | width = item.endDate.diff(range.startDate, "day") * cellWidth + cellWidth; 54 | } 55 | 56 | return { x: getX(), width }; 57 | } 58 | 59 | if (item.startDate.isAfter(range.startDate) && item.endDate.isAfter(range.endDate)) { 60 | let width; 61 | switch (zoom) { 62 | case 2: 63 | width = (range.endDate.diff(item.startDate, "minute") / minutesInHour) * cellWidth; 64 | break; 65 | default: 66 | width = range.endDate.diff(item.startDate, "day") * cellWidth + cellWidth; 67 | } 68 | 69 | return { x: getX(), width }; 70 | } 71 | 72 | if (item.startDate.isBefore(range.startDate) && item.endDate.isAfter(range.endDate)) { 73 | let width; 74 | switch (zoom) { 75 | case 2: 76 | width = (range.endDate.diff(range.startDate, "minute") / minutesInHour) * cellWidth; 77 | break; 78 | default: 79 | width = range.endDate.diff(range.startDate, "day") * cellWidth + cellWidth; 80 | } 81 | 82 | return { x: getX(), width }; 83 | } 84 | return { x: getX(), width: 0 }; 85 | }; 86 | -------------------------------------------------------------------------------- /src/utils/getTimeOccupancy.ts: -------------------------------------------------------------------------------- 1 | import { maxHoursPerDay, maxHoursPerWeek, minutesInHour } from "@/constants"; 2 | import { OccupancyData, TimeUnits } from "@/types/global"; 3 | 4 | export const getTimeOccupancy = ( 5 | timeUnits: TimeUnits, 6 | zoom: number 7 | ): Omit => { 8 | let maxHours = maxHoursPerDay; 9 | switch (zoom) { 10 | case 0: 11 | maxHours = maxHoursPerWeek; 12 | break; 13 | case 1: 14 | maxHours = maxHoursPerDay; 15 | break; 16 | case 2: 17 | maxHours = 1; 18 | break; 19 | } 20 | 21 | const getFreeTime = () => { 22 | let hours = maxHours - timeUnits.hours - 1; 23 | let minutes = minutesInHour - timeUnits.minutes; 24 | 25 | if (minutes === minutesInHour) { 26 | hours++; 27 | minutes = 0; 28 | } 29 | return { hours: Math.max(0, hours), minutes: hours < 0 ? 0 : minutes }; 30 | }; 31 | 32 | const getOverTime = () => { 33 | const overHours = timeUnits.hours - maxHours; 34 | const overMinutes = timeUnits.minutes; 35 | return { hours: Math.max(0, overHours), minutes: overHours < 0 ? 0 : overMinutes }; 36 | }; 37 | 38 | return { 39 | free: getFreeTime(), 40 | overtime: getOverTime() 41 | }; 42 | }; 43 | -------------------------------------------------------------------------------- /src/utils/getTooltipData.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { weekWidth, boxHeight, dayWidth, zoom2ColumnWidth } from "@/constants"; 3 | import { Day, Coords, SchedulerProjectData, TooltipData, ZoomLevel } from "@/types/global"; 4 | import { getOccupancy } from "./getOccupancy"; 5 | 6 | export const getTooltipData = ( 7 | startDate: Day, 8 | cursorPosition: Coords, 9 | rowsPerPerson: number[], 10 | resourcesData: SchedulerProjectData[][][], 11 | zoom: ZoomLevel, 12 | includeTakenHoursOnWeekendsInDayView = false 13 | ): TooltipData => { 14 | let timeUnit: dayjs.ManipulateType = "weeks"; 15 | let currBoxWidth; 16 | switch (zoom) { 17 | case 0: 18 | timeUnit = "weeks"; 19 | currBoxWidth = weekWidth; 20 | break; 21 | case 1: 22 | timeUnit = "days"; 23 | currBoxWidth = dayWidth; 24 | break; 25 | case 2: 26 | timeUnit = "hours"; 27 | currBoxWidth = zoom2ColumnWidth; 28 | break; 29 | } 30 | const column = 31 | zoom === 2 32 | ? Math.ceil((cursorPosition.x - 0.5 * currBoxWidth) / currBoxWidth) 33 | : Math.ceil(cursorPosition.x / currBoxWidth); 34 | const focusedDate = dayjs( 35 | `${startDate.year}-${startDate.month + 1}-${startDate.dayOfMonth}T${startDate.hour}:00:00` 36 | ).add(column - 1, timeUnit); 37 | 38 | const rowPosition = Math.ceil(cursorPosition.y / boxHeight); 39 | const resourceIndex = rowsPerPerson.findIndex((_, index, array) => { 40 | const sumOfRows = array.slice(0, index + 1).reduce((acc, cur) => acc + cur, 0); 41 | return sumOfRows >= rowPosition; 42 | }); 43 | const xPos = zoom === 2 ? (column + 1) * currBoxWidth : column * currBoxWidth; 44 | const yPos = (rowPosition - 1) * boxHeight + boxHeight; 45 | 46 | const disposition = getOccupancy( 47 | resourcesData[resourceIndex], 48 | resourceIndex, 49 | focusedDate, 50 | zoom, 51 | includeTakenHoursOnWeekendsInDayView 52 | ); 53 | return { coords: { x: xPos, y: yPos }, resourceIndex, disposition }; 54 | }; 55 | -------------------------------------------------------------------------------- /src/utils/getTotalHoursAndMinutes.ts: -------------------------------------------------------------------------------- 1 | import { minutesInHour } from "@/constants"; 2 | import { TimeUnits } from "@/types/global"; 3 | 4 | export const getTotalHoursAndMinutes = (item: TimeUnits[]) => { 5 | let minutesSum = 0; 6 | let totalHours = 0; 7 | let totalMinutes = 0; 8 | 9 | item.forEach((item) => { 10 | minutesSum += item.minutes; 11 | const additionalHours = Math.floor(minutesSum / minutesInHour); 12 | totalHours += item.hours + additionalHours; 13 | 14 | totalMinutes += minutesSum % minutesInHour; 15 | if (totalMinutes >= minutesInHour) { 16 | totalHours++; 17 | totalMinutes -= minutesInHour; 18 | } 19 | }); 20 | 21 | return { hours: totalHours, minutes: totalMinutes }; 22 | }; 23 | -------------------------------------------------------------------------------- /src/utils/getTotalRowsPerPage.ts: -------------------------------------------------------------------------------- 1 | import { PaginatedSchedulerData } from "@/types/global"; 2 | 3 | export const getTotalRowsPerPage = (page: PaginatedSchedulerData) => 4 | page ? page.map((page) => page.data.length).reduce((acc, curr) => acc + Math.max(curr, 1), 0) : 0; 5 | -------------------------------------------------------------------------------- /src/utils/getWeekOccupancy.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { businessDays } from "@/constants"; 3 | import { OccupancyData, SchedulerProjectData, TimeUnits, ZoomLevel } from "@/types/global"; 4 | import { getDuration } from "./getDuration"; 5 | import { getTotalHoursAndMinutes } from "./getTotalHoursAndMinutes"; 6 | import { getTimeOccupancy } from "./getTimeOccupancy"; 7 | 8 | export const getWeekOccupancy = ( 9 | occupancy: SchedulerProjectData[], 10 | focusedDate: dayjs.Dayjs, 11 | zoom: ZoomLevel 12 | ): OccupancyData => { 13 | const focusedWeek = focusedDate.isoWeek(); 14 | 15 | const getHoursAndMinutes: TimeUnits[] = occupancy.map((item) => { 16 | const startWeekNum = dayjs(item.startDate).isoWeek(); 17 | const startDateDayNum = dayjs(item.startDate).isoWeekday(); 18 | 19 | const endWeekNum = dayjs(item.endDate).isoWeek(); 20 | const endDateDayNum = dayjs(item.endDate).isoWeekday(); 21 | 22 | const { hours: itemHours, minutes: itemMinutes } = getDuration(item.occupancy); 23 | 24 | if (focusedWeek === startWeekNum) { 25 | const hours = (businessDays + 1 - startDateDayNum) * itemHours; 26 | const minutes = (businessDays + 1 - startDateDayNum) * itemMinutes; 27 | return { hours: Math.max(0, hours), minutes }; 28 | } else if (focusedWeek === endWeekNum) { 29 | const hours = 30 | endDateDayNum > businessDays ? businessDays * itemHours : endDateDayNum * itemHours; 31 | const minutes = 32 | endDateDayNum > businessDays ? businessDays * itemMinutes : endDateDayNum * itemMinutes; 33 | return { hours, minutes }; 34 | } else if (dayjs(focusedDate).isBetween(item.startDate, item.endDate)) { 35 | return { hours: businessDays * itemHours, minutes: businessDays * itemMinutes }; 36 | } 37 | return { hours: 0, minutes: 0 }; 38 | }); 39 | 40 | const { hours: totalHours, minutes: totalMinutes } = getTotalHoursAndMinutes(getHoursAndMinutes); 41 | const { free, overtime } = getTimeOccupancy({ hours: totalHours, minutes: totalMinutes }, zoom); 42 | 43 | return { 44 | taken: { hours: Math.max(0, totalHours), minutes: Math.max(0, totalMinutes) }, 45 | free, 46 | overtime 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/utils/resizeCanvas.ts: -------------------------------------------------------------------------------- 1 | export const resizeCanvas = (ctx: CanvasRenderingContext2D, width: number, height: number) => { 2 | ctx.canvas.width = width * window.devicePixelRatio; 3 | ctx.canvas.height = height * window.devicePixelRatio; 4 | ctx.canvas.style.width = width + "px"; 5 | ctx.canvas.style.height = height + "px"; 6 | ctx.scale(window.devicePixelRatio, window.devicePixelRatio); 7 | }; 8 | -------------------------------------------------------------------------------- /src/utils/setProjectsInRows.ts: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import { SchedulerProjectData } from "@/types/global"; 3 | 4 | export const setProjectsInRows = (projects: SchedulerProjectData[]): SchedulerProjectData[][] => { 5 | const rows: SchedulerProjectData[][] = []; 6 | for (const project of projects) { 7 | let isAdded = false; 8 | if (rows.length) { 9 | for (const row of rows) { 10 | let isColliding = false; 11 | for (let i = 0; i < row.length; i++) { 12 | if ( 13 | dayjs(project.startDate).isBetween(row[i].startDate, row[i].endDate, null, "[]") || 14 | dayjs(project.endDate).isBetween(row[i].startDate, row[i].endDate, null, "[]") 15 | ) { 16 | isColliding = true; 17 | break; 18 | } 19 | if ( 20 | dayjs(project.startDate).isBefore(row[i].startDate, "day") && 21 | dayjs(project.endDate).isAfter(row[i].endDate, "day") 22 | ) { 23 | isColliding = true; 24 | break; 25 | } 26 | } 27 | if (!isColliding) { 28 | row.push(project); 29 | isAdded = true; 30 | break; 31 | } 32 | } 33 | } 34 | if (!isAdded) { 35 | rows.push([project]); 36 | } 37 | } 38 | return rows; 39 | }; 40 | -------------------------------------------------------------------------------- /src/utils/splitToPages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PaginatedSchedulerData, 3 | PaginatedSchedulerRow, 4 | SchedulerData, 5 | SchedulerProjectData 6 | } from "@/types/global"; 7 | 8 | export const splitToPages = ( 9 | data: SchedulerData, 10 | projectsPerPerson: SchedulerProjectData[][][], 11 | rowsPerPerson: number[], 12 | recordsThreshold: number 13 | ) => { 14 | const pages: PaginatedSchedulerData[] = []; 15 | 16 | let leftIndex = 0; 17 | let singlePage: PaginatedSchedulerRow[] = []; 18 | let pageRecords = 0; 19 | 20 | if (projectsPerPerson.length > recordsThreshold) { 21 | projectsPerPerson.forEach((projects, i) => { 22 | const newItem = { id: data[i].id, label: data[i].label, data: projects }; 23 | 24 | if (pageRecords >= recordsThreshold) { 25 | pages.push(singlePage); 26 | leftIndex += singlePage.length; 27 | singlePage = []; 28 | pageRecords = 0; 29 | } 30 | 31 | pageRecords++; 32 | singlePage.push(newItem); 33 | }); 34 | 35 | if (rowsPerPerson.slice(leftIndex).length <= recordsThreshold) { 36 | singlePage = []; 37 | projectsPerPerson.slice(leftIndex).forEach((projects, i) => { 38 | const newItem = { 39 | id: data[i + leftIndex].id, 40 | label: data[i + leftIndex].label, 41 | data: projects 42 | }; 43 | singlePage.push(newItem); 44 | 45 | if (i === projectsPerPerson.length - leftIndex - 1) pages.push(singlePage); 46 | }); 47 | } 48 | 49 | return pages; 50 | } 51 | projectsPerPerson.forEach((projects, i) => { 52 | const newItem = { id: data[i].id, label: data[i].label, data: projects }; 53 | singlePage.push(newItem); 54 | }); 55 | 56 | pages.push(singlePage); 57 | 58 | return pages; 59 | }; 60 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "paths": { "@/*": ["./src/*"] }, 4 | "target": "ESNext", 5 | "useDefineForClassFields": true, 6 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 7 | "allowJs": false, 8 | "skipLibCheck": true, 9 | "esModuleInterop": false, 10 | "allowSyntheticDefaultImports": true, 11 | "strict": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "ESNext", 14 | "moduleResolution": "Node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx", 19 | "declaration": true, 20 | "typeRoots": ["./dist/index.d.ts"] 21 | }, 22 | "include": ["src", "index.ts"], 23 | "exclude": ["node_modules"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "skipLibCheck": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /vite.config.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line filenames/match-exported 2 | declare const _default: import("vite").UserConfigExport; 3 | export default _default; 4 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | import { resolve } from "path"; 3 | import { defineConfig } from "vite"; 4 | import react from "@vitejs/plugin-react"; 5 | import dts from "vite-plugin-dts"; 6 | import { visualizer } from "rollup-plugin-visualizer"; 7 | import svgr from "vite-plugin-svgr"; 8 | 9 | export default defineConfig({ 10 | resolve: { 11 | alias: { 12 | "@": resolve(__dirname, "./src") 13 | } 14 | }, 15 | plugins: [ 16 | react({ 17 | babel: { 18 | env: { 19 | production: { 20 | plugins: [["babel-plugin-styled-components", { displayName: false, pure: true }]] 21 | }, 22 | development: { 23 | plugins: [["babel-plugin-styled-components", { displayName: true, pure: true }]] 24 | } 25 | } 26 | } 27 | }), 28 | dts({ 29 | rollupTypes: true 30 | }), 31 | svgr(), 32 | visualizer({ 33 | template: "treemap" 34 | }) 35 | ], 36 | build: { 37 | lib: { 38 | entry: resolve(__dirname, "src/index.ts"), 39 | name: "react-scheduler", 40 | fileName: "index" 41 | }, 42 | rollupOptions: { 43 | external: ["react", "react-dom", "react/jsx-runtime"], 44 | output: { 45 | globals: { 46 | react: "React", 47 | "react-dom": "ReactDOM", 48 | "react/jsx-runtime": "react/jsx-runtime" 49 | } 50 | } 51 | } 52 | }, 53 | server: { 54 | host: "0.0.0.0" 55 | } 56 | }); 57 | --------------------------------------------------------------------------------