├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------