├── .github
└── FUNDING.yml
├── .gitignore
├── .npmignore
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── jest.config.js
├── package.json
├── src
├── Epg
│ ├── Epg.tsx
│ ├── __tests__
│ │ └── Epg.test.tsx
│ ├── components
│ │ ├── Channel.tsx
│ │ ├── Channels.tsx
│ │ ├── Layout.tsx
│ │ ├── Line
│ │ │ ├── Line.tsx
│ │ │ └── index.ts
│ │ ├── Loader.tsx
│ │ ├── Program.tsx
│ │ ├── Timeline.tsx
│ │ ├── __tests__
│ │ │ ├── Channel.test.tsx
│ │ │ ├── Layout.test.tsx
│ │ │ └── Program.test.tsx
│ │ └── index.ts
│ ├── helpers
│ │ ├── __tests__
│ │ │ ├── common.test.ts
│ │ │ └── time.test.ts
│ │ ├── common.ts
│ │ ├── enums.ts
│ │ ├── epg.ts
│ │ ├── index.ts
│ │ ├── interfaces.ts
│ │ ├── time.ts
│ │ ├── types.ts
│ │ └── variables.ts
│ ├── hooks
│ │ ├── __tests__
│ │ │ ├── useLayout.test.tsx
│ │ │ ├── useProgram.test.tsx
│ │ │ └── useTimeline.test.tsx
│ │ ├── index.ts
│ │ ├── useEpg.tsx
│ │ ├── useInterval.tsx
│ │ ├── useLayout.tsx
│ │ ├── useLine.tsx
│ │ ├── useProgram.tsx
│ │ └── useTimeline.tsx
│ ├── index.ts
│ ├── styles
│ │ ├── Channel.styles.ts
│ │ ├── Channels.styles.ts
│ │ ├── Epg.styles.ts
│ │ ├── Line.styles.ts
│ │ ├── Loader.styles.ts
│ │ ├── Program.styles.ts
│ │ ├── Timeline.styles.ts
│ │ ├── global.styles.ts
│ │ └── index.ts
│ ├── test
│ │ ├── db
│ │ │ ├── channels.ts
│ │ │ ├── epg.ts
│ │ │ └── index.ts
│ │ ├── helpers.ts
│ │ ├── index.ts
│ │ └── test-utils.tsx
│ └── theme
│ │ ├── emotion.d.ts
│ │ ├── index.ts
│ │ └── theme.ts
└── index.ts
├── tsconfig.json
└── yarn.lock
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [karolkozer]
2 | open_collective: planby
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.log
2 | .DS_Store
3 | node_modules
4 | .cache
5 | dist
6 | package-lock.json
7 | yarn.lock
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | /images
2 | /src
3 | jest.config.js
4 | tsconfig.json
5 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Code of Conduct - Planby
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to make participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to a positive environment for our
15 | community include:
16 |
17 | * Demonstrating empathy and kindness toward other people
18 | * Being respectful of differing opinions, viewpoints, and experiences
19 | * Giving and gracefully accepting constructive feedback
20 | * Accepting responsibility and apologizing to those affected by our mistakes,
21 | and learning from the experience
22 | * Focusing on what is best not just for us as individuals, but for the
23 | overall community
24 |
25 | Examples of unacceptable behavior include:
26 |
27 | * The use of sexualized language or imagery, and sexual attention or
28 | advances
29 | * Trolling, insulting or derogatory comments, and personal or political attacks
30 | * Public or private harassment
31 | * Publishing others' private information, such as a physical or email
32 | address, without their explicit permission
33 | * Other conduct which could reasonably be considered inappropriate in a
34 | professional setting
35 |
36 | ## Our Responsibilities
37 |
38 | Project maintainers are responsible for clarifying and enforcing our standards of
39 | acceptable behavior and will take appropriate and fair corrective action in
40 | response to any instances of unacceptable behavior.
41 |
42 | Project maintainers have the right and responsibility to remove, edit, or reject
43 | comments, commits, code, wiki edits, issues, and other contributions that are
44 | not aligned to this Code of Conduct, or to ban
45 | temporarily or permanently any contributor for other behaviors that they deem
46 | inappropriate, threatening, offensive, or harmful.
47 |
48 | ## Scope
49 |
50 | This Code of Conduct applies within all community spaces, and also applies when
51 | an individual is officially representing the community in public spaces.
52 | Examples of representing our community include using an official e-mail address,
53 | posting via an official social media account, or acting as an appointed
54 | representative at an online or offline event.
55 |
56 | ## Enforcement
57 |
58 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
59 | reported to the community leaders responsible for enforcement at .
60 | All complaints will be reviewed and investigated promptly and fairly.
61 |
62 | All community leaders are obligated to respect the privacy and security of the
63 | reporter of any incident.
64 |
65 | ## Attribution
66 |
67 | This Code of Conduct is adapted from the [Contributor Covenant](https://contributor-covenant.org/), version
68 | [1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct/code_of_conduct.md) and
69 | [2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/code_of_conduct.md).
70 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Planby
2 |
3 | First off all, thanks for taking the time to contribute! ❤️
4 |
5 | All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for different ways to help and details about how this project handles them. Please make sure to read the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉
6 |
7 | > And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
8 | >
9 | > - Star the project
10 | > - Tweet about it
11 | > - Refer this project in your project's readme
12 | > - Mention the project at local meetups and tell your friends/colleagues
13 |
14 | ## Table of Contents
15 |
16 | - [Code of Conduct](#code-of-conduct)
17 | - [I Have a Question](#i-have-a-question)
18 | - [I Want To Contribute](#i-want-to-contribute)
19 | - [Reporting Bugs](#reporting-bugs)
20 | - [Suggesting Enhancements](#suggesting-enhancements)
21 | - [Your First Code Contribution](#your-first-code-contribution)
22 | - [Improving The Documentation](#improving-the-documentation)
23 | - [Styleguides](#styleguides)
24 | - [Commit Messages](#commit-messages)
25 | - [Join The Project Team](#join-the-project-team)
26 |
27 | ## I Have a Question
28 |
29 | > If you want to ask a question, we assume that you have read the available [Documentation](https://github.com/karolkozer/planby).
30 |
31 | Before you ask a question, it is best to search for existing [Issues](https://github.com/karolkozer/planby/issues) that might help you. In case you have found a suitable issue and still need clarification, you can write your question in this issue. It is also advisable to search the internet for answers first.
32 |
33 | If you then still feel the need to ask a question and need clarification, we recommend the following:
34 |
35 | - Open an [Issue](https://github.com/karolkozer/planby/issues/new).
36 | - Provide as much context as you can about what you're running into.
37 | - Provide project and platform versions (nodejs, npm, etc), depending on what seems relevant.
38 |
39 | We will then take care of the issue as soon as possible.
40 |
41 | ## I Want To Contribute
42 |
43 | ### Reporting Bugs
44 |
45 | #### Before Submitting a Bug Report
46 |
47 | A good bug report shouldn't leave others needing to chase you up for more information. Therefore, we ask you to investigate carefully, collect information and describe the issue in detail in your report. Please complete the following steps in advance to help us fix any potential bug as fast as possible.
48 |
49 | - Make sure that you are using the latest version.
50 | - Determine if your bug is really a bug and not an error on your side e.g. using incompatible environment components/versions (Make sure that you have read the [documentation](https://github.com/karolkozer/planby). If you are looking for support, you might want to check [this section](#i-have-a-question)).
51 | - To see if other users have experienced (and potentially already solved) the same issue you are having, check if there is not already a bug report existing for your bug or error in the [bug tracker](https://github.com/karolkozer/planbyissues?q=label%3Abug).
52 | - Also make sure to search the internet (including Stack Overflow) to see if users outside of the GitHub community have discussed the issue.
53 | - Collect information about the bug:
54 | - Stack trace (Traceback)
55 | - OS, Platform and Version (Windows, Linux, macOS)
56 | - Version of the compiler, runtime environment, package manager, depending on what seems relevant.
57 | - Possibly your input and the output
58 | - Can you reliably reproduce the issue? And can you also reproduce it with older versions?
59 |
60 | We use GitHub issues to track bugs and errors.
61 |
62 | We use GitHub issues to track bugs and errors.
63 |
64 | If you run into an issue with the project:
65 |
66 | - Open an [Issue](https://github.com/karolkozer/planby/issues/new). (Since we can't be sure at this point whether it is a bug or not, we ask you not to talk about a bug yet and not to label the issue.)
67 | - Explain the behavior you would expect and the actual behavior.
68 | - Please provide as much context as possible and describe the _reproduction steps_ that someone else can follow to recreate the issue on their own. This usually includes your code. For good bug reports you should isolate the problem and create a reduced test case.
69 | - Provide the information you collected in the previous section.
70 |
71 | Once it's filed:
72 |
73 | - The project team will label the issue accordingly.
74 | - A team member will try to reproduce the issue with your provided steps. If there are no reproduction steps or no obvious way to reproduce the issue, the team will ask you for those steps and mark the issue as `needs-repro`. Bugs with the `needs-repro` tag will not be addressed until they are reproduced.
75 | - If the team is able to reproduce the issue, it will be marked `needs-fix`, as well as possibly other tags (such as `critical`), and the issue will be left to be [implemented by someone](#your-first-code-contribution).
76 |
77 |
78 |
79 | ### Suggesting Enhancements
80 |
81 | This section guides you through submitting an enhancement suggestion for Planby, **including completely new features and minor improvements to existing functionality**. Following these guidelines will help maintainers and the community to understand your suggestion and find related suggestions.
82 |
83 | #### Before Submitting an Enhancement
84 |
85 | - Make sure that you are using the latest version.
86 | - Read the [documentation](https://github.com/karolkozer/planby) carefully and find out if the functionality is already covered, maybe by an individual configuration.
87 | - Perform a [search](https://github.com/karolkozer/planby/issues) to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
88 | - Find out whether your idea fits with the scope and aims of the project. It's up to you to make a strong case to convince the project's developers of the merits of this feature. Keep in mind that we want features that will be useful to the majority of our users and not just a small subset. If you're just targeting a minority of users, consider writing an add-on/plugin library.
89 |
90 | #### How Do I Submit a Good Enhancement Suggestion?
91 |
92 | Enhancement suggestions are tracked as [GitHub issues](https://github.com/karolkozer/planby/issues).
93 |
94 | - Use a **clear and descriptive title** for the issue to identify the suggestion.
95 | - Provide a **step-by-step description of the suggested enhancement** in as many details as possible.
96 | - **Describe the current behavior** and **explain which behavior you expected to see instead** and why. At this point you can also tell which alternatives do not work for you.
97 | - You may want to **include screenshots and animated GIFs** which help you demonstrate the steps or point out the part which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux.
98 | - **Explain why this enhancement would be useful** to most Planby users. You may also want to point out the other projects that solved it better and which could serve as inspiration.
99 |
100 |
101 |
102 | ### Your First Code Contribution
103 |
104 |
108 |
109 | ### Improving The Documentation
110 |
111 |
115 |
116 | ## Styleguides
117 |
118 | ### Commit Messages
119 |
120 |
123 |
124 | ## Join The Project Team
125 |
126 |
127 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Custom License - All Rights Reserved
2 |
3 | Copyright (c) 2025 Nessprim Karol Kozer
4 |
5 | Operator hereby grants a non-exclusive, free of charge, non-transferable license i.e. without the right to grant sublicense to use the Software.
6 | The license does not authorize the Company or the User to use the Software for purposes other than use the Software with intended purpose.
7 |
8 | The Company and the User has no right to reproduce, sell or otherwise market or distribute the Software in whole or in part, in any form, in particular, transmit or make available in computer systems and networks, or any other communication.
9 |
10 | It’s not allowed:
11 |
12 | 1. repeated and systematic downloading or re-using of information from the Software, contrary to normal use and resulting in an unjustified violation of the Operator’s legitimate interests;
13 | 2. repeated and systematic downloading or re-using contrary to normal use and resulting in an unjustified violation of the legitimate interests of the Operator;
14 | 3. copying parts of the database beyond normal personal use.
15 |
16 | THE PLATFORM IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21 | OUT OF OR IN CONNECTION WITH THE PLATFORM OR THE USE OR OTHER DEALINGS IN THE
22 | PLATFORM.
23 |
24 | The above note is only a shortened version. Its full version is presented in the Terms & Conditions and should be interpreted and applied in accordance with their content.
25 |
26 | This license does not grant you any rights to trademarks, service marks, or
27 | any other intellectual property of the author. For any specific licensing
28 | needs or questions regarding this license, please contact the author.
29 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
6 |
7 |
19 |
20 | # 🔥 An exclusive new experience — React Native support is on its way to Planby! 🔥
21 |
22 | ## React Native: To request beta access, email us at contact@planby.app with your position and company name.
23 |
24 | ## Description
25 |
26 | Planby is a React based component for a quick implementation of Epg, schedules, live streaming, music events, timelines and many more ideas. It uses a custom virtual view which allows you to operate on a really big number of data. The component has a simple API that you can easily integrate with other third party UI libraries. The component theme is customised to the needs of the application design.
27 |
28 |
33 |
38 |
43 |
48 |
49 | ## Codesandbox example
50 |
51 | [Live example - Codesandbox](https://codesandbox.io/s/5o3tsy)
52 |
53 | [Live example - Typescript Codesandbox](https://codesandbox.io/s/planby-epg-demo-ts-lp66v5)
54 |
55 | [Live example - website with control panel](https://planby.netlify.app/)
56 |
57 | ## Testimonials
58 |
59 |
64 |
69 |
70 | ## 🚀 [Become a Sponsor!](https://opencollective.com/planby) 🚀
71 |
72 | Become a sponsor, support, and help us in continuing our development. -> [Opencollective](https://opencollective.com/planby)
73 |
74 | ## Getting Started
75 |
76 | ### Installation
77 |
78 | - yarn
79 |
80 | ```sh
81 | yarn add planby
82 | ```
83 |
84 | - npm
85 |
86 | ```sh
87 | npm install planby
88 | ```
89 |
90 | ## Usage
91 |
92 | ```tsx
93 | import { useEpg, Epg, Layout } from 'planby';
94 |
95 | const channels = React.useMemo(
96 | () => [
97 | {
98 | logo: 'https://via.placeholder.com',
99 | uuid: '10339a4b-7c48-40ab-abad-f3bcaf95d9fa',
100 | ...
101 | },
102 | ],
103 | []
104 | );
105 |
106 | const epg = React.useMemo(
107 | () => [
108 | {
109 | channelUuid: '30f5ff1c-1346-480a-8047-a999dd908c1e',
110 | description:
111 | 'Ut anim nisi consequat minim deserunt...',
112 | id: 'b67ccaa3-3dd2-4121-8256-33dbddc7f0e6',
113 | image: 'https://via.placeholder.com',
114 | since: "2022-02-02T23:50:00",
115 | till: "2022-02-02T00:55:00",
116 | title: 'Title',
117 | ...
118 | },
119 | ],
120 | []
121 | );
122 |
123 | const {
124 | getEpgProps,
125 | getLayoutProps,
126 | onScrollToNow,
127 | onScrollLeft,
128 | onScrollRight,
129 | } = useEpg({
130 | epg,
131 | channels,
132 | startDate: '2022-02-02T00:00:00'
133 | });
134 |
135 | return (
136 |
137 |
138 |
139 |
142 |
143 |
144 |
145 | );
146 | ```
147 |
148 | or
149 |
150 | #### Custom width and height
151 |
152 | ```tsx
153 | const {
154 | getEpgProps,
155 | getLayoutProps,
156 | ...
157 | } = useEpg({
158 | epg,
159 | channels,
160 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
161 | width: 1200,
162 | height: 600
163 | });
164 |
165 | return (
166 |
167 |
168 |
171 |
172 |
173 |
174 | ```
175 |
176 | or
177 |
178 | #### Time range
179 |
180 | ```tsx
181 | const {
182 | getEpgProps,
183 | getLayoutProps,
184 | ...
185 | } = useEpg({
186 | epg,
187 | channels,
188 | startDate: '2022-02-02T10:00:00',
189 | endDate: '2022-02-02T20:00:00',
190 | width: 1200,
191 | height: 600
192 | });
193 |
194 | return (
195 |
196 |
197 |
200 |
201 |
202 |
203 | ```
204 |
205 | ## API
206 |
207 | ### useEpg
208 |
209 | #### Options
210 |
211 | Available options in useEpg
212 |
213 | | Property | Type | Status | Description | Access |
214 | | ------------------------ | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------ |
215 | | `channels` | `array` | required | Array with channels data | |
216 | | `epg` | `array` | required | Array with EPG data | |
217 | | `width` | `number` | optional | EPG width | |
218 | | `height` | `number` | optional | EPG height | |
219 | | `sidebarWidth` | `number` | optional | Width of the sidebar with channels | |
220 | | `timelineHeight` | `number` | optional | Height of the timeline | `PRO` |
221 | | `itemHeight` | `number` | optional | Height of channels and programs in the EPG. Default value is 80 | |
222 | | `dayWidth` | `number` | optional | Width of the day. Default value is 7200. Calculation to set up day width with own hour width value e.g., 24h \* 300px (your custom hour width) = 7200px -> `dayWidth` | |
223 | | `startDate` | `string` | optional | Date format `2022/02/02` or `2022-02-02T00:00:00`. You can set your own start time, e.g., `2022-02-02T10:00:00`, `2022-02-02T14:00:00`, etc. Full clock hours only | |
224 | | `endDate` | `string` | optional | Date format `2022-02-02T00:00:00`, `2022-02-02T20:00:00`, etc. Must be within the same 24-hour period as `startDate`. Full clock hours only. Scroll through `multiple days` and timeline mode is available only in `PRO` plan. | `PRO` |
225 | | `hoursInDays` | `array` | optional | Set start time and end time of each day in `multiple days` feature if your data for each day has some time spaces between items in the day. | `PRO` |
226 | | `initialScrollPositions` | `object` | optional | Set initial scroll position in Layout, e.g., `initialScrollPositions: { top: 500, left: 800 }` | `PRO` |
227 | | `liveRefreshTime` | `number` | optional | Live refresh time of the events. Default value is 120 sec. | `PRO` |
228 | | `isBaseTimeFormat` | `boolean` | optional | Convert to 12-hour format, e.g., `2:00am`, `4:00pm`, etc. Default value is false. | |
229 | | `isCurrentTime` | `boolean` | optional | Show current time in Timeline. Default value is false. | `PRO` |
230 | | `isInitialScrollToNow` | `boolean` | optional | Scroll to the current live element. | `PRO` |
231 | | `isVerticalMode` | `boolean` | optional | Show Timeline in vertical view. Default value is false. | `PRO` |
232 | | `isResize` | `boolean` | optional | Possibility to resize the element. | `PRO` |
233 | | `isSidebar` | `boolean` | optional | Show/hide sidebar | |
234 | | `isTimeline` | `boolean` | optional | Show/hide timeline | |
235 | | `isLine` | `boolean` | optional | Show/hide line | |
236 | | `isRTL` | `boolean` | optional | Change direction to RTL or LTR. Default value is false. | `PRO` |
237 | | `theme` | `object` | optional | Object with theme schema | |
238 | | `timezone` | `object` | optional | Convert and display data from UTC format to your own time zone | `PRO` |
239 | | `areas` | `array` | optional | Area gives possibilities to add field ranges to the Timeline layout. | `PRO` |
240 | | `mode` | `object` | optional | Type values: `day/week/month`. Style values: `default/modern` Define the mode and style of the timeline. Default mode is `day` and style is `default` | `PRO` |
241 | | `overlap` | `object` | optional | Enable the element overlaps in the layout. Mode values: `stack/layer`, layerOverlapLevel: `number` | `PRO` |
242 | | `drag and drop` | `object` | optional | Drag and move the element in the layout. Mode values: `row/multi-rows` | `PRO` |
243 | | `grid layout` | `object` | optional | Background grid on the layout. Mode hoverHighlight values: `true/false`, onGridItemClick: function with all the properties on clicked item grid | `PRO` |
244 | | `channelMapKey` | `string` | optional | The Channel `uuid` attribute can be controlled by prop. Key map gives a possibilities to use specific prop from own data instead of needing to map to uuid in own data | `PRO` |
245 | | `programChannelMapKey` | `string` | optional | The Programs `channelUuid` attributes can be controlled by prop. Key map gives a possibilities to use a specific prop from own data instead of needing to map to channelUuid in your data | `PRO` |
246 | | `globalStyles` | `string` | optional | Inject custom global styles and font. Font weight: 400,500,600. Default font is "Inter" | `PRO` |
247 |
248 | #### Note about width and height props
249 |
250 | Without declaring the `width` and `length` properties, the component takes the dimensions of the parent element.
251 |
252 | #### globalStyles
253 |
254 | Inject own custom font and other global styles.
255 |
256 | ```tsx
257 | const globalStyles = `
258 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
259 |
260 | /* Available in PRO plan */
261 | .planby {
262 | font-family: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica,
263 | Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
264 |
265 | /* Layout */
266 | .planby-layout {}
267 |
268 | /* Line */
269 | .planby-line {}
270 |
271 | /* Current time */
272 | .planby-current-time {}
273 | .planby-current-content {}
274 |
275 | /* Channels */
276 | .planby-channels {}
277 |
278 | /* Channel */
279 | .planby-channel {}
280 |
281 | /* Program */
282 | .planby-program {}
283 | .planby-program-content {}
284 | .planby-program-flex {}
285 | .planby-program-stack {}
286 | .planby-program-title {}
287 | .planby-program-text {}
288 |
289 | /* Timeline */
290 | .planby-timeline-wrapper {}
291 | .planby-timeline-box {}
292 | .planby-timeline-time {}
293 | .planby-timeline-dividers {}
294 | .planby-timeline-wrapper {}
295 | }
296 |
297 | `;
298 | ```
299 |
300 | #### Instance Properties
301 |
302 | Properties returned from useEpg
303 |
304 | | Property | Type | Description |
305 | | --------------- | ------------------------- | ------------------------------------ |
306 | | `scrollY` | `number` | Current scroll y value |
307 | | `scrollX` | `number` | Current scroll x value |
308 | | `onScrollLeft` | `function(value: number)` | Default value is 300 |
309 | | `onScrollRight` | `function(value: number)` | Default value is 300 |
310 | | `onScrollToNow` | `function()` | Scroll to current time/live programs |
311 | | `onScrollTop` | `function(value: number)` | Default value is 300 |
312 |
313 | ### Channel schema
314 |
315 | | Property | Type | Status |
316 | | -------- | -------- | -------- |
317 | | `logo` | `string` | required |
318 | | `uuid` | `string` | required |
319 |
320 | ### Epg schema
321 |
322 | | Property | Type | Status | Description | Access |
323 | | ----------------- | --------- | -------- | -------------------------------------------------------------------- | -------- |
324 | | `channelUuid` | `string` | required |
325 | | `id` | `string` | required |
326 | | `image` | `string` | required |
327 | | `since` | `string` | required |
328 | | `till` | `string` | required |
329 | | `title` | `string` | required |
330 | | `fixedVisibility` | `boolean` | optional | The element is always visible in the layout during the scroll events | Sponsors |
331 |
332 | ### Epg
333 |
334 | #### Base props
335 |
336 | Available props in Epg
337 |
338 | | Property | Type | Description | Status |
339 | | ----------- | ----------- | ----------------------- | -------- |
340 | | `isLoading` | `boolean` | Loader state | optional |
341 | | `loader` | `Component` | Loader custom component | optional |
342 |
343 | ### Layout
344 |
345 | #### Base props
346 |
347 | Available props in Layout.
348 |
349 | | Property | Type | Description | Status | Access |
350 | | ------------------- | ------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | -------- | ---------- |
351 | | `renderProgram` | `function({ program: { data: object, position: object})` | `data` object contains all properties related to the program, `position` object includes all position styles | optional |
352 | | `renderChannel` | `function({ channel: { ..., position: object})` | `channel` object contains all properties related to the channel, `position` object includes all position styles | optional |
353 | | `renderTimeline` | `function({sidebarWidth: number})` | `sidebarWidth` value of the channel's sidebar width | optional |
354 | | `renderLine` | `function({styles: object})` | basic `styles` and `position` values for the custom live tracking Line | optional | `Sponsors` |
355 | | `renderCurrentTime` | `function({styles: object, isRTL: boolean, isBaseTimeFormat: boolean, time: string})` | basic `styles` values for the custom current time | optional | `Sponsors` |
356 |
357 | # Render functions
358 |
359 | You can use Plaby's style components to develop main features. Moreover, you can integrate with third party UI library eg. Chakra UI, Material UI etc or make custom styles.
360 |
361 | ## renderProgram
362 |
363 | Below is an example that allows you to render your custom Program component using Plaby's style components.
364 |
365 | ```tsx
366 | import {
367 | useEpg,
368 | Epg,
369 | Layout,
370 | ProgramBox,
371 | ProgramContent,
372 | ProgramFlex,
373 | ProgramStack,
374 | ProgramTitle,
375 | ProgramText,
376 | ProgramImage,
377 | useProgram,
378 | Program,
379 | ProgramItem
380 | } from "planby";
381 |
382 |
383 | const Item = ({ program,...rest }: ProgramItem) => {
384 | const { styles, formatTime, isLive, isMinWidth } = useProgram({ program,...rest });
385 |
386 | const { data } = program;
387 | const { image, title, since, till } = data;
388 |
389 | const sinceTime = formatTime(since);
390 | const tillTime = formatTime(till);
391 |
392 | return (
393 |
394 |
398 |
399 | {isLive && isMinWidth && }
400 |
401 | {title}
402 |
403 | {sinceTime} - {tillTime}
404 |
405 |
406 |
407 |
408 |
409 | );
410 | };
411 |
412 | function App() {
413 |
414 | ...
415 |
416 | const {
417 | getEpgProps,
418 | getLayoutProps,
419 | } = useEpg({
420 | epg,
421 | channels,
422 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
423 | });
424 |
425 | return (
426 |
427 |
428 |
429 | (
432 |
433 | )}
434 | />
435 |
436 |
437 |
438 | );
439 | }
440 |
441 | export default App;
442 | ```
443 |
444 | ## renderProgram - 12 hours time format
445 |
446 | Below is an example that allows you to render your custom Program component with 12 hours time format using Plaby's style components.
447 |
448 | ```tsx
449 | ...
450 | const Item = ({ program, ...rest }: ProgramItem) => {
451 | const {
452 | styles,
453 | formatTime,
454 | set12HoursTimeFormat,
455 | isLive,
456 | isMinWidth,
457 | } = useProgram({
458 | program,
459 | ...rest
460 | });
461 |
462 | const { data } = program;
463 | const { image, title, since, till } = data;
464 |
465 | const sinceTime = formatTime(since, set12HoursTimeFormat()).toLowerCase();
466 | const tillTime = formatTime(till, set12HoursTimeFormat()).toLowerCase();
467 |
468 | return (
469 |
470 |
474 |
475 | {isLive && isMinWidth && }
476 |
477 | {title}
478 |
479 | {sinceTime} - {tillTime}
480 |
481 |
482 |
483 |
484 |
485 | );
486 | };
487 |
488 | function App() {
489 |
490 | ...
491 |
492 | const {
493 | getEpgProps,
494 | getLayoutProps,
495 | } = useEpg({
496 | epg,
497 | channels,
498 | isBaseTimeFormat: true,
499 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
500 | });
501 |
502 | ...
503 | }
504 |
505 | export default App;
506 | ```
507 |
508 | ## renderProgram - RTL direction
509 |
510 | Below is an example that allows you to render your custom Program component with RTL direction using Plaby's style components.
511 |
512 | ```tsx
513 | ...
514 | const Item = ({ program, ...rest }: ProgramItem) => {
515 | const {
516 | isRTL,
517 | isLive,
518 | isMinWidth,
519 | formatTime,
520 | styles,
521 | set12HoursTimeFormat,
522 | getRTLSinceTime,
523 | getRTLTillTime,
524 | } = useProgram({
525 | program,
526 | ...rest
527 | });
528 |
529 | const { data } = program;
530 | const { image, title, since, till } = data;
531 |
532 | const sinceTime = formatTime(
533 | getRTLSinceTime(since),
534 | set12HoursTimeFormat()
535 | ).toLowerCase();
536 | const tillTime = formatTime(
537 | getRTLTillTime(till),
538 | set12HoursTimeFormat()
539 | ).toLowerCase();
540 |
541 | return (
542 |
543 |
544 |
545 | {isLive && isMinWidth && }
546 |
547 | {title}
548 |
549 | {sinceTime} - {tillTime}
550 |
551 |
552 |
553 |
554 |
555 | );
556 | };
557 |
558 | function App() {
559 |
560 | ...
561 |
562 | const {
563 | getEpgProps,
564 | getLayoutProps,
565 | } = useEpg({
566 | epg,
567 | channels,
568 | isBaseTimeFormat: true,
569 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
570 | });
571 |
572 | ...
573 | }
574 |
575 | export default App;
576 | ```
577 |
578 | ## renderChannel
579 |
580 | Below is an example that allows you to render your custom Channel component using Plaby's style components.
581 |
582 | ```tsx
583 | import { useEpg, Epg, Layout, ChannelBox, ChannelLogo, Channel } from 'planby';
584 |
585 | interface ChannelItemProps {
586 | channel: Channel;
587 | }
588 |
589 | const ChannelItem = ({ channel }: ChannelItemProps) => {
590 | const { position, logo } = channel;
591 | return (
592 |
593 | console.log('channel', channel)}
595 | src={logo}
596 | alt="Logo"
597 | />
598 |
599 | );
600 | };
601 |
602 |
603 | function App() {
604 |
605 | ...
606 |
607 | const {
608 | getEpgProps,
609 | getLayoutProps,
610 | } = useEpg({
611 | epg,
612 | channels,
613 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
614 | });
615 |
616 | return (
617 |
618 |
619 |
620 | (
623 |
624 | )}
625 | />
626 |
627 |
628 |
629 | );
630 | }
631 |
632 | ```
633 |
634 | ## renderTimeline
635 |
636 | Below is an example that allows you to render your custom Timeline component using Plaby's style components.
637 |
638 | ```tsx
639 | import {
640 | TimelineWrapper,
641 | TimelineBox,
642 | TimelineTime,
643 | TimelineDivider,
644 | TimelineDividers,
645 | useTimeline,
646 | } from 'planby';
647 |
648 | interface TimelineProps {
649 | isBaseTimeFormat: boolean;
650 | isSidebar: boolean;
651 | dayWidth: number;
652 | hourWidth: number;
653 | numberOfHoursInDay: number;
654 | offsetStartHoursRange: number;
655 | sidebarWidth: number;
656 | }
657 |
658 | export function Timeline({
659 | isBaseTimeFormat,
660 | isSidebar,
661 | dayWidth,
662 | hourWidth,
663 | numberOfHoursInDay,
664 | offsetStartHoursRange,
665 | sidebarWidth,
666 | }: TimelineProps) {
667 | const { time, dividers, formatTime } = useTimeline(
668 | numberOfHoursInDay,
669 | isBaseTimeFormat
670 | );
671 |
672 | const renderTime = (index: number) => (
673 |
674 |
675 | {formatTime(index + offsetStartHoursRange).toLowerCase()}
676 |
677 | {renderDividers()}
678 |
679 | );
680 |
681 | const renderDividers = () =>
682 | dividers.map((_, index) => (
683 |
684 | ));
685 |
686 | return (
687 |
692 | {time.map((_, index) => renderTime(index))}
693 |
694 | );
695 | }
696 |
697 | function App() {
698 |
699 | ...
700 |
701 | const {
702 | getEpgProps,
703 | getLayoutProps,
704 | } = useEpg({
705 | epg,
706 | channels,
707 | startDate: '2022/02/02', // or 2022-02-02T00:00:00
708 | });
709 |
710 | return (
711 |
712 |
713 |
714 | }
717 | />
718 |
719 |
720 |
721 | );
722 | }
723 |
724 | export default App;
725 | ```
726 |
727 | ## renderTimeline - RTL direction
728 |
729 | Below is an example that allows you to render your custom Timeline component using Plaby's style components.
730 |
731 | ```tsx
732 | import {
733 | TimelineWrapper,
734 | TimelineBox,
735 | TimelineTime,
736 | TimelineDivider,
737 | TimelineDividers,
738 | useTimeline,
739 | } from 'planby';
740 |
741 | interface TimelineProps {
742 | isRTL: boolean;
743 | isBaseTimeFormat: boolean;
744 | isSidebar: boolean;
745 | dayWidth: number;
746 | hourWidth: number;
747 | numberOfHoursInDay: number;
748 | offsetStartHoursRange: number;
749 | sidebarWidth: number;
750 | }
751 |
752 | export function Timeline({
753 | isRTL,
754 | isBaseTimeFormat,
755 | isSidebar,
756 | dayWidth,
757 | hourWidth,
758 | numberOfHoursInDay,
759 | offsetStartHoursRange,
760 | sidebarWidth,
761 | }: TimelineProps) {
762 | const { time, dividers, formatTime } = useTimeline(
763 | numberOfHoursInDay,
764 | isBaseTimeFormat
765 | );
766 |
767 | const renderTime = (index: number) => (
768 |
769 |
770 | {formatTime(index + offsetStartHoursRange).toLowerCase()}
771 |
772 | {renderDividers()}
773 |
774 | );
775 |
776 | ...
777 | }
778 |
779 | ```
780 |
781 | ## Theme
782 |
783 | ### Schema
784 |
785 | Make your theme custom. Below is theme schema that you can pass as one of the options to `useEpg` hook.
786 |
787 | ```jsx
788 | const theme = {
789 | primary: {
790 | 600: '#1a202c',
791 | 900: '#171923',
792 | },
793 | grey: { 300: '#d1d1d1' },
794 | white: '#fff',
795 | green: {
796 | 300: '#2C7A7B',
797 | },
798 | loader: {
799 | teal: '#5DDADB',
800 | purple: '#3437A2',
801 | pink: '#F78EB6',
802 | bg: '#171923db',
803 | },
804 | scrollbar: {
805 | border: '#ffffff',
806 | thumb: {
807 | bg: '#e1e1e1',
808 | },
809 | },
810 | gradient: {
811 | blue: {
812 | 300: '#002eb3',
813 | 600: '#002360',
814 | 900: '#051937',
815 | },
816 | },
817 | text: {
818 | grey: {
819 | 300: '#a0aec0',
820 | 500: '#718096',
821 | },
822 | },
823 | timeline: {
824 | divider: {
825 | bg: '#718096',
826 | },
827 | },
828 | };
829 | ```
830 |
831 | ## All import options
832 |
833 | ```tsx
834 | import {
835 | Epg,
836 | Layout,
837 | ChannelBox,
838 | ChannelLogo,
839 | ProgramBox,
840 | ProgramContent,
841 | ProgramFlex,
842 | ProgramStack,
843 | ProgramTitle,
844 | ProgramText,
845 | ProgramImage,
846 | TimelineWrapper,
847 | TimelineBox,
848 | TimelineTime,
849 | TimelineDividers,
850 | useEpg,
851 | useProgram,
852 | useTimeline,
853 | Program, // Interface
854 | Channel, // Interface
855 | ProgramItem, // Interface for program render
856 | Theme, // Interface
857 | } from 'planby';
858 | ```
859 |
860 | ## License
861 |
862 | Custom License - All Rights Reserved. [See `LICENSE` for more information](https://planby.app/docs/planby-license.pdf).
863 |
864 | ## Contact
865 |
866 | Karol Kozer - [@kozerkarol_twitter](https://twitter.com/kozerkarol)
867 |
868 | Project Link: [https://github.com/karolkozer/planby](https://github.com/karolkozer/planby)
869 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
2 | module.exports = {
3 | preset: 'ts-jest',
4 | testEnvironment: 'node',
5 | };
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "planby",
3 | "author": "Karol Kozer",
4 | "version": "2.0.0",
5 | "license": "Custom License",
6 | "repository": {
7 | "type": "git",
8 | "url": "https://github.com/karolkozer/planby"
9 | },
10 | "homepage": "https://planby.app",
11 | "funding": {
12 | "type": "opencollective",
13 | "url": "https://opencollective.com/planby"
14 | },
15 | "main": "dist/index.js",
16 | "typings": "dist/index.d.ts",
17 | "files": [
18 | "dist"
19 | ],
20 | "engines": {
21 | "node": ">=10"
22 | },
23 | "scripts": {
24 | "start": "tsdx watch",
25 | "build": "tsdx build",
26 | "test": "tsdx test --passWithNoTests",
27 | "lint": "tsdx lint",
28 | "prepare": "tsdx build && bundlewatch",
29 | "size": "bundlewatch",
30 | "test:watch": "jest --watchAll"
31 | },
32 | "peerDependencies": {
33 | "react": ">=19"
34 | },
35 | "husky": {
36 | "hooks": {
37 | "pre-commit": "tsdx lint"
38 | }
39 | },
40 | "prettier": {
41 | "printWidth": 80,
42 | "semi": true,
43 | "singleQuote": true,
44 | "trailingComma": "es5"
45 | },
46 | "module": "dist/planby.esm.js",
47 | "bundlewatch": {
48 | "files": [
49 | {
50 | "path": "dist/planby.cjs.production.min.js",
51 | "maxSize": "30kB"
52 | },
53 | {
54 | "path": "dist/planby.esm.js",
55 | "maxSize": "30kB"
56 | }
57 | ]
58 | },
59 | "dependencies": {
60 | "@emotion/react": "^11.9.0",
61 | "@emotion/styled": "^11.8.1",
62 | "date-fns": "^2.28.0",
63 | "use-debounce": "^7.0.1"
64 | },
65 | "keywords": [
66 | "epg",
67 | "schedule",
68 | "harmongram",
69 | "react",
70 | "hooks",
71 | "electronic",
72 | "program",
73 | "guide",
74 | "timeline",
75 | "events"
76 | ],
77 | "devDependencies": {
78 | "@faker-js/faker": "^6.1.2",
79 | "@size-limit/preset-small-lib": "^7.0.8",
80 | "@types/jest": "^27.4.0",
81 | "@types/react": "^17.0.38",
82 | "bundlewatch": "^0.3.3",
83 | "husky": "^7.0.4",
84 | "jest": "^27.5.1",
85 | "size-limit": "^7.0.8",
86 | "ts-jest": "^27.1.3",
87 | "tsdx": "^0.14.1",
88 | "tslib": "^2.3.1",
89 | "typescript": "^4.6.3"
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/src/Epg/Epg.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { ThemeProvider, Global } from "@emotion/react";
3 |
4 | // Import interfaces
5 | import { Theme } from "./helpers/interfaces";
6 |
7 | // Import helpers
8 | import { TIMELINE_HEIGHT } from "./helpers";
9 |
10 | // Import styles
11 | import { globalStyles, EpgStyled } from "./styles";
12 |
13 | // Import components
14 | import { Loader } from "./components";
15 |
16 | interface EpgProps {
17 | ref: React.RefObject;
18 | width?: number;
19 | height?: number;
20 | isRTL?: boolean;
21 | isSidebar: boolean;
22 | isTimeline?: boolean;
23 | isLoading?: boolean;
24 | children: React.ReactNode;
25 | loader?: React.ReactNode;
26 | theme: Theme;
27 | globalStyles?: string;
28 | sidebarWidth: number;
29 | }
30 |
31 | const { Container, Wrapper, Box } = EpgStyled;
32 |
33 | export const Epg = ({
34 | children,
35 | width,
36 | height,
37 | sidebarWidth,
38 | theme,
39 | globalStyles: customGlobalStyles,
40 | isRTL = false,
41 | isSidebar = true,
42 | isTimeline = true,
43 | isLoading = false,
44 | loader: LoaderComponent,
45 | ref: containerRef,
46 | ...rest
47 | }: EpgProps) => {
48 | const renderLoader = () => LoaderComponent ?? ;
49 | const epgGlobalStyles = customGlobalStyles ?? globalStyles;
50 | return (
51 |
52 |
53 |
61 |
62 | {isSidebar && isTimeline && (
63 |
70 | )}
71 | {isLoading && renderLoader()}
72 | {children}
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/src/Epg/__tests__/Epg.test.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, screen, within } from "../test/test-utils";
3 | import { Epg } from "../Epg";
4 | import { Layout } from "../components";
5 |
6 | import { getLayoutProps, getTestTimeDate } from "../test";
7 |
8 | // Import theme
9 | import { theme as defaultTheme } from "../theme";
10 |
11 | type Overrides = { [key: string]: any };
12 | function getEpgProps(overrides: Overrides = {}) {
13 | return {
14 | theme: defaultTheme,
15 | sidebarWidth: 100,
16 | ...overrides,
17 | };
18 | }
19 |
20 | interface RenderEpg {
21 | epgOptions?: Overrides;
22 | layoutOptions?: Overrides;
23 | sliceNumber?: number;
24 | }
25 | function renderEpg({ epgOptions, layoutOptions, sliceNumber }: RenderEpg = {}) {
26 | const layoutProps = getLayoutProps(layoutOptions, sliceNumber);
27 | render(
28 | <>
29 | {/* @ts-ignore:next-line */}
30 |
31 |
32 |
33 | >
34 | );
35 | return { layoutProps };
36 | }
37 |
38 | test("should render Epg coponent properly", () => {
39 | const props = getEpgProps();
40 | const layoutProps = getLayoutProps();
41 | render(
42 |
43 | {/* @ts-ignore:next-line */}
44 |
45 |
46 |
47 |
48 | );
49 |
50 | const container = screen.getByTestId("container");
51 |
52 | expect(container).toBeInTheDocument();
53 | expect(container).toHaveStyle(`height: 100%; width: 100%`);
54 |
55 | const timeline = screen.getByTestId("timeline");
56 | const inTimeline = within(timeline);
57 | expect(timeline).toBeInTheDocument();
58 | expect(inTimeline.getByText("00:00")).toBeInTheDocument();
59 | expect(inTimeline.getAllByTestId("timeline-item")).toHaveLength(24);
60 |
61 | const sidebar = screen.getByTestId("sidebar");
62 | const inSidebar = within(sidebar);
63 | expect(sidebar).toBeInTheDocument();
64 | expect(inSidebar.getAllByTestId("sidebar-item")).toHaveLength(1);
65 | expect(inSidebar.getByRole("img", { name: /logo/i })).toHaveAttribute(
66 | "src",
67 | layoutProps.channels[0].logo
68 | );
69 |
70 | const content = screen.getByTestId("content");
71 | const inContent = within(content);
72 | expect(content).toBeInTheDocument();
73 | expect(inContent.getAllByTestId("program-item")).toHaveLength(1);
74 |
75 | const firstProgram = inContent.getAllByTestId("program-item")[0];
76 | expect(firstProgram).toHaveTextContent(layoutProps.programs[0].data.title);
77 | expect(firstProgram).toHaveStyle(
78 | `top: ${layoutProps.programs[0].position.top}px`
79 | );
80 | expect(firstProgram).toHaveStyle(
81 | `width: ${layoutProps.programs[0].position.width}px`
82 | );
83 | });
84 |
85 | test("should set initial Epg props", () => {
86 | const epgOptions = getEpgProps({ width: 1000, height: 600 });
87 | const layoutOptions = {
88 | dayWidth: 7200,
89 | startDate: getTestTimeDate("01"),
90 | endDate: getTestTimeDate("23"),
91 | hourWidth: 300,
92 | isBaseTimeFormat: true,
93 | numberOfHoursInDay: 22,
94 | offsetStartHoursRange: 1,
95 | };
96 | renderEpg({
97 | epgOptions,
98 | layoutOptions,
99 | sliceNumber: 10,
100 | });
101 |
102 | const container = screen.getByTestId("container");
103 | expect(container).toBeInTheDocument();
104 | expect(container).toHaveStyle(`height: 600px; width: 1000px`);
105 |
106 | const timeline = screen.getByTestId("timeline");
107 | const inTimeline = within(timeline);
108 | expect(timeline).toBeInTheDocument();
109 | expect(inTimeline.queryByText("00:00")).not.toBeInTheDocument();
110 | expect(inTimeline.getByText("1:00am")).toBeInTheDocument();
111 | expect(inTimeline.getAllByTestId("timeline-item")).toHaveLength(22);
112 | expect(inTimeline.getAllByTestId("timeline-item")[0]).toHaveStyle(
113 | `width: ${layoutOptions.hourWidth}px`
114 | );
115 |
116 | const content = screen.getByTestId("content");
117 | expect(content).toHaveStyle(`width: ${layoutOptions.dayWidth}px`);
118 | });
119 |
120 | test("should show loader in Epg layout", () => {
121 | const epgOptions = getEpgProps({ isLoading: true });
122 | renderEpg({ epgOptions });
123 |
124 | expect(screen.getByLabelText(/loading/i)).toBeInTheDocument();
125 | });
126 |
--------------------------------------------------------------------------------
/src/Epg/components/Channel.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // Import interfaces
4 | import { ChannelWithPosition } from "../helpers/types";
5 |
6 | // Import styles
7 | import { ChannelStyled } from "../styles";
8 |
9 | interface ChannelProps {
10 | channel: T;
11 | onClick?: (v: ChannelWithPosition) => void;
12 | }
13 |
14 | const { ChannelBox, ChannelLogo } = ChannelStyled;
15 |
16 | export function Channel({
17 | channel,
18 | onClick,
19 | ...rest
20 | }: ChannelProps) {
21 | const { position, logo } = channel;
22 | return (
23 | onClick?.(channel)}
26 | {...position}
27 | {...rest}
28 | >
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/src/Epg/components/Channels.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | // Import interfaces
3 | import { ChannelWithPosition } from "../helpers/types";
4 |
5 | // Import styles
6 | import { ChannelsStyled } from "../styles";
7 |
8 | // Import Components
9 | import { Channel } from "../components";
10 |
11 | interface ChannelsProps {
12 | isTimeline: boolean;
13 | isRTL: boolean;
14 | isChannelVisible: (position: any) => boolean;
15 | channels: ChannelWithPosition[];
16 | scrollY: number;
17 | sidebarWidth: number;
18 | renderChannel?: (v: { channel: ChannelWithPosition }) => React.ReactNode;
19 | }
20 |
21 | const { Box } = ChannelsStyled;
22 |
23 | export function Channels(props: ChannelsProps) {
24 | const { channels, scrollY, sidebarWidth, renderChannel } = props;
25 | const { isRTL, isTimeline, isChannelVisible } = props;
26 |
27 | const renderChannels = (channel: ChannelWithPosition) => {
28 | const isVisible = isChannelVisible(channel.position);
29 | if (isVisible) {
30 | if (renderChannel) return renderChannel({ channel });
31 | return ;
32 | }
33 | return null;
34 | };
35 |
36 | return (
37 |
44 | {channels.map(renderChannels)}
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/src/Epg/components/Layout.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | // Import types
4 | import {
5 | ProgramItem,
6 | ProgramWithPosition,
7 | ChannelWithPosition,
8 | DateTime,
9 | Position,
10 | BaseTimeFormat,
11 | } from "../helpers/types";
12 |
13 | // Import helpers
14 | import { getProgramOptions, isFutureTime } from "../helpers";
15 |
16 | // Import styles
17 | import { EpgStyled } from "../styles";
18 |
19 | // Import components
20 | import { Timeline, Channels, Program, Line } from "../components";
21 |
22 | interface RenderTimeline {
23 | isBaseTimeFormat: BaseTimeFormat;
24 | isSidebar: boolean;
25 | isRTL: boolean;
26 | sidebarWidth: number;
27 | hourWidth: number;
28 | numberOfHoursInDay: number;
29 | offsetStartHoursRange: number;
30 | dayWidth: number;
31 | }
32 |
33 | interface LayoutProps {
34 | programs: ProgramItem[];
35 | channels: ChannelWithPosition[];
36 | startDate: DateTime;
37 | endDate: DateTime;
38 | scrollY: number;
39 | dayWidth: number;
40 | hourWidth: number;
41 | numberOfHoursInDay: number;
42 | offsetStartHoursRange: number;
43 | sidebarWidth: number;
44 | itemHeight: number;
45 | ref: React.RefObject;
46 | onScroll: (
47 | e: React.UIEvent & { target: Element }
48 | ) => void;
49 | isRTL?: boolean;
50 | isBaseTimeFormat?: BaseTimeFormat;
51 | isSidebar?: boolean;
52 | isTimeline?: boolean;
53 | isLine?: boolean;
54 | isProgramVisible: (position: Position) => boolean;
55 | isChannelVisible: (position: Pick) => boolean;
56 | renderProgram?: (v: {
57 | program: ProgramItem;
58 | isRTL: boolean;
59 | isBaseTimeFormat: BaseTimeFormat;
60 | }) => React.ReactNode;
61 | renderChannel?: (v: { channel: ChannelWithPosition }) => React.ReactNode;
62 | renderTimeline?: (v: RenderTimeline) => React.ReactNode;
63 | }
64 |
65 | const { ScrollBox, Content } = EpgStyled;
66 |
67 | export const Layout =
68 | ({ ref: scrollBoxRef, ...props }: LayoutProps) => {
69 | const { channels, programs, startDate, endDate, scrollY } = props;
70 | const { dayWidth, hourWidth, sidebarWidth, itemHeight } = props;
71 | const { numberOfHoursInDay, offsetStartHoursRange } = props;
72 | const {
73 | isSidebar = true,
74 | isTimeline = true,
75 | isLine = true,
76 | isBaseTimeFormat = false,
77 | isRTL = false,
78 | } = props;
79 |
80 | const {
81 | onScroll,
82 | isProgramVisible,
83 | isChannelVisible,
84 | renderProgram,
85 | renderChannel,
86 | renderTimeline,
87 | } = props;
88 |
89 | const channelsLength = channels.length;
90 | const contentHeight = React.useMemo(() => channelsLength * itemHeight, [
91 | channelsLength,
92 | itemHeight,
93 | ]);
94 | const isFuture = isFutureTime(endDate);
95 |
96 | const renderPrograms = (program: ProgramWithPosition) => {
97 | const { position } = program;
98 | const isVisible = isProgramVisible(position);
99 |
100 | if (isVisible) {
101 | const options = getProgramOptions(program);
102 | if (renderProgram)
103 | return renderProgram({
104 | program: options,
105 | isRTL,
106 | isBaseTimeFormat,
107 | });
108 | return (
109 |
115 | );
116 | }
117 | return null;
118 | };
119 |
120 | const renderTopbar = () => {
121 | const props = {
122 | sidebarWidth,
123 | isSidebar,
124 | isRTL,
125 | dayWidth,
126 | numberOfHoursInDay,
127 | };
128 | const timeProps = {
129 | offsetStartHoursRange,
130 | numberOfHoursInDay,
131 | isBaseTimeFormat,
132 | hourWidth,
133 | };
134 | if (renderTimeline) {
135 | return renderTimeline({ ...timeProps, ...props });
136 | }
137 | return ;
138 | };
139 |
140 | return (
141 |
142 | {isLine && isFuture && (
143 |
151 | )}
152 | {isTimeline && renderTopbar()}
153 | {isSidebar && (
154 |
163 | )}
164 |
171 | {programs.map((program) =>
172 | renderPrograms(program as ProgramWithPosition)
173 | )}
174 |
175 |
176 | );
177 | }
178 |
--------------------------------------------------------------------------------
/src/Epg/components/Line/Line.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { isToday } from "date-fns";
3 |
4 | // Import types
5 | import { DateTime } from "../../helpers/types";
6 |
7 | // Import styles
8 | import { LineStyled } from "../../styles";
9 |
10 | // Import components
11 | import { useLine } from "../../hooks/useLine";
12 |
13 | interface LineProps {
14 | height: number;
15 | startDate: DateTime;
16 | endDate: DateTime;
17 | dayWidth: number;
18 | hourWidth: number;
19 | sidebarWidth: number;
20 | }
21 |
22 | const { Box } = LineStyled;
23 |
24 | export function Line({
25 | height,
26 | startDate,
27 | endDate,
28 | dayWidth,
29 | hourWidth,
30 | sidebarWidth,
31 | }: LineProps) {
32 | const { positionX } = useLine({
33 | startDate,
34 | endDate,
35 | dayWidth,
36 | hourWidth,
37 | sidebarWidth,
38 | });
39 |
40 | const date = new Date(startDate);
41 | if (!isToday(date)) return null;
42 |
43 | return ;
44 | }
45 |
--------------------------------------------------------------------------------
/src/Epg/components/Line/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Line";
2 |
--------------------------------------------------------------------------------
/src/Epg/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { theme } from "../theme/theme";
3 |
4 | // Import styles
5 | import { LoaderStyled } from "../styles";
6 |
7 | const { Box, Shape } = LoaderStyled;
8 |
9 | const Element = ({
10 | width,
11 | color,
12 | animate,
13 | marginRight,
14 | transition,
15 | }: {
16 | width: number;
17 | color: string;
18 | transition: { duration: number; ease?: string; delay?: number };
19 | animate: { right: string[] };
20 | marginRight?: number;
21 | }) => (
22 |
30 | );
31 |
32 | export function Loader() {
33 | return (
34 |
35 |
36 |
39 |
50 |
61 |
62 |
65 |
76 |
77 |
78 |
90 |
101 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/Epg/components/Program.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // Import interfaces
4 | import { Program as ProgramType } from "../helpers/interfaces";
5 | import { BaseTimeFormat } from "../helpers/types";
6 |
7 | // Import types
8 | import { ProgramItem } from "../helpers/types";
9 |
10 | // Import styles
11 | import { ProgramStyled } from "../styles";
12 |
13 | // Import hooks
14 | import { useProgram } from "../hooks";
15 |
16 | interface ProgramProps {
17 | isRTL?: boolean;
18 | isBaseTimeFormat: BaseTimeFormat;
19 | program: T;
20 | onClick?: (v: ProgramType) => void;
21 | }
22 |
23 | const {
24 | ProgramBox,
25 | ProgramContent,
26 | ProgramFlex,
27 | ProgramStack,
28 | ProgramTitle,
29 | ProgramText,
30 | ProgramImage,
31 | } = ProgramStyled;
32 |
33 | export function Program({
34 | program,
35 | onClick,
36 | ...rest
37 | }: ProgramProps) {
38 | const {
39 | isRTL,
40 | isLive,
41 | isMinWidth,
42 | styles,
43 | formatTime,
44 | set12HoursTimeFormat,
45 | getRTLSinceTime,
46 | getRTLTillTime,
47 | } = useProgram({
48 | program,
49 | ...rest,
50 | });
51 |
52 | const { data } = program;
53 | const { image, title, since, till } = data;
54 |
55 | const handleOnContentClick = () => onClick?.(data);
56 |
57 | const sinceTime = formatTime(
58 | getRTLSinceTime(since),
59 | set12HoursTimeFormat()
60 | ).toLowerCase();
61 | const tillTime = formatTime(
62 | getRTLTillTime(till),
63 | set12HoursTimeFormat()
64 | ).toLowerCase();
65 |
66 | return (
67 |
72 |
79 |
80 | {isLive && isMinWidth && }
81 |
82 | {title}
83 |
84 | {sinceTime} - {tillTime}
85 |
86 |
87 |
88 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/src/Epg/components/Timeline.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | // Import types
4 | import { BaseTimeFormat } from "../helpers/types";
5 |
6 | // Import styles
7 | import { TimelineStyled } from "../styles";
8 |
9 | // Import hooks
10 | import { useTimeline } from "../hooks";
11 |
12 | const {
13 | TimelineWrapper,
14 | TimelineBox,
15 | TimelineTime,
16 | TimelineDividers,
17 | TimelineDivider,
18 | } = TimelineStyled;
19 |
20 | interface TimelineProps {
21 | isRTL?: boolean;
22 | isBaseTimeFormat: BaseTimeFormat;
23 | isSidebar: boolean;
24 | dayWidth: number;
25 | hourWidth: number;
26 | numberOfHoursInDay: number;
27 | offsetStartHoursRange: number;
28 | sidebarWidth: number;
29 | }
30 |
31 | export function Timeline({
32 | isRTL,
33 | isBaseTimeFormat,
34 | isSidebar,
35 | dayWidth,
36 | hourWidth,
37 | numberOfHoursInDay,
38 | offsetStartHoursRange,
39 | sidebarWidth,
40 | }: TimelineProps) {
41 | const { time, dividers, formatTime } = useTimeline(
42 | numberOfHoursInDay,
43 | isBaseTimeFormat
44 | );
45 |
46 | const renderTime = (index: number) => (
47 |
48 |
49 | {formatTime(index + offsetStartHoursRange)}
50 |
51 | {renderDividers()}
52 |
53 | );
54 |
55 | const renderDividers = () =>
56 | dividers.map((_, index) => (
57 |
58 | ));
59 |
60 | return (
61 |
67 | {time.map((_, index) => renderTime(index))}
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/src/Epg/components/__tests__/Channel.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "../../test/test-utils";
2 | import { Channel } from "../Channel";
3 | import { buildChannelWithPosition } from "../../test";
4 |
5 | test("should render and show Channel component properly", () => {
6 | const channel = buildChannelWithPosition();
7 | render();
8 | expect(screen.getByRole("img", { name: /logo/i })).toBeInTheDocument();
9 | expect(screen.getByRole("img", { name: /logo/i })).toHaveAttribute(
10 | "src",
11 | channel.logo
12 | );
13 | });
14 |
--------------------------------------------------------------------------------
/src/Epg/components/__tests__/Layout.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen } from "../../test/test-utils";
2 | import { Layout } from "../Layout";
3 | import { getLayoutProps } from "../../test";
4 |
5 | test("should render Layout component properly", () => {
6 | const props = getLayoutProps();
7 |
8 | render();
9 |
10 | const timeline = screen.getByTestId("timeline");
11 | expect(timeline).toBeInTheDocument();
12 |
13 | const sidebar = screen.getByTestId("sidebar");
14 | expect(sidebar).toBeInTheDocument();
15 |
16 | const content = screen.getByTestId("content");
17 | expect(content).toBeInTheDocument();
18 | });
19 |
20 | test("should render Layout with hidden timeline", () => {
21 | const props = getLayoutProps({ isTimeline: false });
22 |
23 | render();
24 |
25 | const timeline = screen.queryByTestId("timeline");
26 | expect(timeline).not.toBeInTheDocument();
27 | });
28 |
29 | test("should render Layout with hidden sidebar", () => {
30 | const props = getLayoutProps({ isSidebar: false });
31 |
32 | render();
33 |
34 | const sideba = screen.queryByTestId("sideba");
35 | expect(sideba).not.toBeInTheDocument();
36 | });
37 |
38 | test("should pass initial Layout props with sidebar width", () => {
39 | const sidebarWidth = 200;
40 | const props = getLayoutProps({ sidebarWidth });
41 |
42 | render();
43 |
44 | expect(screen.getByTestId("sidebar")).toHaveStyle(`width: ${sidebarWidth}px`);
45 | });
46 |
--------------------------------------------------------------------------------
/src/Epg/components/__tests__/Program.test.tsx:
--------------------------------------------------------------------------------
1 | import { render, screen, userEvent } from "../../test/test-utils";
2 | import { Program } from "../Program";
3 | import { buildProgramWithPosition } from "../../test";
4 | import { subMinutes, addMinutes } from "date-fns";
5 |
6 | test("should render and show Program component properly", () => {
7 | const program = buildProgramWithPosition();
8 | render();
9 |
10 | expect(screen.getByText(program.data.title)).toBeInTheDocument();
11 | expect(screen.getByLabelText(/program time/i)).toBeInTheDocument();
12 | expect(screen.getByLabelText(/program time/i)).toHaveTextContent(
13 | `23:50 - 00:55`
14 | );
15 | });
16 |
17 | test("should highlight live program", () => {
18 | const since = subMinutes(new Date(), 60);
19 | const till = addMinutes(new Date(), 60);
20 | const program = buildProgramWithPosition({ program: { since, till } });
21 |
22 | render();
23 |
24 | expect(screen.getByRole("img", { name: /preview/i })).toBeInTheDocument();
25 | expect(screen.getByRole("img", { name: /preview/i })).toHaveAttribute(
26 | "src",
27 | program.data.image
28 | );
29 | expect(screen.getByTestId(/program-content/i)).toHaveStyle(
30 | `background: linear-gradient(to right, #051937, #002360,#002eb3)`
31 | );
32 | });
33 |
34 | test("should handle onClick prop", () => {
35 | const onClick = jest.fn();
36 | const program = buildProgramWithPosition();
37 |
38 | render(
39 |
40 | );
41 |
42 | userEvent.click(screen.getByTestId(/program-content/i));
43 |
44 | expect(onClick).toHaveBeenCalled();
45 | expect(onClick).toHaveBeenCalledTimes(1);
46 | });
47 |
--------------------------------------------------------------------------------
/src/Epg/components/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Layout";
2 | export * from "./Program";
3 | export * from "./Channels";
4 | export * from "./Channel";
5 | export * from "./Timeline";
6 | export * from "./Line";
7 | export * from "./Loader";
8 |
--------------------------------------------------------------------------------
/src/Epg/helpers/__tests__/common.test.ts:
--------------------------------------------------------------------------------
1 | import faker from "@faker-js/faker";
2 | import { omit, getProgramOptions } from "../common";
3 | import { buildChannel, buildProgramWithPosition } from "../../test";
4 |
5 | function filterData(
6 | data: Record,
7 | name: string
8 | ) {
9 | return Object.entries(data).reduce((acc, [key, value]) => {
10 | if (key !== name) {
11 | acc[key as string] = value;
12 | }
13 | return acc;
14 | }, {} as Record);
15 | }
16 |
17 | describe("Common helpers", () => {
18 | it("should omit the keys", () => {
19 | const channel = buildChannel();
20 | const channelWithoutLogo = filterData(channel, "logo");
21 | expect(omit(channel, "logo")).toEqual(channelWithoutLogo);
22 | expect(omit(channel, "logo")).not.toEqual(channel);
23 | });
24 |
25 | it("should get program options with position", () => {
26 | const options = getProgramOptions(
27 | buildProgramWithPosition({
28 | overrides: { edgeEnd: faker.datatype.float() },
29 | })
30 | );
31 | const programPositionConverted = {
32 | ...options,
33 | position: omit(options.position, "edgeEnd"),
34 | };
35 | expect(options).toEqual(programPositionConverted);
36 | expect(options).not.toEqual(
37 | buildProgramWithPosition({
38 | overrides: { edgeEnd: faker.datatype.float() },
39 | })
40 | );
41 | });
42 | });
43 |
--------------------------------------------------------------------------------
/src/Epg/helpers/__tests__/time.test.ts:
--------------------------------------------------------------------------------
1 | import { formatTime, isYesterday } from '../time';
2 |
3 | describe('Time helpers', () => {
4 | it('should format program time', () => {
5 | const time = '2022-02-01 23:50:00';
6 | const timeFormatted = '2022-02-01T23:50:00';
7 | expect(formatTime(time)).toBe(timeFormatted);
8 |
9 | expect(formatTime(new Date(time))).toBe(timeFormatted);
10 | expect(formatTime(new Date(time).getTime())).toBe(timeFormatted);
11 | expect(formatTime(new Date(time).getTime())).not.toBe(
12 | new Date(timeFormatted).getTime()
13 | );
14 |
15 | expect(formatTime(time)).not.toBe('2022-02-01');
16 | expect(formatTime(time)).not.toBe('2022-02-01 23:50');
17 | expect(formatTime(time)).not.toBe('23:50:00');
18 | });
19 |
20 | it('should check if this is yesterday date', () => {
21 | const startDate = '2022-02-02 00:00:00';
22 | const date = '2022-02-01 23:50:00';
23 | expect(isYesterday(date, startDate)).toBe(true);
24 | expect(isYesterday(new Date(date), new Date(startDate))).toBe(true);
25 | expect(
26 | isYesterday(new Date(date).getTime(), new Date(startDate).getTime())
27 | ).toBe(true);
28 |
29 | expect(isYesterday(date, startDate)).not.toBe(false);
30 | expect(isYesterday(startDate, date)).not.toBe(true);
31 | });
32 | });
33 |
--------------------------------------------------------------------------------
/src/Epg/helpers/common.ts:
--------------------------------------------------------------------------------
1 | import { useLayoutEffect, useEffect } from "react";
2 | import { differenceInHours, startOfDay } from "date-fns";
3 | import { HOURS_IN_DAY } from "./variables";
4 |
5 | type DateTime = string | number | Date;
6 |
7 | type OmitObjectType = { [key: string]: any };
8 | export const omit = (obj: OmitObjectType, ...props: string[]) => {
9 | const result = { ...obj };
10 |
11 | for (const property of props) {
12 | delete result[property];
13 | }
14 |
15 | return result;
16 | };
17 |
18 | export const generateArray = (num: number) => new Array(num).fill("");
19 |
20 | type ProgramOptions = {
21 | position: { width: number; height: number; top: number; left: number };
22 | };
23 | export const getProgramOptions = (program: T) => {
24 | const { width, height, top, left } = program.position;
25 | return {
26 | ...program,
27 | position: { width, height, top, left },
28 | };
29 | };
30 |
31 | export const useIsomorphicLayoutEffect = () =>
32 | typeof window !== "undefined" ? useLayoutEffect : useEffect;
33 |
34 | export const getHourWidth = (dayWidth: number) => dayWidth / HOURS_IN_DAY;
35 |
36 | export const getDate = (date: DateTime) => new Date(date);
37 |
38 | const abs = (num: number) => Math.abs(num);
39 | interface DayWidth {
40 | dayWidth: number;
41 | startDate: DateTime;
42 | endDate: DateTime;
43 | }
44 | export const getDayWidthResources = ({
45 | dayWidth,
46 | startDate,
47 | endDate,
48 | }: DayWidth) => {
49 | const startDateTime = getDate(startDate);
50 | const endDateTime = getDate(endDate);
51 |
52 | if (endDateTime < startDateTime) {
53 | console.error(
54 | `Invalid endDate property. Value of endDate must be greater than startDate. Props: startDateTime: ${startDateTime}, endDateTime: ${endDateTime}`
55 | );
56 | }
57 |
58 | const offsetStartHoursRange = differenceInHours(
59 | startDateTime,
60 | startOfDay(startDateTime)
61 | );
62 |
63 | const numberOfHoursInDay = differenceInHours(endDateTime, startDateTime);
64 | const hourWidth = Math.floor(dayWidth / numberOfHoursInDay);
65 | const newDayWidth = hourWidth * numberOfHoursInDay;
66 |
67 | return {
68 | hourWidth: abs(hourWidth),
69 | dayWidth: abs(newDayWidth),
70 | numberOfHoursInDay: abs(numberOfHoursInDay),
71 | offsetStartHoursRange: abs(offsetStartHoursRange),
72 | };
73 | };
74 |
--------------------------------------------------------------------------------
/src/Epg/helpers/enums.ts:
--------------------------------------------------------------------------------
1 | export enum Layers {
2 | Sidebar = 10,
3 | EpgCornerBox = 90,
4 | Line = 10,
5 | Loader = 100,
6 | Program = 1,
7 | Timeline = 10,
8 | }
9 |
--------------------------------------------------------------------------------
/src/Epg/helpers/epg.ts:
--------------------------------------------------------------------------------
1 | import { differenceInMinutes, getTime } from "date-fns";
2 |
3 | // Import interfaces
4 | import { Channel, Program } from "./interfaces";
5 |
6 | // Import types
7 | import { ProgramWithPosition, Position, DateTime } from "./types";
8 |
9 | // Import variables
10 | import { HOUR_IN_MINUTES } from "./variables";
11 |
12 | // Import time helpers
13 | import {
14 | formatTime,
15 | roundToMinutes,
16 | isYesterday as isYesterdayTime,
17 | } from "./time";
18 | import { getDate } from "./common";
19 |
20 | // -------- Program width --------
21 | const getItemDiffWidth = (diff: number, hourWidth: number) =>
22 | (diff * hourWidth) / HOUR_IN_MINUTES;
23 |
24 | export const getPositionX = (
25 | since: DateTime,
26 | till: DateTime,
27 | startDate: DateTime,
28 | endDate: DateTime,
29 | hourWidth: number
30 | ) => {
31 | const isTomorrow = getTime(getDate(till)) > getTime(getDate(endDate));
32 | const isYesterday = getTime(getDate(since)) < getTime(getDate(startDate));
33 |
34 | // When time range is set to 1 hour and program time is greater than 1 hour
35 | if (isYesterday && isTomorrow) {
36 | const diffTime = differenceInMinutes(
37 | roundToMinutes(getDate(endDate)),
38 | getDate(startDate)
39 | );
40 | return getItemDiffWidth(diffTime, hourWidth);
41 | }
42 |
43 | if (isYesterday) {
44 | const diffTime = differenceInMinutes(
45 | roundToMinutes(getDate(till)),
46 | getDate(startDate)
47 | );
48 | return getItemDiffWidth(diffTime, hourWidth);
49 | }
50 |
51 | if (isTomorrow) {
52 | const diffTime = differenceInMinutes(
53 | getDate(endDate),
54 | roundToMinutes(getDate(since))
55 | );
56 |
57 | if (diffTime < 0) return 0;
58 | return getItemDiffWidth(diffTime, hourWidth);
59 | }
60 |
61 | const diffTime = differenceInMinutes(
62 | roundToMinutes(getDate(till)),
63 | roundToMinutes(getDate(since))
64 | );
65 |
66 | return getItemDiffWidth(diffTime, hourWidth);
67 | };
68 |
69 | // -------- Channel position in the Epg --------
70 | export const getChannelPosition = (
71 | channelIndex: number,
72 | itemHeight: number
73 | ) => {
74 | const top = itemHeight * channelIndex;
75 | const position = {
76 | top,
77 | height: itemHeight,
78 | };
79 | return position;
80 | };
81 | // -------- Program position in the Epg --------
82 | export const getProgramPosition = (
83 | program: Program,
84 | channelIndex: number,
85 | itemHeight: number,
86 | hourWidth: number,
87 | startDate: DateTime,
88 | endDate: DateTime
89 | ) => {
90 | const item = {
91 | ...program,
92 | since: formatTime(program.since),
93 | till: formatTime(program.till),
94 | };
95 | const isYesterday = isYesterdayTime(item.since, startDate);
96 |
97 | let width = getPositionX(
98 | item.since,
99 | item.till,
100 | startDate,
101 | endDate,
102 | hourWidth
103 | );
104 | const top = itemHeight * channelIndex;
105 | let left = getPositionX(startDate, item.since, startDate, endDate, hourWidth);
106 | const edgeEnd = getPositionX(
107 | startDate,
108 | item.till,
109 | startDate,
110 | endDate,
111 | hourWidth
112 | );
113 |
114 | if (isYesterday) left = 0;
115 | // If item has negative top position, it means that it is not visible in this day
116 | if (top < 0) width = 0;
117 |
118 | const position = {
119 | width,
120 | height: itemHeight,
121 | top,
122 | left,
123 | edgeEnd,
124 | };
125 | return { position, data: item };
126 | };
127 |
128 | // -------- Converted programs with position data --------
129 | interface ConvertedPrograms {
130 | data: Program[];
131 | channels: Channel[];
132 | startDate: DateTime;
133 | endDate: DateTime;
134 | itemHeight: number;
135 | hourWidth: number;
136 | }
137 | export const getConvertedPrograms = ({
138 | data,
139 | channels,
140 | startDate,
141 | endDate,
142 | itemHeight,
143 | hourWidth,
144 | }: ConvertedPrograms) =>
145 | data.map((next) => {
146 | const channelIndex = channels.findIndex(
147 | ({ uuid }) => uuid === next.channelUuid
148 | );
149 | return getProgramPosition(
150 | next,
151 | channelIndex,
152 | itemHeight,
153 | hourWidth,
154 | startDate,
155 | endDate
156 | );
157 | }, [] as ProgramWithPosition[]);
158 |
159 | // -------- Converted channels with position data --------
160 | export const getConvertedChannels = (channels: Channel[], itemHeight: number) =>
161 | channels.map((channel, index) => ({
162 | ...channel,
163 | position: getChannelPosition(index, itemHeight),
164 | }));
165 |
166 | // -------- Dynamic virtual program visibility in the EPG --------
167 | export const getItemVisibility = (
168 | position: Position,
169 | scrollY: number,
170 | scrollX: number,
171 | containerHeight: number,
172 | containerWidth: number,
173 | itemOverscan: number
174 | ) => {
175 | if (position.width <= 0) {
176 | return false;
177 | }
178 |
179 | if (scrollY > position.top + itemOverscan * 3) {
180 | return false;
181 | }
182 |
183 | if (scrollY + containerHeight <= position.top) {
184 | return false;
185 | }
186 |
187 | if (
188 | scrollX + containerWidth >= position.left &&
189 | scrollX <= position.edgeEnd
190 | ) {
191 | return true;
192 | }
193 |
194 | return false;
195 | };
196 |
197 | export const getSidebarItemVisibility = (
198 | position: Pick,
199 | scrollY: number,
200 | containerHeight: number,
201 | itemOverscan: number
202 | ) => {
203 | if (scrollY > position.top + itemOverscan * 3) {
204 | return false;
205 | }
206 |
207 | if (scrollY + containerHeight <= position.top) {
208 | return false;
209 | }
210 |
211 | return true;
212 | };
213 |
--------------------------------------------------------------------------------
/src/Epg/helpers/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./interfaces";
2 | export * from "./enums";
3 | export * from "./variables";
4 | export * from "./common";
5 | export * from "./time";
6 | export * from "./epg";
7 |
--------------------------------------------------------------------------------
/src/Epg/helpers/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface Program {
2 | channelUuid: string;
3 | id: string;
4 | title: string;
5 | description: string;
6 | since: string | number | Date;
7 | till: string | number | Date;
8 | image: string;
9 | [key: string]: any;
10 | }
11 |
12 | export interface Channel {
13 | uuid: string;
14 | logo: string;
15 | [key: string]: any;
16 | }
17 |
18 | export interface Theme {
19 | primary: {
20 | 600: string;
21 | 900: string;
22 | };
23 | grey: { 300: string };
24 | white: string;
25 | green: { 300: string };
26 | loader: {
27 | teal: string;
28 | purple: string;
29 | pink: string;
30 | bg: string;
31 | };
32 | scrollbar: {
33 | border: string;
34 | thumb: {
35 | bg: string;
36 | };
37 | };
38 | gradient: {
39 | blue: {
40 | 300: string;
41 | 600: string;
42 | 900: string;
43 | };
44 | };
45 |
46 | text: {
47 | grey: {
48 | 300: string;
49 | 500: string;
50 | };
51 | };
52 | timeline: {
53 | divider: {
54 | bg: string;
55 | };
56 | };
57 | }
58 |
--------------------------------------------------------------------------------
/src/Epg/helpers/time.ts:
--------------------------------------------------------------------------------
1 | import { format, roundToNearestMinutes, startOfDay, addDays } from "date-fns";
2 |
3 | // Import types
4 | import { DateTime as DateRangeTime } from "./types";
5 |
6 | // Variables
7 | import { TIME_FORMAT } from "./variables";
8 |
9 | type DateTime = number | string | Date;
10 |
11 | const getTime = (date: DateTime) => new Date(date).getTime();
12 |
13 | export const getLiveStatus = (since: DateTime, till: DateTime) => {
14 | const nowTime = getTime(new Date());
15 | const sinceTime = getTime(since);
16 | const sinceTill = getTime(till);
17 | return nowTime >= sinceTime && sinceTill > nowTime;
18 | };
19 |
20 | export const formatTime = (date: DateTime) =>
21 | format(new Date(date), TIME_FORMAT.DEFAULT).replace(/\s/g, "T");
22 |
23 | export const roundToMinutes = (date: DateTime) =>
24 | roundToNearestMinutes(new Date(date));
25 |
26 | export const isYesterday = (since: DateTime, startDate: DateTime) => {
27 | const sinceTime = getTime(new Date(since));
28 | const startDateTime = getTime(new Date(startDate));
29 |
30 | return startDateTime > sinceTime;
31 | };
32 |
33 | export const isFutureTime = (date: DateTime) => {
34 | const dateTime = getTime(new Date(date));
35 | const now = getTime(new Date());
36 | return dateTime > now;
37 | };
38 |
39 | export const getTimeRangeDates = (
40 | startDate: DateRangeTime,
41 | endDate: DateRangeTime
42 | ) => {
43 | let endDateValue = endDate;
44 | if (endDate === "") {
45 | endDateValue = formatTime(startOfDay(addDays(new Date(startDate), 1)));
46 | }
47 |
48 | return { startDate, endDate: endDateValue };
49 | };
50 |
--------------------------------------------------------------------------------
/src/Epg/helpers/types.ts:
--------------------------------------------------------------------------------
1 | // Interfaces
2 | import { Program, Channel } from "./interfaces";
3 |
4 | export type Position = {
5 | width: number;
6 | height: number;
7 | top: number;
8 | left: number;
9 | edgeEnd: number;
10 | };
11 |
12 | export type ProgramWithPosition = {
13 | position: Position;
14 | data: Program;
15 | };
16 |
17 | export type ProgramItem = {
18 | position: Omit;
19 | data: Program;
20 | };
21 |
22 | export type ChannelWithPosition = Channel & {
23 | position: Pick;
24 | };
25 |
26 | export type DateTime = string | Date;
27 |
28 | export type BaseTimeFormat = boolean;
29 |
--------------------------------------------------------------------------------
/src/Epg/helpers/variables.ts:
--------------------------------------------------------------------------------
1 | // Dimensions
2 | export const DAY_WIDTH = 7200;
3 | export const HOURS_IN_DAY = 24;
4 |
5 | export const HOUR_IN_MINUTES = 60;
6 |
7 | export const TIMELINE_HEIGHT = 60;
8 |
9 | export const SIDEBAR_WIDTH = 100;
10 | export const ITEM_HEIGHT = 80;
11 |
12 | export const ITEM_OVERSCAN = ITEM_HEIGHT;
13 |
14 | // Debounce
15 | export const DEBOUNCE_WAIT = 100;
16 | export const DEBOUNCE_WAIT_MAX = 100;
17 |
18 | // Program refresh
19 | export const PROGRAM_REFRESH = 120000;
20 |
21 | // Theme
22 | export const THEME_MODE = {
23 | DARK: "dark",
24 | LIGHT: "light",
25 | };
26 |
27 | export const TIME_FORMAT = {
28 | DEFAULT: "yyyy-MM-dd HH:mm:ss",
29 | DATE: "yyyy-MM-dd",
30 | HOURS_MIN: "HH:mm",
31 | BASE_HOURS_TIME: "h:mm a",
32 | };
33 |
--------------------------------------------------------------------------------
/src/Epg/hooks/__tests__/useLayout.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react-hooks";
2 | import { useLayout } from "../useLayout";
3 |
4 | const defaultState = (overrides: { [key: string]: any } = {}) => {
5 | return {
6 | containerRef: { current: null },
7 | scrollBoxRef: { current: null },
8 | scrollX: 0,
9 | scrollY: 0,
10 | layoutWidth: undefined,
11 | layoutHeight: undefined,
12 | onScroll: expect.any(Function),
13 | onScrollToNow: expect.any(Function),
14 | onScrollTop: expect.any(Function),
15 | onScrollLeft: expect.any(Function),
16 | onScrollRight: expect.any(Function),
17 | ...overrides,
18 | };
19 | };
20 |
21 | test("should return initial useLayout props", () => {
22 | const { result } = renderHook(() =>
23 | useLayout({
24 | sidebarWidth: 100,
25 | startDate: "2022-03-24T00:00:00",
26 | })
27 | );
28 |
29 | expect(result.current).toEqual(defaultState());
30 | });
31 |
32 | test("should set initial width and height useLayout props", () => {
33 | const width = 800;
34 | const height = 600;
35 | const { result } = renderHook(() =>
36 | useLayout({
37 | width,
38 | height,
39 | sidebarWidth: 100,
40 | startDate: "2022-03-24T00:00:00",
41 | })
42 | );
43 |
44 | expect(result.current).toEqual(
45 | defaultState({ layoutWidth: width, layoutHeight: height })
46 | );
47 | });
48 |
--------------------------------------------------------------------------------
/src/Epg/hooks/__tests__/useProgram.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react-hooks";
2 | import { useProgram } from "../useProgram";
3 | import { buildProgramWithPosition, getTestTimeDate } from "../../test";
4 | import { ProgramItem } from "../../helpers/types";
5 |
6 | interface DefaultState {
7 | overrides?: { [key: string]: any };
8 | styles?: { [key: string]: any };
9 | }
10 | const defaultState = ({ overrides, styles }: DefaultState = {}) => {
11 | return {
12 | formatTime: expect.any(Function),
13 | set12HoursTimeFormat: expect.any(Function),
14 | getRTLSinceTime: expect.any(Function),
15 | getRTLTillTime: expect.any(Function),
16 | isLive: false,
17 | isMinWidth: true,
18 | isRTL: false,
19 | styles,
20 | ...overrides,
21 | };
22 | };
23 |
24 | function getStyles(program: ProgramItem) {
25 | return {
26 | styles: { position: program.position, width: program.position.width },
27 | };
28 | }
29 |
30 | test("should return generated props from useTimeline", () => {
31 | const program = buildProgramWithPosition();
32 | const props = { program, isBaseTimeFormat: false };
33 | const { result } = renderHook(() => useProgram(props));
34 | const options = {
35 | styles: { position: program.position, width: program.position.width },
36 | };
37 |
38 | const { formatTime } = result.current;
39 | expect(formatTime(getTestTimeDate("08", "20"))).toBe("08:20");
40 | expect(formatTime(getTestTimeDate("18", "20"))).toBe("18:20");
41 |
42 | expect(result.current).toEqual(defaultState(options));
43 | });
44 |
45 | test("should specify an initial state in useTimeline", () => {
46 | const program = buildProgramWithPosition();
47 | const props = {
48 | program,
49 | isBaseTimeFormat: true,
50 | minWidth: 800,
51 | };
52 | const { result } = renderHook(() => useProgram(props));
53 | const options = {
54 | ...getStyles(program),
55 | isMinWidth: false,
56 | };
57 | const { formatTime, set12HoursTimeFormat } = result.current;
58 | expect(formatTime(getTestTimeDate("02", "33"), set12HoursTimeFormat())).toBe(
59 | "2:33AM"
60 | );
61 | expect(formatTime(getTestTimeDate("15", "45"), set12HoursTimeFormat())).toBe(
62 | "3:45PM"
63 | );
64 |
65 | expect(result.current).toEqual(defaultState(options));
66 | });
67 |
--------------------------------------------------------------------------------
/src/Epg/hooks/__tests__/useTimeline.test.tsx:
--------------------------------------------------------------------------------
1 | import { renderHook } from "@testing-library/react-hooks";
2 | import { useTimeline } from "../useTimeline";
3 |
4 | test("should return generated props from useTimeline", () => {
5 | const { result } = renderHook(() => useTimeline(24, false));
6 | expect(result.current.time).toHaveLength(24);
7 | expect(result.current.dividers).toHaveLength(4);
8 | expect(result.current.formatTime(10)).toBe("10:00");
9 | expect(result.current.formatTime(8)).toBe("08:00");
10 | });
11 |
12 | test("should specify an 12 hours initial state in useTimeline", () => {
13 | const { result } = renderHook(() => useTimeline(16, true));
14 | expect(result.current.time).toHaveLength(16);
15 | expect(result.current.dividers).toHaveLength(4);
16 | expect(result.current.formatTime(10)).toBe("10:00am");
17 | expect(result.current.formatTime(14)).toBe("2:00pm");
18 | });
19 |
--------------------------------------------------------------------------------
/src/Epg/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useEpg";
2 | export * from "./useInterval";
3 | export * from "./useProgram";
4 | export * from "./useTimeline";
5 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useEpg.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { startOfToday } from "date-fns";
3 |
4 | // Import interfaces
5 | import { Channel, Program, Theme } from "../helpers/interfaces";
6 |
7 | // Import types
8 | import { DateTime, BaseTimeFormat, Position } from "../helpers/types";
9 |
10 | // Import helpers
11 | import {
12 | DAY_WIDTH,
13 | ITEM_HEIGHT,
14 | ITEM_OVERSCAN,
15 | getDayWidthResources,
16 | getTimeRangeDates,
17 | } from "../helpers";
18 |
19 | // Import theme
20 | import { theme as defaultTheme } from "../theme";
21 |
22 | // Import helpers
23 | import {
24 | SIDEBAR_WIDTH,
25 | formatTime,
26 | getConvertedChannels,
27 | getConvertedPrograms,
28 | getItemVisibility,
29 | getSidebarItemVisibility,
30 | } from "../helpers";
31 |
32 | // Import components
33 | import { useLayout } from "./useLayout";
34 |
35 | interface useEpgProps {
36 | channels: Channel[];
37 | epg: Program[];
38 | width?: number;
39 | height?: number;
40 | startDate?: DateTime;
41 | endDate?: DateTime;
42 | isBaseTimeFormat?: BaseTimeFormat;
43 | isSidebar?: boolean;
44 | isTimeline?: boolean;
45 | isRTL?: boolean;
46 | isLine?: boolean;
47 | theme?: Theme;
48 | globalStyles?: string;
49 | dayWidth?: number;
50 | sidebarWidth?: number;
51 | itemHeight?: number;
52 | itemOverscan?: number;
53 | }
54 |
55 | const defaultStartDateTime = formatTime(startOfToday());
56 |
57 | export function useEpg({
58 | channels: channelsEpg,
59 | epg,
60 | startDate: startDateInput = defaultStartDateTime,
61 | endDate: endDateInput = "",
62 | isRTL = false,
63 | isBaseTimeFormat = false,
64 | isSidebar = true,
65 | isTimeline = true,
66 | isLine = true,
67 | theme: customTheme,
68 | globalStyles,
69 | dayWidth: customDayWidth = DAY_WIDTH,
70 | sidebarWidth = SIDEBAR_WIDTH,
71 | itemHeight = ITEM_HEIGHT,
72 | itemOverscan = ITEM_OVERSCAN,
73 | width,
74 | height,
75 | }: useEpgProps) {
76 | // Get converted start and end dates
77 | const { startDate, endDate } = getTimeRangeDates(
78 | startDateInput,
79 | endDateInput
80 | );
81 |
82 | // Get day and hour width of the day
83 | const { hourWidth, dayWidth, ...dayWidthResourcesProps } = React.useMemo(
84 | () =>
85 | getDayWidthResources({ dayWidth: customDayWidth, startDate, endDate }),
86 | [customDayWidth, startDate, endDate]
87 | );
88 |
89 | // -------- Effects --------
90 | const { containerRef, scrollBoxRef, ...layoutProps } = useLayout({
91 | startDate,
92 | endDate,
93 | sidebarWidth,
94 | width,
95 | height,
96 | hourWidth,
97 | });
98 |
99 | const { scrollX, scrollY, layoutWidth, layoutHeight } = layoutProps;
100 | const {
101 | onScroll,
102 | onScrollToNow,
103 | onScrollTop,
104 | onScrollLeft,
105 | onScrollRight,
106 | } = layoutProps;
107 |
108 | //-------- Variables --------
109 | const channels = React.useMemo(
110 | () => getConvertedChannels(channelsEpg, itemHeight),
111 | [channelsEpg, itemHeight]
112 | );
113 |
114 | const startDateTime = formatTime(startDate);
115 | const endDateTime = formatTime(endDate);
116 | const programs = React.useMemo(
117 | () =>
118 | getConvertedPrograms({
119 | data: epg,
120 | channels,
121 | startDate: startDateTime,
122 | endDate: endDateTime,
123 | itemHeight,
124 | hourWidth,
125 | }),
126 | [epg, channels, startDateTime, endDateTime, itemHeight, hourWidth]
127 | );
128 |
129 | const theme: Theme = customTheme || defaultTheme;
130 |
131 | // -------- Handlers --------
132 | const isProgramVisible = React.useCallback(
133 | (position: Position) =>
134 | getItemVisibility(
135 | position,
136 | scrollY,
137 | scrollX,
138 | layoutHeight,
139 | layoutWidth,
140 | itemOverscan
141 | ),
142 | [scrollY, scrollX, layoutHeight, layoutWidth, itemOverscan]
143 | );
144 |
145 | const isChannelVisible = React.useCallback(
146 | (position: Pick) =>
147 | getSidebarItemVisibility(position, scrollY, layoutHeight, itemOverscan),
148 | [scrollY, layoutHeight, itemOverscan]
149 | );
150 |
151 | const getEpgProps = () => ({
152 | isRTL,
153 | isSidebar,
154 | isLine,
155 | isTimeline,
156 | width,
157 | height,
158 | sidebarWidth,
159 | ref: containerRef,
160 | theme,
161 | globalStyles,
162 | });
163 |
164 | const getLayoutProps = () => ({
165 | programs,
166 | channels,
167 | startDate,
168 | endDate,
169 | scrollY,
170 | onScroll,
171 | isRTL,
172 | isBaseTimeFormat,
173 | isSidebar,
174 | isTimeline,
175 | isLine,
176 | isProgramVisible,
177 | isChannelVisible,
178 | dayWidth,
179 | hourWidth,
180 | sidebarWidth,
181 | itemHeight,
182 | ...dayWidthResourcesProps,
183 | ref: scrollBoxRef,
184 | });
185 |
186 | return {
187 | getEpgProps,
188 | getLayoutProps,
189 | onScrollToNow,
190 | onScrollTop,
191 | onScrollLeft,
192 | onScrollRight,
193 | scrollY,
194 | scrollX,
195 | };
196 | }
197 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useInterval.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | // Import helpers
4 | import { useIsomorphicLayoutEffect } from "../helpers";
5 |
6 | export function useInterval(callback: () => void, delay: number | null) {
7 | const useIsomorphicEffect = useIsomorphicLayoutEffect();
8 | const savedCallback = React.useRef(callback);
9 |
10 | useIsomorphicEffect(() => {
11 | savedCallback.current = callback;
12 | }, [callback]);
13 |
14 | React.useEffect(() => {
15 | if (!delay && delay !== 0) {
16 | return;
17 | }
18 |
19 | const id = setInterval(() => savedCallback.current(), delay);
20 |
21 | return () => clearInterval(id);
22 | }, [delay]);
23 | }
24 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useLayout.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useDebouncedCallback } from "use-debounce";
3 | import { startOfToday, isToday as isTodayFns } from "date-fns";
4 |
5 | // Import types
6 | import { DateTime } from "../helpers/types";
7 |
8 | // Import helpers
9 | import {
10 | DEBOUNCE_WAIT,
11 | DEBOUNCE_WAIT_MAX,
12 | getPositionX,
13 | useIsomorphicLayoutEffect,
14 | } from "../helpers";
15 |
16 | interface useLayoutProps {
17 | height?: number;
18 | width?: number;
19 | hourWidth: number;
20 | sidebarWidth: number;
21 | startDate: DateTime;
22 | endDate: DateTime;
23 | }
24 |
25 | export function useLayout({
26 | height,
27 | width,
28 | startDate,
29 | endDate,
30 | hourWidth,
31 | sidebarWidth,
32 | }: useLayoutProps) {
33 | const useIsomorphicEffect = useIsomorphicLayoutEffect();
34 |
35 | const containerRef = React.useRef(null);
36 | const scrollBoxRef = React.useRef(null);
37 | //-------- State --------
38 | const [scrollY, setScrollY] = React.useState(0);
39 | const [scrollX, setScrollX] = React.useState(0);
40 | const [layoutWidth, setLayoutWidth] = React.useState(width as number);
41 | const [layoutHeight, setLayoutHeight] = React.useState(
42 | height as number
43 | );
44 | const isToday = isTodayFns(new Date(startDate));
45 |
46 | // -------- Handlers --------
47 | const handleScrollDebounced = useDebouncedCallback(
48 | (value) => {
49 | setScrollY(value.y);
50 | setScrollX(value.x);
51 | },
52 | DEBOUNCE_WAIT,
53 | { maxWait: DEBOUNCE_WAIT_MAX }
54 | );
55 |
56 | const handleOnScroll = React.useCallback(
57 | (e: React.UIEvent & { target: Element }) => {
58 | handleScrollDebounced({ y: e.target.scrollTop, x: e.target.scrollLeft });
59 | },
60 | [handleScrollDebounced]
61 | );
62 |
63 | const handleOnScrollToNow = React.useCallback(() => {
64 | if (scrollBoxRef?.current && isToday) {
65 | const clientWidth = (width ??
66 | containerRef.current?.clientWidth) as number;
67 |
68 | const newDate = new Date();
69 | const scrollPosition = getPositionX(
70 | startOfToday(),
71 | newDate,
72 | startDate,
73 | endDate,
74 | hourWidth
75 | );
76 | const scrollNow = scrollPosition - clientWidth / 2 + sidebarWidth;
77 | scrollBoxRef.current.scrollLeft = scrollNow;
78 | }
79 | }, [isToday, startDate, endDate, width, sidebarWidth, hourWidth]);
80 |
81 | const handleOnScrollTop = React.useCallback(
82 | (value: number = hourWidth) => {
83 | if (scrollBoxRef?.current) {
84 | const top = scrollBoxRef.current.scrollTop + value;
85 | scrollBoxRef.current.scrollTop = top;
86 | }
87 | },
88 | [hourWidth]
89 | );
90 |
91 | const handleOnScrollRight = React.useCallback(
92 | (value: number = hourWidth) => {
93 | if (scrollBoxRef?.current) {
94 | const right = scrollBoxRef.current.scrollLeft + value;
95 | scrollBoxRef.current.scrollLeft = right;
96 | }
97 | },
98 | [hourWidth]
99 | );
100 |
101 | const handleOnScrollLeft = React.useCallback(
102 | (value: number = hourWidth) => {
103 | if (scrollBoxRef?.current) {
104 | const left = scrollBoxRef.current.scrollLeft - value;
105 | scrollBoxRef.current.scrollLeft = left;
106 | }
107 | },
108 | [hourWidth]
109 | );
110 |
111 | const handleResizeDebounced = useDebouncedCallback(
112 | () => {
113 | if (containerRef?.current && !width) {
114 | const container = containerRef.current;
115 | const { clientWidth } = container;
116 | setLayoutWidth(clientWidth);
117 | }
118 | },
119 | DEBOUNCE_WAIT * 4,
120 | { maxWait: DEBOUNCE_WAIT_MAX * 4 }
121 | );
122 |
123 | // -------- Efffects --------
124 | useIsomorphicEffect(() => {
125 | if (containerRef?.current) {
126 | const container = containerRef.current;
127 | if (!width) {
128 | const { clientWidth } = container;
129 | setLayoutWidth(clientWidth);
130 | }
131 | if (!height) {
132 | const { clientHeight } = container;
133 | setLayoutHeight(clientHeight);
134 | }
135 | }
136 |
137 | if (scrollBoxRef?.current && isToday) {
138 | handleOnScrollToNow();
139 | }
140 | }, [height, width, startDate, isToday, handleOnScrollToNow]);
141 |
142 | useIsomorphicEffect(() => {
143 | window.addEventListener("resize", handleResizeDebounced);
144 |
145 | return () => {
146 | window.removeEventListener("resize", handleResizeDebounced);
147 | };
148 | }, [width]);
149 |
150 | return {
151 | containerRef,
152 | scrollBoxRef,
153 | scrollX,
154 | scrollY,
155 | layoutWidth,
156 | layoutHeight,
157 | onScroll: handleOnScroll,
158 | onScrollToNow: handleOnScrollToNow,
159 | onScrollTop: handleOnScrollTop,
160 | onScrollLeft: handleOnScrollLeft,
161 | onScrollRight: handleOnScrollRight,
162 | };
163 | }
164 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useLine.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { startOfDay } from "date-fns";
3 |
4 | // Import types
5 | import { DateTime } from "../helpers/types";
6 |
7 | // Import helpers
8 | import { HOUR_IN_MINUTES, PROGRAM_REFRESH, getPositionX } from "../helpers";
9 |
10 | // Import hooks
11 | import { useInterval } from ".";
12 |
13 | interface useLineProps {
14 | startDate: DateTime;
15 | endDate: DateTime;
16 | dayWidth: number;
17 | hourWidth: number;
18 | sidebarWidth: number;
19 | }
20 |
21 | export function useLine({
22 | startDate,
23 | endDate,
24 | dayWidth,
25 | hourWidth,
26 | sidebarWidth,
27 | }: useLineProps) {
28 | const initialState =
29 | getPositionX(
30 | startOfDay(new Date(startDate)),
31 | new Date(),
32 | startDate,
33 | endDate,
34 | hourWidth
35 | ) + sidebarWidth;
36 | const [positionX, setPositionX] = React.useState(() => initialState);
37 |
38 | const isDayEnd = positionX <= dayWidth;
39 | const isScrollX = React.useMemo(() => (isDayEnd ? PROGRAM_REFRESH : null), [
40 | isDayEnd,
41 | ]);
42 |
43 | useInterval(() => {
44 | const offset = hourWidth / HOUR_IN_MINUTES;
45 | const positionOffset = offset * 2;
46 | setPositionX((prev) => prev + positionOffset);
47 | }, isScrollX);
48 |
49 | React.useEffect(() => {
50 | const date = new Date(startDate);
51 | const positionX = getPositionX(
52 | startOfDay(date),
53 | new Date(),
54 | startDate,
55 | endDate,
56 | hourWidth
57 | );
58 | const newPositionX = positionX + sidebarWidth;
59 | setPositionX(newPositionX);
60 | }, [startDate, endDate, sidebarWidth, hourWidth]);
61 |
62 | return { positionX };
63 | }
64 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useProgram.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { format } from "date-fns";
3 |
4 | // Import types
5 | import { ProgramItem, BaseTimeFormat } from "../helpers/types";
6 |
7 | // Import helpers
8 | import { PROGRAM_REFRESH, TIME_FORMAT, getLiveStatus, omit } from "../helpers";
9 |
10 | // Import hooks
11 | import { useInterval } from "./useInterval";
12 |
13 | interface useProgramProps {
14 | program: T;
15 | isRTL?: boolean;
16 | isBaseTimeFormat: BaseTimeFormat;
17 | minWidth?: number;
18 | }
19 |
20 | export function useProgram({
21 | isRTL = false,
22 | isBaseTimeFormat,
23 | program,
24 | minWidth = 200,
25 | }: useProgramProps) {
26 | const { data, position } = program;
27 | const { width } = position;
28 |
29 | const { since, till } = data;
30 | const [isLive, setIsLive] = React.useState(() =>
31 | getLiveStatus(since, till)
32 | );
33 |
34 | const newPosition = omit(position, "egdeEnd");
35 |
36 | const formatTime = (
37 | date: string | number | Date,
38 | formatType: string = TIME_FORMAT.HOURS_MIN
39 | ) => format(new Date(date), formatType).replace(/\s/g, "");
40 |
41 | const set12HoursTimeFormat = () => {
42 | if (isBaseTimeFormat) return TIME_FORMAT.BASE_HOURS_TIME;
43 | return TIME_FORMAT.HOURS_MIN;
44 | };
45 |
46 | const getRTLSinceTime = (since: string | number | Date) =>
47 | isRTL ? till : since;
48 | const getRTLTillTime = (till: string | number | Date) =>
49 | isRTL ? since : till;
50 |
51 | useInterval(() => {
52 | const status = getLiveStatus(since, till);
53 | setIsLive(status);
54 | }, PROGRAM_REFRESH);
55 |
56 | const isMinWidth = width > minWidth;
57 |
58 | return {
59 | isLive,
60 | isMinWidth,
61 | isRTL,
62 | formatTime,
63 | set12HoursTimeFormat,
64 | getRTLSinceTime,
65 | getRTLTillTime,
66 | styles: { width, position: newPosition },
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/src/Epg/hooks/useTimeline.tsx:
--------------------------------------------------------------------------------
1 | import { format } from "date-fns";
2 |
3 | // Import types
4 | import { BaseTimeFormat } from "../helpers/types";
5 |
6 | // Import helpers
7 | import { TIME_FORMAT, generateArray } from "../helpers";
8 |
9 | export function useTimeline(
10 | numberOfHoursInDay: number,
11 | isBaseTimeFormat: BaseTimeFormat
12 | ) {
13 | const time = generateArray(numberOfHoursInDay);
14 | const dividers = generateArray(4);
15 |
16 | const formatTime = (index: number) => {
17 | const date = new Date();
18 | const baseDate = format(date, TIME_FORMAT.DATE);
19 | const time = index < 10 ? `0${index}` : index;
20 |
21 | if (isBaseTimeFormat && index <= 24) {
22 | const date = new Date(`${baseDate}T${time}:00:00`);
23 | const timeFormat = format(date, TIME_FORMAT.BASE_HOURS_TIME);
24 | return timeFormat.toLowerCase().replace(/\s/g, "");
25 | }
26 |
27 | return `${time}:00`;
28 | };
29 |
30 | return { time, dividers, formatTime };
31 | }
32 |
--------------------------------------------------------------------------------
/src/Epg/index.ts:
--------------------------------------------------------------------------------
1 | import { ProgramStyled, ChannelStyled, TimelineStyled } from "./styles";
2 |
3 | // Import types
4 | import { Theme as ThemeType, Program as IProgram } from "./helpers/interfaces";
5 | import {
6 | ProgramItem as ProgramItemType,
7 | ChannelWithPosition,
8 | BaseTimeFormat as BaseTimeFormatType,
9 | } from "./helpers/types";
10 |
11 | // Types
12 | export type Theme = ThemeType;
13 | export type Channel = ChannelWithPosition;
14 | export type Program = IProgram;
15 | export type ProgramItem = {
16 | program: ProgramItemType;
17 | isRTL: boolean;
18 | isBaseTimeFormat: BaseTimeFormatType;
19 | };
20 |
21 | // Components
22 | export { Layout } from "./components";
23 | export { Epg } from "./Epg";
24 | export { useEpg, useProgram, useTimeline } from "./hooks";
25 |
26 | // Styles
27 | const { ChannelBox, ChannelLogo } = ChannelStyled;
28 |
29 | const {
30 | ProgramBox,
31 | ProgramContent,
32 | ProgramFlex,
33 | ProgramStack,
34 | ProgramTitle,
35 | ProgramText,
36 | ProgramImage,
37 | } = ProgramStyled;
38 |
39 | const {
40 | TimelineWrapper,
41 | TimelineBox,
42 | TimelineTime,
43 | TimelineDivider,
44 | TimelineDividers,
45 | } = TimelineStyled;
46 |
47 | export {
48 | // Channel
49 | ChannelBox,
50 | ChannelLogo,
51 | // Program
52 | ProgramBox,
53 | ProgramContent,
54 | ProgramFlex,
55 | ProgramStack,
56 | ProgramTitle,
57 | ProgramText,
58 | ProgramImage,
59 | // Timeline
60 | TimelineWrapper,
61 | TimelineBox,
62 | TimelineTime,
63 | TimelineDividers,
64 | TimelineDivider,
65 | };
66 |
--------------------------------------------------------------------------------
/src/Epg/styles/Channel.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Theme } from "../helpers";
3 |
4 | export const ChannelBox = styled.div<{
5 | top: number;
6 | height: number;
7 | theme?: Theme;
8 | }>`
9 | position: absolute;
10 | top: ${({ top }) => top}px;
11 | height: ${({ height }) => height}px;
12 | width: 100%;
13 | display: flex;
14 | align-items: center;
15 | justify-content: center;
16 | background-color: ${({ theme }) => theme.primary[900]};
17 | `;
18 |
19 | export const ChannelLogo = styled.img`
20 | max-height: 60px;
21 | max-width: 60px;
22 | position: relative;
23 | `;
24 |
--------------------------------------------------------------------------------
/src/Epg/styles/Channels.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Layers, Theme } from "../helpers";
3 |
4 | export const Box = styled.div<{
5 | isTimeline: boolean;
6 | isRTL: boolean;
7 | width: number;
8 | bottom: number;
9 | theme?: Theme;
10 | }>`
11 | position: sticky;
12 | width: ${({ width }) => width}px;
13 | float: left;
14 | bottom: ${({ bottom }) => bottom}px;
15 | left: 0;
16 | z-index: ${Layers.Sidebar};
17 | background-color: ${({ theme }) => theme.primary[900]};
18 |
19 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`};
20 | `;
21 |
--------------------------------------------------------------------------------
/src/Epg/styles/Epg.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Layers, Theme } from "../helpers";
3 |
4 | export const Container = styled.div<{
5 | height?: number;
6 | width?: number;
7 | }>`
8 | padding: 5px;
9 | height: ${({ height }) => (height ? `${height}px` : "100%")};
10 | width: ${({ width }) => (width ? `${width}px` : "100%")};
11 |
12 | *,
13 | ::before,
14 | ::after {
15 | box-sizing: border-box;
16 | }
17 | `;
18 |
19 | export const Wrapper = styled.div`
20 | height: 100%;
21 | width: 100%;
22 | display: flex;
23 | flex-direction: column;
24 | position: relative;
25 | border-radius: 6px;
26 | overflow: hidden;
27 | `;
28 |
29 | export const ScrollBox = styled.div<{ theme?: Theme; isRTL?: boolean }>`
30 | height: 100%;
31 | width: 100%;
32 | position: relative;
33 | overflow: auto;
34 | scroll-behavior: smooth;
35 | background: ${({ theme }) => theme.primary[900]};
36 |
37 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`};
38 |
39 | ::-webkit-scrollbar {
40 | width: 10px;
41 | height: 10px;
42 | }
43 |
44 | ::-webkit-scrollbar-thumb {
45 | background: ${({ theme }) => theme.scrollbar.thumb.bg};
46 | border: 10px none ${({ theme }) => theme.white};
47 | border-radius: 20px;
48 | }
49 | ::-webkit-scrollbar-thumb:hover {
50 | background: ${({ theme }) => theme.white};
51 | }
52 |
53 | ::-webkit-scrollbar-track {
54 | background: ${({ theme }) => theme.primary[900]};
55 | border: 22px none ${({ theme }) => theme.white};
56 | border-radius: 0px;
57 | }
58 |
59 | ::-webkit-scrollbar-corner {
60 | background: ${({ theme }) => theme.primary[900]};
61 | }
62 | `;
63 |
64 | export const Box = styled.div<{
65 | isRTL?: boolean;
66 | width: number;
67 | height: number;
68 | left?: number;
69 | top?: number;
70 | theme?: Theme;
71 | }>`
72 | position: absolute;
73 | height: ${({ height }) => height}px;
74 | width: ${({ width }) => width}px;
75 | top: ${({ top = 0 }) => top}px;
76 | background: ${({ theme }) => theme.primary[900]};
77 | z-index: ${Layers.EpgCornerBox};
78 |
79 | ${({ isRTL, left = 0 }) => (isRTL ? `right:0px;` : ` left: ${left}px`)};
80 | `;
81 |
82 | export const Content = styled.div<{
83 | width: number;
84 | height: number;
85 | sidebarWidth: number;
86 | isSidebar: boolean;
87 | theme?: Theme;
88 | }>`
89 | background: ${({ theme }) => theme.primary[900]};
90 | height: ${({ height }) => height}px;
91 | width: ${({ width }) => width}px;
92 | position: relative;
93 | left: ${({ isSidebar, sidebarWidth }) => (isSidebar ? sidebarWidth : 0)}px;
94 | `;
95 |
--------------------------------------------------------------------------------
/src/Epg/styles/Line.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Layers, Theme } from "../helpers";
3 |
4 | export const Box = styled.div<{ height: number; left: number; theme?: Theme }>`
5 | position: absolute;
6 | top: 64px;
7 | left: ${({ left }) => left}px;
8 | height: ${({ height }) => height}px;
9 | width: 3px;
10 | background: ${({ theme }) => theme.green[300]};
11 | pointer-events: none;
12 | z-index: ${Layers.Line};
13 | `;
14 |
--------------------------------------------------------------------------------
/src/Epg/styles/Loader.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { keyframes } from "@emotion/react";
3 | import { Layers, Theme } from "../helpers";
4 |
5 | const time = [0, 50, 0];
6 |
7 | const moveLeft = (animate: { right: string[] }) => keyframes`
8 | ${time.map(
9 | (item, index) => `${item}% {
10 | transform: translateX(-${animate.right[index]});
11 | }`
12 | )}
13 | `;
14 |
15 | export const Box = styled.div<{ theme?: Theme }>`
16 | position: absolute;
17 | top: 0;
18 | left: 0;
19 | bottom: 0;
20 | width: 100%;
21 | background: ${({ theme }) => theme.loader.bg};
22 | display: flex;
23 | justify-content: center;
24 | align-items: center;
25 | z-index: ${Layers.Loader};
26 | `;
27 |
28 | export const Shape = styled.div<{
29 | width: number;
30 | color: string;
31 | marginRight?: number;
32 | transition: { duration: number; ease?: string; delay?: number };
33 | animate: { right: string[] };
34 | theme?: Theme;
35 | }>`
36 | width: ${({ width }) => width * 0.42}px;
37 | background: ${({ color }) => color};
38 | height: 18px;
39 | border-radius: 45px;
40 | margin-right: ${({ marginRight }) => marginRight ?? 0}px;
41 | animation-name: ${({ animate }) => moveLeft(animate)};
42 | animation-duration: ${({ transition }) => transition.duration}s;
43 | animation-timing-function: ${({ transition }) =>
44 | transition.ease ?? "ease-in-out"};
45 | animation-delay: ${({ transition }) => transition.delay ?? 0}s;
46 | animation-iteration-count: infinite;
47 | `;
48 |
--------------------------------------------------------------------------------
/src/Epg/styles/Program.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Layers, Theme } from "../helpers";
3 |
4 | export const ProgramBox = styled.div<{ width: number }>`
5 | position: absolute;
6 | padding: ${({ width }) => (width === 0 ? 0 : 4)}px;
7 | overflow: hidden;
8 | `;
9 |
10 | export const ProgramContent = styled.div<{
11 | isLive: boolean;
12 | width: number;
13 | theme?: Theme;
14 | }>`
15 | position: relative;
16 | display: flex;
17 | font-size: 11px;
18 | height: 100%;
19 | border-radius: 8px;
20 | padding: 10px ${({ width }) => (width < 30 ? 4 : 20)}px;
21 | overflow: hidden;
22 | cursor: pointer;
23 | transition: all 0.4s ease-in-out;
24 | background: ${({ theme: { primary } }) =>
25 | `linear-gradient(to right, ${primary[600]}, ${primary[600]})`};
26 | z-index: ${Layers.Program};
27 |
28 | &:hover {
29 | background: ${({ theme: { gradient } }) =>
30 | `linear-gradient(to right, ${gradient.blue[900]}, ${gradient.blue[600]})`};
31 | }
32 |
33 | ${({ isLive, theme: { gradient } }) =>
34 | isLive &&
35 | `background: linear-gradient(to right, ${gradient.blue[900]}, ${gradient.blue[600]},${gradient.blue[300]})`}
36 | `;
37 |
38 | export const ProgramFlex = styled.div`
39 | width: 100%;
40 | display: flex;
41 | justify-content: flex-start;
42 | `;
43 |
44 | const Elipsis = `
45 | white-space: nowrap;
46 | overflow: hidden;
47 | text-overflow: ellipsis;
48 | `;
49 |
50 | export const ProgramTitle = styled.p<{ theme?: Theme }>`
51 | font-size: 14px;
52 | text-align: left;
53 | margin-top: 0;
54 | margin-bottom: 5px;
55 | font-weight: 500;
56 | color: ${({ theme }) => theme.grey[300]};
57 | ${Elipsis}
58 | `;
59 |
60 | export const ProgramText = styled.span<{ theme?: Theme }>`
61 | display: block;
62 | font-size: 12.5px;
63 | font-weight: 400;
64 | color: ${({ theme }) => theme.text.grey[500]};
65 | text-align: left;
66 | ${Elipsis}
67 | `;
68 |
69 | export const ProgramImage = styled.img`
70 | margin-right: 15px;
71 | border-radius: 6px;
72 | width: 100px;
73 | `;
74 |
75 | export const ProgramStack = styled.div<{ isRTL?: boolean }>`
76 | overflow: hidden;
77 | ${({ isRTL }) =>
78 | isRTL &&
79 | `transform: scale(-1,1);
80 | display: flex;
81 | flex-direction: column;
82 | align-items: flex-end`};
83 | `;
84 |
--------------------------------------------------------------------------------
/src/Epg/styles/Timeline.styles.ts:
--------------------------------------------------------------------------------
1 | import styled from "@emotion/styled/macro";
2 | import { Layers, Theme } from "../helpers";
3 |
4 | // Import helpers
5 | import { ITEM_HEIGHT } from "../helpers";
6 |
7 | export const TimelineTime = styled.span<{
8 | theme?: Theme;
9 | isBaseTimeFormat?: boolean;
10 | isRTL?: boolean;
11 | }>`
12 | color: ${({ theme }) => theme.text.grey[300]};
13 | position: absolute;
14 | top: 18px;
15 | left: ${({ isRTL, isBaseTimeFormat }) =>
16 | isRTL && isBaseTimeFormat ? "-32" : "-18"}px;
17 |
18 | ${({ isRTL }) => isRTL && `transform: scale(-1,1)`};
19 | `;
20 |
21 | export const TimelineDividers = styled.div`
22 | height: 100%;
23 | width: 100%;
24 | display: grid;
25 | grid-template-columns: repeat(4, 1fr);
26 | align-items: end;
27 | padding-bottom: 6px;
28 | `;
29 |
30 | export const TimelineDivider = styled.div<{ width: number; theme?: Theme }>`
31 | background: ${({ theme }) => theme.timeline.divider.bg};
32 | height: 10px;
33 | width: 1px;
34 | margin-right: ${({ width }) => width / 4}px;
35 | `;
36 |
37 | export const TimelineWrapper = styled.div<{
38 | isSidebar: boolean;
39 | dayWidth: number;
40 | sidebarWidth: number;
41 | theme?: Theme;
42 | }>`
43 | position: sticky;
44 | top: 0;
45 | left: ${({ isSidebar, sidebarWidth }) => (isSidebar ? sidebarWidth : 0)}px;
46 | display: flex;
47 | height: ${ITEM_HEIGHT - 20}px;
48 | width: ${({ dayWidth }) => dayWidth}px;
49 | background: ${({ theme }) => theme.primary[900]};
50 | z-index: ${Layers.Timeline};
51 | `;
52 |
53 | export const TimelineBox = styled.div<{ width: number }>`
54 | width: ${({ width }) => width}px;
55 | font-size: 14px;
56 | position: relative;
57 |
58 | &:first-of-type {
59 | ${TimelineTime} {
60 | left: 0px;
61 | }
62 | }
63 | `;
64 |
--------------------------------------------------------------------------------
/src/Epg/styles/global.styles.ts:
--------------------------------------------------------------------------------
1 | import { css } from "@emotion/react";
2 |
3 | export const globalStyles = css`
4 | @import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap");
5 |
6 | .planby {
7 | font-family: "Inter", system-ui, -apple-system,
8 | /* Firefox supports this but not yet system-ui */ "Segoe UI", Roboto,
9 | Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; /* 2 */
10 | }
11 | `;
12 |
--------------------------------------------------------------------------------
/src/Epg/styles/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./global.styles";
2 | export * as EpgStyled from "./Epg.styles";
3 | export * as ChannelsStyled from "./Channels.styles";
4 | export * as ChannelStyled from "./Channel.styles";
5 | export * as ProgramStyled from "./Program.styles";
6 | export * as TimelineStyled from "./Timeline.styles";
7 | export * as LineStyled from "./Line.styles";
8 | export * as LoaderStyled from './Loader.styles'
9 |
--------------------------------------------------------------------------------
/src/Epg/test/db/channels.ts:
--------------------------------------------------------------------------------
1 | import faker from "@faker-js/faker";
2 |
3 | const channelFakeData = {
4 | country: faker.address.country(),
5 | logo: faker.image.image(),
6 | provider: 333,
7 | title: faker.commerce.productName(),
8 | type: faker.commerce.product(),
9 | uuid: "09-3de-34",
10 | year: faker.date.past(),
11 | };
12 |
13 | const position = { top: 0, height: 80 };
14 |
15 | interface BuildChannel {
16 | [key: string]: any;
17 | }
18 | export function buildChannel(overrides: BuildChannel = {}) {
19 | return { ...channelFakeData, ...overrides };
20 | }
21 |
22 | export function buildChannelWithPosition(overrides: BuildChannel = {}) {
23 | return { ...channelFakeData, position, ...overrides };
24 | }
25 |
--------------------------------------------------------------------------------
/src/Epg/test/db/epg.ts:
--------------------------------------------------------------------------------
1 | import faker from "@faker-js/faker";
2 |
3 | export const sinceAndTillTimes = [
4 | { since: "2022-03-13T23:50:00", till: "2022-03-14T00:55:00" },
5 | { since: "2022-03-14T00:55:00", till: "2022-03-14T02:35:00" },
6 | { since: "2022-03-14T02:35:00", till: "2022-03-14T03:30:00" },
7 | { since: "2022-03-14T03:30:00", till: "2022-03-14T04:15:00" },
8 | { since: "2022-03-14T04:15:00", till: "2022-03-14T04:30:00" },
9 | { since: "2022-03-14T04:30:00", till: "2022-03-14T05:10:00" },
10 | { since: "2022-03-14T05:10:00", till: "2022-03-14T05:35:00" },
11 | { since: "2022-03-14T05:35:00", till: "2022-03-14T06:00:00" },
12 | { since: "2022-03-14T06:00:00", till: "2022-03-14T07:00:00" },
13 | { since: "2022-03-14T07:00:00", till: "2022-03-14T07:40:00" },
14 | { since: "2022-03-14T07:40:00", till: "2022-03-14T08:05:00" },
15 | { since: "2022-03-14T08:05:00", till: "2022-03-14T08:20:00" },
16 | { since: "2022-03-14T08:20:00", till: "2022-03-14T08:45:00" },
17 | { since: "2022-03-14T08:45:00", till: "2022-03-14T09:15:00" },
18 | { since: "2022-03-14T09:15:00", till: "2022-03-14T10:20:00" },
19 | { since: "2022-03-14T10:20:00", till: "2022-03-14T11:15:00" },
20 | { since: "2022-03-14T11:15:00", till: "2022-03-14T11:50:00" },
21 | { since: "2022-03-14T11:50:00", till: "2022-03-14T13:45:00" },
22 | { since: "2022-03-14T13:45:00", till: "2022-03-14T13:55:00" },
23 | { since: "2022-03-14T13:55:00", till: "2022-03-14T14:55:00" },
24 | { since: "2022-03-14T14:55:00", till: "2022-03-14T15:55:00" },
25 | { since: "2022-03-14T15:55:00", till: "2022-03-14T17:00:00" },
26 | { since: "2022-03-14T17:00:00", till: "2022-03-14T17:20:00" },
27 | { since: "2022-03-14T17:20:00", till: "2022-03-14T17:30:00" },
28 | { since: "2022-03-14T17:30:00", till: "2022-03-14T18:30:00" },
29 | { since: "2022-03-14T18:30:00", till: "2022-03-14T19:25:00" },
30 | { since: "2022-03-14T19:25:00", till: "2022-03-14T19:30:00" },
31 | { since: "2022-03-14T19:30:00", till: "2022-03-14T20:05:00" },
32 | { since: "2022-03-14T20:05:00", till: "2022-03-14T20:10:00" },
33 | { since: "2022-03-14T20:10:00", till: "2022-03-14T20:35:00" },
34 | ];
35 |
36 | const programFakeData = {
37 | id: "36f",
38 | description: faker.lorem.paragraph(),
39 | title: faker.commerce.product(),
40 | since: sinceAndTillTimes[0].since,
41 | till: sinceAndTillTimes[0].till,
42 | channelUuid: "09-3de-34",
43 | image: faker.image.image(),
44 | country: faker.address.country(),
45 | genre: faker.lorem.word(),
46 | rating: faker.datatype.float(),
47 | };
48 |
49 | interface BuildProgram {
50 | [key: string]: any;
51 | }
52 | export function buildProgram(overrides: BuildProgram = {}) {
53 | return { ...programFakeData, ...overrides };
54 | }
55 |
56 | export interface BuildProgramWithPosition {
57 | overrides?: BuildProgram;
58 | program?: BuildProgram;
59 | }
60 | export function buildProgramWithPosition({
61 | overrides,
62 | program,
63 | }: BuildProgramWithPosition = {}) {
64 | return {
65 | data: { ...programFakeData, ...program },
66 | position: {
67 | height: faker.datatype.float(),
68 | left: faker.datatype.float(),
69 | top: faker.datatype.float(),
70 | width: faker.datatype.float(),
71 | ...overrides,
72 | },
73 | };
74 | }
75 |
76 | export function buildEpgWithPosition() {
77 | return sinceAndTillTimes.map((time) => {
78 | const { since, till } = time;
79 | return {
80 | data: {
81 | id: faker.datatype.uuid(),
82 | description: faker.lorem.paragraph(),
83 | title: faker.commerce.product(),
84 | channelUuid: "09-3de-34",
85 | image: faker.image.image(),
86 | country: faker.address.country(),
87 | genre: faker.lorem.word(),
88 | rating: faker.datatype.float(),
89 | since,
90 | till,
91 | },
92 | position: {
93 | height: faker.datatype.float(),
94 | left: faker.datatype.float(),
95 | top: faker.datatype.float(),
96 | width: faker.datatype.float(),
97 | },
98 | };
99 | });
100 | }
101 |
--------------------------------------------------------------------------------
/src/Epg/test/db/index.ts:
--------------------------------------------------------------------------------
1 | export * from './channels';
2 | export * from './epg';
3 |
--------------------------------------------------------------------------------
/src/Epg/test/helpers.ts:
--------------------------------------------------------------------------------
1 | import { buildChannelWithPosition, buildEpgWithPosition } from "./db";
2 |
3 | type Overrides = { [key: string]: any };
4 | export function getLayoutProps(
5 | overrides: Overrides = {},
6 | sliceNumber: number = 1
7 | ) {
8 | const channels = [buildChannelWithPosition()];
9 | const programs = buildEpgWithPosition().slice(0, sliceNumber);
10 |
11 | return {
12 | programs,
13 | channels,
14 | scrollY: 0,
15 | startDate: "2022-03-23T00:00:00",
16 | endDate: "2022-03-23T23:59:00",
17 | dayWidth: 7200,
18 | hourWidth: 300,
19 | numberOfHoursInDay: 24,
20 | offsetStartHoursRange: 0,
21 | sidebarWidth: 100,
22 | itemHeight: 80,
23 | isSidebar: true,
24 | isTimeline: true,
25 | isLine: true,
26 | isBaseTimeFormat: false,
27 | isProgramVisible: () => true,
28 | isChannelVisible: () => true,
29 | onScroll: () => {},
30 | ...overrides,
31 | };
32 | }
33 |
34 | export const getTestTimeDate = (
35 | h: string = "00",
36 | m: string = "00",
37 | s: string = "00"
38 | ) => `2022-03-23T${h}:${m}:${s}`;
39 |
--------------------------------------------------------------------------------
/src/Epg/test/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./db";
2 | export * from "./helpers";
3 |
--------------------------------------------------------------------------------
/src/Epg/test/test-utils.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { render, RenderOptions } from "@testing-library/react";
3 | import { ThemeProvider, Global } from "@emotion/react";
4 | import userEvent from "@testing-library/user-event";
5 |
6 | // Import theme
7 | import { theme } from "../theme";
8 |
9 | // Import styles
10 | import { globalStyles } from "../styles";
11 |
12 | interface AllTheProvidersProps {
13 | children: React.ReactNode;
14 | }
15 | const AllTheProviders = ({ children }: AllTheProvidersProps) => {
16 | return (
17 |
18 |
19 | {children}
20 |
21 | );
22 | };
23 |
24 | const customRender = (
25 | ui: React.ReactElement,
26 | options?: Omit
27 | ) => render(ui, { wrapper: AllTheProviders as React.FC, ...options });
28 |
29 | export * from "@testing-library/react";
30 | export { customRender as render, userEvent };
31 |
--------------------------------------------------------------------------------
/src/Epg/theme/emotion.d.ts:
--------------------------------------------------------------------------------
1 | import "@emotion/react";
2 | import { Theme as CustomTheme } from "../helpers/interfaces";
3 |
4 | declare module "@emotion/react" {
5 | export interface Theme extends CustomTheme {}
6 | }
7 |
--------------------------------------------------------------------------------
/src/Epg/theme/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./theme";
2 |
--------------------------------------------------------------------------------
/src/Epg/theme/theme.ts:
--------------------------------------------------------------------------------
1 | export const theme = {
2 | primary: {
3 | 600: "#1a202c",
4 | 900: "#171923",
5 | },
6 | grey: { 300: "#d1d1d1" },
7 | white: "#fff",
8 | green: {
9 | 300: "#2C7A7B",
10 | },
11 | loader: {
12 | teal: "#5DDADB",
13 | purple: "#3437A2",
14 | pink: "#F78EB6",
15 | bg: "#171923db",
16 | },
17 | scrollbar: {
18 | border: "#ffffff",
19 | thumb: {
20 | bg: "#e1e1e1",
21 | },
22 | },
23 |
24 | gradient: {
25 | blue: {
26 | 300: "#002eb3",
27 | 600: "#002360",
28 | 900: "#051937",
29 | },
30 | },
31 |
32 | text: {
33 | grey: {
34 | 300: "#a0aec0",
35 | 500: "#718096",
36 | },
37 | },
38 |
39 | timeline: {
40 | divider: {
41 | bg: "#718096",
42 | },
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./Epg";
2 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "esnext",
4 | "lib": ["dom", "esnext"],
5 | "importHelpers": true,
6 | "declaration": true,
7 | "sourceMap": true,
8 | "rootDir": "./src",
9 | "strict": true,
10 | "noImplicitReturns": true,
11 | "noFallthroughCasesInSwitch": true,
12 | "noUnusedLocals": true,
13 | "noUnusedParameters": true,
14 | "moduleResolution": "node",
15 | "jsx": "react",
16 | "esModuleInterop": true,
17 | "skipLibCheck": true,
18 | "forceConsistentCasingInFileNames": true,
19 | "noEmit": true
20 | },
21 | "include": ["src"]
22 | }
23 |
--------------------------------------------------------------------------------